(null);
+ useDialogFocus(dialogRef, true, onClose);
useEffect(() => {
const prev = document.body.style.overflow;
@@ -89,7 +87,9 @@ function ProviderTestSlideOverPanel({
aria-hidden
/>
@@ -170,7 +170,9 @@ function SlideOverHeader({
<>
·
- block
+
+ block
+
{t("deprecated")}
>
@@ -179,7 +181,9 @@ function SlideOverHeader({
<>
·
- info
+
+ info
+
{t("risk")}
>
@@ -192,7 +196,9 @@ function SlideOverHeader({
aria-label={t("close")}
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
- close
+
+ close
+
);
@@ -216,12 +222,18 @@ function TestToolbar({
controls: LlmChatControls | null;
}) {
const t = useTranslations("providerTest");
+ const modelSelectId = useId();
+ const keySelectId = useId();
const hasMessages = controls?.hasMessages ?? false;
return (
- {t("model")}:
+
+ {t("model")}
+ :
+
onModelChange(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
@@ -236,8 +248,12 @@ function TestToolbar({
{keys.length > 0 && (
-
{t("key")}:
+
+ {t("key")}
+ :
+
onSelectedKeyChange(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
@@ -258,7 +274,9 @@ function TestToolbar({
className="text-[11px] text-text-muted hover:text-text-main transition-colors flex items-center gap-1"
title={t("clearConversation")}
>
- delete_sweep
+
+ delete_sweep
+
{t("clear")}
)}
@@ -286,7 +304,9 @@ function SlideOverTabs({ tab, onChange }: { tab: TabKey; onChange: (next: TabKey
active ? "text-accent" : "text-text-muted hover:text-text-main"
}`}
>
- {tabItem.icon}
+
+ {tabItem.icon}
+
{translate(`tabs.${tabItem.label}`)}
{active && (
+
- {icon}
+
+ {icon}
+
{title}
{body}
@@ -389,8 +425,12 @@ function LogsTab({ providerId }: { providerId: string }) {
if (state.status === "loading") {
return (
-
-
+
+
progress_activity
{t("loadingLogs")}
@@ -400,7 +440,12 @@ function LogsTab({ providerId }: { providerId: string }) {
if (state.status === "error") {
return (
- {state.message}} />
+ {state.message}}
+ />
);
}
@@ -419,7 +464,9 @@ function LogsTab({ providerId }: { providerId: string }) {
rel="noopener noreferrer"
>
{t("openFullLogs")}
- open_in_new
+
+ open_in_new
+
>
}
@@ -447,7 +494,9 @@ function LogsTab({ providerId }: { providerId: string }) {
className="text-[10px] text-text-muted hover:text-text-main inline-flex items-center gap-1"
title={t("refreshNow")}
>
- refresh
+
+ refresh
+
{t("refresh")}
@@ -493,6 +542,7 @@ function LogsTab({ providerId }: { providerId: string }) {
{formatDurationMs(log.duration)}
chevron_right
@@ -511,7 +561,9 @@ function LogsTab({ providerId }: { providerId: string }) {
rel="noopener noreferrer"
>
{t("openFullLogs")}
- open_in_new
+
+ open_in_new
+
diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx
index 0ab117604e2..9cd4d2417f3 100644
--- a/src/shared/components/Sidebar.tsx
+++ b/src/shared/components/Sidebar.tsx
@@ -25,6 +25,7 @@ import Button from "./Button";
import Input from "./Input";
import { ConfirmModal } from "./Modal";
import CloudSyncStatus from "./CloudSyncStatus";
+import SidebarSectionHeader from "./SidebarSectionHeader";
import { useTranslations } from "next-intl";
import {
HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY,
@@ -465,14 +466,14 @@ export default function Sidebar({
);
const content = (
<>
-
+
{item.icon}
{!collapsed && (
{item.label}
{item.subtitle && (
- {item.subtitle}
+ {item.subtitle}
)}
)}
@@ -481,6 +482,8 @@ export default function Sidebar({
const sharedProps = {
onMouseEnter: (e: React.MouseEvent) => handleMouseEnter(e, item.id, item.label),
onMouseLeave: handleMouseLeave,
+ // Collapsed (mini) links show only a glyph: name them by their label.
+ "aria-label": collapsed ? item.label : undefined,
};
if (item.external) {
@@ -537,13 +540,15 @@ export default function Sidebar({
isMacElectron ? "pt-3" : "pt-5",
collapsed ? "px-3 justify-center" : "px-4"
)}
- aria-hidden="true"
>
+ {/* Only the decorative window dots are hidden: the collapse button beside them
+ must stay reachable (a focusable control inside aria-hidden is invisible to
+ screen readers yet still takes keyboard focus). */}
{!isMacElectron && (
<>
-
-
-
+
+
+
>
)}
{!collapsed &&
}
@@ -618,7 +623,7 @@ export default function Sidebar({
)}
>
{isSearching && displaySections.length === 0 && (
- {tc("noResults")}
+ {tc("noResults")}
)}
{displaySections.map((section, idx) => {
const sectionId = section.id as SidebarSectionId;
@@ -653,50 +658,14 @@ export default function Sidebar({
// Expanded mode: collapsible section with pin
return (
-
toggleSection(sectionId)}
- role="button"
- aria-expanded={isExpanded}
- >
-
- {section.title}
-
-
- {/* Pin button — right side near chevron */}
- {
- e.stopPropagation();
- togglePin(sectionId);
- }}
- title={isPinned ? t("unpinSection") : t("pinSectionOpen")}
- className={cn(
- "p-0.5 rounded transition-all shrink-0",
- isPinned
- ? "text-primary opacity-100"
- : "text-text-muted/30 opacity-0 group-hover/header:opacity-100 hover:text-text-muted/70"
- )}
- >
-
- push_pin
-
-
-
-
- expand_more
-
-
+
toggleSection(sectionId)}
+ onTogglePin={() => togglePin(sectionId)}
+ />
{isExpanded && (
@@ -709,7 +678,7 @@ export default function Sidebar({
{!separatorHidden && (
@@ -741,25 +710,31 @@ export default function Sidebar({
setShowRestartModal(true)}
title={t("restart")}
+ aria-label={t("restart")}
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
- "text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
+ "text-amber-700 dark:text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
collapsed ? "p-2" : "flex-1 min-w-0 px-2 py-1.5 text-xs"
)}
>
- restart_alt
+
+ restart_alt
+
{!collapsed && {t("restart")} }
setShowShutdownModal(true)}
title={t("shutdown")}
+ aria-label={t("shutdown")}
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
- "text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
+ "text-red-700 dark:text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
collapsed ? "p-2" : "flex-1 min-w-0 px-2 py-1.5 text-xs"
)}
>
- power_settings_new
+
+ power_settings_new
+
{!collapsed && {t("shutdown")} }
diff --git a/src/shared/components/SidebarSectionHeader.tsx b/src/shared/components/SidebarSectionHeader.tsx
new file mode 100644
index 00000000000..16b81944e6c
--- /dev/null
+++ b/src/shared/components/SidebarSectionHeader.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { cn } from "@/shared/utils/cn";
+
+interface SidebarSectionHeaderProps {
+ title: string;
+ isExpanded: boolean;
+ isPinned: boolean;
+ pinLabel: string;
+ onToggle: () => void;
+ onTogglePin: () => void;
+}
+
+/**
+ * Collapsible sidebar section header. The expand control and the pin control are
+ * sibling s: the previous `div[role=button]` wrapped the pin button (nested
+ * interactive controls) and could not be reached or operated from the keyboard.
+ * The pin button is positioned over the header so the visual layout is unchanged.
+ */
+export default function SidebarSectionHeader({
+ title,
+ isExpanded,
+ isPinned,
+ pinLabel,
+ onToggle,
+ onTogglePin,
+}: SidebarSectionHeaderProps) {
+ return (
+
+
+
+ {title}
+
+
+
+ expand_more
+
+
+
+
+
+ push_pin
+
+
+
+ );
+}
diff --git a/src/shared/components/Toggle.tsx b/src/shared/components/Toggle.tsx
index ffac728de7c..4ef5af765ab 100644
--- a/src/shared/components/Toggle.tsx
+++ b/src/shared/components/Toggle.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useId } from "react";
import { cn } from "@/shared/utils/cn";
interface ToggleProps {
@@ -48,6 +49,11 @@ export default function Toggle({
},
};
+ const descriptionId = useId();
+ // The description is announced as a description only when something else names the
+ // switch; with no label it already is the name, so it must not be read twice.
+ const describedBy = description && (ariaLabel || label) ? descriptionId : undefined;
+
const handleClick = () => {
if (!disabled && onChange) {
onChange(!checked);
@@ -67,6 +73,7 @@ export default function Toggle({
role="switch"
aria-checked={checked}
aria-label={ariaLabel || label || description || title || "Toggle"}
+ aria-describedby={describedBy}
title={title}
disabled={disabled}
onClick={handleClick}
@@ -94,7 +101,11 @@ export default function Toggle({
{(label || description) && (
{label && {label} }
- {description && {description} }
+ {description && (
+
+ {description}
+
+ )}
)}
diff --git a/src/shared/components/layouts/DashboardLayout.tsx b/src/shared/components/layouts/DashboardLayout.tsx
index f56f73478c5..21ed34be143 100644
--- a/src/shared/components/layouts/DashboardLayout.tsx
+++ b/src/shared/components/layouts/DashboardLayout.tsx
@@ -1,6 +1,8 @@
"use client";
-import { Suspense, useEffect, useInsertionEffect, useState } from "react";
+import { Suspense, useCallback, useEffect, useInsertionEffect, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useDialogFocus } from "@/shared/hooks/useDialogFocus";
import Sidebar from "../Sidebar";
import Header from "../Header";
import NotificationToast from "../NotificationToast";
@@ -19,10 +21,15 @@ const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
export default function DashboardLayout({ children }) {
+ const t = useTranslations("sidebar");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const isElectron = useIsElectron();
const [collapsed, setCollapsed] = useState(false);
+ const drawerRef = useRef(null);
+ const closeSidebar = useCallback(() => setSidebarOpen(false), []);
+ // Mobile drawer: Escape closes, focus moves in and is trapped, and returns to the menu button.
+ useDialogFocus(drawerRef, sidebarOpen, closeSidebar);
useEffect(() => {
try {
@@ -87,7 +94,8 @@ export default function DashboardLayout({ children }) {
{sidebarOpen && (
setSidebarOpen(false)}
+ onClick={closeSidebar}
+ aria-hidden="true"
/>
)}
@@ -101,12 +109,19 @@ export default function DashboardLayout({ children }) {
{/* Sidebar - Mobile: full viewport height with proper scroll containment */}
+ {/* Closed, the off-canvas drawer is `inert`: its links stayed in the tab order and the
+ accessibility tree while translated out of view. Open, it is a modal dialog. */}
- setSidebarOpen(false)} isMacElectron={isMacElectron} />
+
{/* Main content */}
diff --git a/src/shared/hooks/useDialogFocus.ts b/src/shared/hooks/useDialogFocus.ts
new file mode 100644
index 00000000000..bce9f9042d1
--- /dev/null
+++ b/src/shared/hooks/useDialogFocus.ts
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, type RefObject } from "react";
+
+// Disabled controls cannot receive focus, so leaving them in the list made the trap
+// "wrap" onto an element that silently refused focus and let Tab escape the dialog.
+const FOCUSABLE_SELECTOR = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled]):not([type='hidden'])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ "[tabindex]:not([tabindex='-1'])",
+].join(", ");
+
+const INITIAL_FOCUS_DELAY_MS = 50;
+
+function getFocusableElements(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+}
+
+function trapTabKey(dialog: HTMLElement, event: KeyboardEvent) {
+ if (event.key !== "Tab") return;
+ const focusable = getFocusableElements(dialog);
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+}
+
+/**
+ * Keyboard contract shared by every modal surface (centered Modal, slide-over drawer):
+ * - Escape closes;
+ * - the first focusable control receives focus when the dialog opens;
+ * - Tab / Shift+Tab stay inside the dialog;
+ * - focus returns to the opener on close (only if the opener is still in the DOM).
+ */
+export function useDialogFocus(
+ dialogRef: RefObject,
+ isOpen: boolean,
+ onClose: () => void
+) {
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [isOpen, onClose]);
+
+ // Declared before the focus effect so the opener is captured before focus moves.
+ useEffect(() => {
+ if (!isOpen) return;
+ const activeElement = document.activeElement;
+ const opener = activeElement instanceof HTMLElement ? activeElement : null;
+ return () => {
+ if (opener?.isConnected) opener.focus();
+ };
+ }, [isOpen]);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!isOpen || !dialog) return;
+
+ const firstFocusable = getFocusableElements(dialog)[0];
+ const focusTimer = firstFocusable
+ ? window.setTimeout(() => firstFocusable.focus(), INITIAL_FOCUS_DELAY_MS)
+ : undefined;
+ const handleTab = (event: KeyboardEvent) => trapTabKey(dialog, event);
+
+ dialog.addEventListener("keydown", handleTab);
+ return () => {
+ if (focusTimer !== undefined) window.clearTimeout(focusTimer);
+ dialog.removeEventListener("keydown", handleTab);
+ };
+ }, [isOpen, dialogRef]);
+}
diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts
index fee84b30e5c..cf6d5cf4c91 100644
--- a/src/store/themeStore.ts
+++ b/src/store/themeStore.ts
@@ -86,10 +86,19 @@ function applyColorTheme(colorTheme: string, customColor: string) {
if (typeof window === "undefined") return;
const root = document.documentElement;
+ // The default preset (and unknown ids, which fall back to it) follows the theme-aware
+ // CSS tokens in globals.css: a deeper coral in light mode for WCAG AA contrast and the
+ // original coral in dark mode. An inline value on would beat both.
+ const usesDefaultPreset =
+ colorTheme !== "custom" && (colorTheme === "coral" || !COLOR_THEMES[colorTheme]);
+ if (usesDefaultPreset) {
+ root.style.removeProperty("--color-primary");
+ root.style.removeProperty("--color-primary-hover");
+ return;
+ }
+
const baseColor =
- colorTheme === "custom"
- ? normalizeHexColor(customColor)
- : COLOR_THEMES[colorTheme] || COLOR_THEMES.coral;
+ colorTheme === "custom" ? normalizeHexColor(customColor) : COLOR_THEMES[colorTheme];
const hoverColor = shadeHexColor(baseColor, -0.14);
root.style.setProperty("--color-primary", baseColor);
diff --git a/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts
index d8c85ba2498..7110a6d3dca 100644
--- a/tests/e2e/a11y.spec.ts
+++ b/tests/e2e/a11y.spec.ts
@@ -1,126 +1,177 @@
/**
* tests/e2e/a11y.spec.ts
*
- * Accessibility gate using @axe-core/playwright (Task 13 — Fase 7).
+ * Accessibility gate using @axe-core/playwright (Task 13 — Fase 7, hardened in Fase 9).
*
- * NIGHTLY advisory: this suite is scheduled in the NIGHTLY CI job, not in the
- * per-PR job, because axe analysis adds ~10–20 s per page and the results are
- * frozen baselines (see approach below).
+ * NIGHTLY: this suite is scheduled in the NIGHTLY CI job, not in the per-PR job,
+ * because axe analysis adds ~10–20 s per page × width (see REQUIRE_AXE below).
*
- * Approach — freeze-and-alert (not fail-on-first-violation):
- * 1. Run axe on each key page.
- * 2. Count `violations.length` per page.
- * 3. Assert the count has NOT increased since the frozen baseline.
- * 4. Report violations in the test output so they are visible in CI logs.
+ * Two assertions per audited page:
+ * 1. ZERO `critical` or `serious` violations at every responsive width in
+ * A11Y_WIDTHS (768 / 900 / 1024 / 1280 / 1440). A single blocking violation fails.
+ * 2. Ratchet on the TOTAL violation count (any impact) at the 1280px desktop
+ * viewport: the count may never exceed VIOLATION_BASELINES. Lower the baseline
+ * whenever a violation is fixed; never raise it.
*
- * This means:
- * - Existing violations are GRANDFATHERED (baseline frozen).
- * - A new violation (count grows) FAILS the gate — catraca `down`.
- * - Fixing a violation (count drops) passes + you can lower the baseline.
+ * Violations are fixed at the source, never silenced with `disableRules`.
*
* Graceful degradation:
- * - If @axe-core/playwright is not installed the entire suite is skipped
- * with a clear message instead of crashing the job.
- * - The frozen baselines below are ADVISORY defaults (0). On the first real
- * run update them to the actual counts (grep "axeViolationCount" in CI logs).
+ * - If @axe-core/playwright is not installed the suite is skipped with a clear
+ * message instead of crashing the job (the meta-test fails when REQUIRE_AXE=1).
*
- * Pages audited (key dashboard surfaces):
+ * Pages audited:
+ * /login — public auth gate
* /dashboard — main overview
* /dashboard/providers — provider management (most complex UI surface)
- * /login — public auth gate (a11y critical for users)
- * /dashboard/settings — settings (redirects to /dashboard/settings/general)
+ * /dashboard/settings — settings (ratchet only, default viewport)
*
- * Run locally (requires the app running on localhost:20128):
- * npx playwright test tests/e2e/a11y.spec.ts --headed
+ * Run locally (requires the app running on the Playwright baseURL):
+ * REQUIRE_AXE=1 npx playwright test tests/e2e/a11y.spec.ts
*/
import { test, expect, type Page } from "@playwright/test";
+// Type-only: erased at runtime, so the graceful skip below still works without the package.
+import type AxeBuilderClass from "@axe-core/playwright";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
+import { VIEWPORTS } from "./responsiveSpecs";
// ---------------------------------------------------------------------------
// Conditional import — skip entire suite if @axe-core/playwright is absent.
// ---------------------------------------------------------------------------
-let AxeBuilder: (new (args: { page: Page }) => {
- analyze(): Promise<{ violations: Array<{ id: string; description: string; impact: string | null; nodes: unknown[] }> }>;
- withTags(tags: string[]): unknown;
- exclude(selector: string): unknown;
- disableRules(rules: string[]): unknown;
-}) | null = null;
+type AxeResults = Awaited>;
+type AxeViolation = AxeResults["violations"][number];
+
+let AxeBuilder: typeof AxeBuilderClass | null = null;
try {
// Dynamic import so the module parse does not fail when the package is absent.
const mod = await import("@axe-core/playwright");
- AxeBuilder = mod.default ?? (mod as unknown as { AxeBuilder: typeof AxeBuilder }).AxeBuilder ?? null;
+ AxeBuilder = mod.default ?? null;
} catch {
// Package not installed — suite will skip gracefully below.
AxeBuilder = null;
}
// ---------------------------------------------------------------------------
-// Frozen violation baselines.
-//
-// Update these after the first real run by reading the "axeViolationCount"
-// lines from the CI log and setting each value to the actual count.
-// Format: { [pageLabel]: maxAllowedViolations }
+// Frozen total-violation baselines (any impact) at the 1280px desktop viewport.
+// Values can only go DOWN. Critical/serious are asserted to be zero separately.
// ---------------------------------------------------------------------------
-// Frozen from the first real nightly measurement (run 27852779527, REQUIRE_AXE=1,
-// wcag2a/2aa/21a/21aa). Each value is the actual `axeViolationCount` for that page —
-// existing violations are grandfathered; a NEW violation (count grows) fails the gate.
-// Lower a value (and re-run) whenever a violation is fixed.
+// Phase 9 (2026-09-14): /login, /dashboard and /dashboard/providers measured 0 violations
+// of any impact at 375/768/900/1024/1280/1440px (were 1 / 4 / 3). /dashboard/settings was
+// not re-measured in that run and keeps its previous frozen value.
const VIOLATION_BASELINES: Record = {
- "/login": 1,
- "/dashboard": 4,
- "/dashboard/providers": 3,
+ "/login": 0,
+ "/dashboard": 0,
+ "/dashboard/providers": 0,
"/dashboard/settings": 5,
};
+const BLOCKING_IMPACTS = new Set(["critical", "serious"]);
+const RATCHET_WIDTH = VIEWPORTS.desktop.width;
+const A11Y_VIEWPORTS = [
+ VIEWPORTS.tablet,
+ VIEWPORTS.smallLaptop,
+ VIEWPORTS.laptop,
+ VIEWPORTS.desktop,
+ VIEWPORTS.wide,
+];
+// Each width reloads the page and runs a full axe pass.
+const WIDTH_SWEEP_TIMEOUT_MS = 600_000;
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-type AxeViolation = {
- id: string;
- description: string;
- impact: string | null;
- nodes: unknown[];
-};
+function skipUnlessAxeRequired() {
+ // Nightly-only: the real axe analysis runs in the nightly job (REQUIRE_AXE=1), NOT in
+ // the per-PR e2e shards — a11y.spec.ts is matched by the per-PR `tests/e2e/*.spec.ts`
+ // glob, so without this gate installing the package would flip axe on for every PR.
+ test.skip(
+ !AxeBuilder || process.env.REQUIRE_AXE !== "1",
+ AxeBuilder
+ ? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
+ : "@axe-core/playwright not installed"
+ );
+}
-async function runAxe(
- page: Page,
- label: string
-): Promise {
+async function runAxe(page: Page, label: string): Promise {
if (!AxeBuilder) {
throw new Error("@axe-core/playwright not available");
}
- const results = await new AxeBuilder({ page })
- .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
- // Exclude third-party iframes / injected widgets that we don't control.
- .exclude("[data-axe-exclude]")
- .analyze();
+ const builder = new AxeBuilder({ page });
+ builder.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]);
+ // Exclude third-party iframes / injected widgets that we don't control.
+ builder.exclude("[data-axe-exclude]");
+ const results = await builder.analyze();
// Emit machine-parseable line for CI baseline tracking.
console.log(`axeViolationCount page=${label} count=${results.violations.length}`);
-
if (results.violations.length > 0) {
const summary = results.violations
- .map((v) => ` [${v.impact ?? "unknown"}] ${v.id}: ${v.description} (${(v.nodes as unknown[]).length} nodes)`)
+ .map(
+ (v) => ` [${v.impact ?? "unknown"}] ${v.id}: ${v.description} (${v.nodes.length} nodes)`
+ )
.join("\n");
console.log(`axeViolations page=${label}:\n${summary}`);
}
-
return results.violations;
}
+function describeBlocking(violations: AxeViolation[]): string[] {
+ return violations
+ .filter((v) => BLOCKING_IMPACTS.has(v.impact ?? ""))
+ .map((v) => {
+ const targets = v.nodes
+ .slice(0, 3)
+ .map((node) => JSON.stringify(node.target))
+ .join(", ");
+ return `[${v.impact}] ${v.id} (${v.nodes.length} nodes: ${targets})`;
+ });
+}
+
+async function openPage(page: Page, path: string) {
+ if (path === "/login") {
+ await page.goto(path);
+ await page.locator('input[type="password"]').first().waitFor({ state: "visible" });
+ return;
+ }
+ await gotoDashboardRoute(page, path);
+ await page.locator("main, #main-content").first().waitFor({ state: "visible" });
+}
+
+/**
+ * Audits `path` at every responsive width: zero critical/serious violations at each
+ * width, and the total count at the desktop width may not exceed the frozen baseline.
+ */
+async function auditAcrossWidths(page: Page, path: string) {
+ const blockingByWidth: string[] = [];
+ let ratchetCount: number | null = null;
+
+ for (const viewport of A11Y_VIEWPORTS) {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await openPage(page, path);
+ const violations = await runAxe(page, `${path}@${viewport.width}`);
+ for (const line of describeBlocking(violations)) {
+ blockingByWidth.push(`${viewport.width}px ${line}`);
+ }
+ if (viewport.width === RATCHET_WIDTH) ratchetCount = violations.length;
+ }
+
+ expect(blockingByWidth, `Critical/serious a11y violations on ${path}`).toEqual([]);
+ const baseline = VIOLATION_BASELINES[path] ?? 0;
+ expect(ratchetCount, `axe did not run at ${RATCHET_WIDTH}px on ${path}`).not.toBeNull();
+ expect(ratchetCount ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(baseline);
+}
+
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
-test.describe("A11y — Dashboard key surfaces (@axe-core, nightly advisory)", () => {
+test.describe("A11y — Dashboard key surfaces (@axe-core, nightly)", () => {
test.beforeAll(() => {
if (!AxeBuilder) {
- // Log once; individual tests will call test.skip().
console.log(
"[a11y.spec.ts] SKIP: @axe-core/playwright is not installed.\n" +
"Install with: npm install --save-dev @axe-core/playwright"
@@ -128,119 +179,18 @@ test.describe("A11y — Dashboard key surfaces (@axe-core, nightly advisory)", (
}
});
- // -------------------------------------------------------------------------
- // /login — public auth gate
- // -------------------------------------------------------------------------
- test("/login — axe wcag2a/wcag2aa violations must not exceed baseline", async ({ page }) => {
- if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
- // Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
- // (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
- // per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
- // would silently flip axe on for every PR (and fail at baseline 0).
- test.skip(
- true,
- AxeBuilder
- ? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
- : "@axe-core/playwright not installed"
- );
- return;
- }
-
- await page.goto("/login");
- await page.locator("body").waitFor({ state: "visible" });
-
- const violations = await runAxe(page, "/login");
- const baseline = VIOLATION_BASELINES["/login"] ?? 0;
-
- expect(violations.length).toBeLessThanOrEqual(
- baseline,
- `New a11y violations introduced on /login. ` +
- `Expected ≤${baseline}, got ${violations.length}. ` +
- `Run axe locally and update VIOLATION_BASELINES["/login"] if the new count is intentional.`
- );
- });
-
- // -------------------------------------------------------------------------
- // /dashboard — main overview
- // -------------------------------------------------------------------------
- test("/dashboard — axe wcag2a/wcag2aa violations must not exceed baseline", async ({ page }) => {
- if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
- // Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
- // (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
- // per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
- // would silently flip axe on for every PR (and fail at baseline 0).
- test.skip(
- true,
- AxeBuilder
- ? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
- : "@axe-core/playwright not installed"
- );
- return;
- }
-
- await gotoDashboardRoute(page, "/dashboard");
-
- const violations = await runAxe(page, "/dashboard");
- const baseline = VIOLATION_BASELINES["/dashboard"] ?? 0;
-
- expect(violations.length).toBeLessThanOrEqual(
- baseline,
- `New a11y violations introduced on /dashboard. ` +
- `Expected ≤${baseline}, got ${violations.length}.`
- );
- });
-
- // -------------------------------------------------------------------------
- // /dashboard/providers — provider management (most complex UI surface)
- // -------------------------------------------------------------------------
- test("/dashboard/providers — axe wcag2a/wcag2aa violations must not exceed baseline", async ({
- page,
- }) => {
- if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
- // Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
- // (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
- // per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
- // would silently flip axe on for every PR (and fail at baseline 0).
- test.skip(
- true,
- AxeBuilder
- ? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
- : "@axe-core/playwright not installed"
- );
- return;
- }
-
- await gotoDashboardRoute(page, "/dashboard/providers");
-
- const violations = await runAxe(page, "/dashboard/providers");
- const baseline = VIOLATION_BASELINES["/dashboard/providers"] ?? 0;
-
- expect(violations.length).toBeLessThanOrEqual(
- baseline,
- `New a11y violations introduced on /dashboard/providers. ` +
- `Expected ≤${baseline}, got ${violations.length}.`
- );
- });
+ for (const path of ["/login", "/dashboard", "/dashboard/providers"]) {
+ test(`${path} — zero critical/serious violations at 768–1440px and total within baseline`, async ({
+ page,
+ }) => {
+ skipUnlessAxeRequired();
+ test.setTimeout(WIDTH_SWEEP_TIMEOUT_MS);
+ await auditAcrossWidths(page, path);
+ });
+ }
- // -------------------------------------------------------------------------
- // /dashboard/settings — settings area
- // -------------------------------------------------------------------------
- test("/dashboard/settings — axe wcag2a/wcag2aa violations must not exceed baseline", async ({
- page,
- }) => {
- if (!AxeBuilder || process.env.REQUIRE_AXE !== "1") {
- // Nightly-only: the real axe analysis (~10–20 s/page) runs in the nightly job
- // (REQUIRE_AXE=1), NOT in the per-PR e2e shards — a11y.spec.ts is matched by the
- // per-PR `tests/e2e/*.spec.ts` glob, so without this gate installing the package
- // would silently flip axe on for every PR (and fail at baseline 0).
- test.skip(
- true,
- AxeBuilder
- ? "axe analysis runs in the nightly job only (set REQUIRE_AXE=1)"
- : "@axe-core/playwright not installed"
- );
- return;
- }
+ test("/dashboard/settings — axe violations must not exceed baseline", async ({ page }) => {
+ skipUnlessAxeRequired();
// The settings route redirects to /dashboard/settings/general; follow it.
await gotoDashboardRoute(page, "/dashboard/settings");
@@ -248,11 +198,7 @@ test.describe("A11y — Dashboard key surfaces (@axe-core, nightly advisory)", (
const violations = await runAxe(page, "/dashboard/settings");
const baseline = VIOLATION_BASELINES["/dashboard/settings"] ?? 0;
- expect(violations.length).toBeLessThanOrEqual(
- baseline,
- `New a11y violations introduced on /dashboard/settings. ` +
- `Expected ≤${baseline}, got ${violations.length}.`
- );
+ expect(violations.length).toBeLessThanOrEqual(baseline);
});
// -------------------------------------------------------------------------
@@ -262,20 +208,15 @@ test.describe("A11y — Dashboard key surfaces (@axe-core, nightly advisory)", (
// -------------------------------------------------------------------------
test("axe package availability is declared (meta-test)", async () => {
if (AxeBuilder !== null) {
- // Package is present — nothing to check.
expect(AxeBuilder).toBeTruthy();
- } else {
- // Package absent — this is acceptable in PR CI; fatal in the NIGHTLY job.
- // In the nightly job, set REQUIRE_AXE=1 and the check below will fail.
- const requireAxe = process.env.REQUIRE_AXE === "1";
- if (requireAxe) {
- throw new Error(
- "REQUIRE_AXE=1 but @axe-core/playwright is not installed. " +
- "Add it as a devDependency and run npm install."
- );
- }
- // Advisory skip in PR context.
- test.skip(true, "@axe-core/playwright not installed — advisory skip in PR context");
+ return;
+ }
+ if (process.env.REQUIRE_AXE === "1") {
+ throw new Error(
+ "REQUIRE_AXE=1 but @axe-core/playwright is not installed. " +
+ "Add it as a devDependency and run npm install."
+ );
}
+ test.skip(true, "@axe-core/playwright not installed — advisory skip in PR context");
});
});
diff --git a/tests/e2e/keyboard-a11y.spec.ts b/tests/e2e/keyboard-a11y.spec.ts
new file mode 100644
index 00000000000..efd9d287971
--- /dev/null
+++ b/tests/e2e/keyboard-a11y.spec.ts
@@ -0,0 +1,109 @@
+/**
+ * tests/e2e/keyboard-a11y.spec.ts
+ *
+ * Keyboard operability of the main flows (Fase 9): sign-in without a mouse, a visible
+ * focus indicator, the skip link as the first dashboard tab stop, and the sidebar
+ * section headers operable with Enter/Space.
+ */
+
+import { test, expect, type Locator, type Page } from "@playwright/test";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
+
+const E2E_PASSWORD =
+ process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
+
+async function focusRingOf(locator: Locator): Promise {
+ return locator.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return `${style.outlineStyle}|${style.boxShadow}`;
+ });
+}
+
+function hasVisibleFocusRing(ring: string): boolean {
+ const [outlineStyle, boxShadow] = ring.split("|");
+ return (
+ (outlineStyle !== "none" && outlineStyle !== "") || (boxShadow !== "none" && boxShadow !== "")
+ );
+}
+
+async function pressTabUntil(page: Page, target: Locator, maxSteps = 15) {
+ for (let step = 0; step < maxSteps; step += 1) {
+ if (await target.evaluate((element) => element === document.activeElement)) return;
+ await page.keyboard.press("Tab");
+ }
+ await expect(target).toBeFocused();
+}
+
+test.describe("Keyboard operability", () => {
+ test("login can be completed with the keyboard and shows a visible focus ring", async ({
+ page,
+ }) => {
+ await page.goto("/login");
+ const password = page.getByLabel(/password/i).first();
+ const hasPasswordForm = await password
+ .waitFor({ state: "visible", timeout: 60_000 })
+ .then(() => true)
+ .catch(() => false);
+ test.skip(!hasPasswordForm, "Instance is not configured with password login.");
+
+ await password.focus();
+ await page.keyboard.type(E2E_PASSWORD);
+
+ const submit = page.locator("form").getByRole("button").first();
+ await pressTabUntil(page, submit);
+ expect(hasVisibleFocusRing(await focusRingOf(submit))).toBe(true);
+
+ await Promise.all([page.waitForURL(/\/dashboard(\/.*)?$/), page.keyboard.press("Enter")]);
+ });
+
+ test("the skip link is the first tab stop on the dashboard", async ({ page }) => {
+ // Desktop width so the persistent sidebar (which owns the skip link) is displayed.
+ await page.setViewportSize({ width: 1280, height: 800 });
+ // `/home` directly: `/dashboard` redirects there client-side, racing the helper's auth probe.
+ await gotoDashboardRoute(page, "/home");
+ // Load the landing page directly: `/dashboard` redirects client-side to `/home`, which
+ // would destroy the page mid-test. A fresh load also resets the sequential-focus
+ // starting point to the document start (clicking first would move it past the link).
+ await page.goto("/home", { waitUntil: "load" });
+ await page.waitForURL(/\/home(\?.*)?$/);
+ await page.locator("#main-content").waitFor({ state: "visible" });
+
+ await page.keyboard.press("Tab");
+ const skipLink = page.locator('a[href="#main-content"]').first();
+ const focused = await page.evaluate(() => {
+ const el = document.activeElement;
+ return el ? `${el.tagName.toLowerCase()} ${el.getAttribute("href") ?? ""}`.trim() : "none";
+ });
+ await expect(skipLink, `first Tab focused: ${focused}`).toBeFocused();
+ const box = await skipLink.boundingBox();
+ expect(box?.width ?? 0).toBeGreaterThan(1);
+ });
+
+ test("sidebar section headers toggle with Enter and Space", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
+ // `/home` directly: `/dashboard` redirects there client-side, racing the helper's auth probe.
+ await gotoDashboardRoute(page, "/home");
+
+ const header = page.locator("nav button[aria-expanded]").first();
+ await expect(header).toBeVisible();
+
+ // The Sidebar restores the persisted expansion state once after hydration, and nothing
+ // in the DOM signals when that has happened: a key press that lands earlier is
+ // overwritten. Read the state right before each press and retry until the flip sticks.
+ const pressFlips = async (key: "Enter" | "Space") => {
+ await expect(async () => {
+ const before = await header.getAttribute("aria-expanded");
+ await header.focus();
+ await page.keyboard.press(key);
+ await expect(header).toHaveAttribute(
+ "aria-expanded",
+ before === "true" ? "false" : "true",
+ { timeout: 2_000 }
+ );
+ }).toPass({ timeout: 30_000 });
+ };
+
+ await pressFlips("Enter");
+ await pressFlips("Space");
+ });
+});
diff --git a/tests/e2e/responsive.spec.ts b/tests/e2e/responsive.spec.ts
index 8a2a8502313..e1c80e3631f 100644
--- a/tests/e2e/responsive.spec.ts
+++ b/tests/e2e/responsive.spec.ts
@@ -1,20 +1,62 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
-import { A11Y_CHECKS, generateTestMatrix } from "./responsiveSpecs";
+import { gotoDashboardRoute } from "./helpers/dashboardAuth";
+import { A11Y_CHECKS, generateTestMatrix, PAGES } from "./responsiveSpecs";
const executableChecks = A11Y_CHECKS.filter((check) => check.kind === "evaluate");
+async function waitForSettledPage(page: Page, requiresAuth: boolean) {
+ await page.waitForLoadState("load");
+ await page.locator(requiresAuth ? "#main-content" : "body").waitFor({ state: "visible" });
+}
+
+function isContextDestroyed(error: unknown): boolean {
+ return String(error).includes("Execution context was destroyed");
+}
+
+async function openResponsivePage(page: Page, pageSpec: (typeof PAGES)[number]) {
+ if (!pageSpec.requiresAuth) {
+ await page.goto(pageSpec.path);
+ } else {
+ try {
+ await gotoDashboardRoute(page, pageSpec.path);
+ } catch (error) {
+ // The `/dashboard` -> `/home` client redirect can land while the shared helper is
+ // evaluating its auth probe. The session is already established at that point.
+ if (!isContextDestroyed(error)) throw error;
+ }
+ }
+ await waitForSettledPage(page, pageSpec.requiresAuth);
+}
+
+/**
+ * `/dashboard` redirects client-side (to `/home`) after load, which can destroy the
+ * execution context mid-evaluate. Retry once after the new page settles.
+ */
+async function evaluateOnSettledPage(
+ page: Page,
+ pageSpec: (typeof PAGES)[number],
+ fn: () => T
+): Promise {
+ try {
+ return await page.evaluate(fn);
+ } catch (error) {
+ if (!isContextDestroyed(error)) throw error;
+ await waitForSettledPage(page, pageSpec.requiresAuth);
+ return await page.evaluate(fn);
+ }
+}
+
test.describe("Responsive matrix", () => {
for (const { viewport, page: pageSpec, testName } of generateTestMatrix()) {
- test(`${testName} has no basic responsive regressions`, async ({ page }) => {
- test.skip(pageSpec.requiresAuth, "Requires authenticated session before responsive checks.");
-
+ test(`${testName} has no horizontal overflow`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
- await page.goto(pageSpec.path);
+ await openResponsivePage(page, pageSpec);
for (const check of executableChecks) {
- const passed = await page.evaluate(check.evaluate);
- expect(passed, check.criteria).toBe(true);
+ if (!check.evaluate) continue;
+ const offenders = await evaluateOnSettledPage(page, pageSpec, check.evaluate);
+ expect(offenders, check.criteria).toEqual([]);
}
});
}
diff --git a/tests/e2e/responsiveSpecs.ts b/tests/e2e/responsiveSpecs.ts
index 38adc7926b9..9c9ba4b3cc6 100644
--- a/tests/e2e/responsiveSpecs.ts
+++ b/tests/e2e/responsiveSpecs.ts
@@ -16,7 +16,12 @@
export const VIEWPORTS = {
mobile: { width: 375, height: 812, label: "Mobile (375px)" },
tablet: { width: 768, height: 1024, label: "Tablet (768px)" },
+ // 900/1024 sit below the `lg` (1024px) sidebar breakpoint and above `md`: the band
+ // where the header and wizard cards used to overflow (audit C-09).
+ smallLaptop: { width: 900, height: 800, label: "Small laptop (900px)" },
+ laptop: { width: 1024, height: 768, label: "Laptop (1024px)" },
desktop: { width: 1280, height: 800, label: "Desktop (1280px)" },
+ wide: { width: 1440, height: 900, label: "Wide (1440px)" },
};
/**
@@ -36,8 +41,35 @@ export const A11Y_CHECKS = [
{
id: "overflow-x",
kind: "evaluate",
- evaluate: () => document.body.scrollWidth <= document.documentElement.clientWidth,
- criteria: "No horizontal overflow (scrollWidth <= clientWidth)",
+ // Returns the offending elements ([] = pass). The dashboard shell clips horizontal
+ // overflow (`overflow-hidden` / `overflow-x-hidden`), so `body.scrollWidth` alone never
+ // grows there: content is cut off instead. Any visible, non-fixed element crossing the
+ // viewport edge counts unless it sits inside a real horizontal scroller.
+ evaluate: () => {
+ const viewportWidth = document.documentElement.clientWidth;
+ const offenders: string[] = [];
+ if (document.documentElement.scrollWidth > viewportWidth) {
+ offenders.push(`document scrollWidth=${document.documentElement.scrollWidth}`);
+ }
+ const insideHorizontalScroller = (element: Element) => {
+ for (let node = element.parentElement; node; node = node.parentElement) {
+ if (/(auto|scroll)/.test(getComputedStyle(node).overflowX)) return true;
+ }
+ return false;
+ };
+ for (const element of document.querySelectorAll("body *")) {
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ const crossesEdge =
+ rect.width > 0 && rect.left < viewportWidth && rect.right > viewportWidth + 1;
+ if (!crossesEdge || style.position === "fixed" || style.visibility === "hidden") continue;
+ if (insideHorizontalScroller(element)) continue;
+ offenders.push(`${element.tagName.toLowerCase()} right=${Math.round(rect.right)}`);
+ if (offenders.length >= 5) break;
+ }
+ return offenders;
+ },
+ criteria: "No element crosses the right viewport edge outside a horizontal scroller",
description: "No horizontal overflow",
},
{
diff --git a/tests/unit/batch-b-final.test.ts b/tests/unit/batch-b-final.test.ts
index 91c64dd5b6b..ca7a1bdd382 100644
--- a/tests/unit/batch-b-final.test.ts
+++ b/tests/unit/batch-b-final.test.ts
@@ -305,6 +305,11 @@ describe("responsiveSpecs", () => {
assert.equal(VIEWPORTS.tablet.width, 768);
});
+ it("should cover the intermediate widths where the layout used to overflow (C-09)", () => {
+ const widths = Object.values(VIEWPORTS).map((viewport) => viewport.width);
+ assert.deepEqual(widths, [375, 768, 900, 1024, 1280, 1440]);
+ });
+
it("should define pages to test", () => {
assert.ok(PAGES.length >= 4);
assert.ok(PAGES.some((p) => p.path === "/login"));
@@ -313,7 +318,7 @@ describe("responsiveSpecs", () => {
it("should generate test matrix", () => {
const matrix = generateTestMatrix();
- assert.equal(matrix.length, 3 * PAGES.length); // 3 viewports × n pages
+ assert.equal(matrix.length, 6 * PAGES.length); // 6 viewports × n pages
assert.ok(matrix[0].testName);
assert.ok(matrix[0].viewport);
assert.ok(matrix[0].page);
@@ -321,7 +326,7 @@ describe("responsiveSpecs", () => {
it("should get viewport names", () => {
const names = getViewportNames();
- assert.deepEqual(names, ["mobile", "tablet", "desktop"]);
+ assert.deepEqual(names, ["mobile", "tablet", "smallLaptop", "laptop", "desktop", "wide"]);
});
it("should separate executable and manual accessibility checks", () => {
diff --git a/tests/unit/shared/components/Checkbox.a11y.test.tsx b/tests/unit/shared/components/Checkbox.a11y.test.tsx
new file mode 100644
index 00000000000..f82c941b636
--- /dev/null
+++ b/tests/unit/shared/components/Checkbox.a11y.test.tsx
@@ -0,0 +1,65 @@
+// @vitest-environment jsdom
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import Checkbox from "@/shared/components/Checkbox";
+
+function ControlledCheckbox() {
+ const [checked, setChecked] = useState(false);
+ return (
+ setChecked(event.target.checked)}
+ />
+ );
+}
+
+describe("Checkbox a11y contract", () => {
+ it("explicitly associates the visible label with the native checkbox when no id is passed", () => {
+ render( );
+
+ const box = screen.getByRole("checkbox", { name: "Enable cache" });
+ const label = screen.getByText("Enable cache").closest("label");
+
+ expect(box.id).not.toBe("");
+ expect(label?.htmlFor).toBe(box.id);
+ });
+
+ it("keeps a caller-provided id for the label association", () => {
+ render( );
+
+ const box = screen.getByRole("checkbox", { name: "Enable cache" });
+ expect(box.id).toBe("cache-toggle");
+ expect(screen.getByText("Enable cache").closest("label")?.htmlFor).toBe("cache-toggle");
+ });
+
+ it("uses aria-label as the accessible name when rendered without a visible label", () => {
+ render( );
+
+ expect(screen.getByRole("checkbox", { name: "Select row 1" })).toBeInTheDocument();
+ });
+
+ it("is reachable with Tab and toggles its checked state with Space", async () => {
+ const user = userEvent.setup();
+ render( );
+ const box = screen.getByRole("checkbox", { name: "Enable cache" });
+
+ await user.tab();
+ expect(box).toHaveFocus();
+ expect(box).not.toBeChecked();
+
+ await user.keyboard(" ");
+ expect(box).toBeChecked();
+ });
+
+ it("toggles when the label text is clicked", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText("Enable cache"));
+ expect(screen.getByRole("checkbox", { name: "Enable cache" })).toBeChecked();
+ });
+});
diff --git a/tests/unit/shared/components/DashboardLayout.drawer.a11y.test.tsx b/tests/unit/shared/components/DashboardLayout.drawer.a11y.test.tsx
new file mode 100644
index 00000000000..1a98a477289
--- /dev/null
+++ b/tests/unit/shared/components/DashboardLayout.drawer.a11y.test.tsx
@@ -0,0 +1,128 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+// The layout's own drawer contract is under test; its children are replaced by minimal
+// stand-ins (the mobile Sidebar instance is the one rendered with an onClose prop).
+vi.mock("@/shared/components/Sidebar", () => ({
+ default: ({ onClose }: { onClose?: () => void }) => (
+
+
+
+ Close menu
+
+
+ ),
+}));
+vi.mock("@/shared/components/Header", () => ({
+ default: ({ onMenuClick }: { onMenuClick: () => void }) => (
+
+ Open menu
+
+ ),
+}));
+vi.mock("@/shared/components/NotificationToast", () => ({ default: () => null }));
+vi.mock("@/shared/components/CommandPalette", () => ({ default: () => null }));
+vi.mock("@/shared/components/NavigationProgress", () => ({ default: () => null }));
+vi.mock("@/shared/components/MaintenanceBanner", () => ({ default: () => null }));
+vi.mock("@/shared/components/Breadcrumbs", () => ({ default: () => null }));
+vi.mock("@/shared/hooks/useElectron", () => ({ useIsElectron: () => false }));
+vi.mock("@/shared/utils/dashboardCsrf", () => ({
+ installDashboardCsrfFetch: () => () => {},
+ prefetchDashboardCsrfToken: async () => {},
+}));
+vi.mock("@/shared/utils/basePathFetch", () => ({
+ installBasePathFetch: () => () => {},
+}));
+
+const { default: DashboardLayout } = await import("@/shared/components/layouts/DashboardLayout");
+
+function mobileDrawer(): HTMLElement {
+ const nav = document.querySelector('[data-sidebar-instance="mobile"]');
+ if (!nav?.parentElement) throw new Error("mobile sidebar drawer not rendered");
+ return nav.parentElement;
+}
+
+async function renderLayout() {
+ const user = userEvent.setup();
+ await act(async () => {
+ render(
+
+ Page body
+
+ );
+ });
+ return { user, menuButton: screen.getByRole("button", { name: "Open menu" }) };
+}
+
+describe("DashboardLayout mobile drawer a11y contract", () => {
+ it("keeps the closed off-canvas drawer out of the tab order and accessibility tree", async () => {
+ await renderLayout();
+
+ const drawer = mobileDrawer();
+ expect(drawer).toHaveAttribute("inert");
+ expect(drawer).not.toHaveAttribute("role", "dialog");
+ });
+
+ it("opens as a named modal dialog and moves focus inside", async () => {
+ const { user, menuButton } = await renderLayout();
+
+ await user.click(menuButton);
+
+ const dialog = screen.getByRole("dialog", { name: "mainNavigation" });
+ expect(dialog).toBe(mobileDrawer());
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).not.toHaveAttribute("inert");
+ await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
+ });
+
+ it("traps Tab inside the open drawer", async () => {
+ const { user, menuButton } = await renderLayout();
+ await user.click(menuButton);
+ const dialog = screen.getByRole("dialog", { name: "mainNavigation" });
+ const filter = screen
+ .getAllByRole("textbox", { name: "Filter navigation" })
+ .find((el) => dialog.contains(el));
+ const close = screen
+ .getAllByRole("button", { name: "Close menu" })
+ .find((el) => dialog.contains(el));
+ // Let the deferred initial focus land first so it cannot race the manual focus below.
+ await waitFor(() => expect(filter).toHaveFocus());
+
+ close!.focus();
+ await user.tab();
+ expect(filter).toHaveFocus();
+ await user.tab({ shift: true });
+ expect(close).toHaveFocus();
+ });
+
+ it("closes on Escape and returns focus to the menu button", async () => {
+ const { user, menuButton } = await renderLayout();
+ await user.click(menuButton);
+ expect(screen.getByRole("dialog", { name: "mainNavigation" })).toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(mobileDrawer()).toHaveAttribute("inert");
+ expect(menuButton).toHaveFocus();
+ });
+
+ it("still closes when the backdrop is clicked", async () => {
+ const { user, menuButton } = await renderLayout();
+ await user.click(menuButton);
+
+ const backdrop = document.querySelector(".fixed.inset-0.bg-black\\/20");
+ expect(backdrop).not.toBeNull();
+ expect(backdrop).toHaveAttribute("aria-hidden", "true");
+ await user.click(backdrop!);
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/shared/components/Modal.a11y.test.tsx b/tests/unit/shared/components/Modal.a11y.test.tsx
new file mode 100644
index 00000000000..e6de684de0e
--- /dev/null
+++ b/tests/unit/shared/components/Modal.a11y.test.tsx
@@ -0,0 +1,78 @@
+// @vitest-environment jsdom
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import Modal from "@/shared/components/Modal";
+
+function renderModal(onClose = vi.fn()) {
+ render(
+
+ Save
+
+ Delete
+
+ >
+ }
+ >
+
+
+ );
+ return onClose;
+}
+
+describe("Modal a11y contract", () => {
+ it("is a modal dialog named by its title", () => {
+ renderModal();
+ const dialog = screen.getByRole("dialog", { name: "Edit connection" });
+
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ });
+
+ it("gives the icon-only close button an accessible name and hides its glyph", () => {
+ renderModal();
+ const close = screen.getByRole("button", { name: "Close" });
+
+ expect(close.querySelector(".material-symbols-outlined")).toHaveAttribute(
+ "aria-hidden",
+ "true"
+ );
+ });
+
+ it("moves focus into the dialog when it opens", async () => {
+ renderModal();
+ const dialog = screen.getByRole("dialog");
+
+ await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
+ });
+
+ it("wraps Tab from the last enabled control back to the first, skipping disabled ones", async () => {
+ const user = userEvent.setup();
+ renderModal();
+ const save = screen.getByRole("button", { name: "Save" });
+ const close = screen.getByRole("button", { name: "Close" });
+ // Let the deferred initial focus land first so it cannot race the manual focus below.
+ await waitFor(() => expect(close).toHaveFocus());
+
+ save.focus();
+ await user.tab();
+ expect(close).toHaveFocus();
+
+ await user.tab({ shift: true });
+ expect(save).toHaveFocus();
+ });
+
+ it("closes on Escape", async () => {
+ const user = userEvent.setup();
+ const onClose = renderModal();
+
+ await user.keyboard("{Escape}");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/unit/shared/components/ProviderTestSlideOver.a11y.test.tsx b/tests/unit/shared/components/ProviderTestSlideOver.a11y.test.tsx
new file mode 100644
index 00000000000..531f8d107ea
--- /dev/null
+++ b/tests/unit/shared/components/ProviderTestSlideOver.a11y.test.tsx
@@ -0,0 +1,124 @@
+// @vitest-environment jsdom
+import React, { useState } from "react";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard", () => ({
+ LlmChatCard: () => chat surface
,
+}));
+vi.mock("@/app/(dashboard)/dashboard/providers/hooks/useApiKey", () => ({
+ useApiKey: () => ({ keys: [{ id: "k1", key: "key-one", name: "Primary" }] }),
+}));
+vi.mock("@/app/(dashboard)/dashboard/providers/hooks/useProviderModels", () => ({
+ useProviderModels: () => ({ models: [{ id: "model-a" }, { id: "model-b" }] }),
+}));
+vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null }));
+
+const { default: ProviderTestSlideOver } =
+ await import("@/shared/components/ProviderTestSlideOver");
+
+function Harness({ initialTab }: { initialTab?: "test" | "logs" }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>
+ Open test drawer
+
+ setOpen(false)}
+ providerId="acme"
+ provider={{ name: "Acme", apiType: "openai" }}
+ initialTab={initialTab}
+ />
+ >
+ );
+}
+
+async function openDrawer(initialTab?: "test" | "logs") {
+ const user = userEvent.setup();
+ render( );
+ const opener = screen.getByRole("button", { name: "Open test drawer" });
+ await user.click(opener);
+ return { user, opener, dialog: screen.getByRole("dialog", { name: "Test Acme" }) };
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("ProviderTestSlideOver a11y contract", () => {
+ it("is a modal dialog with an accessible name", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => {}))
+ );
+ const { dialog } = await openDrawer();
+
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ });
+
+ it("names its model and key selects", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => {}))
+ );
+ const { dialog } = await openDrawer();
+
+ expect(within(dialog).getByRole("combobox", { name: "Model" })).toBeInTheDocument();
+ expect(within(dialog).getByRole("combobox", { name: "Key" })).toBeInTheDocument();
+ });
+
+ it("moves focus inside, traps Tab, closes on Escape and restores focus to the opener", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => {}))
+ );
+ const { user, opener, dialog } = await openDrawer();
+
+ const close = within(dialog).getByRole("button", { name: "Close" });
+ await waitFor(() => expect(close).toHaveFocus());
+
+ await user.tab({ shift: true });
+ expect(dialog.contains(document.activeElement)).toBe(true);
+ await user.tab();
+ expect(close).toHaveFocus();
+
+ await user.keyboard("{Escape}");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(opener).toHaveFocus();
+ });
+
+ it("hides decorative glyphs from assistive technology", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => new Promise(() => {}))
+ );
+ const { dialog } = await openDrawer();
+
+ for (const glyph of dialog.querySelectorAll(".material-symbols-outlined")) {
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
+ }
+ });
+
+ it("announces the logs loading state and a load failure", async () => {
+ let failRequest: (error: Error) => void = () => {};
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ () =>
+ new Promise((_resolve, reject) => {
+ failRequest = reject;
+ })
+ )
+ );
+ const { dialog } = await openDrawer("logs");
+
+ expect(within(dialog).getByRole("status")).toHaveTextContent("Loading logs…");
+
+ failRequest(new Error("HTTP 500"));
+ const alert = await within(dialog).findByRole("alert");
+ expect(alert).toHaveTextContent("Failed to load logs");
+ });
+});
diff --git a/tests/unit/shared/components/Sidebar.a11y.test.tsx b/tests/unit/shared/components/Sidebar.a11y.test.tsx
new file mode 100644
index 00000000000..d72b1e99d1e
--- /dev/null
+++ b/tests/unit/shared/components/Sidebar.a11y.test.tsx
@@ -0,0 +1,84 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Skip CloudSyncStatus (polls /api/sync/cloud and needs a router), as the search test does.
+process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => {
+ const translate = (key: string) => key;
+ translate.has = () => false;
+ return translate;
+ },
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/dashboard",
+}));
+
+const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), select, textarea";
+
+async function renderSidebar(props: { collapsed?: boolean } = {}) {
+ const { default: Sidebar } = await import("@/shared/components/Sidebar");
+ let view: ReturnType | undefined;
+ await act(async () => {
+ view = render( {}} {...props} />);
+ });
+ return view!;
+}
+
+beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }) as Response)
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetModules();
+});
+
+describe("Sidebar a11y contract", () => {
+ it("never places a focusable control inside an aria-hidden subtree", async () => {
+ const { container } = await renderSidebar();
+
+ const hiddenFocusables = [...container.querySelectorAll('[aria-hidden="true"]')].flatMap(
+ (hidden) => [...hidden.querySelectorAll(FOCUSABLE)]
+ );
+ expect(hiddenFocusables).toHaveLength(0);
+ expect(screen.getByRole("button", { name: "collapseSidebar" })).toBeInTheDocument();
+ });
+
+ it("renders collapsible section headers as real buttons without nested interactive content", async () => {
+ const { container } = await renderSidebar();
+
+ expect(container.querySelector('[role="button"] button')).toBeNull();
+ const nav = screen.getByRole("navigation", { name: "mainNavigation" });
+ const headers = within(nav)
+ .getAllByRole("button")
+ .filter((button) => button.hasAttribute("aria-expanded"));
+ expect(headers.length).toBeGreaterThan(0);
+ for (const header of headers) {
+ expect(header.tagName).toBe("BUTTON");
+ expect(header.querySelector("button")).toBeNull();
+ }
+ });
+
+ it("names icon-only navigation links and footer actions by label, not by glyph ligature", async () => {
+ const { container } = await renderSidebar({ collapsed: true });
+
+ for (const glyph of container.querySelectorAll("nav .material-symbols-outlined")) {
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
+ }
+ const nav = screen.getByRole("navigation", { name: "mainNavigation" });
+ for (const link of within(nav).getAllByRole("link")) {
+ expect(link).toHaveAccessibleName();
+ expect(link.getAttribute("aria-label")).toBeTruthy();
+ }
+ expect(screen.getByRole("button", { name: "restart" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "shutdown" })).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/shared/components/SidebarSectionHeader.a11y.test.tsx b/tests/unit/shared/components/SidebarSectionHeader.a11y.test.tsx
new file mode 100644
index 00000000000..c921e322fe9
--- /dev/null
+++ b/tests/unit/shared/components/SidebarSectionHeader.a11y.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+
+import SidebarSectionHeader from "@/shared/components/SidebarSectionHeader";
+
+function Harness() {
+ const [expanded, setExpanded] = useState(false);
+ const [pinned, setPinned] = useState(false);
+ return (
+ setExpanded((value) => !value)}
+ onTogglePin={() => setPinned((value) => !value)}
+ />
+ );
+}
+
+describe("SidebarSectionHeader a11y contract", () => {
+ it("renders the expand and pin controls as sibling buttons (no nested interactive content)", () => {
+ render( );
+ const toggle = screen.getByRole("button", { name: "Routing" });
+ const pin = screen.getByRole("button", { name: "Pin section open" });
+
+ expect(toggle.contains(pin)).toBe(false);
+ expect(pin.contains(toggle)).toBe(false);
+ expect(toggle.querySelector("button, [role='button']")).toBeNull();
+ });
+
+ it("exposes the expanded state and toggles it from the keyboard", async () => {
+ const user = userEvent.setup();
+ render( );
+ const toggle = screen.getByRole("button", { name: "Routing" });
+
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await user.tab();
+ expect(toggle).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
+ await user.keyboard(" ");
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("reaches the pin control with Tab and exposes its pressed state without toggling the section", async () => {
+ const user = userEvent.setup();
+ render( );
+ const toggle = screen.getByRole("button", { name: "Routing" });
+
+ await user.tab();
+ await user.tab();
+ const pin = screen.getByRole("button", { name: "Pin section open" });
+ expect(pin).toHaveFocus();
+ expect(pin).toHaveAttribute("aria-pressed", "false");
+
+ await user.keyboard("{Enter}");
+ expect(screen.getByRole("button", { name: "Unpin section" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ });
+
+ it("hides the decorative glyphs from assistive technology", () => {
+ const { container } = render( );
+
+ for (const glyph of container.querySelectorAll(".material-symbols-outlined")) {
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
+ }
+ });
+});
diff --git a/tests/unit/shared/components/Toggle.a11y.test.tsx b/tests/unit/shared/components/Toggle.a11y.test.tsx
new file mode 100644
index 00000000000..cad452e723a
--- /dev/null
+++ b/tests/unit/shared/components/Toggle.a11y.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import Toggle from "@/shared/components/Toggle";
+
+function ControlledToggle(props: { label?: string; description?: string; ariaLabel?: string }) {
+ const [checked, setChecked] = useState(false);
+ return ;
+}
+
+describe("Toggle a11y contract", () => {
+ it("exposes role=switch with aria-checked reflecting the state", async () => {
+ const user = userEvent.setup();
+ render( );
+ const toggle = screen.getByRole("switch", { name: "Auto retry" });
+
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ await user.click(toggle);
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("is keyboard operable with Space and Enter", async () => {
+ const user = userEvent.setup();
+ render( );
+ const toggle = screen.getByRole("switch", { name: "Auto retry" });
+
+ await user.tab();
+ expect(toggle).toHaveFocus();
+ await user.keyboard(" ");
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ await user.keyboard("{Enter}");
+ expect(toggle).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("names the switch from its visible label and announces the description", () => {
+ render( );
+ const toggle = screen.getByRole("switch", { name: "Auto retry" });
+
+ expect(toggle).toHaveAccessibleDescription("Retry failed requests once");
+ });
+
+ it("keeps an explicit ariaLabel as the name and still announces the description", () => {
+ render(
+
+ );
+ const toggle = screen.getByRole("switch", { name: "Enable provider Acme" });
+
+ expect(toggle).toHaveAccessibleDescription("Routes traffic to Acme");
+ });
+
+ it("falls back to the description as the name when there is no label", () => {
+ render( );
+
+ expect(screen.getByRole("switch", { name: "Compact mode" })).not.toHaveAttribute(
+ "aria-describedby"
+ );
+ });
+
+ it("does not change state while disabled", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render( );
+ const toggle = screen.getByRole("switch", { name: "Locked" });
+
+ expect(toggle).toBeDisabled();
+ await user.click(toggle);
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/ui/login-page-a11y.test.tsx b/tests/unit/ui/login-page-a11y.test.tsx
new file mode 100644
index 00000000000..463fa9ed00c
--- /dev/null
+++ b/tests/unit/ui/login-page-a11y.test.tsx
@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
+}));
+
+const { default: LoginPage } = await import("@/app/login/page");
+
+type FetchHandler = (url: string) => Promise;
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ } as Response;
+}
+
+const REQUIRE_LOGIN = {
+ requireLogin: true,
+ hasPassword: true,
+ setupComplete: true,
+ oidcEnabled: false,
+ oidcDisablePasswordLogin: false,
+};
+
+function stubFetch(handler: FetchHandler) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((input: RequestInfo | URL) => handler(String(input)))
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("Login page a11y contract", () => {
+ it("announces the initial loading state", () => {
+ stubFetch(() => new Promise(() => {}));
+ render( );
+
+ expect(screen.getByRole("status")).toHaveTextContent("Loading");
+ });
+
+ it("associates the visible password label with the password input", async () => {
+ stubFetch(async () => jsonResponse(REQUIRE_LOGIN));
+ render( );
+
+ const input = await screen.findByLabelText("Password");
+ expect(input).toHaveAttribute("type", "password");
+ });
+
+ it("announces a failed sign-in through an alert", async () => {
+ const user = userEvent.setup();
+ stubFetch(async (url) =>
+ url.includes("/api/auth/login")
+ ? jsonResponse({ error: "Invalid password" }, 401)
+ : jsonResponse(REQUIRE_LOGIN)
+ );
+ render( );
+
+ const input = await screen.findByLabelText("Password");
+ await user.type(input, "wrong-password{Enter}");
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Invalid password");
+ });
+
+ it("hides decorative glyphs from assistive technology", async () => {
+ stubFetch(async () => jsonResponse(REQUIRE_LOGIN));
+ const { container } = render( );
+ await screen.findByLabelText("Password");
+
+ for (const glyph of container.querySelectorAll(".material-symbols-outlined")) {
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
+ }
+ });
+});
diff --git a/tests/unit/ui/theme-store-default-primary.test.tsx b/tests/unit/ui/theme-store-default-primary.test.tsx
new file mode 100644
index 00000000000..b9318372930
--- /dev/null
+++ b/tests/unit/ui/theme-store-default-primary.test.tsx
@@ -0,0 +1,63 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from "vitest";
+
+const { default: useThemeStore } = await import("@/store/themeStore");
+
+const root = () => document.documentElement;
+const inlinePrimary = () => root().style.getPropertyValue("--color-primary");
+const inlineHover = () => root().style.getPropertyValue("--color-primary-hover");
+
+afterEach(() => {
+ root().style.removeProperty("--color-primary");
+ root().style.removeProperty("--color-primary-hover");
+});
+
+describe("themeStore primary color", () => {
+ it("leaves the default coral preset to the theme-aware CSS tokens (no inline override)", () => {
+ root().style.setProperty("--color-primary", "#e54d5e");
+ root().style.setProperty("--color-primary-hover", "#c93d4e");
+
+ useThemeStore.getState().setColorTheme("coral");
+
+ expect(inlinePrimary()).toBe("");
+ expect(inlineHover()).toBe("");
+ });
+
+ it("still applies a non-default preset inline, with a derived hover shade", () => {
+ useThemeStore.getState().setColorTheme("blue");
+
+ expect(inlinePrimary()).toBe("#3b82f6");
+ expect(inlineHover()).not.toBe("");
+ expect(inlineHover()).not.toBe("#3b82f6");
+ });
+
+ it("still applies a custom color inline", () => {
+ useThemeStore.getState().setCustomColorTheme("#123456");
+
+ expect(inlinePrimary()).toBe("#123456");
+ });
+
+ it("clears a previous preset override when switching back to the default", () => {
+ useThemeStore.getState().setColorTheme("green");
+ expect(inlinePrimary()).toBe("#22c55e");
+
+ useThemeStore.getState().setColorTheme("coral");
+
+ expect(inlinePrimary()).toBe("");
+ });
+
+ it("treats an unknown preset id like the default preset", () => {
+ useThemeStore.getState().setColorTheme("not-a-preset");
+
+ expect(inlinePrimary()).toBe("");
+ });
+
+ it("initTheme removes a stale inline coral persisted by the previous behavior", () => {
+ useThemeStore.setState({ colorTheme: "coral" });
+ root().style.setProperty("--color-primary", "#e54d5e");
+
+ useThemeStore.getState().initTheme();
+
+ expect(inlinePrimary()).toBe("");
+ });
+});