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
@@ -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