# WCAG 2.2 AAA Accessibility Remediation - Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Fix 147 WCAG 2.2 AAA accessibility issues across the TypoGenie app without breaking the dark aesthetic. **Architecture:** 8 sequential tasks, each a self-contained commit. CSS foundation first (unlocks everything else), then modals, keyboard access, ARIA live regions, semantics, DOCX output, contrast validation, and miscellaneous. No new dependencies - uses native ``, existing hooks, and pure utility functions. **Tech Stack:** React 19, TypeScript 5.8, Tailwind v4, Framer Motion (motion/react), native HTML ``, docx library --- ### Task 1: CSS Foundation **Files:** - Modify: `index.html` - Modify: `src/index.css` - Modify: `src/App.tsx` (color class replacements) - Modify: `src/components/FileUpload.tsx` (color class replacements) - Modify: `src/components/StyleSelector.tsx` (color class replacements) - Modify: `src/components/Preview.tsx` (color class replacements) - Modify: `src/components/ExportOptionsModal.tsx` (color class replacements) **Step 1: Fix index.css** In `src/index.css`, make these changes: 1. Change line 35 from `font-size: 16px;` to `font-size: 100%;` 2. Change line 42 from `overflow: hidden;` to `overflow-x: hidden;` 3. Change lines 48-51 `#root` block: replace `overflow: hidden;` with `overflow-x: hidden;` 4. Change line 107 from `rgba(99, 102, 241, 0.3)` to `rgba(99, 102, 241, 0.8)` 5. Add after line 110: ```css /* Reduced motion */ @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 / high contrast mode */ @media (forced-colors: active) { .focus-ring-spacing:focus-within { outline: 2px solid LinkText; } } /* Screen reader only utility */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } ``` **Step 2: Fix color classes across all components** Search and replace in all `.tsx` files (NOT in `index.css` or template files): - `text-zinc-600` -> `text-zinc-400` (fixes AA contrast failures) - `text-zinc-500` -> `text-zinc-400` (fixes AAA contrast failures) Exceptions to preserve: - Keep `text-zinc-500` only when it's used for the `placeholder:text-zinc-600` in `StyleSelector.tsx:367` - change that to `placeholder:text-zinc-500` - Keep `bg-zinc-500` and `border-zinc-500` unchanged (only text colors need fixing) - The `text-zinc-600` in `StyleSelector.tsx:397` (`.text-zinc-600`) changes to `text-zinc-400` Specific files and locations: - `App.tsx:67` `text-zinc-400` (already fine) - `App.tsx:236,249` `text-zinc-500` -> `text-zinc-400` - `App.tsx:263` `text-zinc-500` -> `text-zinc-400` - `App.tsx:436,438` `text-zinc-600` -> `text-zinc-400` - `FileUpload.tsx:177` `text-zinc-500` -> `text-zinc-400` - `FileUpload.tsx:185` `text-zinc-600` -> `text-zinc-400` - `StyleSelector.tsx:304,377,409,412,433,449,451,452` `text-zinc-500`/`text-zinc-600` -> `text-zinc-400` - `Preview.tsx:394` `text-zinc-500` -> `text-zinc-400` - `ExportOptionsModal.tsx:76,119` `text-zinc-500` -> `text-zinc-400` **Step 3: Fix index.html** In `index.html`: 1. Change line 6 title to: `TypoGenie - Markdown to Word Converter` 2. Add after `` on line 20: ```html Skip to main content ``` **Step 4: Verify and commit** Run: `cd "D:/gdfhbfgdbnbdfbdf/typogenie" && npm run build` Expected: Build succeeds with no errors. ```bash git add src/index.css index.html src/App.tsx src/components/FileUpload.tsx src/components/StyleSelector.tsx src/components/Preview.tsx src/components/ExportOptionsModal.tsx git commit -m "a11y: fix CSS foundation - contrast, overflow, focus ring, reduced motion" ``` --- ### Task 2: Modal System **Files:** - Create: `src/hooks/useDialog.ts` - Modify: `src/App.tsx` (KeyboardShortcutsHelp) - Modify: `src/components/ExportOptionsModal.tsx` - Modify: `src/components/StylePreviewModal.tsx` - Modify: `src/index.css` (dialog styles) **Step 1: Create useDialog hook** Create `src/hooks/useDialog.ts`: ```typescript import { useRef, useEffect, useCallback } from 'react'; interface UseDialogOptions { onClose: () => void; } export function useDialog(isOpen: boolean, options: UseDialogOptions) { const dialogRef = useRef(null); const triggerRef = useRef(null); const close = useCallback(() => { dialogRef.current?.close(); options.onClose(); }, [options.onClose]); useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (isOpen) { triggerRef.current = document.activeElement; if (!dialog.open) { dialog.showModal(); } } else { if (dialog.open) { dialog.close(); } // Restore focus to trigger if (triggerRef.current instanceof HTMLElement) { triggerRef.current.focus(); } } }, [isOpen]); // Handle native cancel event (Escape key) useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; const handleCancel = (e: Event) => { e.preventDefault(); close(); }; dialog.addEventListener('cancel', handleCancel); return () => dialog.removeEventListener('cancel', handleCancel); }, [close]); // Handle backdrop click const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { close(); } }, [close]); return { dialogRef, handleBackdropClick, close }; } ``` **Step 2: Add dialog CSS to index.css** Add to `src/index.css` after the forced-colors rule: ```css /* Native dialog styles */ dialog { background: transparent; border: none; padding: 0; max-width: 100vw; max-height: 100vh; overflow: visible; } dialog::backdrop { background: rgba(9, 9, 11, 0.8); backdrop-filter: blur(4px); } dialog[open] { display: flex; align-items: center; justify-content: center; } ``` **Step 3: Convert KeyboardShortcutsHelp in App.tsx** Replace the `KeyboardShortcutsHelp` component (lines 17-80) with: ```tsx const KeyboardShortcutsHelp: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose }); const shortcuts = [ { key: '↑ / ↓', description: 'Navigate styles' }, { key: '← / →', description: 'Navigate categories (when focused)' }, { key: 'Enter', description: 'Select style or category' }, { key: 'Home / End', description: 'First/last item' }, { key: 'PgUp / PgDn', description: 'Jump 5 items' }, { key: 'Tab', description: 'Switch between sections' }, { key: 'Ctrl + Enter', description: 'Generate document' }, { key: 'Escape', description: 'Go back / Close' }, ]; if (!isOpen) return null; return ( e.stopPropagation()} >

