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 { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { RouteAnnouncer } from "./components/common/route-announcer";
import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile";
@@ -221,6 +222,13 @@ export function App() {
<ConnectionMonitor />
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
<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>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
@@ -134,7 +134,7 @@ export function BeforeAfterSlider({
<div
ref={containerRef}
role="slider"
aria-label="Before/after comparison slider"
aria-label={t.a11y.beforeAfterSlider}
aria-valuenow={Math.round(position)}
aria-valuemin={0}
aria-valuemax={100}
@@ -1,6 +1,8 @@
import { useDrag } from "@use-gesture/react";
import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface BottomSheetProps {
open: boolean;
@@ -17,7 +19,9 @@ export function BottomSheet({
children,
maxHeight = "70dvh",
}: BottomSheetProps) {
const { t } = useTranslation();
const sheetRef = useRef<HTMLDivElement>(null);
useFocusTrap(sheetRef, open);
const [translateY, setTranslateY] = useState(0);
// Close on Escape key
@@ -74,6 +78,9 @@ export function BottomSheet({
{/* Sheet */}
<div
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"
style={{
maxHeight,
@@ -89,11 +96,14 @@ export function BottomSheet({
{/* Header */}
{title && (
<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
type="button"
onClick={handleDismiss}
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" />
</button>
+3 -3
View File
@@ -205,7 +205,7 @@ export function Dropzone({
return (
<section
aria-label="File drop zone"
aria-label={t.a11y.fileDropZone}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
@@ -239,7 +239,7 @@ export function Dropzone({
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
{t.dropzone.dropPrompt}
</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>
<button
type="button"
@@ -256,7 +256,7 @@ export function Dropzone({
<Upload className="h-4 w-4" />
{t.common.upload}
</button>
<p className="text-xs text-muted-foreground/50">
<p className="text-xs text-muted-foreground">
{acceptDescription ?? t.dropzone.defaultFormats}
</p>
@@ -1,6 +1,7 @@
import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import {
apiListFiles,
formatHeaders,
@@ -40,7 +41,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
if (failed) {
return (
<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>
);
}
@@ -64,6 +65,8 @@ interface FileLibraryModalProps {
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
const [files, setFiles] = useState<UserFile[]>([]);
const [loading, setLoading] = 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"
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 */}
<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" />
<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}
</h2>
<button
@@ -1,6 +1,7 @@
import { useGesture } from "@use-gesture/react";
import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import { cn } from "@/lib/utils";
@@ -49,6 +50,7 @@ export function ImageViewer({
imageWrapperStyle,
imageWrapperChildren,
}: ImageViewerProps) {
const { t } = useTranslation();
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
@@ -203,6 +205,7 @@ export function ImageViewer({
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"
title="Zoom out"
aria-label={t.a11y.zoomOut}
>
<ZoomOut className="h-4 w-4" />
</button>
@@ -215,6 +218,7 @@ export function ImageViewer({
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"
title="Zoom in"
aria-label={t.a11y.zoomIn}
>
<ZoomIn className="h-4 w-4" />
</button>
@@ -224,6 +228,7 @@ export function ImageViewer({
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"}`}
title="Fit to view"
aria-label={t.a11y.fitToView}
>
<Maximize className="h-3.5 w-3.5" />
</button>
@@ -232,6 +237,7 @@ export function ImageViewer({
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"}`}
title="Actual size (100%)"
aria-label={t.a11y.actualSize}
>
<Minimize2 className="h-3.5 w-3.5" />
</button>
@@ -251,7 +257,7 @@ export function ImageViewer({
<div className="flex flex-col items-center justify-center gap-3 text-center">
<FileImage className="h-8 w-8 text-muted-foreground" />
<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>
) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? (
/* 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 { ImageViewer } from "@/components/common/image-viewer";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { useTranslation } from "@/contexts/i18n-context";
import { useFileStore } from "@/stores/file-store";
const BROWSER_PREVIEWABLE_EXTS = new Set([
@@ -24,6 +25,7 @@ function canBrowserPreview(url: string): boolean {
}
export function MultiImageViewer() {
const { t } = useTranslation();
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
const handleKeyDown = useCallback(
@@ -65,7 +67,7 @@ export function MultiImageViewer() {
return (
<section
aria-label="Image viewer"
aria-label={t.a11y.imageViewer}
className="flex flex-col w-full h-full min-h-0"
onKeyDown={hasMultiple ? handleKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -76,7 +78,7 @@ export function MultiImageViewer() {
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
aria-label={t.a11y.previousImage}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -120,7 +122,7 @@ export function MultiImageViewer() {
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
aria-label={t.a11y.nextImage}
>
<ChevronRight className="h-4 w-4" />
</button>
@@ -22,7 +22,11 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · ");
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="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
{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}
onChange={(e) => onChange(e.target.value)}
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"
/>
</div>
+21 -4
View File
@@ -36,8 +36,9 @@ export function ToolCard({ tool }: ToolCardProps) {
<div className="group flex items-center gap-3 relative">
<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}
aria-label={t.toolCard.addToFavourites}
>
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button>
@@ -58,10 +59,26 @@ export function ToolCard({ tool }: ToolCardProps) {
{t.common.experimental}
</span>
)}
{aiStatus === "not_installed" && <Download className="h-3.5 w-3.5 text-muted-foreground" />}
{aiStatus === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
{aiStatus === "not_installed" && (
<>
<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" && (
<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>
</div>
@@ -1,6 +1,7 @@
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 { useFocusTrap } from "@/hooks/use-focus-trap";
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
import { format } from "@/lib/format";
import { extractUrls } from "@/lib/url-parser";
@@ -45,6 +46,8 @@ function filenameFromUrl(url: string): string {
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, true);
const [text, setText] = useState("");
const [adding, setAdding] = useState(false);
@@ -104,18 +107,22 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
{/* Modal card */}
<div
ref={dialogRef}
role="dialog"
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"
>
{/* Header */}
<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" />
<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
type="button"
onClick={handleClose}
aria-label="Close"
aria-label={t.common.close}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
@@ -190,7 +197,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
>
{adding ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t.urlImport.adding}
</>
) : (
@@ -215,7 +222,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
>
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t.urlImport.adding}
</>
) : (
@@ -1,5 +1,6 @@
import { X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { AnchorPosition } from "@/types/editor";
@@ -21,6 +22,7 @@ const ANCHOR_POSITIONS: AnchorPosition[] = [
];
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
@@ -57,7 +59,7 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose:
<button
type="button"
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"
>
<X className="h-4 w-4" />
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type {
@@ -63,6 +64,7 @@ function getMimeType(format: ExportFormat): string {
}
export function ExportDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize);
const markClean = useEditorStore((s) => s.markClean);
@@ -454,7 +456,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
type="button"
onClick={onClose}
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Close"
aria-label={t.common.close}
>
<X size={16} />
</button>
@@ -1,6 +1,7 @@
// apps/web/src/components/editor/common/fill-dialog.tsx
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn, generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
@@ -44,6 +45,7 @@ function resolveColor(
}
export function FillDialog({ open, onClose }: FillDialogProps) {
const { t } = useTranslation();
const [content, setContent] = useState<FillContent>("foreground");
const [customColor, setCustomColor] = useState("#ff0000");
const [opacity, setOpacity] = useState(100);
@@ -119,7 +121,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {
<div
className="w-80 rounded-lg bg-card border border-border shadow-xl p-4"
role="dialog"
aria-label="Fill"
aria-label={t.a11y.fill}
>
<h3 className="text-sm font-semibold text-foreground mb-3">Fill</h3>
@@ -1,9 +1,11 @@
import { Lock, Unlock, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage);
@@ -68,7 +70,7 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
<button
type="button"
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"
>
<X className="h-4 w-4" />
@@ -1,9 +1,11 @@
// apps/web/src/components/editor/common/loading-overlay.tsx
import { X } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
export function LoadingOverlay() {
const { t } = useTranslation();
const loadingState = useEditorStore((s) => s.loadingState);
const setLoadingState = useEditorStore((s) => s.setLoadingState);
@@ -43,7 +45,7 @@ export function LoadingOverlay() {
type="button"
onClick={() => setLoadingState(null)}
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} />
Cancel
@@ -60,7 +60,7 @@ export function WelcomeScreen() {
return (
<>
<section
aria-label="Image drop zone"
aria-label={t.a11y.imageDropZone}
className="absolute inset-0 flex items-center justify-center z-10"
onDrop={handleDrop}
onDragOver={handleDragOver}
@@ -404,7 +404,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
className={cn(
"flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm",
item.disabled
? "text-muted-foreground/50"
? "text-muted-foreground"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`menu-item-${toTestId(item.label)}`}
@@ -436,7 +436,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
className={cn(
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-start",
item.disabled
? "text-muted-foreground/50 pointer-events-none"
? "text-muted-foreground pointer-events-none"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
disabled={item.disabled}
@@ -1,5 +1,6 @@
// apps/web/src/components/editor/editor-right-panel.tsx
import { ChevronRight } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import { AdjustmentsPanel } from "./panels/adjustments-panel";
@@ -15,6 +16,7 @@ const TABS = [
];
export function EditorRightPanel() {
const { t } = useTranslation();
const visible = useEditorStore((s) => s.rightPanelVisible);
const activeTab = useEditorStore((s) => s.rightPanelTab);
const setTab = useEditorStore((s) => s.setRightPanelTab);
@@ -27,7 +29,7 @@ export function EditorRightPanel() {
type="button"
onClick={togglePanel}
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" />
</button>
@@ -63,7 +65,7 @@ export function EditorRightPanel() {
type="button"
onClick={togglePanel}
className="px-1.5 py-2 text-muted-foreground hover:text-foreground"
aria-label="Collapse panel"
aria-label={t.a11y.collapsePanel}
>
<ChevronRight size={14} />
</button>
@@ -1,6 +1,7 @@
import { ArrowLeftRight, Check, X } from "lucide-react";
import { useCallback, useState } from "react";
import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -9,6 +10,7 @@ import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
export function CropOptions() {
const { t } = useTranslation();
const cropState = useEditorStore((s) => s.cropState);
const setCropState = useEditorStore((s) => s.setCropState);
const applyCrop = useEditorStore((s) => s.applyCrop);
@@ -123,7 +125,7 @@ export function CropOptions() {
type="button"
onClick={handleSwap}
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"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
@@ -150,7 +152,7 @@ export function CropOptions() {
type="button"
onClick={handleApply}
title="Apply Crop (Enter)"
aria-label="Apply Crop"
aria-label={t.a11y.applyCrop}
className={cn(
"flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground",
"hover:bg-primary/90 transition-colors",
@@ -163,7 +165,7 @@ export function CropOptions() {
type="button"
onClick={handleCancel}
title="Cancel Crop (Escape)"
aria-label="Cancel Crop"
aria-label={t.a11y.cancelCrop}
className={cn(
"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",
@@ -11,6 +11,7 @@ import {
Underline,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor";
@@ -131,6 +132,7 @@ function NumberInput({
// ---------------------------------------------------------------------------
function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const fonts = useMemo(() => getAllFonts(), []);
@@ -162,7 +164,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
<div ref={containerRef} className="relative">
<button
type="button"
aria-label="Font family"
aria-label={t.a11y.fontFamily}
onClick={() => setOpen((v) => !v)}
className={cn(
"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() {
const { t } = useTranslation();
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects);
@@ -409,7 +412,7 @@ export function TextOptions() {
/>
<input
type="color"
aria-label="Text color"
aria-label={t.a11y.textColor}
value={attrs.fill}
onChange={(e) => updateSelected({ fill: e.target.value })}
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 { useCallback } from "react";
import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
@@ -55,6 +56,7 @@ function NumericInput({
}
export function TransformOptions({ api }: { api: TransformToolApi }) {
const { t } = useTranslation();
const {
values,
lockedAspect,
@@ -132,7 +134,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
type="button"
onClick={flipHorizontal}
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"
>
<FlipHorizontal2 className="h-4 w-4" />
@@ -141,7 +143,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
type="button"
onClick={flipVertical}
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"
>
<FlipVertical2 className="h-4 w-4" />
@@ -3,6 +3,7 @@
import { ArrowLeftRight, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import { ColorSwatch } from "../common/color-swatch";
@@ -108,6 +109,7 @@ function ColorInputFields({
color: string;
onColorChange: (hex: string) => void;
}) {
const { t } = useTranslation();
const rgb = hexToRgb(color);
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
@@ -130,7 +132,7 @@ function ColorInputFields({
)}
maxLength={7}
spellCheck={false}
aria-label="Hex color value"
aria-label={t.a11y.hexColorValue}
data-testid="color-hex-input"
/>
</div>
@@ -296,6 +298,7 @@ function ColorPickerPopover({
// --- Main component ---
export function ColorPanel() {
const { t } = useTranslation();
const foregroundColor = useEditorStore((s) => s.foregroundColor);
const backgroundColor = useEditorStore((s) => s.backgroundColor);
const recentColors = useEditorStore((s) => s.recentColors);
@@ -362,7 +365,7 @@ export function ColorPanel() {
pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border",
)}
style={{ backgroundColor: backgroundColor }}
aria-label="Background color"
aria-label={t.a11y.backgroundColor}
data-testid="bg-color-swatch"
/>
{/* Foreground swatch (top-left, overlapping) */}
@@ -374,7 +377,7 @@ export function ColorPanel() {
pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border",
)}
style={{ backgroundColor: foregroundColor }}
aria-label="Foreground color"
aria-label={t.a11y.foregroundColor}
data-testid="fg-color-swatch"
/>
@@ -388,7 +391,7 @@ export function ColorPanel() {
"bg-card/80 hover:bg-muted",
)}
title="Swap colors (X)"
aria-label="Swap foreground and background colors"
aria-label={t.a11y.swapColors}
data-testid="swap-colors"
>
<ArrowLeftRight size={10} />
@@ -404,7 +407,7 @@ export function ColorPanel() {
"bg-card/80 hover:bg-muted",
)}
title="Reset colors (D)"
aria-label="Reset to default black and white"
aria-label={t.a11y.resetColors}
data-testid="reset-colors"
>
<RotateCcw size={10} />
@@ -426,7 +429,7 @@ export function ColorPanel() {
)}
maxLength={7}
spellCheck={false}
aria-label="Foreground color hex value"
aria-label={t.a11y.foregroundHex}
data-testid="foreground-hex-input"
/>
</div>
@@ -22,6 +22,7 @@ import {
Undo2,
} from "lucide-react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -80,6 +81,7 @@ interface HistoryEntry {
}
export function HistoryPanel() {
const { t } = useTranslation();
const lastAction = useEditorStore((s) => s.lastAction);
// 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/30 cursor-not-allowed",
)}
aria-label="Undo"
aria-label={t.a11y.undo}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
@@ -185,7 +187,7 @@ export function HistoryPanel() {
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
aria-label="Redo"
aria-label={t.a11y.redo}
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 size={14} />
@@ -14,6 +14,7 @@ import {
Unlock,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { EditorLayer, ObjectEffects } from "@/types/editor";
@@ -80,6 +81,7 @@ const DEFAULT_STROKE: NonNullable<ObjectEffects["stroke"]> = {
// ---------------------------------------------------------------------------
export function LayersPanel() {
const { t } = useTranslation();
const layers = useEditorStore((s) => s.layers);
const activeLayerId = useEditorStore((s) => s.activeLayerId);
const objects = useEditorStore((s) => s.objects);
@@ -167,7 +169,11 @@ export function LayersPanel() {
</div>
{/* 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) => {
const realIndex = layers.findIndex((l) => l.id === layer.id);
return (
@@ -202,7 +208,7 @@ export function LayersPanel() {
onClick={addLayer}
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)"
aria-label="Add layer"
aria-label={t.a11y.addLayer}
data-testid="add-layer-btn"
>
<Plus size={16} />
@@ -217,7 +223,7 @@ export function LayersPanel() {
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"
title="Delete Layer"
aria-label="Delete layer"
aria-label={t.a11y.deleteLayer}
data-testid="delete-layer-btn"
>
<Trash2 size={16} />
@@ -523,7 +529,7 @@ function LayerRow({
className="w-full h-full object-cover"
/>
) : (
<Layers size={12} className="text-muted-foreground/50" />
<Layers size={12} className="text-muted-foreground" />
)}
</div>
@@ -673,7 +679,7 @@ function LayerEffectsSection({
}) {
return (
<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
</p>
@@ -2,6 +2,7 @@
import { Minus, Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useEditorStore } from "@/stores/editor-store";
const THUMBNAIL_MAX_HEIGHT = 80;
@@ -10,6 +11,7 @@ const MIN_ZOOM = 0.01;
const MAX_ZOOM = 64;
export function NavigatorPanel() {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
@@ -245,7 +247,7 @@ export function NavigatorPanel() {
type="button"
onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))}
className="p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Zoom out"
aria-label={t.a11y.zoomOut}
>
<Minus size={12} />
</button>
@@ -262,7 +264,7 @@ export function NavigatorPanel() {
type="button"
onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))}
className="p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Zoom in"
aria-label={t.a11y.zoomIn}
>
<Plus size={12} />
</button>
@@ -44,7 +44,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
if (failed) {
return (
<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>
);
}
+15 -3
View File
@@ -1,7 +1,8 @@
import { APP_VERSION } from "@snapotter/shared";
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 { useFocusTrap } from "@/hooks/use-focus-trap";
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
interface HelpDialogProps {
@@ -25,6 +26,8 @@ const SHORTCUTS = [
export function HelpDialog({ open, onClose }: HelpDialogProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, open);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
@@ -44,14 +47,23 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
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 */}
<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
type="button"
onClick={onClose}
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" />
</button>
+18 -4
View File
@@ -1,6 +1,7 @@
import { Globe, Menu, X } from "lucide-react";
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
@@ -30,7 +31,9 @@ export function AppLayout({
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const { locale, setLocale, supportedLocales } = useTranslation();
const mobileSidebarRef = useRef<HTMLDivElement>(null);
useFocusTrap(mobileSidebarRef, mobileSidebarOpen);
const { t, locale, setLocale, supportedLocales } = useTranslation();
const isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
@@ -58,7 +61,13 @@ export function AppLayout({
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
<div
ref={mobileSidebarRef}
role="dialog"
aria-modal="true"
aria-label={t.a11y.openSidebar}
className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left"
>
<div className="flex items-center justify-between p-3 border-b border-border">
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
@@ -70,6 +79,7 @@ export function AppLayout({
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-2.5 rounded-lg hover:bg-muted"
aria-label={t.a11y.closeSidebar}
>
<X className="h-5 w-5" />
</button>
@@ -118,6 +128,7 @@ export function AppLayout({
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-2.5 -ms-1 rounded-lg hover:bg-muted"
aria-label={t.a11y.openSidebar}
>
<Menu className="h-5 w-5" />
</button>
@@ -132,7 +143,10 @@ export function AppLayout({
{showToolPanel && !isMobile && <ToolPanel />}
<main className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}>
<main
id="main-content"
className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}
>
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
{children || <Dropzone onFiles={onFiles} onUrlImport={onUrlImport} accept="image/*" />}
</div>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useTheme } from "@/hooks/use-theme";
function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation();
const { t, locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -52,7 +52,7 @@ function LanguageSelector() {
stroke="currentColor"
strokeWidth="2"
role="img"
aria-label="Selected"
aria-label={t.a11y.selected}
>
<polyline points="20 6 9 17 4 12" />
</svg>
+5 -4
View File
@@ -44,6 +44,7 @@ export function Sidebar({
expanded = false,
}: SidebarProps) {
const location = useLocation();
const { t } = useTranslation();
const { topItems, bottomItems } = useNavItems();
const renderItem = (item: SidebarItem, isActive: boolean) => {
@@ -101,11 +102,11 @@ export function Sidebar({
if (expanded) {
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))}
<div className="border-t border-border my-2" />
{bottomItems.map((item) => renderItem(item, false))}
</div>
</nav>
);
}
@@ -115,9 +116,9 @@ export function Sidebar({
<OtterLogo className="h-7 w-7 text-primary" />
</div>
<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))}
</div>
</nav>
<div className="border-t border-border w-10 my-2" />
<div className="flex flex-col gap-1">
{bottomItems.map((item) => renderItem(item, false))}
@@ -299,7 +299,7 @@ function BundleCard({
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"
>
<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}
</button>
)}
@@ -26,9 +26,10 @@ import {
Wrench,
X,
} 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 { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile";
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
import { format, plural } from "@/lib/format";
@@ -127,6 +128,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
const { t } = useTranslation();
const isMobile = useMobile();
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(
(item) =>
@@ -148,14 +153,23 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
if (isMobile) {
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 */}
<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
type="button"
onClick={onClose}
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" />
</button>
@@ -169,7 +183,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
type="button"
onClick={() => setSection(item.id)}
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
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
@@ -211,14 +225,18 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
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"
>
{/* Sidebar nav */}
<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">
<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>
{visibleNavItems.map((item) => (
<button
@@ -244,6 +262,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
type="button"
onClick={onClose}
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" />
</button>
@@ -426,6 +445,7 @@ function GeneralSection() {
<select
value={defaultToolView}
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"
>
<option value="sidebar">{t.settings.general.sidebarOption}</option>
@@ -440,6 +460,7 @@ function GeneralSection() {
<select
value={locale}
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"
>
{supportedLocales.map((l) => (
@@ -464,7 +485,7 @@ function GeneralSection() {
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"
>
{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}
</button>
{saveMsg && (
@@ -553,6 +574,7 @@ function SystemSection() {
type="number"
value={settings.fileUploadLimitMb || "100"}
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"
min={1}
/>
@@ -565,6 +587,7 @@ function SystemSection() {
<select
value={settings.defaultTheme || "system"}
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"
>
<option value="light">{t.settings.system.lightOption}</option>
@@ -580,6 +603,7 @@ function SystemSection() {
<select
value={settings.defaultLocale || "en"}
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"
>
{SUPPORTED_LOCALES.map((l) => (
@@ -598,6 +622,7 @@ function SystemSection() {
type="number"
value={settings.loginAttemptLimit || "5"}
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"
min={1}
max={100}
@@ -617,6 +642,7 @@ function SystemSection() {
type="number"
value={settings.tempFileMaxAgeHours || "24"}
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"
min={1}
/>
@@ -627,6 +653,9 @@ function SystemSection() {
>
<button
type="button"
role="switch"
aria-checked={settings.startupCleanup !== "false"}
aria-label={t.settings.fileManagement.startupCleanup}
onClick={() =>
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
}
@@ -651,7 +680,7 @@ function SystemSection() {
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"
>
{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}
</button>
{saveMsg && (
@@ -731,13 +760,19 @@ function SecuritySection() {
<div className="space-y-3 max-w-sm">
<div className="relative">
<label htmlFor="current-password" className="sr-only">
{t.settings.security.currentPasswordPlaceholder}
</label>
<input
id="current-password"
type={showCurrent ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
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"
required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/>
<button
type="button"
@@ -749,13 +784,19 @@ function SecuritySection() {
</div>
<div className="relative">
<label htmlFor="new-password" className="sr-only">
{t.settings.security.newPasswordPlaceholder}
</label>
<input
id="new-password"
type={showNew ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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"
required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/>
<button
type="button"
@@ -767,13 +808,19 @@ function SecuritySection() {
</div>
<div className="relative">
<label htmlFor="confirm-password" className="sr-only">
{t.settings.security.confirmPasswordPlaceholder}
</label>
<input
id="confirm-password"
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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"
required
aria-invalid={message?.type === "error" || undefined}
aria-describedby={message ? "password-change-error" : undefined}
/>
<button
type="button"
@@ -787,6 +834,8 @@ function SecuritySection() {
{message && (
<p
id="password-change-error"
role="alert"
className={cn(
"text-sm",
message.type === "error"
@@ -803,7 +852,7 @@ function SecuritySection() {
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"
>
{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}
</button>
</div>
@@ -1098,16 +1147,26 @@ function PeopleSection() {
{t.settings.people.newMemberHeading}
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
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">
<div>
<label htmlFor="new-user-username" className="sr-only">
{t.settings.people.usernamePlaceholder}
</label>
<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"}
value={newPassword}
onChange={(e) => {
@@ -1188,7 +1247,7 @@ function PeopleSection() {
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"
>
{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}
</button>
<button
@@ -1223,7 +1282,11 @@ function PeopleSection() {
{t.settings.people.copyPasswordWarning}
</p>
)}
{addError && <p className="text-sm text-destructive">{addError}</p>}
{addError && (
<p role="alert" className="text-sm text-destructive">
{addError}
</p>
)}
</form>
)}
@@ -1440,6 +1503,7 @@ function PeopleSection() {
}}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Actions"
aria-label={t.common.actions}
>
<MoreVertical className="h-4 w-4" />
</button>
@@ -1595,6 +1659,7 @@ function ApiKeysSection() {
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
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"
/>
<button
@@ -1603,7 +1668,11 @@ function ApiKeysSection() {
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"
>
{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}
</button>
</div>
@@ -1678,6 +1747,7 @@ function ApiKeysSection() {
onClick={() => copyKey(newKey)}
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
title="Copy"
aria-label={t.common.copy}
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</button>
@@ -1718,6 +1788,7 @@ function ApiKeysSection() {
onClick={() => deleteKey(k.id)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete key"
aria-label={t.a11y.deleteKey}
>
<Trash2 className="h-4 w-4" />
</button>
@@ -1895,7 +1966,7 @@ function TeamsSection() {
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"
>
{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}
</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"
title="Edit role"
aria-label={t.a11y.editRole}
>
<Pencil className="h-4 w-4" />
</button>
@@ -2367,6 +2439,7 @@ function RolesSection() {
onClick={() => handleDelete(role)}
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete role"
aria-label={t.a11y.deleteRole}
>
<Trash2 className="h-4 w-4" />
</button>
@@ -2740,6 +2813,9 @@ function ToolsSection() {
</div>
<button
type="button"
role="switch"
aria-checked={!isDisabled}
aria-label={getToolName(t, tool.id, tool.name)}
onClick={() => toggleTool(tool.id)}
className={cn(
"w-11 h-6 rounded-full transition-colors relative shrink-0 ms-3",
@@ -2780,7 +2856,7 @@ function ToolsSection() {
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"
>
{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}
</button>
<span className="text-xs text-muted-foreground">
@@ -2825,6 +2901,9 @@ function AnalyticsSection() {
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={t.analytics.settingsTitle}
onClick={() => toggleAnalytics(!enabled)}
className={cn(
"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 }) {
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}
</p>
);
@@ -260,7 +260,7 @@ export function BarcodeReadSettings() {
<span className="text-sm text-muted-foreground">Thorough scan</span>
<span
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>
@@ -335,7 +335,7 @@ export function BarcodeReadSettings() {
)}
{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) => {
const idx = globalIndex++;
@@ -128,7 +128,7 @@ export function BulkRenameSettings() {
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing
? t.toolSettings["bulk-rename"].renaming
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
@@ -83,6 +83,7 @@ export function CollagePreview() {
/** Dropzone for initial image upload. */
function UploadArea() {
const { t } = useTranslation();
const addImages = useCollageStore((s) => s.addImages);
const [isDragging, setIsDragging] = useState(false);
@@ -118,7 +119,7 @@ function UploadArea() {
return (
<section
aria-label="File drop zone"
aria-label={t.a11y.fileDropZone}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
@@ -322,6 +323,7 @@ function CollageCell({
gridRow: string;
backgroundColor: string;
}) {
const { t } = useTranslation();
const store = useCollageStore();
const cellRef = useRef<HTMLDivElement>(null);
const [controlsVisible, setControlsVisible] = useState(false);
@@ -507,7 +509,7 @@ function CollageCell({
{isSelected && image && !isLoading && (
<div
role="toolbar"
aria-label="Image controls"
aria-label={t.a11y.imageControls}
className="absolute top-1.5 right-1.5 flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
@@ -541,7 +543,7 @@ function CollageCell({
ref={setDragRef}
{...listeners}
{...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"
>
<GripVertical className="h-3.5 w-3.5" />
@@ -553,7 +555,7 @@ function CollageCell({
{isSelected && image && !isLoading && (
<div
role="toolbar"
aria-label="Zoom controls"
aria-label={t.a11y.zoomControls}
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",
controlsVisible ? "opacity-100" : "opacity-0",
@@ -373,7 +373,7 @@ export function CollageSettings() {
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"
>
{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)`}
</button>
@@ -61,7 +61,7 @@ export function ColorPaletteSettings() {
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"
>
{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}
</button>
@@ -428,7 +428,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -457,7 +457,7 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{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>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span>
</div>
@@ -99,7 +99,7 @@ export function ColorizeSettings() {
onChange={(e) => setIntensity(Number(e.target.value))}
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.
</p>
</div>
@@ -152,7 +152,7 @@ export function ColorizeSettings() {
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -94,7 +94,7 @@ export function CompareSettings() {
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing ? "Comparing..." : "Compare"}
</button>
@@ -167,7 +167,7 @@ export function ComposeSettings() {
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"
>
{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}
</button>
@@ -92,7 +92,7 @@ export function EnhanceFacesControls({
onChange={(e) => setStrength(Number(e.target.value))}
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>Maximum</span>
</div>
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
/>
<span className="text-sm text-foreground">Only enhance main face</span>
</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
</p>
</div>
@@ -133,7 +133,7 @@ export function EnhanceFacesControls({
onChange={(e) => setSensitivity(Number(e.target.value))}
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>More faces</span>
</div>
@@ -266,7 +266,7 @@ export function FindDuplicatesResults() {
if (!results) {
return (
<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">
Choose a detection mode and click "Scan" to find duplicates.
</p>
@@ -309,7 +309,7 @@ export function FindDuplicatesSettings() {
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"
>
{scanning && <Loader2 className="h-4 w-4 animate-spin" />}
{scanning && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{scanning
? uploadProgress < 100
? `Uploading... ${uploadProgress}%`
@@ -175,7 +175,7 @@ export function HtmlToImageSettings() {
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"
>
{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}
</button>
</form>
@@ -285,7 +285,7 @@ export function ImageEnhancementControls({
)}
{/* 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}
</p>
<div className="grid grid-cols-3 gap-1">
@@ -309,7 +309,7 @@ export function ImageEnhancementControls({
{/* Intensity slider */}
<div className="pt-1">
<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}
</p>
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
@@ -337,6 +337,8 @@ export function ImageEnhancementControls({
</div>
<button
type="button"
role="switch"
aria-checked={deepEnhance}
onClick={() => setDeepEnhance(!deepEnhance)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
deepEnhance ? "bg-primary" : "bg-muted"
@@ -360,7 +362,7 @@ export function ImageEnhancementControls({
{analysis && !analyzing && (
<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}
</p>
{analysis.issues.length === 0 ? (
@@ -412,7 +414,7 @@ export function ImageEnhancementControls({
style={{ width: `${score}%` }}
/>
</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>
@@ -84,7 +84,7 @@ export function ImageToBase64Settings() {
{/* Output Format */}
<div>
<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
</p>
<div className="flex flex-wrap gap-1.5">
@@ -123,9 +123,7 @@ export function ImageToBase64Settings() {
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1 accent-primary"
/>
<p className="text-[10px] text-muted-foreground/70">
Lower quality = smaller base64 string
</p>
<p className="text-[10px] text-muted-foreground">Lower quality = smaller base64 string</p>
</div>
)}
@@ -141,7 +139,7 @@ export function ImageToBase64Settings() {
value={maxWidth}
onChange={(e) => setMaxWidth(Math.max(0, Number(e.target.value)))}
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>
@@ -157,9 +155,9 @@ export function ImageToBase64Settings() {
value={maxHeight}
onChange={(e) => setMaxHeight(Math.max(0, Number(e.target.value)))}
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.
</p>
</div>
@@ -172,7 +170,7 @@ export function ImageToBase64Settings() {
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing
? "Converting..."
: `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`}
@@ -133,7 +133,7 @@ export function InfoSettings() {
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"
>
{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}
</button>
@@ -267,7 +267,7 @@ function EditorSettings() {
>
{generating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Generating...
</>
) : (
@@ -34,7 +34,7 @@ const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -253,7 +253,7 @@ export function OcrSettings() {
</span>
<span
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" />
</span>
@@ -264,7 +264,7 @@ export function OcrSettings() {
<button
type="button"
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" />}
Language
@@ -157,7 +157,7 @@ function groupByRegion(): Map<PassportRegion, PassportSpec[]> {
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -326,7 +326,7 @@ function CountryOption({
>
<span>{spec.flag}</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)}
</span>
{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;
return (
<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]}
</p>
{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"
/>
) : (
<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>
@@ -265,7 +265,7 @@ export function PdfToImageSettings() {
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"
>
{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
? t.toolSettings["pdf-to-image"].converting
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
@@ -73,7 +73,7 @@ const GRADIENT_PRESETS = [
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -425,7 +425,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
<button
type="button"
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" />}
Effects
@@ -397,7 +397,7 @@ export function SharpeningSettings() {
function SectionLabel({ children }: { children: React.ReactNode }) {
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}
</p>
);
@@ -429,7 +429,7 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{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>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{displayValue}
@@ -81,7 +81,7 @@ export function SplitCanvas() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Generating preview...</p>
<p className="text-xs text-muted-foreground/60">{filename}</p>
<p className="text-xs text-muted-foreground">{filename}</p>
</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">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<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>
);
}
@@ -402,7 +402,7 @@ export function SplitSettings() {
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing
? "Splitting..."
: files.length > 1
@@ -321,7 +321,7 @@ export function StitchSettings() {
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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{processing
? uploadProgress < 100
? `Uploading... ${uploadProgress}%`
@@ -1,6 +1,7 @@
import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
@@ -48,6 +49,7 @@ function parseSvgDimensions(file: File): Promise<SvgDims | null> {
}
export function SvgToRasterSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("svg-to-raster");
@@ -322,13 +324,13 @@ export function SvgToRasterSettings() {
type="button"
onClick={() => setBgColor("#ffffff")}
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
type="button"
onClick={() => setBgColor("#000000")}
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
type="color"
@@ -53,8 +53,9 @@ export function TransparencyFixerControls({
</div>
<button
type="button"
role="switch"
data-testid="remove-watermark-toggle"
aria-pressed={removeWatermark}
aria-checked={removeWatermark}
onClick={() => setRemoveWatermark(!removeWatermark)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
removeWatermark ? "bg-primary" : "bg-muted"
@@ -72,7 +73,7 @@ export function TransparencyFixerControls({
<button
type="button"
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" />}
Advanced
@@ -100,7 +101,7 @@ export function TransparencyFixerControls({
{/* Output Format dropdown */}
<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
</p>
<select
@@ -158,7 +158,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
onChange={(e) => setDenoise(Number(e.target.value))}
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.
</p>
</div>
@@ -329,6 +329,9 @@ export function VectorizeSettings() {
<span className="text-xs text-muted-foreground">Invert Colors</span>
<button
type="button"
role="switch"
aria-checked={invert}
aria-label={t.toolSettings.vectorize.invertColors}
onClick={() => updateSetting(setInvert)(!invert)}
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}
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..."
: 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>
</div>
<p className="text-center text-xs text-muted-foreground/60">
{t.analytics.consentChangeable}
</p>
<p className="text-center text-xs text-muted-foreground">{t.analytics.consentChangeable}</p>
<div className="flex gap-2.5">
<button
+3 -3
View File
@@ -910,7 +910,7 @@ export function AutomatePage() {
{/* Preview content */}
{!previewCollapsed && (
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col overflow-hidden min-h-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
@@ -921,7 +921,7 @@ export function AutomatePage() {
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
aria-label={t.a11y.previousImage}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -931,7 +931,7 @@ export function AutomatePage() {
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
aria-label={t.a11y.nextImage}
>
<ChevronRight className="h-4 w-4" />
</button>
+1
View File
@@ -151,6 +151,7 @@ export function EditorPage() {
return (
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage}
+3 -1
View File
@@ -18,6 +18,7 @@ export function FilesPage() {
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile tabs */}
<div className="flex border-b border-border">
@@ -68,7 +69,7 @@ export function FilesPage() {
<div
role="dialog"
aria-modal="true"
aria-label="File Details"
aria-label={t.a11y.fileDetails}
className="fixed inset-0 z-50 bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setShowDetails(false);
@@ -95,6 +96,7 @@ export function FilesPage() {
return (
<AppLayout showToolPanel={false}>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
{activeTab === "recent" ? (
@@ -138,6 +138,7 @@ export function FullscreenGridPage() {
{/* Grid */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<h1 className="sr-only">{t.nav.tools}</h1>
{activeCategories.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
+63 -11
View File
@@ -99,6 +99,7 @@ export function HomePage() {
if (isMobile && hasFile) {
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex flex-col h-full w-full">
{/* File info bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
@@ -141,11 +142,25 @@ export function HomePage() {
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground" />
<>
<Download className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -160,7 +175,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
@@ -182,6 +197,7 @@ export function HomePage() {
// File uploaded — desktop: tool selector on left, image preview on right
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex h-full w-full">
{/* Left panel: Tool selector */}
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0">
@@ -233,13 +249,31 @@ export function HomePage() {
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -279,13 +313,31 @@ export function HomePage() {
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
@@ -305,7 +357,7 @@ export function HomePage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
+3 -3
View File
@@ -38,7 +38,7 @@ function RotatingPhrase() {
}
function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation();
const { t, locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -74,7 +74,7 @@ function LanguageSelector() {
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Language"
aria-label={t.a11y.language}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
@@ -112,7 +112,7 @@ function LanguageSelector() {
strokeLinejoin="round"
className="text-primary shrink-0"
role="img"
aria-label="Selected"
aria-label={t.a11y.selected}
>
<polyline points="20 6 9 17 4 12" />
</svg>
+32 -11
View File
@@ -27,6 +27,7 @@ import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
import { getToolName } from "@/lib/tool-i18n";
import { getToolRegistryEntry } from "@/lib/tool-registry";
@@ -117,7 +118,7 @@ function FileSelectionInfo({
>
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
<span className="truncate flex-1 min-w-0">{file.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground/70">
<span className="shrink-0 text-[10px] text-muted-foreground">
{getFileFormat(file.name)}
</span>
<span className="shrink-0 text-[10px] tabular-nums">{formatFileSize(file.size)}</span>
@@ -203,6 +204,13 @@ export function ToolPage() {
const hasPrev = selectedIndex > 0;
const hasNext = selectedIndex < entries.length - 1;
const liveMessage = useMemo(() => {
if (!currentEntry) return "";
if (currentEntry.status === "completed" && processedUrl) return t.a11y.processingComplete;
if (currentEntry.status === "failed") return t.a11y.processingFailed;
return "";
}, [currentEntry, processedUrl, t.a11y.processingComplete, t.a11y.processingFailed]);
const handleImageKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
@@ -635,7 +643,7 @@ export function ToolPage() {
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.toolPage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
);
}
@@ -706,7 +714,7 @@ export function ToolPage() {
type="button"
onClick={navigatePrev}
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Previous image"
aria-label={t.a11y.previousImage}
>
<ChevronLeft className="h-4 w-4" />
</button>
@@ -716,13 +724,20 @@ export function ToolPage() {
type="button"
onClick={navigateNext}
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
aria-label="Next image"
aria-label={t.a11y.nextImage}
>
<ChevronRight className="h-4 w-4" />
</button>
)}
{hasMultiple && (
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
<div
role="status"
aria-label={format(t.a11y.imageNOfTotal, {
n: selectedIndex + 1,
total: entries.length,
})}
className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums"
>
{selectedIndex + 1} / {entries.length}
</div>
)}
@@ -796,9 +811,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">
<h1 className="font-semibold text-lg text-foreground flex-1">
{getToolName(t, tool.id, tool.name)}
</h2>
</h1>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
@@ -810,11 +825,14 @@ export function ToolPage() {
{/* Main area: image viewer (full height) */}
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{liveMessage}
</div>
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0 min-w-0">
{renderNavArrows()}
{renderImageArea()}
@@ -851,9 +869,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground">
<h1 className="font-semibold text-lg text-foreground">
{getToolName(t, tool.id, tool.name)}
</h2>
</h1>
</div>
{renderSettingsContent()}
@@ -861,11 +879,14 @@ export function ToolPage() {
{/* Main area: image viewer */}
<section
aria-label="Image area"
aria-label={t.a11y.imageArea}
className="flex-1 flex flex-col min-h-0 min-w-0"
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
tabIndex={hasMultiple ? 0 : undefined}
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{liveMessage}
</div>
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0 min-w-0">
{renderNavArrows()}
{renderImageArea()}
+16 -1
View File
@@ -10,7 +10,7 @@
}
@theme {
--color-primary: #3b82f6;
--color-primary: #2563eb;
--color-primary-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #0f172a;
@@ -126,3 +126,18 @@ input[type="range"]::-moz-range-track {
input[type="checkbox"] { width: 20px; height: 20px; }
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;
}
}