feat(a11y): WCAG 2.2 AA accessibility compliance (#209)

* feat(a11y): add i18n keys for ARIA labels and screen reader text

* fix(security): harden API against pentest findings

- Default TRUST_PROXY=false to prevent XFF rate limit bypass (PT-01)
- Return 400 instead of 500 on malformed JSON input (PT-03)
- Default MAX_PIPELINE_STEPS=20 to prevent DoS (PT-04)
- Validate clientJobId length (max 128) across all routes (PT-06)
- Add security headers to all reply.hijack() streaming responses (PT-07)
- Sanitize usernames in audit log to prevent stored XSS (PT-08)
- Block TRACE method with 405 response (PT-10)
- Add 429 RateLimited response to OpenAPI spec (PT-12)
- Default MAX_SVG_SIZE_MB=50 to limit SVGZ decompression (PT-13)
- Pin Dockerfile base images by digest
- Sanitize OIDC IdP error and sub claim in audit log
- Sync Docker compose/Dockerfile defaults with env.ts

* feat(a11y): convert all hardcoded aria-labels to i18n keys

Replace 49 hardcoded aria-label="..." strings across 25 files with
their corresponding t.a11y.* and t.common.* i18n references. Add
useTranslation import and hook call to 15 components that lacked it.
Zero hardcoded aria-labels remain in the codebase.

* feat(a11y): add aria-labels to icon-only buttons, aria-hidden on decorative icons, sr-only status text

* feat(a11y): add aria-live regions for processing status announcements

* feat(a11y): add skip-nav link, route announcer, main content landmark, and page h1 elements

* feat(a11y): add prefers-reduced-motion support, preserve functional spinners

* feat(a11y): add useFocusTrap hook for modal focus management

* feat(a11y): add focus trapping and dialog roles to all modals

* feat(a11y): add toggle switch roles, form labels, and error association

* fix(a11y): fix contrast failures, touch targets, and add nav landmark to sidebar

* fix(a11y): add role=switch to remaining toggle buttons found in verification sweep
This commit is contained in:
SnapOtter
2026-06-07 23:32:41 +08:00
committed by GitHub
parent ace41168bc
commit 6f276b4ef0
75 changed files with 614 additions and 183 deletions
+18 -4
View File
@@ -1,6 +1,7 @@
import { Globe, Menu, X } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
@@ -30,7 +31,9 @@ export function AppLayout({
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const { locale, setLocale, supportedLocales } = useTranslation();
const mobileSidebarRef = useRef<HTMLDivElement>(null);
useFocusTrap(mobileSidebarRef, mobileSidebarOpen);
const { t, locale, setLocale, supportedLocales } = useTranslation();
const isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
@@ -58,7 +61,13 @@ export function AppLayout({
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
<div
ref={mobileSidebarRef}
role="dialog"
aria-modal="true"
aria-label={t.a11y.openSidebar}
className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left"
>
<div className="flex items-center justify-between p-3 border-b border-border">
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
@@ -70,6 +79,7 @@ export function AppLayout({
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-2.5 rounded-lg hover:bg-muted"
aria-label={t.a11y.closeSidebar}
>
<X className="h-5 w-5" />
</button>
@@ -118,6 +128,7 @@ export function AppLayout({
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-2.5 -ms-1 rounded-lg hover:bg-muted"
aria-label={t.a11y.openSidebar}
>
<Menu className="h-5 w-5" />
</button>
@@ -132,7 +143,10 @@ export function AppLayout({
{showToolPanel && !isMobile && <ToolPanel />}
<main className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}>
<main
id="main-content"
className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}
>
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
{children || <Dropzone onFiles={onFiles} onUrlImport={onUrlImport} accept="image/*" />}
</div>