Keyboard Shortcuts

{shortcuts.map((shortcut) => (
{shortcut.description}
{shortcut.key}
))}

Press Escape or click outside to close

); }; ``` Also update how it's called (around line 197-201). Change from: ```tsx {showShortcuts && ( setShowShortcuts(false)} /> )} ``` To: ```tsx setShowShortcuts(false)} /> ``` Add import for `useDialog` at the top of App.tsx: ```tsx import { useDialog } from './hooks/useDialog'; ``` **Step 4: Convert ExportOptionsModal** Rewrite `src/components/ExportOptionsModal.tsx`. Key changes: - Use `` with `useDialog` hook - Add `aria-labelledby="export-title"` on dialog - Wrap radio buttons in `
` - Close button: `aria-label="Close export options"`, min 44x44 target - Cancel/Export buttons: increase padding to `px-5 py-3` Replace the entire component: ```tsx import { X } from 'lucide-react'; import { useState } from 'react'; import { useDialog } from '../hooks/useDialog'; interface ExportOptionsModalProps { isOpen: boolean; onClose: () => void; onExport: (useTableHeaders: boolean) => void; } export default function ExportOptionsModal({ isOpen, onClose, onExport }: ExportOptionsModalProps) { const [selectedMode, setSelectedMode] = useState<'table' | 'semantic'>('semantic'); const { dialogRef, handleBackdropClick, close } = useDialog(isOpen, { onClose }); if (!isOpen) return null; const handleExport = () => { onExport(selectedMode === 'table'); }; return (
e.stopPropagation()}>

Export Options

Choose how headers should be rendered in your Word document:
); } ``` **Step 5: Convert StylePreviewModal** Rewrite `src/components/StylePreviewModal.tsx`. Key changes: - Use `` with `useDialog` hook - `aria-labelledby="preview-title"` on dialog - Close button: `aria-label="Close preview"`, 44x44 target - Iframe HTML: add `lang="en"` to `` Replace entire component with same pattern - dialog wrapping, useDialog hook, aria-labelledby pointing to `

`. In the iframe HTML template (line 48 area), change `` to ``. Remove the manual Escape key handler (lines 35-39) since `` handles it natively. **Step 6: Verify and commit** Run: `npm run build` ```bash git add src/hooks/useDialog.ts src/index.css src/App.tsx src/components/ExportOptionsModal.tsx src/components/StylePreviewModal.tsx git commit -m "a11y: convert all modals to native dialog with focus management" ``` --- ### Task 3: Keyboard Access & Interactive Elements **Files:** - Modify: `src/App.tsx` - Modify: `src/components/StyleSelector.tsx` - Modify: `src/components/Preview.tsx` - Modify: `src/components/FileUpload.tsx` **Step 1: Fix logo button in App.tsx** Replace lines 211-224 (the `motion.div onClick={handleReset}` block) with: ```tsx

TypoGenie

``` **Step 2: Fix single-character shortcut in App.tsx** Replace lines 105-114 (the global keydown listener) with: ```tsx useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only fire when not typing in an input/textarea/contenteditable const tag = (e.target as HTMLElement)?.tagName; const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable; if (isEditable) return; if (e.key === '?' || e.key === '/') { e.preventDefault(); setShowShortcuts(prev => !prev); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); ``` **Step 3: Fix style cards as listbox in StyleSelector.tsx** In the scrollable list section (lines 374-418), wrap the card container: Change the outer `
` to add listbox role: ```tsx
``` Change each card `
` (line 380-416) to: ```tsx
onSelectStyle(style.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectStyle(style.id); } }} className={`p-4 rounded-xl border transition-all cursor-pointer group relative outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 ${selectedStyle === style.id ? 'border-indigo-500 bg-indigo-500/10 shadow-[0_0_15px_rgba(99,102,241,0.15)]' : 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800'}`} > ``` **Step 4: Fix favorite button** Change the favorite button (lines 393-401) to include aria attributes: ```tsx ``` **Step 5: Fix category filter buttons** Wrap the category button container (lines 322-355) in a role group. Change the outer `
` to: ```tsx
``` Add `aria-pressed` to each category button. For example, the "fav" button: ```tsx aria-pressed={activeCategory === 'fav'} ``` The "All" button: ```tsx aria-pressed={activeCategory === 'All'} ``` Each category button: ```tsx aria-pressed={activeCategory === cat} ``` **Step 6: Fix paper size buttons** Wrap paper size buttons (lines 296-309). Change `
` to: ```tsx
``` Add to each paper size button: ```tsx aria-pressed={selectedPaperSize === size} ``` **Step 7: Fix search input** Add `aria-label` to the search input (line 362-367): ```tsx setSearchQuery(e.target.value)} className="..." /> ``` Add `aria-hidden="true"` to the Search icon (line 361): ```tsx