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
+8
View File
@@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
import { Toaster, toast } from "sonner"; import { Toaster, toast } from "sonner";
import { ConnectionMonitor } from "./components/common/connection-monitor"; import { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { RouteAnnouncer } from "./components/common/route-announcer";
import { I18nProvider } from "./contexts/i18n-context"; import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth"; import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile"; import { useMobile } from "./hooks/use-mobile";
@@ -221,6 +222,13 @@ export function App() {
<ConnectionMonitor /> <ConnectionMonitor />
<Toaster position={isMobile ? "top-center" : "bottom-right"} /> <Toaster position={isMobile ? "top-center" : "bottom-right"} />
<BrowserRouter> <BrowserRouter>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium focus:shadow-lg"
>
{en.a11y.skipToContent}
</a>
<RouteAnnouncer />
<KeyboardShortcutProvider> <KeyboardShortcutProvider>
<AuthGuard> <AuthGuard>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
@@ -134,7 +134,7 @@ export function BeforeAfterSlider({
<div <div
ref={containerRef} ref={containerRef}
role="slider" role="slider"
aria-label="Before/after comparison slider" aria-label={t.a11y.beforeAfterSlider}
aria-valuenow={Math.round(position)} aria-valuenow={Math.round(position)}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
@@ -1,6 +1,8 @@
import { useDrag } from "@use-gesture/react"; import { useDrag } from "@use-gesture/react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface BottomSheetProps { interface BottomSheetProps {
open: boolean; open: boolean;
@@ -17,7 +19,9 @@ export function BottomSheet({
children, children,
maxHeight = "70dvh", maxHeight = "70dvh",
}: BottomSheetProps) { }: BottomSheetProps) {
const { t } = useTranslation();
const sheetRef = useRef<HTMLDivElement>(null); const sheetRef = useRef<HTMLDivElement>(null);
useFocusTrap(sheetRef, open);
const [translateY, setTranslateY] = useState(0); const [translateY, setTranslateY] = useState(0);
// Close on Escape key // Close on Escape key
@@ -74,6 +78,9 @@ export function BottomSheet({
{/* Sheet */} {/* Sheet */}
<div <div
ref={sheetRef} ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "bottom-sheet-title" : undefined}
className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl flex flex-col animate-in slide-in-from-bottom" className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl flex flex-col animate-in slide-in-from-bottom"
style={{ style={{
maxHeight, maxHeight,
@@ -89,11 +96,14 @@ export function BottomSheet({
{/* Header */} {/* Header */}
{title && ( {title && (
<div className="flex items-center justify-between px-4 pb-2 shrink-0"> <div className="flex items-center justify-between px-4 pb-2 shrink-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2> <h2 id="bottom-sheet-title" className="text-sm font-semibold text-foreground">
{title}
</h2>
<button <button
type="button" type="button"
onClick={handleDismiss} onClick={handleDismiss}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t.common.close}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
+3 -3
View File
@@ -205,7 +205,7 @@ export function Dropzone({
return ( return (
<section <section
aria-label="File drop zone" aria-label={t.a11y.fileDropZone}
onDragEnter={handleDrag} onDragEnter={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
@@ -239,7 +239,7 @@ export function Dropzone({
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}> <p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
{t.dropzone.dropPrompt} {t.dropzone.dropPrompt}
</p> </p>
<p className="text-sm text-muted-foreground/70">{t.dropzone.browseOrPaste}</p> <p className="text-sm text-muted-foreground">{t.dropzone.browseOrPaste}</p>
</div> </div>
<button <button
type="button" type="button"
@@ -256,7 +256,7 @@ export function Dropzone({
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />
{t.common.upload} {t.common.upload}
</button> </button>
<p className="text-xs text-muted-foreground/50"> <p className="text-xs text-muted-foreground">
{acceptDescription ?? t.dropzone.defaultFormats} {acceptDescription ?? t.dropzone.defaultFormats}
</p> </p>
@@ -1,6 +1,7 @@
import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react"; import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { import {
apiListFiles, apiListFiles,
formatHeaders, formatHeaders,
@@ -40,7 +41,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
if (failed) { if (failed) {
return ( return (
<div className={cn("flex items-center justify-center bg-muted/50", className)}> <div className={cn("flex items-center justify-center bg-muted/50", className)}>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
</div> </div>
); );
} }
@@ -64,6 +65,8 @@ interface FileLibraryModalProps {
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) { export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
const [files, setFiles] = useState<UserFile[]>([]); const [files, setFiles] = useState<UserFile[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -149,11 +152,17 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default" className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose} onClick={onClose}
/> />
<div className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"> <div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="file-library-title"
className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
>
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<FolderOpen className="h-5 w-5 text-primary" /> <FolderOpen className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1"> <h2 id="file-library-title" className="text-sm font-semibold text-foreground flex-1">
{t.automate.importFromLibrary} {t.automate.importFromLibrary}
</h2> </h2>
<button <button
@@ -1,6 +1,7 @@
import { useGesture } from "@use-gesture/react"; import { useGesture } from "@use-gesture/react";
import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react"; import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download"; import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -49,6 +50,7 @@ export function ImageViewer({
imageWrapperStyle, imageWrapperStyle,
imageWrapperChildren, imageWrapperChildren,
}: ImageViewerProps) { }: ImageViewerProps) {
const { t } = useTranslation();
const [zoom, setZoom] = useState(DEFAULT_ZOOM); const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null); const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null); const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
@@ -203,6 +205,7 @@ export function ImageViewer({
disabled={zoom <= ZOOM_STEPS[0]} disabled={zoom <= ZOOM_STEPS[0]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed" className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom out" title="Zoom out"
aria-label={t.a11y.zoomOut}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
</button> </button>
@@ -215,6 +218,7 @@ export function ImageViewer({
disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed" className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom in" title="Zoom in"
aria-label={t.a11y.zoomIn}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
</button> </button>
@@ -224,6 +228,7 @@ export function ImageViewer({
onClick={fitToContainer} onClick={fitToContainer}
className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`} className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Fit to view" title="Fit to view"
aria-label={t.a11y.fitToView}
> >
<Maximize className="h-3.5 w-3.5" /> <Maximize className="h-3.5 w-3.5" />
</button> </button>
@@ -232,6 +237,7 @@ export function ImageViewer({
onClick={actualSize} onClick={actualSize}
className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`} className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
title="Actual size (100%)" title="Actual size (100%)"
aria-label={t.a11y.actualSize}
> >
<Minimize2 className="h-3.5 w-3.5" /> <Minimize2 className="h-3.5 w-3.5" />
</button> </button>
@@ -251,7 +257,7 @@ export function ImageViewer({
<div className="flex flex-col items-center justify-center gap-3 text-center"> <div className="flex flex-col items-center justify-center gap-3 text-center">
<FileImage className="h-8 w-8 text-muted-foreground" /> <FileImage className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Preview not available</p> <p className="text-sm text-muted-foreground">Preview not available</p>
<p className="text-xs text-muted-foreground/60">{filename}</p> <p className="text-xs text-muted-foreground">{filename}</p>
</div> </div>
) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? ( ) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? (
/* Layered bg-removal preview: background layer + subject layer */ /* Layered bg-removal preview: background layer + subject layer */
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { ImageViewer } from "@/components/common/image-viewer"; import { ImageViewer } from "@/components/common/image-viewer";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { useTranslation } from "@/contexts/i18n-context";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
const BROWSER_PREVIEWABLE_EXTS = new Set([ const BROWSER_PREVIEWABLE_EXTS = new Set([
@@ -24,6 +25,7 @@ function canBrowserPreview(url: string): boolean {
} }
export function MultiImageViewer() { export function MultiImageViewer() {
const { t } = useTranslation();
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore(); const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -65,7 +67,7 @@ export function MultiImageViewer() {
return ( return (
<section <section
aria-label="Image viewer" aria-label={t.a11y.imageViewer}
className="flex flex-col w-full h-full min-h-0" className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined} onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined} tabIndex={hasMultiple ? 0 : undefined}
@@ -76,7 +78,7 @@ export function MultiImageViewer() {
type="button" type="button"
onClick={navigatePrev} 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" 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" /> <ChevronLeft className="h-4 w-4" />
</button> </button>
@@ -120,7 +122,7 @@ export function MultiImageViewer() {
type="button" type="button"
onClick={navigateNext} 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" 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" /> <ChevronRight className="h-4 w-4" />
</button> </button>
@@ -22,7 +22,11 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · "); const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · ");
return ( return (
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5"> <div
role="status"
aria-live="polite"
className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5"
>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> <div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
{icon} {icon}
@@ -0,0 +1,48 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function RouteAnnouncer() {
const location = useLocation();
const isFirstRender = useRef(true);
const announcerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const timer = setTimeout(() => {
const h1 = document.querySelector("h1");
if (h1) {
if (!h1.hasAttribute("tabindex")) {
h1.setAttribute("tabindex", "-1");
}
h1.focus({ preventScroll: true });
if (announcerRef.current) {
announcerRef.current.textContent = h1.textContent || "";
}
} else {
const main = document.getElementById("main-content");
if (main) {
if (!main.hasAttribute("tabindex")) {
main.setAttribute("tabindex", "-1");
}
main.focus({ preventScroll: true });
}
}
}, 300);
return () => clearTimeout(timer);
}, [location.pathname]);
return (
<div
ref={announcerRef}
className="sr-only"
aria-live="polite"
aria-atomic="true"
role="status"
/>
);
}
@@ -19,6 +19,7 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps) {
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={resolvedPlaceholder} placeholder={resolvedPlaceholder}
aria-label={resolvedPlaceholder}
className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/> />
</div> </div>
+21 -4
View File
@@ -36,8 +36,9 @@ export function ToolCard({ tool }: ToolCardProps) {
<div className="group flex items-center gap-3 relative"> <div className="group flex items-center gap-3 relative">
<button <button
type="button" type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5" className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity absolute -left-7 p-2"
title={t.toolCard.addToFavourites} title={t.toolCard.addToFavourites}
aria-label={t.toolCard.addToFavourites}
> >
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" /> <Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button> </button>
@@ -58,10 +59,26 @@ export function ToolCard({ tool }: ToolCardProps) {
{t.common.experimental} {t.common.experimental}
</span> </span>
)} )}
{aiStatus === "not_installed" && <Download className="h-3.5 w-3.5 text-muted-foreground" />} {aiStatus === "not_installed" && (
{aiStatus === "queued" && <Clock 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>
</>
)}
{aiStatus === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{aiStatus === "installing" && ( {aiStatus === "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>
</>
)} )}
</Link> </Link>
</div> </div>
@@ -1,6 +1,7 @@
import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react"; import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import"; import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
import { format } from "@/lib/format"; import { format } from "@/lib/format";
import { extractUrls } from "@/lib/url-parser"; import { extractUrls } from "@/lib/url-parser";
@@ -45,6 +46,8 @@ function filenameFromUrl(url: string): string {
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
@@ -104,18 +107,22 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
{/* Modal card */} {/* Modal card */}
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="url-import-title"
className="relative z-10 w-full max-w-lg bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4" className="relative z-10 w-full max-w-lg bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
> >
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0"> <div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<Link className="h-5 w-5 text-primary" /> <Link className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1">{t.urlImport.title}</h2> <h2 id="url-import-title" className="text-sm font-semibold text-foreground flex-1">
{t.urlImport.title}
</h2>
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
aria-label="Close" aria-label={t.common.close}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground" className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -190,7 +197,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
> >
{adding ? ( {adding ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t.urlImport.adding} {t.urlImport.adding}
</> </>
) : ( ) : (
@@ -215,7 +222,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
> >
{importing ? ( {importing ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t.urlImport.adding} {t.urlImport.adding}
</> </>
) : ( ) : (
@@ -1,5 +1,6 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { AnchorPosition } from "@/types/editor"; import type { AnchorPosition } from "@/types/editor";
@@ -21,6 +22,7 @@ const ANCHOR_POSITIONS: AnchorPosition[] = [
]; ];
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize); const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeCanvas = useEditorStore((s) => s.resizeCanvas); const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
@@ -57,7 +59,7 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose:
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t.common.close}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -13,6 +13,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { editorStageRefHolder } from "@/components/editor/editor-canvas"; import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { import type {
@@ -63,6 +64,7 @@ function getMimeType(format: ExportFormat): string {
} }
export function ExportDialog({ onClose }: { onClose: () => void }) { export function ExportDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize); const canvasSize = useEditorStore((s) => s.canvasSize);
const markClean = useEditorStore((s) => s.markClean); const markClean = useEditorStore((s) => s.markClean);
@@ -454,7 +456,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors" className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Close" aria-label={t.common.close}
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -1,6 +1,7 @@
// apps/web/src/components/editor/common/fill-dialog.tsx // apps/web/src/components/editor/common/fill-dialog.tsx
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn, generateId } from "@/lib/utils"; import { cn, generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor"; import type { CanvasObject } from "@/types/editor";
@@ -44,6 +45,7 @@ function resolveColor(
} }
export function FillDialog({ open, onClose }: FillDialogProps) { export function FillDialog({ open, onClose }: FillDialogProps) {
const { t } = useTranslation();
const [content, setContent] = useState<FillContent>("foreground"); const [content, setContent] = useState<FillContent>("foreground");
const [customColor, setCustomColor] = useState("#ff0000"); const [customColor, setCustomColor] = useState("#ff0000");
const [opacity, setOpacity] = useState(100); const [opacity, setOpacity] = useState(100);
@@ -119,7 +121,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {
<div <div
className="w-80 rounded-lg bg-card border border-border shadow-xl p-4" className="w-80 rounded-lg bg-card border border-border shadow-xl p-4"
role="dialog" role="dialog"
aria-label="Fill" aria-label={t.a11y.fill}
> >
<h3 className="text-sm font-semibold text-foreground mb-3">Fill</h3> <h3 className="text-sm font-semibold text-foreground mb-3">Fill</h3>
@@ -1,9 +1,11 @@
import { Lock, Unlock, X } from "lucide-react"; import { Lock, Unlock, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) { export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize); const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage); const resizeImage = useEditorStore((s) => s.resizeImage);
@@ -68,7 +70,7 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t.common.close}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -1,9 +1,11 @@
// apps/web/src/components/editor/common/loading-overlay.tsx // apps/web/src/components/editor/common/loading-overlay.tsx
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
export function LoadingOverlay() { export function LoadingOverlay() {
const { t } = useTranslation();
const loadingState = useEditorStore((s) => s.loadingState); const loadingState = useEditorStore((s) => s.loadingState);
const setLoadingState = useEditorStore((s) => s.setLoadingState); const setLoadingState = useEditorStore((s) => s.setLoadingState);
@@ -43,7 +45,7 @@ export function LoadingOverlay() {
type="button" type="button"
onClick={() => setLoadingState(null)} onClick={() => setLoadingState(null)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1"
aria-label="Cancel operation" aria-label={t.a11y.cancelOperation}
> >
<X size={12} /> <X size={12} />
Cancel Cancel
@@ -60,7 +60,7 @@ export function WelcomeScreen() {
return ( return (
<> <>
<section <section
aria-label="Image drop zone" aria-label={t.a11y.imageDropZone}
className="absolute inset-0 flex items-center justify-center z-10" className="absolute inset-0 flex items-center justify-center z-10"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -404,7 +404,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
className={cn( className={cn(
"flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm", "flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm",
item.disabled item.disabled
? "text-muted-foreground/50" ? "text-muted-foreground"
: "text-foreground hover:bg-accent hover:text-accent-foreground", : "text-foreground hover:bg-accent hover:text-accent-foreground",
)} )}
data-testid={`menu-item-${toTestId(item.label)}`} data-testid={`menu-item-${toTestId(item.label)}`}
@@ -436,7 +436,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
className={cn( className={cn(
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-start", "flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-start",
item.disabled item.disabled
? "text-muted-foreground/50 pointer-events-none" ? "text-muted-foreground pointer-events-none"
: "text-foreground hover:bg-accent hover:text-accent-foreground", : "text-foreground hover:bg-accent hover:text-accent-foreground",
)} )}
disabled={item.disabled} disabled={item.disabled}
@@ -1,5 +1,6 @@
// apps/web/src/components/editor/editor-right-panel.tsx // apps/web/src/components/editor/editor-right-panel.tsx
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { AdjustmentsPanel } from "./panels/adjustments-panel"; import { AdjustmentsPanel } from "./panels/adjustments-panel";
@@ -15,6 +16,7 @@ const TABS = [
]; ];
export function EditorRightPanel() { export function EditorRightPanel() {
const { t } = useTranslation();
const visible = useEditorStore((s) => s.rightPanelVisible); const visible = useEditorStore((s) => s.rightPanelVisible);
const activeTab = useEditorStore((s) => s.rightPanelTab); const activeTab = useEditorStore((s) => s.rightPanelTab);
const setTab = useEditorStore((s) => s.setRightPanelTab); const setTab = useEditorStore((s) => s.setRightPanelTab);
@@ -27,7 +29,7 @@ export function EditorRightPanel() {
type="button" type="button"
onClick={togglePanel} onClick={togglePanel}
className="flex items-center justify-center w-6 bg-card border-l border-border" className="flex items-center justify-center w-6 bg-card border-l border-border"
aria-label="Expand panel" aria-label={t.a11y.expandPanel}
> >
<ChevronRight size={14} className="text-muted-foreground rotate-180" /> <ChevronRight size={14} className="text-muted-foreground rotate-180" />
</button> </button>
@@ -63,7 +65,7 @@ export function EditorRightPanel() {
type="button" type="button"
onClick={togglePanel} onClick={togglePanel}
className="px-1.5 py-2 text-muted-foreground hover:text-foreground" className="px-1.5 py-2 text-muted-foreground hover:text-foreground"
aria-label="Collapse panel" aria-label={t.a11y.collapsePanel}
> >
<ChevronRight size={14} /> <ChevronRight size={14} />
</button> </button>
@@ -1,6 +1,7 @@
import { ArrowLeftRight, Check, X } from "lucide-react"; import { ArrowLeftRight, Check, X } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool"; import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
@@ -9,6 +10,7 @@ import { useEditorStore } from "@/stores/editor-store";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CropOptions() { export function CropOptions() {
const { t } = useTranslation();
const cropState = useEditorStore((s) => s.cropState); const cropState = useEditorStore((s) => s.cropState);
const setCropState = useEditorStore((s) => s.setCropState); const setCropState = useEditorStore((s) => s.setCropState);
const applyCrop = useEditorStore((s) => s.applyCrop); const applyCrop = useEditorStore((s) => s.applyCrop);
@@ -123,7 +125,7 @@ export function CropOptions() {
type="button" type="button"
onClick={handleSwap} onClick={handleSwap}
title="Swap dimensions" title="Swap dimensions"
aria-label="Swap dimensions" aria-label={t.a11y.swapDimensions}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<ArrowLeftRight className="h-3.5 w-3.5" /> <ArrowLeftRight className="h-3.5 w-3.5" />
@@ -150,7 +152,7 @@ export function CropOptions() {
type="button" type="button"
onClick={handleApply} onClick={handleApply}
title="Apply Crop (Enter)" title="Apply Crop (Enter)"
aria-label="Apply Crop" aria-label={t.a11y.applyCrop}
className={cn( className={cn(
"flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground", "flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground",
"hover:bg-primary/90 transition-colors", "hover:bg-primary/90 transition-colors",
@@ -163,7 +165,7 @@ export function CropOptions() {
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
title="Cancel Crop (Escape)" title="Cancel Crop (Escape)"
aria-label="Cancel Crop" aria-label={t.a11y.cancelCrop}
className={cn( className={cn(
"flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs", "flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors", "text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
@@ -11,6 +11,7 @@ import {
Underline, Underline,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor"; import type { TextAttrs } from "@/types/editor";
@@ -131,6 +132,7 @@ function NumberInput({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) { function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const fonts = useMemo(() => getAllFonts(), []); const fonts = useMemo(() => getAllFonts(), []);
@@ -162,7 +164,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<button <button
type="button" type="button"
aria-label="Font family" aria-label={t.a11y.fontFamily}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={cn( className={cn(
"flex items-center gap-1.5 h-7 px-2 rounded border border-border bg-background", "flex items-center gap-1.5 h-7 px-2 rounded border border-border bg-background",
@@ -231,6 +233,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function TextOptions() { export function TextOptions() {
const { t } = useTranslation();
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects); const objects = useEditorStore((s) => s.objects);
@@ -409,7 +412,7 @@ export function TextOptions() {
/> />
<input <input
type="color" type="color"
aria-label="Text color" aria-label={t.a11y.textColor}
value={attrs.fill} value={attrs.fill}
onChange={(e) => updateSelected({ fill: e.target.value })} onChange={(e) => updateSelected({ fill: e.target.value })}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
@@ -1,6 +1,7 @@
import { Check, FlipHorizontal2, FlipVertical2, Lock, Unlock, X } from "lucide-react"; import { Check, FlipHorizontal2, FlipVertical2, Lock, Unlock, X } from "lucide-react";
import { useCallback } from "react"; import { useCallback } from "react";
import type { TransformToolApi } from "@/components/editor/tools/transform-tool"; import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -55,6 +56,7 @@ function NumericInput({
} }
export function TransformOptions({ api }: { api: TransformToolApi }) { export function TransformOptions({ api }: { api: TransformToolApi }) {
const { t } = useTranslation();
const { const {
values, values,
lockedAspect, lockedAspect,
@@ -132,7 +134,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
type="button" type="button"
onClick={flipHorizontal} onClick={flipHorizontal}
title="Flip Horizontal" title="Flip Horizontal"
aria-label="Flip Horizontal" aria-label={t.a11y.flipHorizontal}
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<FlipHorizontal2 className="h-4 w-4" /> <FlipHorizontal2 className="h-4 w-4" />
@@ -141,7 +143,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
type="button" type="button"
onClick={flipVertical} onClick={flipVertical}
title="Flip Vertical" title="Flip Vertical"
aria-label="Flip Vertical" aria-label={t.a11y.flipVertical}
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<FlipVertical2 className="h-4 w-4" /> <FlipVertical2 className="h-4 w-4" />
@@ -3,6 +3,7 @@
import { ArrowLeftRight, RotateCcw } from "lucide-react"; import { ArrowLeftRight, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { ColorSwatch } from "../common/color-swatch"; import { ColorSwatch } from "../common/color-swatch";
@@ -108,6 +109,7 @@ function ColorInputFields({
color: string; color: string;
onColorChange: (hex: string) => void; onColorChange: (hex: string) => void;
}) { }) {
const { t } = useTranslation();
const rgb = hexToRgb(color); const rgb = hexToRgb(color);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
@@ -130,7 +132,7 @@ function ColorInputFields({
)} )}
maxLength={7} maxLength={7}
spellCheck={false} spellCheck={false}
aria-label="Hex color value" aria-label={t.a11y.hexColorValue}
data-testid="color-hex-input" data-testid="color-hex-input"
/> />
</div> </div>
@@ -296,6 +298,7 @@ function ColorPickerPopover({
// --- Main component --- // --- Main component ---
export function ColorPanel() { export function ColorPanel() {
const { t } = useTranslation();
const foregroundColor = useEditorStore((s) => s.foregroundColor); const foregroundColor = useEditorStore((s) => s.foregroundColor);
const backgroundColor = useEditorStore((s) => s.backgroundColor); const backgroundColor = useEditorStore((s) => s.backgroundColor);
const recentColors = useEditorStore((s) => s.recentColors); const recentColors = useEditorStore((s) => s.recentColors);
@@ -362,7 +365,7 @@ export function ColorPanel() {
pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border", pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border",
)} )}
style={{ backgroundColor: backgroundColor }} style={{ backgroundColor: backgroundColor }}
aria-label="Background color" aria-label={t.a11y.backgroundColor}
data-testid="bg-color-swatch" data-testid="bg-color-swatch"
/> />
{/* Foreground swatch (top-left, overlapping) */} {/* Foreground swatch (top-left, overlapping) */}
@@ -374,7 +377,7 @@ export function ColorPanel() {
pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border", pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border",
)} )}
style={{ backgroundColor: foregroundColor }} style={{ backgroundColor: foregroundColor }}
aria-label="Foreground color" aria-label={t.a11y.foregroundColor}
data-testid="fg-color-swatch" data-testid="fg-color-swatch"
/> />
@@ -388,7 +391,7 @@ export function ColorPanel() {
"bg-card/80 hover:bg-muted", "bg-card/80 hover:bg-muted",
)} )}
title="Swap colors (X)" title="Swap colors (X)"
aria-label="Swap foreground and background colors" aria-label={t.a11y.swapColors}
data-testid="swap-colors" data-testid="swap-colors"
> >
<ArrowLeftRight size={10} /> <ArrowLeftRight size={10} />
@@ -404,7 +407,7 @@ export function ColorPanel() {
"bg-card/80 hover:bg-muted", "bg-card/80 hover:bg-muted",
)} )}
title="Reset colors (D)" title="Reset colors (D)"
aria-label="Reset to default black and white" aria-label={t.a11y.resetColors}
data-testid="reset-colors" data-testid="reset-colors"
> >
<RotateCcw size={10} /> <RotateCcw size={10} />
@@ -426,7 +429,7 @@ export function ColorPanel() {
)} )}
maxLength={7} maxLength={7}
spellCheck={false} spellCheck={false}
aria-label="Foreground color hex value" aria-label={t.a11y.foregroundHex}
data-testid="foreground-hex-input" data-testid="foreground-hex-input"
/> />
</div> </div>
@@ -22,6 +22,7 @@ import {
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
@@ -80,6 +81,7 @@ interface HistoryEntry {
} }
export function HistoryPanel() { export function HistoryPanel() {
const { t } = useTranslation();
const lastAction = useEditorStore((s) => s.lastAction); const lastAction = useEditorStore((s) => s.lastAction);
// Force re-render when history changes by subscribing to history version // Force re-render when history changes by subscribing to history version
@@ -170,7 +172,7 @@ export function HistoryPanel() {
? "text-muted-foreground hover:text-foreground hover:bg-muted" ? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed", : "text-muted-foreground/30 cursor-not-allowed",
)} )}
aria-label="Undo" aria-label={t.a11y.undo}
title="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)"
> >
<Undo2 size={14} /> <Undo2 size={14} />
@@ -185,7 +187,7 @@ export function HistoryPanel() {
? "text-muted-foreground hover:text-foreground hover:bg-muted" ? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed", : "text-muted-foreground/30 cursor-not-allowed",
)} )}
aria-label="Redo" aria-label={t.a11y.redo}
title="Redo (Ctrl+Shift+Z)" title="Redo (Ctrl+Shift+Z)"
> >
<Redo2 size={14} /> <Redo2 size={14} />
@@ -14,6 +14,7 @@ import {
Unlock, Unlock,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { EditorLayer, ObjectEffects } from "@/types/editor"; import type { EditorLayer, ObjectEffects } from "@/types/editor";
@@ -80,6 +81,7 @@ const DEFAULT_STROKE: NonNullable<ObjectEffects["stroke"]> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function LayersPanel() { export function LayersPanel() {
const { t } = useTranslation();
const layers = useEditorStore((s) => s.layers); const layers = useEditorStore((s) => s.layers);
const activeLayerId = useEditorStore((s) => s.activeLayerId); const activeLayerId = useEditorStore((s) => s.activeLayerId);
const objects = useEditorStore((s) => s.objects); const objects = useEditorStore((s) => s.objects);
@@ -167,7 +169,11 @@ export function LayersPanel() {
</div> </div>
{/* Layer list */} {/* Layer list */}
<div className="flex-1 overflow-y-auto py-1 min-h-0" role="listbox" aria-label="Layers"> <div
className="flex-1 overflow-y-auto py-1 min-h-0"
role="listbox"
aria-label={t.a11y.layers}
>
{displayLayers.map((layer) => { {displayLayers.map((layer) => {
const realIndex = layers.findIndex((l) => l.id === layer.id); const realIndex = layers.findIndex((l) => l.id === layer.id);
return ( return (
@@ -202,7 +208,7 @@ export function LayersPanel() {
onClick={addLayer} onClick={addLayer}
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="New Layer (Ctrl+Shift+N)" title="New Layer (Ctrl+Shift+N)"
aria-label="Add layer" aria-label={t.a11y.addLayer}
data-testid="add-layer-btn" data-testid="add-layer-btn"
> >
<Plus size={16} /> <Plus size={16} />
@@ -217,7 +223,7 @@ export function LayersPanel() {
disabled={layers.length <= 1} disabled={layers.length <= 1}
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Delete Layer" title="Delete Layer"
aria-label="Delete layer" aria-label={t.a11y.deleteLayer}
data-testid="delete-layer-btn" data-testid="delete-layer-btn"
> >
<Trash2 size={16} /> <Trash2 size={16} />
@@ -523,7 +529,7 @@ function LayerRow({
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<Layers size={12} className="text-muted-foreground/50" /> <Layers size={12} className="text-muted-foreground" />
)} )}
</div> </div>
@@ -673,7 +679,7 @@ function LayerEffectsSection({
}) { }) {
return ( return (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto"> <div className="space-y-0.5 max-h-[200px] overflow-y-auto">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70 px-1"> <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1">
Layer Effects Layer Effects
</p> </p>
@@ -2,6 +2,7 @@
import { Minus, Plus } from "lucide-react"; import { Minus, Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
const THUMBNAIL_MAX_HEIGHT = 80; const THUMBNAIL_MAX_HEIGHT = 80;
@@ -10,6 +11,7 @@ const MIN_ZOOM = 0.01;
const MAX_ZOOM = 64; const MAX_ZOOM = 64;
export function NavigatorPanel() { export function NavigatorPanel() {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
@@ -245,7 +247,7 @@ export function NavigatorPanel() {
type="button" type="button"
onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))} onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))}
className="p-0.5 text-muted-foreground hover:text-foreground" className="p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Zoom out" aria-label={t.a11y.zoomOut}
> >
<Minus size={12} /> <Minus size={12} />
</button> </button>
@@ -262,7 +264,7 @@ export function NavigatorPanel() {
type="button" type="button"
onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))} onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))}
className="p-0.5 text-muted-foreground hover:text-foreground" className="p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Zoom in" aria-label={t.a11y.zoomIn}
> >
<Plus size={12} /> <Plus size={12} />
</button> </button>
@@ -44,7 +44,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
if (failed) { if (failed) {
return ( return (
<div className={cn("flex items-center justify-center bg-muted/50", className)}> <div className={cn("flex items-center justify-center bg-muted/50", className)}>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" /> <ImageIcon className="h-8 w-8 text-muted-foreground" />
</div> </div>
); );
} }
+15 -3
View File
@@ -1,7 +1,8 @@
import { APP_VERSION } from "@snapotter/shared"; import { APP_VERSION } from "@snapotter/shared";
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react"; import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts"; import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
interface HelpDialogProps { interface HelpDialogProps {
@@ -25,6 +26,8 @@ const SHORTCUTS = [
export function HelpDialog({ open, onClose }: HelpDialogProps) { export function HelpDialog({ open, onClose }: HelpDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@@ -44,14 +47,23 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
onClick={onClose} onClick={onClose}
/> />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden"> <div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="help-dialog-title"
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0"> <div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">{t.help.heading}</h2> <h2 id="help-dialog-title" className="text-lg font-semibold text-foreground">
{t.help.heading}
</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground" className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t.a11y.closeHelp}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
+18 -4
View File
@@ -1,6 +1,7 @@
import { Globe, Menu, X } from "lucide-react"; import { Globe, Menu, X } from "lucide-react";
import { useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store"; import { useConnectionStore } from "@/stores/connection-store";
@@ -30,7 +31,9 @@ export function AppLayout({
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = 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 isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status); const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected"; 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" className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)} 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 justify-between p-3 border-b border-border">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" /> <OtterLogo className="h-5 w-5 text-primary" />
@@ -70,6 +79,7 @@ export function AppLayout({
type="button" type="button"
onClick={() => setMobileSidebarOpen(false)} onClick={() => setMobileSidebarOpen(false)}
className="p-2.5 rounded-lg hover:bg-muted" className="p-2.5 rounded-lg hover:bg-muted"
aria-label={t.a11y.closeSidebar}
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -118,6 +128,7 @@ export function AppLayout({
type="button" type="button"
onClick={() => setMobileSidebarOpen(true)} onClick={() => setMobileSidebarOpen(true)}
className="p-2.5 -ms-1 rounded-lg hover:bg-muted" className="p-2.5 -ms-1 rounded-lg hover:bg-muted"
aria-label={t.a11y.openSidebar}
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</button> </button>
@@ -132,7 +143,10 @@ export function AppLayout({
{showToolPanel && !isMobile && <ToolPanel />} {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"> <div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
{children || <Dropzone onFiles={onFiles} onUrlImport={onUrlImport} accept="image/*" />} {children || <Dropzone onFiles={onFiles} onUrlImport={onUrlImport} accept="image/*" />}
</div> </div>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
function LanguageSelector() { function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation(); const { t, locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -52,7 +52,7 @@ function LanguageSelector() {
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
role="img" role="img"
aria-label="Selected" aria-label={t.a11y.selected}
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
+5 -4
View File
@@ -44,6 +44,7 @@ export function Sidebar({
expanded = false, expanded = false,
}: SidebarProps) { }: SidebarProps) {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
const { topItems, bottomItems } = useNavItems(); const { topItems, bottomItems } = useNavItems();
const renderItem = (item: SidebarItem, isActive: boolean) => { const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -101,11 +102,11 @@ export function Sidebar({
if (expanded) { if (expanded) {
return ( return (
<div className="flex flex-col p-3 gap-1"> <nav aria-label={t.a11y.navigationMenu} className="flex flex-col p-3 gap-1">
{topItems.map((item) => renderItem(item, location.pathname === item.href))} {topItems.map((item) => renderItem(item, location.pathname === item.href))}
<div className="border-t border-border my-2" /> <div className="border-t border-border my-2" />
{bottomItems.map((item) => renderItem(item, false))} {bottomItems.map((item) => renderItem(item, false))}
</div> </nav>
); );
} }
@@ -115,9 +116,9 @@ export function Sidebar({
<OtterLogo className="h-7 w-7 text-primary" /> <OtterLogo className="h-7 w-7 text-primary" />
</div> </div>
<div className="border-t border-border w-10 mb-2" /> <div className="border-t border-border w-10 mb-2" />
<div className="flex flex-col gap-1 flex-1"> <nav aria-label={t.a11y.navigationMenu} className="flex flex-col gap-1 flex-1">
{topItems.map((item) => renderItem(item, location.pathname === item.href))} {topItems.map((item) => renderItem(item, location.pathname === item.href))}
</div> </nav>
<div className="border-t border-border w-10 my-2" /> <div className="border-t border-border w-10 my-2" />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{bottomItems.map((item) => renderItem(item, false))} {bottomItems.map((item) => renderItem(item, false))}
@@ -299,7 +299,7 @@ function BundleCard({
disabled disabled
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50"
> >
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
{t.settings.aiFeatures.installing} {t.settings.aiFeatures.installing}
</button> </button>
)} )}
@@ -26,9 +26,10 @@ import {
Wrench, Wrench,
X, X,
} from "lucide-react"; } from "lucide-react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
import { format, plural } from "@/lib/format"; import { format, plural } from "@/lib/format";
@@ -127,6 +128,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useMobile(); const isMobile = useMobile();
const NAV_ITEMS = useNavItems(); const NAV_ITEMS = useNavItems();
const dialogRef = useRef<HTMLDivElement>(null);
const mobileDialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open && !isMobile);
useFocusTrap(mobileDialogRef, open && isMobile);
const visibleNavItems = NAV_ITEMS.filter( const visibleNavItems = NAV_ITEMS.filter(
(item) => (item) =>
@@ -148,14 +153,23 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
if (isMobile) { if (isMobile) {
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-background"> <div
ref={mobileDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="settings-dialog-title-mobile"
className="fixed inset-0 z-50 flex flex-col bg-background"
>
{/* Mobile header */} {/* Mobile header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2 shrink-0"> <div className="flex items-center justify-between px-4 pt-4 pb-2 shrink-0">
<h2 className="text-sm font-semibold text-foreground">{t.settings.heading}</h2> <h2 id="settings-dialog-title-mobile" className="text-sm font-semibold text-foreground">
{t.settings.heading}
</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="p-2.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground" className="p-2.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t.a11y.closeSettings}
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -169,7 +183,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
type="button" type="button"
onClick={() => setSection(item.id)} onClick={() => setSection(item.id)}
className={cn( className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap shrink-0", "flex items-center gap-1.5 px-3 py-2.5 rounded-full text-xs font-medium whitespace-nowrap shrink-0",
section === item.id section === item.id
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground", : "bg-muted text-muted-foreground",
@@ -211,14 +225,18 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Dialog */} {/* Dialog */}
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="settings-dialog-title"
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85dvh] flex overflow-hidden" className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85dvh] flex overflow-hidden"
> >
{/* Sidebar nav */} {/* Sidebar nav */}
<div className="w-48 border-r border-border bg-muted/30 p-3 space-y-1 shrink-0"> <div className="w-48 border-r border-border bg-muted/30 p-3 space-y-1 shrink-0">
<div className="flex items-center justify-between mb-4 px-2"> <div className="flex items-center justify-between mb-4 px-2">
<h2 className="text-sm font-semibold text-foreground">{t.settings.heading}</h2> <h2 id="settings-dialog-title" className="text-sm font-semibold text-foreground">
{t.settings.heading}
</h2>
</div> </div>
{visibleNavItems.map((item) => ( {visibleNavItems.map((item) => (
<button <button
@@ -244,6 +262,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
type="button" type="button"
onClick={onClose} onClick={onClose}
className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground" className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t.a11y.closeSettings}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -426,6 +445,7 @@ function GeneralSection() {
<select <select
value={defaultToolView} value={defaultToolView}
onChange={(e) => setDefaultToolView(e.target.value)} onChange={(e) => setDefaultToolView(e.target.value)}
aria-label={t.settings.general.defaultToolViewLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
> >
<option value="sidebar">{t.settings.general.sidebarOption}</option> <option value="sidebar">{t.settings.general.sidebarOption}</option>
@@ -440,6 +460,7 @@ function GeneralSection() {
<select <select
value={locale} value={locale}
onChange={(e) => setLocale(e.target.value)} onChange={(e) => setLocale(e.target.value)}
aria-label={t.settings.system.languageLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
> >
{supportedLocales.map((l) => ( {supportedLocales.map((l) => (
@@ -464,7 +485,7 @@ function GeneralSection() {
disabled={saving} disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.general.saveButton} {t.settings.general.saveButton}
</button> </button>
{saveMsg && ( {saveMsg && (
@@ -553,6 +574,7 @@ function SystemSection() {
type="number" type="number"
value={settings.fileUploadLimitMb || "100"} value={settings.fileUploadLimitMb || "100"}
onChange={(e) => updateSetting("fileUploadLimitMb", e.target.value)} onChange={(e) => updateSetting("fileUploadLimitMb", e.target.value)}
aria-label={t.settings.system.fileUploadLimitLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1} min={1}
/> />
@@ -565,6 +587,7 @@ function SystemSection() {
<select <select
value={settings.defaultTheme || "system"} value={settings.defaultTheme || "system"}
onChange={(e) => updateSetting("defaultTheme", e.target.value)} onChange={(e) => updateSetting("defaultTheme", e.target.value)}
aria-label={t.settings.system.defaultThemeLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
> >
<option value="light">{t.settings.system.lightOption}</option> <option value="light">{t.settings.system.lightOption}</option>
@@ -580,6 +603,7 @@ function SystemSection() {
<select <select
value={settings.defaultLocale || "en"} value={settings.defaultLocale || "en"}
onChange={(e) => updateSetting("defaultLocale", e.target.value)} onChange={(e) => updateSetting("defaultLocale", e.target.value)}
aria-label={t.settings.system.languageLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
> >
{SUPPORTED_LOCALES.map((l) => ( {SUPPORTED_LOCALES.map((l) => (
@@ -598,6 +622,7 @@ function SystemSection() {
type="number" type="number"
value={settings.loginAttemptLimit || "5"} value={settings.loginAttemptLimit || "5"}
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)} onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
aria-label={t.settings.system.loginAttemptLimitLabel}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1} min={1}
max={100} max={100}
@@ -617,6 +642,7 @@ function SystemSection() {
type="number" type="number"
value={settings.tempFileMaxAgeHours || "24"} value={settings.tempFileMaxAgeHours || "24"}
onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)} onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)}
aria-label={t.settings.fileManagement.maxAge}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24" className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1} min={1}
/> />
@@ -627,6 +653,9 @@ function SystemSection() {
> >
<button <button
type="button" type="button"
role="switch"
aria-checked={settings.startupCleanup !== "false"}
aria-label={t.settings.fileManagement.startupCleanup}
onClick={() => onClick={() =>
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false") updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
} }
@@ -651,7 +680,7 @@ function SystemSection() {
disabled={saving} disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.system.saveButton} {t.settings.system.saveButton}
</button> </button>
{saveMsg && ( {saveMsg && (
@@ -731,13 +760,19 @@ function SecuritySection() {
<div className="space-y-3 max-w-sm"> <div className="space-y-3 max-w-sm">
<div className="relative"> <div className="relative">
<label htmlFor="current-password" className="sr-only">
{t.settings.security.currentPasswordPlaceholder}
</label>
<input <input
id="current-password"
type={showCurrent ? "text" : "password"} type={showCurrent ? "text" : "password"}
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t.settings.security.currentPasswordPlaceholder} placeholder={t.settings.security.currentPasswordPlaceholder}
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
required required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/> />
<button <button
type="button" type="button"
@@ -749,13 +784,19 @@ function SecuritySection() {
</div> </div>
<div className="relative"> <div className="relative">
<label htmlFor="new-password" className="sr-only">
{t.settings.security.newPasswordPlaceholder}
</label>
<input <input
id="new-password"
type={showNew ? "text" : "password"} type={showNew ? "text" : "password"}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.settings.security.newPasswordPlaceholder} placeholder={t.settings.security.newPasswordPlaceholder}
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
required required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/> />
<button <button
type="button" type="button"
@@ -767,13 +808,19 @@ function SecuritySection() {
</div> </div>
<div className="relative"> <div className="relative">
<label htmlFor="confirm-password" className="sr-only">
{t.settings.security.confirmPasswordPlaceholder}
</label>
<input <input
id="confirm-password"
type={showConfirm ? "text" : "password"} type={showConfirm ? "text" : "password"}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t.settings.security.confirmPasswordPlaceholder} placeholder={t.settings.security.confirmPasswordPlaceholder}
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
required required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/> />
<button <button
type="button" type="button"
@@ -787,6 +834,8 @@ function SecuritySection() {
{message && ( {message && (
<p <p
id="password-change-error"
role="alert"
className={cn( className={cn(
"text-sm", "text-sm",
message.type === "error" message.type === "error"
@@ -803,7 +852,7 @@ function SecuritySection() {
disabled={submitting} disabled={submitting}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.security.changePasswordButton} {t.settings.security.changePasswordButton}
</button> </button>
</div> </div>
@@ -1098,16 +1147,26 @@ function PeopleSection() {
{t.settings.people.newMemberHeading} {t.settings.people.newMemberHeading}
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input <div>
type="text" <label htmlFor="new-user-username" className="sr-only">
value={newUsername} {t.settings.people.usernamePlaceholder}
onChange={(e) => setNewUsername(e.target.value)} </label>
placeholder={t.settings.people.usernamePlaceholder}
required
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<div className="flex items-center gap-1.5">
<input <input
id="new-user-username"
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
placeholder={t.settings.people.usernamePlaceholder}
required
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex items-center gap-1.5">
<label htmlFor="new-user-password" className="sr-only">
{t.auth.password}
</label>
<input
id="new-user-password"
type={showGeneratedPw ? "text" : "password"} type={showGeneratedPw ? "text" : "password"}
value={newPassword} value={newPassword}
onChange={(e) => { onChange={(e) => {
@@ -1188,7 +1247,7 @@ function PeopleSection() {
disabled={adding || atLimit} disabled={adding || atLimit}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{adding && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {adding && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.common.create} {t.common.create}
</button> </button>
<button <button
@@ -1223,7 +1282,11 @@ function PeopleSection() {
{t.settings.people.copyPasswordWarning} {t.settings.people.copyPasswordWarning}
</p> </p>
)} )}
{addError && <p className="text-sm text-destructive">{addError}</p>} {addError && (
<p role="alert" className="text-sm text-destructive">
{addError}
</p>
)}
</form> </form>
)} )}
@@ -1440,6 +1503,7 @@ function PeopleSection() {
}} }}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Actions" title="Actions"
aria-label={t.common.actions}
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</button> </button>
@@ -1595,6 +1659,7 @@ function ApiKeysSection() {
value={keyName} value={keyName}
onChange={(e) => setKeyName(e.target.value)} onChange={(e) => setKeyName(e.target.value)}
placeholder={t.settings.apiKeys.keyNamePlaceholder} placeholder={t.settings.apiKeys.keyNamePlaceholder}
aria-label={t.settings.apiKeys.keyNamePlaceholder}
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48" className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48"
/> />
<button <button
@@ -1603,7 +1668,11 @@ function ApiKeysSection() {
disabled={generating} disabled={generating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />} {generating ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Key className="h-4 w-4" aria-hidden="true" />
)}
{t.settings.apiKeys.generateButton} {t.settings.apiKeys.generateButton}
</button> </button>
</div> </div>
@@ -1678,6 +1747,7 @@ function ApiKeysSection() {
onClick={() => copyKey(newKey)} onClick={() => copyKey(newKey)}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0" className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
title="Copy" title="Copy"
aria-label={t.common.copy}
> >
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />} {copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</button> </button>
@@ -1718,6 +1788,7 @@ function ApiKeysSection() {
onClick={() => deleteKey(k.id)} onClick={() => deleteKey(k.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors" className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete key" title="Delete key"
aria-label={t.a11y.deleteKey}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -1895,7 +1966,7 @@ function TeamsSection() {
disabled={creating} disabled={creating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{creating && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {creating && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.common.create} {t.common.create}
</button> </button>
<button <button
@@ -2359,6 +2430,7 @@ function RolesSection() {
}} }}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Edit role" title="Edit role"
aria-label={t.a11y.editRole}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</button> </button>
@@ -2367,6 +2439,7 @@ function RolesSection() {
onClick={() => handleDelete(role)} onClick={() => handleDelete(role)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors" className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete role" title="Delete role"
aria-label={t.a11y.deleteRole}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
@@ -2740,6 +2813,9 @@ function ToolsSection() {
</div> </div>
<button <button
type="button" type="button"
role="switch"
aria-checked={!isDisabled}
aria-label={getToolName(t, tool.id, tool.name)}
onClick={() => toggleTool(tool.id)} onClick={() => toggleTool(tool.id)}
className={cn( className={cn(
"w-11 h-6 rounded-full transition-colors relative shrink-0 ms-3", "w-11 h-6 rounded-full transition-colors relative shrink-0 ms-3",
@@ -2780,7 +2856,7 @@ function ToolsSection() {
disabled={saving || loadFailed} disabled={saving || loadFailed}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
> >
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />} {saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.tools.saveButton} {t.settings.tools.saveButton}
</button> </button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -2825,6 +2901,9 @@ function AnalyticsSection() {
</span> </span>
<button <button
type="button" type="button"
role="switch"
aria-checked={enabled}
aria-label={t.analytics.settingsTitle}
onClick={() => toggleAnalytics(!enabled)} onClick={() => toggleAnalytics(!enabled)}
className={cn( className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors", "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
@@ -56,7 +56,7 @@ function getBadgeColor(type: string): string {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -260,7 +260,7 @@ export function BarcodeReadSettings() {
<span className="text-sm text-muted-foreground">Thorough scan</span> <span className="text-sm text-muted-foreground">Thorough scan</span>
<span <span
title="Spends more time analyzing the image. Enable for small, damaged, or low-contrast barcodes." title="Spends more time analyzing the image. Enable for small, damaged, or low-contrast barcodes."
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help" className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
> >
? ?
</span> </span>
@@ -335,7 +335,7 @@ export function BarcodeReadSettings() {
)} )}
{fileResult.barcodes.length === 0 ? ( {fileResult.barcodes.length === 0 ? (
<p className="text-xs text-muted-foreground/60 italic py-1">No barcodes found</p> <p className="text-xs text-muted-foreground italic py-1">No barcodes found</p>
) : ( ) : (
fileResult.barcodes.map((barcode) => { fileResult.barcodes.map((barcode) => {
const idx = globalIndex++; const idx = globalIndex++;
@@ -128,7 +128,7 @@ export function BulkRenameSettings() {
disabled={!hasFiles || processing || !pattern} disabled={!hasFiles || processing || !pattern}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing {processing
? t.toolSettings["bulk-rename"].renaming ? t.toolSettings["bulk-rename"].renaming
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })} : format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
@@ -83,6 +83,7 @@ export function CollagePreview() {
/** Dropzone for initial image upload. */ /** Dropzone for initial image upload. */
function UploadArea() { function UploadArea() {
const { t } = useTranslation();
const addImages = useCollageStore((s) => s.addImages); const addImages = useCollageStore((s) => s.addImages);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -118,7 +119,7 @@ function UploadArea() {
return ( return (
<section <section
aria-label="File drop zone" aria-label={t.a11y.fileDropZone}
onDragEnter={handleDrag} onDragEnter={handleDrag}
onDragOver={handleDrag} onDragOver={handleDrag}
onDragLeave={handleDrag} onDragLeave={handleDrag}
@@ -322,6 +323,7 @@ function CollageCell({
gridRow: string; gridRow: string;
backgroundColor: string; backgroundColor: string;
}) { }) {
const { t } = useTranslation();
const store = useCollageStore(); const store = useCollageStore();
const cellRef = useRef<HTMLDivElement>(null); const cellRef = useRef<HTMLDivElement>(null);
const [controlsVisible, setControlsVisible] = useState(false); const [controlsVisible, setControlsVisible] = useState(false);
@@ -507,7 +509,7 @@ function CollageCell({
{isSelected && image && !isLoading && ( {isSelected && image && !isLoading && (
<div <div
role="toolbar" role="toolbar"
aria-label="Image controls" aria-label={t.a11y.imageControls}
className="absolute top-1.5 right-1.5 flex items-center gap-1" className="absolute top-1.5 right-1.5 flex items-center gap-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
@@ -541,7 +543,7 @@ function CollageCell({
ref={setDragRef} ref={setDragRef}
{...listeners} {...listeners}
{...attributes} {...attributes}
aria-label="Drag to reorder" aria-label={t.a11y.dragToReorder}
className="bg-black/50 backdrop-blur-sm text-white rounded p-1 cursor-grab active:cursor-grabbing hover:bg-black/70 transition-colors" className="bg-black/50 backdrop-blur-sm text-white rounded p-1 cursor-grab active:cursor-grabbing hover:bg-black/70 transition-colors"
> >
<GripVertical className="h-3.5 w-3.5" /> <GripVertical className="h-3.5 w-3.5" />
@@ -553,7 +555,7 @@ function CollageCell({
{isSelected && image && !isLoading && ( {isSelected && image && !isLoading && (
<div <div
role="toolbar" role="toolbar"
aria-label="Zoom controls" aria-label={t.a11y.zoomControls}
className={cn( className={cn(
"absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 py-2 bg-black/50 backdrop-blur-sm transition-opacity duration-300", "absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 py-2 bg-black/50 backdrop-blur-sm transition-opacity duration-300",
controlsVisible ? "opacity-100" : "opacity-0", controlsVisible ? "opacity-100" : "opacity-0",
@@ -373,7 +373,7 @@ export function CollageSettings() {
disabled={!hasImages || phase === "processing"} disabled={!hasImages || phase === "processing"}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{phase === "processing" && <Loader2 className="h-4 w-4 animate-spin" />} {phase === "processing" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{phase === "processing" ? "Creating..." : `Create Collage (${imageCount} images)`} {phase === "processing" ? "Creating..." : `Create Collage (${imageCount} images)`}
</button> </button>
@@ -61,7 +61,7 @@ export function ColorPaletteSettings() {
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing ? ts.extracting : ts.submit} {processing ? ts.extracting : ts.submit}
</button> </button>
@@ -428,7 +428,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -457,7 +457,7 @@ function SliderControl({
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}> <label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label} {label}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>} {hint && <span className="text-[10px] text-muted-foreground ms-1">({hint})</span>}
</label> </label>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span> <span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span>
</div> </div>
@@ -99,7 +99,7 @@ export function ColorizeSettings() {
onChange={(e) => setIntensity(Number(e.target.value))} onChange={(e) => setIntensity(Number(e.target.value))}
className="w-full mt-0.5" className="w-full mt-0.5"
/> />
<p className="text-[10px] text-muted-foreground/60 mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
Lower values produce more muted, vintage-style colors. Lower values produce more muted, vintage-style colors.
</p> </p>
</div> </div>
@@ -152,7 +152,7 @@ export function ColorizeSettings() {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -94,7 +94,7 @@ export function CompareSettings() {
disabled={!hasFile || !secondFile || processing} disabled={!hasFile || !secondFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing ? "Comparing..." : "Compare"} {processing ? "Comparing..." : "Compare"}
</button> </button>
@@ -167,7 +167,7 @@ export function ComposeSettings() {
disabled={!hasFile || !overlayFile || processing} disabled={!hasFile || !overlayFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing ? "Processing..." : t.toolSettings.compose.submit} {processing ? "Processing..." : t.toolSettings.compose.submit}
</button> </button>
@@ -92,7 +92,7 @@ export function EnhanceFacesControls({
onChange={(e) => setStrength(Number(e.target.value))} onChange={(e) => setStrength(Number(e.target.value))}
className="w-full mt-1" className="w-full mt-1"
/> />
<div className="flex justify-between text-[10px] text-muted-foreground/70 mt-0.5"> <div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Subtle</span> <span>Subtle</span>
<span>Maximum</span> <span>Maximum</span>
</div> </div>
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
/> />
<span className="text-sm text-foreground">Only enhance main face</span> <span className="text-sm text-foreground">Only enhance main face</span>
</label> </label>
<p className="text-[11px] text-muted-foreground/70 ms-6 mt-0.5"> <p className="text-[11px] text-muted-foreground ms-6 mt-0.5">
For portraits - ignores background faces For portraits - ignores background faces
</p> </p>
</div> </div>
@@ -133,7 +133,7 @@ export function EnhanceFacesControls({
onChange={(e) => setSensitivity(Number(e.target.value))} onChange={(e) => setSensitivity(Number(e.target.value))}
className="w-full mt-1" className="w-full mt-1"
/> />
<div className="flex justify-between text-[10px] text-muted-foreground/70 mt-0.5"> <div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Fewer faces</span> <span>Fewer faces</span>
<span>More faces</span> <span>More faces</span>
</div> </div>
@@ -266,7 +266,7 @@ export function FindDuplicatesResults() {
if (!results) { if (!results) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4"> <div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<Search className="h-10 w-10 text-muted-foreground/50" /> <Search className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose a detection mode and click "Scan" to find duplicates. Choose a detection mode and click "Scan" to find duplicates.
</p> </p>
@@ -309,7 +309,7 @@ export function FindDuplicatesSettings() {
disabled={!hasFiles || scanning} disabled={!hasFiles || scanning}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{scanning && <Loader2 className="h-4 w-4 animate-spin" />} {scanning && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{scanning {scanning
? uploadProgress < 100 ? uploadProgress < 100
? `Uploading... ${uploadProgress}%` ? `Uploading... ${uploadProgress}%`
@@ -175,7 +175,7 @@ export function HtmlToImageSettings() {
disabled={(store.mode === "url" ? !store.url : !store.htmlContent) || store.capturing} disabled={(store.mode === "url" ? !store.url : !store.htmlContent) || store.capturing}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50" className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
> >
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" />} {store.capturing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{store.capturing ? ts.capturing : ts.submit} {store.capturing ? ts.capturing : ts.submit}
</button> </button>
</form> </form>
@@ -285,7 +285,7 @@ export function ImageEnhancementControls({
)} )}
{/* Mode selector */} {/* Mode selector */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t.toolSettings.imageEnhancement.enhancementMode} {t.toolSettings.imageEnhancement.enhancementMode}
</p> </p>
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
@@ -309,7 +309,7 @@ export function ImageEnhancementControls({
{/* Intensity slider */} {/* Intensity slider */}
<div className="pt-1"> <div className="pt-1">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t.toolSettings.imageEnhancement.intensity} {t.toolSettings.imageEnhancement.intensity}
</p> </p>
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span> <span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
@@ -337,6 +337,8 @@ export function ImageEnhancementControls({
</div> </div>
<button <button
type="button" type="button"
role="switch"
aria-checked={deepEnhance}
onClick={() => setDeepEnhance(!deepEnhance)} onClick={() => setDeepEnhance(!deepEnhance)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
deepEnhance ? "bg-primary" : "bg-muted" deepEnhance ? "bg-primary" : "bg-muted"
@@ -360,7 +362,7 @@ export function ImageEnhancementControls({
{analysis && !analyzing && ( {analysis && !analyzing && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t.toolSettings.imageEnhancement.detectedIssues} {t.toolSettings.imageEnhancement.detectedIssues}
</p> </p>
{analysis.issues.length === 0 ? ( {analysis.issues.length === 0 ? (
@@ -412,7 +414,7 @@ export function ImageEnhancementControls({
style={{ width: `${score}%` }} style={{ width: `${score}%` }}
/> />
</div> </div>
<span className="text-[10px] text-muted-foreground/60 w-10 shrink-0">{label}</span> <span className="text-[10px] text-muted-foreground w-10 shrink-0">{label}</span>
</div> </div>
))} ))}
</div> </div>
@@ -84,7 +84,7 @@ export function ImageToBase64Settings() {
{/* Output Format */} {/* Output Format */}
<div> <div>
<span className="text-xs font-medium text-muted-foreground">Output Image Format</span> <span className="text-xs font-medium text-muted-foreground">Output Image Format</span>
<p className="text-[10px] text-muted-foreground/70 mb-1.5"> <p className="text-[10px] text-muted-foreground mb-1.5">
Convert before encoding to control MIME type and size Convert before encoding to control MIME type and size
</p> </p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -123,9 +123,7 @@ export function ImageToBase64Settings() {
onChange={(e) => setQuality(Number(e.target.value))} onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1 accent-primary" className="w-full mt-1 accent-primary"
/> />
<p className="text-[10px] text-muted-foreground/70"> <p className="text-[10px] text-muted-foreground">Lower quality = smaller base64 string</p>
Lower quality = smaller base64 string
</p>
</div> </div>
)} )}
@@ -141,7 +139,7 @@ export function ImageToBase64Settings() {
value={maxWidth} value={maxWidth}
onChange={(e) => setMaxWidth(Math.max(0, Number(e.target.value)))} onChange={(e) => setMaxWidth(Math.max(0, Number(e.target.value)))}
placeholder="0 = no limit" placeholder="0 = no limit"
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none" className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground outline-none"
/> />
</div> </div>
@@ -157,9 +155,9 @@ export function ImageToBase64Settings() {
value={maxHeight} value={maxHeight}
onChange={(e) => setMaxHeight(Math.max(0, Number(e.target.value)))} onChange={(e) => setMaxHeight(Math.max(0, Number(e.target.value)))}
placeholder="0 = no limit" placeholder="0 = no limit"
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 outline-none" className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground outline-none"
/> />
<p className="text-[10px] text-muted-foreground/70 mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
Resize before encoding. Aspect ratio is preserved. 0 = no limit. Resize before encoding. Aspect ratio is preserved. 0 = no limit.
</p> </p>
</div> </div>
@@ -172,7 +170,7 @@ export function ImageToBase64Settings() {
disabled={!hasFiles || processing} disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing {processing
? "Converting..." ? "Converting..."
: `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`} : `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`}
@@ -133,7 +133,7 @@ export function InfoSettings() {
disabled={!hasFile || processing} disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing ? ts.reading : ts.readInfo} {processing ? ts.reading : ts.readInfo}
</button> </button>
@@ -267,7 +267,7 @@ function EditorSettings() {
> >
{generating ? ( {generating ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Generating... Generating...
</> </>
) : ( ) : (
@@ -34,7 +34,7 @@ const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -253,7 +253,7 @@ export function OcrSettings() {
</span> </span>
<span <span
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy." title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help" className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
> >
<Info className="h-2.5 w-2.5" /> <Info className="h-2.5 w-2.5" />
</span> </span>
@@ -264,7 +264,7 @@ export function OcrSettings() {
<button <button
type="button" type="button"
onClick={() => setLangOpen(!langOpen)} onClick={() => setLangOpen(!langOpen)}
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1" className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
> >
{langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Language Language
@@ -157,7 +157,7 @@ function groupByRegion(): Map<PassportRegion, PassportSpec[]> {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -326,7 +326,7 @@ function CountryOption({
> >
<span>{spec.flag}</span> <span>{spec.flag}</span>
<span className="flex-1 text-start">{spec.name}</span> <span className="flex-1 text-start">{spec.name}</span>
<span className="text-muted-foreground/60 tabular-nums text-[10px]"> <span className="text-muted-foreground tabular-nums text-[10px]">
{formatDimensions(doc)} {formatDimensions(doc)}
</span> </span>
{selected && <Check className="h-3 w-3 text-primary shrink-0" />} {selected && <Check className="h-3 w-3 text-primary shrink-0" />}
@@ -656,7 +656,7 @@ export function PassportPhotoSettings() {
if (!specs || specs.length === 0) return null; if (!specs || specs.length === 0) return null;
return ( return (
<div key={region}> <div key={region}>
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60"> <p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{REGION_LABELS[region]} {REGION_LABELS[region]}
</p> </p>
{specs.map((spec) => ( {specs.map((spec) => (
@@ -207,7 +207,7 @@ export function PdfToImageSettings() {
className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/> />
) : ( ) : (
<p className="text-xs text-muted-foreground/60 mt-1">{DPI_LABELS[store.dpi] ?? ""}</p> <p className="text-xs text-muted-foreground mt-1">{DPI_LABELS[store.dpi] ?? ""}</p>
)} )}
</div> </div>
@@ -265,7 +265,7 @@ export function PdfToImageSettings() {
disabled={!store.file || !store.pageCount || store.processing || selectedCount === 0} disabled={!store.file || !store.pageCount || store.processing || selectedCount === 0}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />} {store.processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{store.processing {store.processing
? t.toolSettings["pdf-to-image"].converting ? t.toolSettings["pdf-to-image"].converting
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`} : `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
@@ -73,7 +73,7 @@ const GRADIENT_PRESETS = [
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -425,7 +425,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
<button <button
type="button" type="button"
onClick={() => setEffectsOpen(!effectsOpen)} onClick={() => setEffectsOpen(!effectsOpen)}
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1" className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
> >
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Effects Effects
@@ -397,7 +397,7 @@ export function SharpeningSettings() {
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
{children} {children}
</p> </p>
); );
@@ -429,7 +429,7 @@ function SliderControl({
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}> <label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label} {label}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>} {hint && <span className="text-[10px] text-muted-foreground ms-1">({hint})</span>}
</label> </label>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end"> <span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{displayValue} {displayValue}
@@ -81,7 +81,7 @@ export function SplitCanvas() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center"> <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" /> <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Generating preview...</p> <p className="text-sm text-muted-foreground">Generating preview...</p>
<p className="text-xs text-muted-foreground/60">{filename}</p> <p className="text-xs text-muted-foreground">{filename}</p>
</div> </div>
); );
} }
@@ -91,7 +91,7 @@ export function SplitCanvas() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center p-4"> <div className="flex flex-col items-center justify-center h-full gap-3 text-center p-4">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Loading preview...</p> <p className="text-sm text-muted-foreground">Loading preview...</p>
<p className="text-xs text-muted-foreground/60">{filename}</p> <p className="text-xs text-muted-foreground">{filename}</p>
</div> </div>
); );
} }
@@ -402,7 +402,7 @@ export function SplitSettings() {
disabled={!hasFile || processing || fileStoreProcessing} disabled={!hasFile || processing || fileStoreProcessing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing {processing
? "Splitting..." ? "Splitting..."
: files.length > 1 : files.length > 1
@@ -321,7 +321,7 @@ export function StitchSettings() {
disabled={!hasEnoughFiles || processing} disabled={!hasEnoughFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing {processing
? uploadProgress < 100 ? uploadProgress < 100
? `Uploading... ${uploadProgress}%` ? `Uploading... ${uploadProgress}%`
@@ -1,6 +1,7 @@
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card"; import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
@@ -48,6 +49,7 @@ function parseSvgDimensions(file: File): Promise<SvgDims | null> {
} }
export function SvgToRasterSettings() { export function SvgToRasterSettings() {
const { t } = useTranslation();
const { files } = useFileStore(); const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("svg-to-raster"); useToolProcessor("svg-to-raster");
@@ -322,13 +324,13 @@ export function SvgToRasterSettings() {
type="button" type="button"
onClick={() => setBgColor("#ffffff")} onClick={() => setBgColor("#ffffff")}
className={`w-8 h-8 rounded border-2 bg-white ${bgColor === "#ffffff" ? "border-primary" : "border-border"}`} className={`w-8 h-8 rounded border-2 bg-white ${bgColor === "#ffffff" ? "border-primary" : "border-border"}`}
aria-label="White background" aria-label={t.a11y.whiteBackground}
/> />
<button <button
type="button" type="button"
onClick={() => setBgColor("#000000")} onClick={() => setBgColor("#000000")}
className={`w-8 h-8 rounded border-2 bg-black ${bgColor === "#000000" ? "border-primary" : "border-border"}`} className={`w-8 h-8 rounded border-2 bg-black ${bgColor === "#000000" ? "border-primary" : "border-border"}`}
aria-label="Black background" aria-label={t.a11y.blackBackground}
/> />
<input <input
type="color" type="color"
@@ -53,8 +53,9 @@ export function TransparencyFixerControls({
</div> </div>
<button <button
type="button" type="button"
role="switch"
data-testid="remove-watermark-toggle" data-testid="remove-watermark-toggle"
aria-pressed={removeWatermark} aria-checked={removeWatermark}
onClick={() => setRemoveWatermark(!removeWatermark)} onClick={() => setRemoveWatermark(!removeWatermark)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
removeWatermark ? "bg-primary" : "bg-muted" removeWatermark ? "bg-primary" : "bg-muted"
@@ -72,7 +73,7 @@ export function TransparencyFixerControls({
<button <button
type="button" type="button"
onClick={() => setAdvancedOpen(!advancedOpen)} onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1" className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
> >
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Advanced Advanced
@@ -100,7 +101,7 @@ export function TransparencyFixerControls({
{/* Output Format dropdown */} {/* Output Format dropdown */}
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 mb-1"> <p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Output Format Output Format
</p> </p>
<select <select
@@ -158,7 +158,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
onChange={(e) => setDenoise(Number(e.target.value))} onChange={(e) => setDenoise(Number(e.target.value))}
className="w-full mt-1" className="w-full mt-1"
/> />
<p className="text-[11px] text-muted-foreground/70 mt-1"> <p className="text-[11px] text-muted-foreground mt-1">
Smooths out grain and noise. Higher values remove more noise but may soften details. Smooths out grain and noise. Higher values remove more noise but may soften details.
</p> </p>
</div> </div>
@@ -329,6 +329,9 @@ export function VectorizeSettings() {
<span className="text-xs text-muted-foreground">Invert Colors</span> <span className="text-xs text-muted-foreground">Invert Colors</span>
<button <button
type="button" type="button"
role="switch"
aria-checked={invert}
aria-label={t.toolSettings.vectorize.invertColors}
onClick={() => updateSetting(setInvert)(!invert)} onClick={() => updateSetting(setInvert)(!invert)}
className={`w-9 h-5 rounded-full transition-colors ${invert ? "bg-primary" : "bg-muted"}`} className={`w-9 h-5 rounded-full transition-colors ${invert ? "bg-primary" : "bg-muted"}`}
> >
@@ -197,7 +197,7 @@ export function WatermarkImageSettings() {
disabled={!hasFile || !watermarkFile || processing} disabled={!hasFile || !watermarkFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{processing && <Loader2 className="h-4 w-4 animate-spin" />} {processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing {processing
? "Processing..." ? "Processing..."
: files.length > 1 : files.length > 1
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef } from "react";
const FOCUSABLE_SELECTOR = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean) {
const returnFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active || !containerRef.current) return;
returnFocusRef.current = document.activeElement as HTMLElement;
const container = containerRef.current;
const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.closest("[aria-hidden='true']"),
);
const focusFirst = () => {
const elements = getFocusableElements();
if (elements.length > 0) {
elements[0].focus();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const elements = getFocusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
const observer = new MutationObserver(() => {
const elements = getFocusableElements();
if (elements.length > 0 && !container.contains(document.activeElement)) {
elements[0].focus();
}
});
observer.observe(container, { childList: true, subtree: true });
container.addEventListener("keydown", handleKeyDown);
requestAnimationFrame(focusFirst);
return () => {
container.removeEventListener("keydown", handleKeyDown);
observer.disconnect();
if (returnFocusRef.current && returnFocusRef.current.isConnected) {
returnFocusRef.current.focus();
}
};
}, [active, containerRef]);
}
@@ -54,9 +54,7 @@ export function AnalyticsConsentPage() {
</p> </p>
</div> </div>
<p className="text-center text-xs text-muted-foreground/60"> <p className="text-center text-xs text-muted-foreground">{t.analytics.consentChangeable}</p>
{t.analytics.consentChangeable}
</p>
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<button <button
+3 -3
View File
@@ -910,7 +910,7 @@ export function AutomatePage() {
{/* Preview content */} {/* Preview content */}
{!previewCollapsed && ( {!previewCollapsed && (
<section <section
aria-label="Image area" aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col overflow-hidden min-h-0" className="flex-1 flex flex-col overflow-hidden min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined} onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined} tabIndex={hasMultiple ? 0 : undefined}
@@ -921,7 +921,7 @@ export function AutomatePage() {
type="button" type="button"
onClick={navigatePrev} 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" 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" /> <ChevronLeft className="h-4 w-4" />
</button> </button>
@@ -931,7 +931,7 @@ export function AutomatePage() {
type="button" type="button"
onClick={navigateNext} 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" 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" /> <ChevronRight className="h-4 w-4" />
</button> </button>
+1
View File
@@ -151,6 +151,7 @@ export function EditorPage() {
return ( return (
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground"> <div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
<EditorMenuBar <EditorMenuBar
onNewDocument={() => setShowNewDocument(true)} onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage} onOpenImage={handleOpenImage}
+3 -1
View File
@@ -18,6 +18,7 @@ export function FilesPage() {
if (isMobile) { if (isMobile) {
return ( return (
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex flex-col h-full w-full overflow-hidden"> <div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile tabs */} {/* Mobile tabs */}
<div className="flex border-b border-border"> <div className="flex border-b border-border">
@@ -68,7 +69,7 @@ export function FilesPage() {
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="File Details" aria-label={t.a11y.fileDetails}
className="fixed inset-0 z-50 bg-black/50" className="fixed inset-0 z-50 bg-black/50"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) setShowDetails(false); if (e.target === e.currentTarget) setShowDetails(false);
@@ -95,6 +96,7 @@ export function FilesPage() {
return ( return (
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex h-full w-full overflow-hidden"> <div className="flex h-full w-full overflow-hidden">
<FilesNav /> <FilesNav />
{activeTab === "recent" ? ( {activeTab === "recent" ? (
@@ -138,6 +138,7 @@ export function FullscreenGridPage() {
{/* Grid */} {/* Grid */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6"> <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 ? ( {activeCategories.length === 0 ? (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" /> <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) { if (isMobile && hasFile) {
return ( return (
<AppLayout showToolPanel={false} onFiles={handleFiles}> <AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
{/* File info bar */} {/* File info bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border"> <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)} {getToolName(t, tool.id, tool.name)}
</span> </span>
{status === "not_installed" && ( {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" && ( {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> </button>
); );
@@ -160,7 +175,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center"> <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" /> <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-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> </div>
) : originalBlobUrl ? ( ) : originalBlobUrl ? (
<ImageViewer <ImageViewer
@@ -182,6 +197,7 @@ export function HomePage() {
// File uploaded — desktop: tool selector on left, image preview on right // File uploaded — desktop: tool selector on left, image preview on right
return ( return (
<AppLayout showToolPanel={false} onFiles={handleFiles}> <AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex h-full w-full"> <div className="flex h-full w-full">
{/* Left panel: Tool selector */} {/* Left panel: Tool selector */}
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0"> <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)} {getToolName(t, tool.id, tool.name)}
</span> </span>
{status === "not_installed" && ( {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" && ( {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" && ( {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> </button>
); );
@@ -279,13 +313,31 @@ export function HomePage() {
<Icon className="h-4 w-4 text-muted-foreground shrink-0" /> <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span> <span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
{status === "not_installed" && ( {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" && ( {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" && ( {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> </button>
); );
@@ -305,7 +357,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center"> <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" /> <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-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> </div>
) : originalBlobUrl ? ( ) : originalBlobUrl ? (
<ImageViewer <ImageViewer
+3 -3
View File
@@ -38,7 +38,7 @@ function RotatingPhrase() {
} }
function LanguageSelector() { function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation(); const { t, locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -74,7 +74,7 @@ function LanguageSelector() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
role="img" role="img"
aria-label="Language" aria-label={t.a11y.language}
> >
<circle cx="12" cy="12" r="10" /> <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" /> <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" strokeLinejoin="round"
className="text-primary shrink-0" className="text-primary shrink-0"
role="img" role="img"
aria-label="Selected" aria-label={t.a11y.selected}
> >
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
+32 -11
View File
@@ -27,6 +27,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download"; import { formatFileSize } from "@/lib/download";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map"; import { ICON_MAP } from "@/lib/icon-map";
import { getToolName } from "@/lib/tool-i18n"; import { getToolName } from "@/lib/tool-i18n";
import { getToolRegistryEntry } from "@/lib/tool-registry"; import { getToolRegistryEntry } from "@/lib/tool-registry";
@@ -117,7 +118,7 @@ function FileSelectionInfo({
> >
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />} {isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
<span className="truncate flex-1 min-w-0">{file.name}</span> <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)} {getFileFormat(file.name)}
</span> </span>
<span className="shrink-0 text-[10px] tabular-nums">{formatFileSize(file.size)}</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 hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1; 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( const handleImageKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") { 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"> <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" /> <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-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> </div>
); );
} }
@@ -706,7 +714,7 @@ export function ToolPage() {
type="button" type="button"
onClick={navigatePrev} 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" 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" /> <ChevronLeft className="h-4 w-4" />
</button> </button>
@@ -716,13 +724,20 @@ export function ToolPage() {
type="button" type="button"
onClick={navigateNext} 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" 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" /> <ChevronRight className="h-4 w-4" />
</button> </button>
)} )}
{hasMultiple && ( {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} {selectedIndex + 1} / {entries.length}
</div> </div>
)} )}
@@ -796,9 +811,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground"> <div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" /> <IconComponent className="h-5 w-5" />
</div> </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)} {getToolName(t, tool.id, tool.name)}
</h2> </h1>
<button <button
type="button" type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)} onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
@@ -810,11 +825,14 @@ export function ToolPage() {
{/* Main area: image viewer (full height) */} {/* Main area: image viewer (full height) */}
<section <section
aria-label="Image area" aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0" className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined} onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : 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"> <div className="flex-1 relative flex items-center justify-center p-4 min-h-0 min-w-0">
{renderNavArrows()} {renderNavArrows()}
{renderImageArea()} {renderImageArea()}
@@ -851,9 +869,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground"> <div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" /> <IconComponent className="h-5 w-5" />
</div> </div>
<h2 className="font-semibold text-lg text-foreground"> <h1 className="font-semibold text-lg text-foreground">
{getToolName(t, tool.id, tool.name)} {getToolName(t, tool.id, tool.name)}
</h2> </h1>
</div> </div>
{renderSettingsContent()} {renderSettingsContent()}
@@ -861,11 +879,14 @@ export function ToolPage() {
{/* Main area: image viewer */} {/* Main area: image viewer */}
<section <section
aria-label="Image area" aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0" className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined} onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : 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"> <div className="flex-1 relative flex items-center justify-center p-6 min-h-0 min-w-0">
{renderNavArrows()} {renderNavArrows()}
{renderImageArea()} {renderImageArea()}
+16 -1
View File
@@ -10,7 +10,7 @@
} }
@theme { @theme {
--color-primary: #3b82f6; --color-primary: #2563eb;
--color-primary-foreground: #ffffff; --color-primary-foreground: #ffffff;
--color-background: #ffffff; --color-background: #ffffff;
--color-foreground: #0f172a; --color-foreground: #0f172a;
@@ -126,3 +126,18 @@ input[type="range"]::-moz-range-track {
input[type="checkbox"] { width: 20px; height: 20px; } input[type="checkbox"] { width: 20px; height: 20px; }
button, [role="button"], a { touch-action: manipulation; } button, [role="button"], a { touch-action: manipulation; }
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-spin {
animation-duration: 1.5s !important;
animation-iteration-count: infinite !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
describe("useFocusTrap", () => {
it("should be importable", async () => {
const mod = await import("../../apps/web/src/hooks/use-focus-trap");
expect(mod.useFocusTrap).toBeDefined();
});
});