mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user