Files
typogenie/docs/plans/2026-02-18-wcag-aaa-accessibility-design.md
TypoGenie 3ab4432fcf docs: add WCAG 2.2 AAA accessibility remediation design
Covers 147 issues across 8 sections: CSS foundation, modal system,
keyboard access, ARIA live regions, semantic structure, DOCX output,
template contrast validation, and miscellaneous fixes.
2026-02-18 23:11:05 +02:00

8.2 KiB

WCAG 2.2 AAA Accessibility Remediation Design

Date: 2026-02-18 Status: Approved Scope: 147 accessibility issues across 17 existing files, 3 new files

Decisions

Decision Choice
Visual approach Balance accessibility with dark aesthetic
Modals Native <dialog> element
Style cards Listbox pattern with existing useFocusableList hook
Template colors Runtime contrast validation only, no JSON template edits
Justified text Override to left at render time
Contrast enforcement Auto-correct via ensureContrast() utility at render time

Section 1: CSS Foundation (~25 issues)

Root font size

Change :root { font-size: 16px } to font-size: 100% in index.css so user browser preferences are respected.

Overflow

Remove overflow: hidden from body and #root. Replace with overflow-x: hidden to prevent horizontal scroll but allow vertical content access when zoomed to 200%.

Focus indicators

Replace rgba(99, 102, 241, 0.3) focus ring with rgba(99, 102, 241, 0.8) at 2px. Meets 3:1 contrast on dark backgrounds while fitting indigo theme.

Reduced motion

Add global CSS rule:

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

Forced colors

Add @media (forced-colors: active) rules for Windows High Contrast Mode compatibility.

Color replacements

Across all components: text-zinc-600 -> text-zinc-400, text-zinc-500 -> text-zinc-400. All text hits 7:1+ contrast on zinc-950. Keeps dark aesthetic.

Section 2: Modal System (~20 issues)

New src/hooks/useDialog.ts

Reusable hook wrapping native <dialog>:

  • Manages dialogRef, showModal()/close()
  • Restores focus to trigger element on close
  • Wires aria-labelledby
  • Framer Motion animations on inner content div

KeyboardShortcutsHelp (App.tsx)

Convert from motion.div overlay to <dialog>. Add aria-labelledby to heading. Close button gets aria-label="Close shortcuts". Escape/focus trap handled natively.

ExportOptionsModal

Same <dialog> conversion. Radio buttons wrapped in <fieldset><legend>. Close button labeled.

StylePreviewModal

Same <dialog> conversion. Close button labeled. Iframe HTML gets lang="en".

Animation approach

dialog[open] CSS selector for entrance. ::backdrop gets fade-in. Framer Motion on inner content for exit.

Section 3: Keyboard Access & Interactive Elements (~20 issues)

Logo button

Replace <motion.div onClick> with <motion.button aria-label="TypoGenie - Reset to home">.

Single-character shortcut

Scope ?// listener to only fire when document.activeElement is body or non-input element. Prevents intercepting typing in search.

Style cards as listbox

Container: role="listbox" + aria-label="Typography styles". Each card: role="option" + tabIndex + aria-selected + onKeyDown. Wire useFocusableList for arrow keys. Add :focus-visible indigo ring.

Favorite button

Add aria-label (dynamic: "Add to favorites"/"Remove from favorites") + aria-pressed.

Category filters

Wrap in <div role="group" aria-label="Filter by category">. Active button: aria-pressed="true".

Paper size buttons

Wrap in <div role="group" aria-label="Paper size">. Active button: aria-pressed="true".

Search input

Add aria-label="Search templates" + aria-describedby pointing to live result count region.

Icon-only buttons

Add aria-label to all: zoom buttons, external link (Preview), close buttons. Decorative icons next to text: aria-hidden="true".

Target sizes

Increase padding on small buttons to hit 44x44px minimum. Use min-w-[44px] min-h-[44px] with centered content where inline growth not possible.

Section 4: ARIA Live Regions & Status Announcements (~15 issues)

Root status region

Persistent <div role="status" aria-live="polite" className="sr-only"> in App.tsx root. Updated on appState changes.

