From 3b8e80c3a3cbef60873d6e461992c393573b9293 Mon Sep 17 00:00:00 2001 From: TypoGenie Date: Wed, 18 Feb 2026 23:17:00 +0200 Subject: [PATCH] docs: add WCAG 2.2 AAA implementation plan --- .../2026-02-18-wcag-aaa-implementation.md | 1378 +++++++++++++++++ 1 file changed, 1378 insertions(+) create mode 100644 docs/plans/2026-02-18-wcag-aaa-implementation.md diff --git a/docs/plans/2026-02-18-wcag-aaa-implementation.md b/docs/plans/2026-02-18-wcag-aaa-implementation.md new file mode 100644 index 0000000..b5a7547 --- /dev/null +++ b/docs/plans/2026-02-18-wcag-aaa-implementation.md @@ -0,0 +1,1378 @@ +# 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 +