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
@@ -54,9 +54,7 @@ export function AnalyticsConsentPage() {
</p>
</div>
<p className="text-center text-xs text-muted-foreground/60">
{t.analytics.consentChangeable}
</p>
<p className="text-center text-xs text-muted-foreground">{t.analytics.consentChangeable}</p>
<div className="flex gap-2.5">
<button
+3 -3
View File
@@ -910,7 +910,7 @@ export function AutomatePage() {
{/* Preview content */}
{!previewCollapsed && (
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col overflow-hidden min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -921,7 +921,7 @@ export function AutomatePage() {
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
aria-label={t.a11y.previousImage}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -931,7 +931,7 @@ export function AutomatePage() {
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
aria-label={t.a11y.nextImage}
>
<ChevronRight className="h-4 w-4" />
</button>
+1
View File
@@ -151,6 +151,7 @@ export function EditorPage() {
return (
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage}
+3 -1
View File
@@ -18,6 +18,7 @@ export function FilesPage() {
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile tabs */}
<div className="flex border-b border-border">
@@ -68,7 +69,7 @@ export function FilesPage() {
<div
role="dialog"
aria-modal="true"
aria-label="File Details"
aria-label={t.a11y.fileDetails}
className="fixed inset-0 z-50 bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setShowDetails(false);
@@ -95,6 +96,7 @@ export function FilesPage() {
return (
<AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
{activeTab === "recent" ? (
@@ -138,6 +138,7 @@ export function FullscreenGridPage() {
{/* Grid */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<h1 className="sr-only">{t.nav.tools}</h1>
{activeCategories.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
+63 -11
View File
@@ -99,6 +99,7 @@ export function HomePage() {
if (isMobile && hasFile) {
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex flex-col h-full w-full">
{/* File info bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
@@ -141,11 +142,25 @@ export function HomePage() {
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground" />
<>
<Download className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -160,7 +175,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
@@ -182,6 +197,7 @@ export function HomePage() {
// File uploaded — desktop: tool selector on left, image preview on right
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex h-full w-full">
{/* Left panel: Tool selector */}
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0">
@@ -233,13 +249,31 @@ export function HomePage() {
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -279,13 +313,31 @@ export function HomePage() {
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -305,7 +357,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
+3 -3
View File
@@ -38,7 +38,7 @@ function RotatingPhrase() {
}
function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation();
const { t, locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -74,7 +74,7 @@ function LanguageSelector() {
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Language"
aria-label={t.a11y.language}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
@@ -112,7 +112,7 @@ function LanguageSelector() {
strokeLinejoin="round"
className="text-primary shrink-0"
role="img"
aria-label="Selected"
aria-label={t.a11y.selected}
>
<polyline points="20 6 9 17 4 12" />
</svg>
+32 -11
View File
@@ -27,6 +27,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
import { getToolName } from "@/lib/tool-i18n";
import { getToolRegistryEntry } from "@/lib/tool-registry";
@@ -117,7 +118,7 @@ function FileSelectionInfo({
>
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
<span className="truncate flex-1 min-w-0">{file.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground/70">
<span className="shrink-0 text-[10px] text-muted-foreground">
{getFileFormat(file.name)}
</span>
<span className="shrink-0 text-[10px] tabular-nums">{formatFileSize(file.size)}</span>
@@ -203,6 +204,13 @@ export function ToolPage() {
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const liveMessage = useMemo(() => {
if (!currentEntry) return "";
if (currentEntry.status === "completed" && processedUrl) return t.a11y.processingComplete;
if (currentEntry.status === "failed") return t.a11y.processingFailed;
return "";
}, [currentEntry, processedUrl, t.a11y.processingComplete, t.a11y.processingFailed]);
const handleImageKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
@@ -635,7 +643,7 @@ export function ToolPage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.toolPage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
);
}
@@ -706,7 +714,7 @@ export function ToolPage() {
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
aria-label={t.a11y.previousImage}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -716,13 +724,20 @@ export function ToolPage() {
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
aria-label={t.a11y.nextImage}
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
<div
role="status"
aria-label={format(t.a11y.imageNOfTotal, {
n: selectedIndex + 1,
total: entries.length,
})}
className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums"
>
{selectedIndex + 1} / {entries.length}
</div>
)}
@@ -796,9 +811,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">
<h1 className="font-semibold text-lg text-foreground flex-1">
{getToolName(t, tool.id, tool.name)}
</h2>
</h1>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
@@ -810,11 +825,14 @@ export function ToolPage() {
{/* Main area: image viewer (full height) */}
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{liveMessage}
</div>
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0 min-w-0">
{renderNavArrows()}
{renderImageArea()}
@@ -851,9 +869,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground">
<h1 className="font-semibold text-lg text-foreground">
{getToolName(t, tool.id, tool.name)}
</h2>
</h1>
</div>
{renderSettingsContent()}
@@ -861,11 +879,14 @@ export function ToolPage() {
{/* Main area: image viewer */}
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{liveMessage}
</div>
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0 min-w-0">
{renderNavArrows()}
{renderImageArea()}