Generating state

Add aria-busy="true" + role="status" to container.

Preview loading

Wrap "Loading..." in role="status".

Success message

aria-live="polite" on button text area for "Saved!" announcement.

Search results

Visually-hidden role="status" announcing "{N} templates found". Debounced 300ms.

Error fixes

  • Remove aria-live="polite" where role="alert" already set (implies assertive)
  • Remove 5-second auto-dismiss from FileUpload error
  • Add aria-describedby linking FileUpload error to dropzone
  • Template loading: role="status". Template error: role="alert"

Loading state

Replace return null with minimal <div role="status">Loading TypoGenie</div>.

Section 5: Semantic Structure & Landmarks (~15 issues)

Skip navigation

<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a> in index.html.

Page title

<title>TypoGenie - Markdown to Word Converter</title>

Noscript

<noscript><p>TypoGenie requires JavaScript to run.</p></noscript>

Landmarks

  • id="main-content" on <main> in both app states
  • <nav aria-label="Style filters"> around category buttons
  • <section aria-label="Style list"> and <section aria-label="Style preview"> in StyleSelector

Progress stepper

Wrap in <nav aria-label="Progress"><ol>. Active step: aria-current="step".

Shortcuts list

Convert to <dl> with <dt>/<dd>.

Decorative elements

aria-hidden="true" on blob backgrounds, window-chrome dots, visual dividers.

Iframe lang

Both Preview.tsx and StylePreviewModal.tsx: inject <html lang="en">.

Section 6: DOCX Output Accessibility (~8 issues)

Document metadata

Add title (from filename/first h1), description, language to Document constructor.

Image handling

New <img> handler producing [Image: {alt text}] placeholder paragraph in italic.

Table headers

Set tableHeader: true on TableRow containing <th> elements.

Heading preservation in table mode

Add HeadingLevel to paragraph inside processHeaderAsTable table cells.

Code block style

Add custom "Code" paragraph style to DOCX styles. Apply to code blocks.

Section 7: Template Contrast Validation (~20 issues)

New src/utils/contrastUtils.ts

  • hexToRgb(hex) - parse hex to RGB
  • relativeLuminance(rgb) - WCAG luminance formula
  • contrastRatio(color1, color2) - ratio calculation
  • ensureContrast(fg, bg, minRatio) - auto-correct foreground color until ratio met

Integration in templateRenderer.ts

Run ensureContrast() on every text/background pair at CSS generation time. Body text: 7:1. Large text (18pt+ or 14pt+ bold): 4.5:1.

del element fix

When resolving del color, if border palette color fails contrast, fall back to textSecondary.

Justified text

Override justify to left at render time (1.4.8 AAA prohibits justified text).

Line-height floor

Enforce minimum 1.5 for body text, 1.0 for headings. Clamp upward if template specifies lower.

Section 8: Remaining Miscellaneous (~10 issues)

Iframe keyboard

tabIndex={-1} on preview iframe in StyleSelector (passive preview, not interactive).

Error boundary

New src/components/ErrorBoundary.tsx wrapping <App />. Renders accessible error with role="alert" on crash.

Framer Motion reduced motion

Import useReducedMotion from motion/react in App.tsx. When true, disable infinite blob/gradient animations via conditional animate props.

Hidden buttons on small screens

Add always-visible <kbd>?</kbd> hint regardless of breakpoint.

Truncation

Add title attribute with full description on line-clamp-2 elements.

Files Modified (17)

index.html, src/index.css, src/App.tsx, src/main.tsx, src/components/StyleSelector.tsx, src/components/Preview.tsx, src/components/ExportOptionsModal.tsx, src/components/StylePreviewModal.tsx, src/components/FileUpload.tsx, src/hooks/useSettings.ts, src/hooks/useTemplates.ts, src/services/templateRenderer.ts, src/utils/docxConverter.ts, src/utils/fontUtils.ts

Files Created (3)

src/utils/contrastUtils.ts, src/hooks/useDialog.ts, src/components/ErrorBoundary.tsx