Skip to content

Accessibility Audit — WCAG 2.1 AA

Date: 2026-05-20 Tool: axe-core ~4.11.4 (via @axe-core/playwright 4.11.3) Standard: WCAG 2.1 Level AA (axe tags: wcag2a, wcag2aa, wcag21a, wcag21aa)

Executive Summary

Route Critical Serious Moderate Minor Passes
/ (Home) 0 0 0 0 18
/convert/pdf-to-md 0* 0* 0 0 21
/convert/md-to-pdf 0* 0* 0 0 21
/about 0 0 0 0 20

* Fixed in this PR — 4 violations (2 critical, 2 serious) remediated.

Before this PR: 4 violations across 2 routes (critical × 2, serious × 2). After this PR: 0 violations across all routes. 4/4 tests pass.

Issues Found & Fixed

1. CRITICAL — Form elements missing labels

WCAG: 4.1.2 Name, Role, Value (Level A) Routes: /convert/pdf-to-md, /convert/md-to-pdf Component: DropZone.tsx

The hidden file <input type="file"> inside the DropZone had no aria-label attribute. Screen readers could not announce what the input was for.

Fix: Removed aria-label from the file input and placed it on the parent button-role div instead (now aria-label={t.dropzone.ariaLabel(acceptLabel)}). The input is hidden from AT via aria-hidden="true" — the wrapper div carries the accessible name for the widget.

2. SERIOUS — Nested interactive controls

WCAG: 4.1.2 Name, Role, Value (Level A) Routes: /convert/pdf-to-md, /convert/md-to-pdf Component: DropZone.tsx

The outer <div> had role="button" with tabIndex={0}, and contained a focusable <input type="file">. Nested interactive controls confuse screen readers — the inner input would be announced inside the button context, creating ambiguity.

Fix: Moved the file input outside the button-role div (now a sibling in a wrapper, not nested). Added tabIndex={-1} and aria-hidden="true" to the file input so it is not focusable via keyboard. The parent div retains role="button", tabIndex={0}, keyboard handlers, and aria-label — it is the sole interactive element for this widget.

3. IMPROVEMENT — Navigation landmark label

Component: App.tsx

The <nav> element had aria-label={t.nav.pdfToMd} which resolved to "PDF · MD". This is not a descriptive label for the navigation landmark.

Fix: Changed to aria-label="Main navigation" which accurately describes the nav element's purpose.

Component: App.tsx, globals.css

Added a visually hidden "Skip to content" link that becomes visible on keyboard focus. Allows keyboard and screen reader users to bypass the header navigation and jump directly to the main content.

Implementation: - <a className="skip-link" href="#main-content">Skip to content</a> before <header> - <main id="main-content"> as the target - CSS: .skip-link is positioned off-screen (top: -100%) by default, moves to top: 0 on :focus

5. IMPROVEMENT — Live region for batch progress

Component: BatchPanel.tsx

The batch progress text (e.g. "2 of 5 complete") was not announced to screen readers during batch conversion.

Fix: Added aria-live="polite" to the progress <span>, so screen readers announce progress updates as files are converted.

Routes Audited

/ (Home)

  • Status: Clean — 0 violations
  • Semantic heading hierarchy (h1 → h2)
  • Links are descriptive ("Convert a PDF", "Generate a PDF")
  • Cards use proper HTML structure

/convert/pdf-to-md

  • Status: Clean after fixes
  • DropZone: wrapper div has aria-label, no nested interactive controls
  • BatchPanel: progress announced via aria-live
  • Warnings list has aria-label
  • Download buttons have accessible names

/convert/md-to-pdf

  • Status: Clean after fixes
  • Same DropZone fixes as pdf-to-md
  • Textarea has aria-label
  • Toast notifications use role="alert" (via Toast component)

/about

  • Status: Clean — 0 violations
  • Pure content page with proper heading hierarchy
  • Links are descriptive

Remaining Recommendations (non-blocking)

These are improvements that don't block WCAG 2.1 AA compliance but would enhance the experience:

  1. Focus visible styles — Audit :focus-visible styles on all interactive elements. Current a styles use border-bottom which may not be visible on all backgrounds.
  2. Color contrast — Verify all text/background combinations meet 4.5:1 ratio (current theme appears compliant based on visual inspection, but no automated contrast check was performed in this pass).
  3. Reduced motion — Add @media (prefers-reduced-motion: reduce) to disable the fade-in animation for users who prefer reduced motion.

Test Reproduction

To reproduce the audit:

cd apps/web
npm install
npm run dev
npx playwright test e2e/audit.spec.ts

Or run axe-core manually in browser DevTools: 1. Open DevTools → Console 2. Paste axe-core script from axe-core docs 3. Run: await axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] } })