diff --git a/config/quality/e2e-timings.json b/config/quality/e2e-timings.json index 673c0d2e3e1..ed18dc0d528 100644 --- a/config/quality/e2e-timings.json +++ b/config/quality/e2e-timings.json @@ -1,5 +1,5 @@ { - "_meta": "Relative weights for scripts/quality/balance-e2e-shards.mjs (LPT shard packing). Unitless \u2014 only ratios matter. Seeded from spec LOC (proxy) on 2026-07-13; replace values with real per-file durations (seconds) from a full CI run's reports whenever convenient. New specs without an entry get the median weight.", + "_meta": "Relative weights for scripts/quality/balance-e2e-shards.mjs (LPT shard packing). Unitless — only ratios matter. Seeded from spec LOC (proxy) on 2026-07-13; replace values with real per-file durations (seconds) from a full CI run's reports whenever convenient. New specs without an entry get the median weight.", "a11y-resilience.spec.ts": 59, "a11y.spec.ts": 281, "agent-bridge-traffic-cross.spec.ts": 155, @@ -17,6 +17,7 @@ "group-b-quota-plans-config.spec.ts": 154, "group-b-quota-share-pools.spec.ts": 98, "group-b-redirect-logs-activity.spec.ts": 56, + "keyboard-a11y.spec.ts": 109, "memory-engine.spec.ts": 607, "memory-qdrant-routes.spec.ts": 360, "memory-settings.spec.ts": 193, diff --git a/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx b/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx index 7c2c58d5d71..44761640898 100644 --- a/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx +++ b/src/app/(dashboard)/dashboard/CheaperInferenceSponsorBanner.tsx @@ -92,7 +92,7 @@ export default function CheaperInferenceSponsorBanner() { open_in_new - {t("partnerLinkNote")} + {t("partnerLinkNote")} ); @@ -295,7 +297,9 @@ export default function ProviderSummaryCard({ : "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30" }`} > - {chip.icon} + {providerText(t, chip.labelKey, chip.fallback)} ); diff --git a/src/app/globals.css b/src/app/globals.css index 1d2229e3552..735306e99f3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -34,9 +34,10 @@ --desktop-safe-bottom: 0px; color-scheme: light; - /* Primary - Coral Red (OpenClaw) */ - --color-primary: #e54d5e; - --color-primary-hover: #c93d4e; + /* Primary - Coral Red (OpenClaw). Light theme uses a deeper shade so primary text and + white-on-primary reach WCAG AA 4.5:1 (#e54d5e was 3.1–3.8:1); .dark keeps #e54d5e. */ + --color-primary: #b83242; + --color-primary-hover: #9f2a38; /* Accent - Indigo/Violet (buttons, links, gradients) */ --color-accent: #6366f1; @@ -71,7 +72,8 @@ --color-border: rgba(0, 0, 0, 0.08); --color-text-main: #1a1a2e; --color-text-primary: #1a1a2e; - --color-text-muted: #71717a; + /* #71717a measured 4.25:1 on bg-subtle and 3.81:1 on control grays (axe color-contrast). */ + --color-text-muted: #65656e; /* Fumadocs light theme */ --color-fd-background: #f9f9fb; @@ -125,6 +127,10 @@ .dark { /* Dark theme (ClawHub deep) */ color-scheme: dark; + /* The light-theme primary is darkened for contrast; on dark surfaces the original + coral already passes (5.1:1 on --color-bg) and a darker shade would not. */ + --color-primary: #e54d5e; + --color-primary-hover: #c93d4e; --color-bg: #0b0e14; --color-bg-alt: #111520; --color-bg-primary: #0b0e14; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5c80eeebbc2..5c70d6852d0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -6,6 +6,8 @@ import { useState, useEffect } from "react"; import { Button, Input } from "@/shared/components"; import { useRouter } from "next/navigation"; +const PASSWORD_INPUT_ID = "login-password"; + export default function LoginPage() { const t = useTranslations("auth"); const [password, setPassword] = useState(""); @@ -103,7 +105,12 @@ export default function LoginPage() {
- error +

@@ -114,13 +121,17 @@ export default function LoginPage() {

- terminal + {t("nodeIncompatibleFixLabel")}
nvm install 22 && nvm use 22

- info + {t("nodeIncompatibleHint")}

@@ -137,8 +148,8 @@ export default function LoginPage() { return (
{nodeWarningBanner} -
-
+
+ @@ -157,7 +168,10 @@ export default function LoginPage() { >
- +
@@ -195,7 +209,10 @@ export default function LoginPage() { >
- +
@@ -239,7 +256,12 @@ export default function LoginPage() {
- hub +
OmniRoute @@ -261,7 +283,9 @@ export default function LoginPage() { className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2" onClick={() => (window.location.href = "/api/auth/oidc/login")} > - login + {t("continueWithOidc")}
@@ -269,8 +293,14 @@ export default function LoginPage() { <>
- + {error && ( -

- error +

+ {error}

)} @@ -316,7 +351,9 @@ export default function LoginPage() { className="w-full h-11 text-sm font-medium flex items-center justify-center gap-2" onClick={() => (window.location.href = "/api/auth/oidc/login")} > - login + {t("continueWithOidc")}
@@ -370,7 +407,10 @@ export default function LoginPage() { className="flex items-start gap-4 p-4 rounded-xl bg-surface/50 border border-border" >
- +
diff --git a/src/shared/components/Breadcrumbs.tsx b/src/shared/components/Breadcrumbs.tsx index bee21c62efb..6bc50d17d64 100644 --- a/src/shared/components/Breadcrumbs.tsx +++ b/src/shared/components/Breadcrumbs.tsx @@ -139,7 +139,9 @@ export default function Breadcrumbs() { alignItems: "center", gap: "6px", fontSize: "13px", - color: "var(--text-secondary, #888)", + // --text-primary/--text-secondary were never defined, so the dark-theme fallbacks + // (#e0e0e0 / #888) rendered in light mode at 1.25:1 and 3.4:1 contrast. + color: "var(--color-text-muted)", padding: "8px 0", marginBottom: "8px", }} @@ -152,25 +154,22 @@ export default function Breadcrumbs() { )} {crumb.isLast ? ( - + {crumb.label} ) : ( - ((e.currentTarget as HTMLElement).style.color = "var(--accent, #818cf8)") + ((e.currentTarget as HTMLElement).style.color = "var(--color-accent)") } onMouseLeave={(e) => - ((e.currentTarget as HTMLElement).style.color = "var(--text-secondary, #888)") + ((e.currentTarget as HTMLElement).style.color = "var(--color-text-muted)") } > {crumb.label} diff --git a/src/shared/components/Checkbox.tsx b/src/shared/components/Checkbox.tsx index 56fc423b6c4..c427cf5fe2f 100644 --- a/src/shared/components/Checkbox.tsx +++ b/src/shared/components/Checkbox.tsx @@ -1,5 +1,6 @@ "use client"; +import { useId } from "react"; import { cn } from "@/shared/utils/cn"; interface CheckboxProps extends React.InputHTMLAttributes { @@ -10,12 +11,17 @@ interface CheckboxProps extends React.InputHTMLAttributes { * Checkbox — token-driven native checkbox (brand accent + keyboard focus ring). * Replaces the ad-hoc `` * pattern scattered across the dashboard. Optional `label` wraps it in a clickable row. + * Without a `label`, pass `aria-label` (or `aria-labelledby`) so the box has a name. */ export default function Checkbox({ label, className, id, ...props }: CheckboxProps) { + const generatedId = useId(); + // The label wraps the input, but an explicit id/htmlFor pair keeps the association + // intact for assistive tech that ignores implicit wrapping. + const inputId = id ?? generatedId; const box = ( {box} diff --git a/src/shared/components/Modal.tsx b/src/shared/components/Modal.tsx index 61f361714e4..7e2f849ea71 100644 --- a/src/shared/components/Modal.tsx +++ b/src/shared/components/Modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useId } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@/shared/utils/cn"; +import { useDialogFocus } from "@/shared/hooks/useDialogFocus"; import Button, { type ButtonVariant } from "./Button"; // #6265 — preset for content-heavy modals: caps height on the OUTERMOST dialog @@ -55,8 +56,7 @@ export default function Modal({ }: ModalProps) { const t = useTranslations("common"); const titleId = useId(); - const dialogRef = useRef(null); - const previouslyFocusedRef = useRef(null); + const dialogRef = useRef(null); const sizes = { sm: "max-w-sm", @@ -78,71 +78,9 @@ export default function Modal({ }; }, [isOpen]); - // Handle escape key - useEffect(() => { - const handleEscape = (e) => { - if (e.key === "Escape" && isOpen) { - onClose(); - } - }; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [isOpen, onClose]); - - // Return keyboard users to the control that opened the dialog. - useEffect(() => { - if (!isOpen) return; - - const activeElement = document.activeElement; - previouslyFocusedRef.current = activeElement instanceof HTMLElement ? activeElement : null; - - return () => { - const previouslyFocused = previouslyFocusedRef.current; - previouslyFocusedRef.current = null; - if (previouslyFocused?.isConnected) { - previouslyFocused.focus(); - } - }; - }, [isOpen]); - - // Focus trap - useEffect(() => { - if (!isOpen || !dialogRef.current) return; - - const dialog = dialogRef.current; - const focusableSelector = - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; - - // Focus first focusable element - const firstFocusable = dialog.querySelector(focusableSelector); - const focusTimer = firstFocusable - ? window.setTimeout(() => firstFocusable.focus(), 50) - : undefined; - - const handleTab = (e) => { - if (e.key !== "Tab") return; - - const focusable = [...dialog.querySelectorAll(focusableSelector)]; - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; - - dialog.addEventListener("keydown", handleTab); - return () => { - if (focusTimer !== undefined) window.clearTimeout(focusTimer); - dialog.removeEventListener("keydown", handleTab); - }; - }, [isOpen]); + // Escape to close, initial focus, Tab trap (skipping disabled controls) and focus + // restore to the opener — shared with the slide-over drawers. + useDialogFocus(dialogRef, isOpen, onClose); if (!isOpen) return null; diff --git a/src/shared/components/ProviderTestSlideOver.tsx b/src/shared/components/ProviderTestSlideOver.tsx index cfba945a251..be841ca4bae 100644 --- a/src/shared/components/ProviderTestSlideOver.tsx +++ b/src/shared/components/ProviderTestSlideOver.tsx @@ -1,9 +1,11 @@ "use client"; -import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; +import { useDialogFocus } from "@/shared/hooks/useDialogFocus"; + import { LlmChatCard, type LlmChatControls, @@ -62,13 +64,9 @@ function ProviderTestSlideOverPanel({ const { models } = useProviderModels(providerId); const firstModel = models[0]?.id ?? ""; - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose]); + // The panel only mounts while open: Escape, focus trap and focus restore on unmount. + const dialogRef = useRef(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 + {t("deprecated")} @@ -179,7 +181,9 @@ function SlideOverHeader({ <> · - 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 +
); @@ -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 (
- + 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 + {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} + {translate(`tabs.${tabItem.label}`)} {active && ( +
- {icon} +

{title}

{body}
@@ -389,8 +425,12 @@ function LogsTab({ providerId }: { providerId: string }) { if (state.status === "loading") { return ( -
- +
+ {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 + } @@ -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 + {t("refresh")}
@@ -493,6 +542,7 @@ function LogsTab({ providerId }: { providerId: string }) { {formatDurationMs(log.duration)}
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 = ( <> - + {!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 && ( <> -
-
-
+