Files
typogenie/docs/plans/2026-02-18-wcag-aaa-implementation.md
2026-02-18 23:17:00 +02:00

47 KiB

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 <dialog>, existing hooks, and pure utility functions.

Tech Stack: React 19, TypeScript 5.8, Tailwind v4, Framer Motion (motion/react), native HTML <dialog>, 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:
/* 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: <title>TypoGenie - Markdown to Word Converter</title>
  2. Add after <body> on line 20:
    <a href="#main-content" class="sr-only" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" onfocus="this.style.position='static';this.style.width='auto';this.style.height='auto';this.style.overflow='visible';" onblur="this.style.position='absolute';this.style.left='-9999px';this.style.width='1px';this.style.height='1px';this.style.overflow='hidden';">Skip to main content</a>
    <noscript><p style="padding:2rem;color:#e4e4e7;background:#09090b;font-family:sans-serif;">TypoGenie requires JavaScript to run.</p></noscript>

Step 4: Verify and commit

Run: cd "D:/gdfhbfgdbnbdfbdf/typogenie" && npm run build Expected: Build succeeds with no errors.

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:

import { useRef, useEffect, useCallback } from 'react';

interface UseDialogOptions {
  onClose: () => void;
}

export function useDialog(isOpen: boolean, options: UseDialogOptions) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const triggerRef = useRef<Element | null>(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<HTMLDialogElement>) => {
    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:

/* 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:

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 (
    <dialog
      ref={dialogRef}
      onClick={handleBackdropClick}
      aria-labelledby="shortcuts-title"
      className="fixed inset-0 z-50 p-4"
    >
      <motion.div
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-md w-full shadow-2xl"
        onClick={e => e.stopPropagation()}
      >
        <div className="flex justify-between items-center mb-6">
          <div className="flex items-center gap-3">
            <div className="p-2 bg-indigo-500/10 rounded-lg text-indigo-400" aria-hidden="true">
              <Keyboard size={20} />
            </div>
            <h2 id="shortcuts-title" className="text-xl font-bold text-white">Keyboard Shortcuts</h2>
          </div>
          <button
            onClick={close}
            className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-white transition-colors"
            aria-label="Close shortcuts"
          >
            <X size={20} />
          </button>
        </div>
        <dl className="space-y-3">
          {shortcuts.map((shortcut) => (
            <div
              key={shortcut.key}
              className="flex justify-between items-center py-2 border-b border-zinc-800 last:border-0"
            >
              <dt className="text-zinc-400">{shortcut.description}</dt>
              <dd className="ml-4">
                <kbd className="px-2 py-1 bg-zinc-800 rounded text-sm font-mono text-zinc-300 border border-zinc-700">
                  {shortcut.key}
                </kbd>
              </dd>
            </div>
          ))}
        </dl>
        <p className="mt-6 text-xs text-zinc-400 text-center">
          Press Escape or click outside to close
        </p>
      </motion.div>
    </dialog>
  );
};

Also update how it's called (around line 197-201). Change from:

<AnimatePresence>
  {showShortcuts && (
    <KeyboardShortcutsHelp onClose={() => setShowShortcuts(false)} />
  )}
</AnimatePresence>

To:

<KeyboardShortcutsHelp isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />

Add import for useDialog at the top of App.tsx:

import { useDialog } from './hooks/useDialog';

Step 4: Convert ExportOptionsModal

Rewrite src/components/ExportOptionsModal.tsx. Key changes:

  • Use <dialog> with useDialog hook
  • Add aria-labelledby="export-title" on dialog
  • Wrap radio buttons in <fieldset><legend>
  • Close button: aria-label="Close export options", min 44x44 target
  • Cancel/Export buttons: increase padding to px-5 py-3

Replace the entire component:

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 (
        <dialog
            ref={dialogRef}
            onClick={handleBackdropClick}
            aria-labelledby="export-title"
            className="fixed inset-0 z-50"
        >
            <div className="relative w-full max-w-2xl bg-zinc-900 rounded-2xl shadow-2xl m-4 overflow-hidden border border-zinc-700" onClick={e => e.stopPropagation()}>
                <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50">
                    <h2 id="export-title" className="text-xl font-semibold text-white">Export Options</h2>
                    <button
                        onClick={close}
                        className="p-2 min-w-[44px] min-h-[44px] flex items-center justify-center text-zinc-400 hover:text-white transition-colors rounded-md hover:bg-zinc-800"
                        aria-label="Close export options"
                    >
                        <X size={20} />
                    </button>
                </div>

                <div className="px-6 py-4 space-y-4 bg-zinc-900">
                    <fieldset>
                        <legend className="text-sm text-zinc-400 mb-6">
                            Choose how headers should be rendered in your Word document:
                        </legend>

                        <label
                            className={`block p-4 border-2 rounded-lg cursor-pointer transition-all mb-4 ${selectedMode === 'table'
                                    ? 'border-indigo-500 bg-indigo-500/10'
                                    : 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
                                }`}
                        >
                            <div className="flex items-start">
                                <input
                                    type="radio"
                                    name="exportMode"
                                    value="table"
                                    checked={selectedMode === 'table'}
                                    onChange={() => setSelectedMode('table')}
                                    className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
                                />
                                <div className="flex-1">
                                    <div className="font-semibold text-white mb-2">High-Fidelity Layout</div>
                                    <div className="space-y-1 text-sm">
                                        <div className="flex items-start">
                                            <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
                                            <span className="text-zinc-300">Perfect padding and border alignment</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
                                            <span className="text-zinc-300">Backgrounds contained precisely</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-red-400 mr-2" aria-hidden="true">&#10007;</span>
                                            <span className="text-zinc-300">No automatic Table of Contents</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-red-400 mr-2" aria-hidden="true">&#10007;</span>
                                            <span className="text-zinc-300">Document outline/navigation disabled</span>
                                        </div>
                                    </div>
                                    <div className="mt-3 text-xs text-zinc-400 italic">
                                        Best for: Portfolios, brochures, print-ready designs
                                    </div>
                                </div>
                            </div>
                        </label>

                        <label
                            className={`block p-4 border-2 rounded-lg cursor-pointer transition-all ${selectedMode === 'semantic'
                                    ? 'border-indigo-500 bg-indigo-500/10'
                                    : 'border-zinc-700 hover:border-zinc-600 bg-zinc-800/50'
                                }`}
                        >
                            <div className="flex items-start">
                                <input
                                    type="radio"
                                    name="exportMode"
                                    value="semantic"
                                    checked={selectedMode === 'semantic'}
                                    onChange={() => setSelectedMode('semantic')}
                                    className="mt-1 mr-3 text-indigo-600 focus:ring-indigo-500 bg-zinc-700 border-zinc-600"
                                />
                                <div className="flex-1">
                                    <div className="font-semibold text-white mb-2">Semantic Structure</div>
                                    <div className="space-y-1 text-sm">
                                        <div className="flex items-start">
                                            <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
                                            <span className="text-zinc-300">Auto-generated Table of Contents</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
                                            <span className="text-zinc-300">Document navigation panel works</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-green-500 mr-2" aria-hidden="true">&#10003;</span>
                                            <span className="text-zinc-300">Screen reader accessible</span>
                                        </div>
                                        <div className="flex items-start">
                                            <span className="text-yellow-400 mr-2" aria-hidden="true">&#9888;</span>
                                            <span className="text-zinc-300">Minor padding/border alignment issues</span>
                                        </div>
                                    </div>
                                    <div className="mt-3 text-xs text-zinc-400 italic">
                                        Best for: Academic papers, reports, accessible documents
                                    </div>
                                </div>
                            </div>
                        </label>
                    </fieldset>
                </div>

                <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-zinc-800 bg-zinc-900">
                    <button
                        onClick={close}
                        className="px-5 py-3 text-sm font-medium text-zinc-300 bg-zinc-800 border border-zinc-700 rounded-md hover:bg-zinc-700 transition-colors"
                    >
                        Cancel
                    </button>
                    <button
                        onClick={handleExport}
                        className="px-5 py-3 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-500 transition-colors"
                    >
                        Export to Word
                    </button>
                </div>
            </div>
        </dialog>
    );
}

Step 5: Convert StylePreviewModal

Rewrite src/components/StylePreviewModal.tsx. Key changes:

  • Use <dialog> with useDialog hook
  • aria-labelledby="preview-title" on dialog
  • Close button: aria-label="Close preview", 44x44 target
  • Iframe HTML: add lang="en" to <html>

Replace entire component with same pattern - dialog wrapping, useDialog hook, aria-labelledby pointing to <h3 id="preview-title">.

In the iframe HTML template (line 48 area), change <html> to <html lang="en">.

Remove the manual Escape key handler (lines 35-39) since <dialog> handles it natively.

Step 6: Verify and commit

Run: npm run build

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:

<motion.button
  className="flex items-center gap-2 cursor-pointer bg-transparent border-none"
  onClick={handleReset}
  whileHover={{ scale: 1.02 }}
  whileTap={{ scale: 0.98 }}
  aria-label="TypoGenie - Reset to home"
>
  <motion.div
    className="bg-gradient-to-br from-indigo-500 to-violet-600 p-2 rounded-lg"
    whileHover={{ rotate: [0, -10, 10, 0], transition: { duration: 0.5 } }}
    aria-hidden="true"
  >
    <FileType className="text-white" size={20} />
  </motion.div>
  <h1 className="text-xl font-bold tracking-tight text-white">TypoGenie</h1>
</motion.button>

Step 2: Fix single-character shortcut in App.tsx

Replace lines 105-114 (the global keydown listener) with:

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 <div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar"> to add listbox role:

<div
  className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar"
  role="listbox"
  aria-label="Typography styles"
  aria-activedescendant={selectedStyle ? `style-${selectedStyle}` : undefined}
>

Change each card <div> (line 380-416) to:

<div
  key={style.id}
  id={`style-${style.id}`}
  role="option"
  aria-selected={selectedStyle === style.id}
  tabIndex={selectedStyle === style.id ? 0 : -1}
  onClick={() => 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:

<button
  onClick={(e) => toggleFavorite(e, style.id)}
  className={`p-2 min-w-[44px] min-h-[44px] flex items-center justify-center rounded-full transition-all ${favorites.includes(style.id)
      ? 'text-rose-400 bg-rose-500/10 hover:bg-rose-500/20'
      : 'text-zinc-400 hover:text-zinc-300 hover:bg-zinc-700/50'
    }`}
  aria-label={favorites.includes(style.id) ? `Remove ${style.name} from favorites` : `Add ${style.name} to favorites`}
  aria-pressed={favorites.includes(style.id)}
>
  <Heart size={14} className={favorites.includes(style.id) ? 'fill-current' : ''} aria-hidden="true" />
</button>

Step 5: Fix category filter buttons

Wrap the category button container (lines 322-355) in a role group. Change the outer <div className="flex gap-2"> to:

<div className="flex gap-2" role="group" aria-label="Filter by category">

Add aria-pressed to each category button. For example, the "fav" button:

aria-pressed={activeCategory === 'fav'}

The "All" button:

aria-pressed={activeCategory === 'All'}

Each category button:

aria-pressed={activeCategory === cat}

Step 6: Fix paper size buttons

Wrap paper size buttons (lines 296-309). Change <div className="flex gap-1"> to:

<div className="flex gap-1" role="group" aria-label="Paper size">

Add to each paper size button:

aria-pressed={selectedPaperSize === size}

Step 7: Fix search input

Add aria-label to the search input (line 362-367):

<input
  type="text"
  placeholder="Search templates..."
  aria-label="Search templates"
  value={searchQuery}
  onChange={(e) => setSearchQuery(e.target.value)}
  className="..."
/>

Add aria-hidden="true" to the Search icon (line 361):

<Search size={14} aria-hidden="true" className="..." />

Step 8: Fix icon-only buttons and decorative icons

In Preview.tsx:

  • External link button (line 104-111): add aria-label="View on Google Fonts"
  • Zoom buttons already have aria-label - good
  • Increase zoom button padding: change p-1 to p-2 min-w-[44px] min-h-[44px] flex items-center justify-center on both zoom buttons (lines 32, 43)
  • Font download button (line 94-103): add aria-label={Download ${font} font}
  • Decorative icons next to text: add aria-hidden="true" to FileType (line 221), RefreshCw (line 239), Keyboard (line 252), Loader2 (line 407), Sparkles if present, Type in StyleSelector (line 283), Printer (line 293, 465), Search (line 450), Check (line 404)

In FileUpload.tsx:

  • Upload icon (line 160): add aria-hidden="true"
  • AlertCircle icon (line 219): add aria-hidden="true"

Step 9: Fix iframe keyboard trap in StyleSelector

Add tabIndex={-1} to the preview iframe (line 443-447):

<iframe
  ref={iframeRef}
  className="w-full h-full border-0 block"
  title="Style Preview"
  tabIndex={-1}
/>

Step 10: Verify and commit

Run: npm run build

git add src/App.tsx src/components/StyleSelector.tsx src/components/Preview.tsx src/components/FileUpload.tsx
git commit -m "a11y: fix keyboard access, listbox pattern, ARIA labels, target sizes"

Task 4: ARIA Live Regions & Status Announcements

Files:

  • Modify: src/App.tsx
  • Modify: src/components/StyleSelector.tsx
  • Modify: src/components/Preview.tsx
  • Modify: src/components/FileUpload.tsx

Step 1: Add root status region in App.tsx

Add a state for status announcements near the top of the App component (after line 90):

const [statusMessage, setStatusMessage] = useState('');

Add a useEffect to announce state changes (after the existing useEffects):

useEffect(() => {
  const messages: Record<AppState, string> = {
    [AppState.UPLOAD]: 'Upload screen',
    [AppState.CONFIG]: 'Style configuration',
    [AppState.GENERATING]: 'Generating document',
    [AppState.PREVIEW]: 'Document preview',
  };
  setStatusMessage(messages[appState] || '');
}, [appState]);

Add the live region just inside the root div (before the header, around line 196):

<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>

Also add it in the Preview return path (line 170 area, inside the wrapping div):

<div role="status" aria-live="polite" className="sr-only">{statusMessage}</div>

Step 2: Fix generating state

In the generating state block (lines 388-425), add role="status" and aria-busy="true" to the container:

<motion.div
  key="generating"
  role="status"
  aria-busy="true"
  ...
>

Step 3: Fix loading state

Replace return null (lines 164-166) with:

if (!isLoaded) {
  return (
    <div className="h-screen w-screen flex items-center justify-center bg-zinc-950" role="status" aria-live="polite">
      <span className="sr-only">Loading TypoGenie</span>
    </div>
  );
}

Step 4: Fix error live region conflicts

In App.tsx line 378-379, remove aria-live="polite":

role="alert"

(Just role="alert", no explicit aria-live)

In FileUpload.tsx line 212-213, same fix - remove aria-live="polite", keep role="alert".

Also in FileUpload.tsx, add id="upload-error" to the error div, and add aria-describedby={error ? "upload-error" : undefined} to the dropzone div (line 111).

Remove the auto-dismiss timer in FileUpload.tsx (lines 90-96). Delete the entire useEffect block.

Step 5: Add search results live region in StyleSelector

After the search input (around line 370), add:

<div role="status" aria-live="polite" className="sr-only">
  {searchQuery.trim() ? `${filteredStyles.length} template${filteredStyles.length !== 1 ? 's' : ''} found` : ''}
</div>

Step 6: Fix loading and error states in StyleSelector

In the loading state (lines 251-259), add role="status":

<div className="w-full h-full flex items-center justify-center" role="status">

In the error state (lines 262-274), add role="alert":

<div className="w-full h-full flex flex-col items-center justify-center p-8 text-center" role="alert">

Add aria-hidden="true" to the warning emoji (line 267):

<span className="text-2xl" aria-hidden="true">&#9888;&#65039;</span>

Step 7: Fix Preview status messages

In Preview.tsx, the loading state (line 375-377), add role="status":

if (!style) {
  return <div className="h-screen flex items-center justify-center text-white" role="status">Loading...</div>;
}

For the save button success/generating text (lines 404-416), wrap in a live region. Add aria-live="polite" to the button:

<motion.button
  ...
  aria-live="polite"
>

Replace the alert() on line 243 with a state-based error: Add error state: const [exportError, setExportError] = useState<string | null>(null); Change line 243: setExportError("Failed to generate DOCX: " + e); Add error display after the ExportOptionsModal:

{exportError && (
  <div role="alert" className="fixed bottom-4 right-4 z-50 p-4 bg-red-900/90 border border-red-800 rounded-xl text-red-200 max-w-md">
    {exportError}
    <button onClick={() => setExportError(null)} className="ml-3 text-red-400 hover:text-white" aria-label="Dismiss error">&#10005;</button>
  </div>
)}

Step 8: Verify and commit

Run: npm run build

git add src/App.tsx src/components/StyleSelector.tsx src/components/Preview.tsx src/components/FileUpload.tsx
git commit -m "a11y: add ARIA live regions for status announcements and errors"

Task 5: Semantic Structure & Landmarks

Files:

  • Modify: src/App.tsx
  • Modify: src/components/StyleSelector.tsx
  • Modify: src/components/Preview.tsx
  • Modify: src/components/StylePreviewModal.tsx

Step 1: Add main landmark id

In App.tsx, the existing <main> on line 278, add id="main-content":

<main id="main-content" className="flex-1 relative overflow-hidden">

In the Preview return path (line 174 area), wrap content in <main>:

<main id="main-content" className="h-full w-full flex flex-col">
  <Preview ... />
</main>

Step 2: Fix progress stepper

Replace lines 257-271 (the stepper spans) with:

<nav aria-label="Progress">
  <ol className="flex items-center gap-4 text-sm text-zinc-400">
    <li className={appState === AppState.CONFIG ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.CONFIG ? "step" : undefined}>Configure</li>
    <li aria-hidden="true">/</li>
    <li className={appState === AppState.GENERATING ? "text-indigo-400 font-medium" : ""} aria-current={appState === AppState.GENERATING ? "step" : undefined}>Generate</li>
    <li aria-hidden="true">/</li>
    <li aria-current={appState === AppState.PREVIEW ? "step" : undefined}>Preview</li>
  </ol>
</nav>

Step 3: Add landmarks in StyleSelector

Wrap category filters section in <nav aria-label="Style filters">: Change the filter container (around line 320) from <div className="flex flex-col border-b ..."> to:

<nav aria-label="Style filters" className="flex flex-col border-b border-zinc-800/50 bg-zinc-900/20">

Wrap the card list in <section aria-label="Style list">:

<section aria-label="Style list" className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar" role="listbox" aria-label="Typography styles" ...>

(Note: section with aria-label becomes a landmark; the role="listbox" goes on an inner div or replaces section's role)

Actually, simpler approach: keep the role="listbox" div inside a <section>:

<section aria-label="Style list" className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
  <div role="listbox" aria-label="Typography styles">
    {/* cards */}
  </div>
</section>

Wrap preview column in <section aria-label="Style preview">:

<section aria-label="Style preview" className="lg:col-span-8 flex flex-col min-h-0 bg-zinc-950 rounded-2xl border border-zinc-800 shadow-2xl relative overflow-hidden">

Step 4: Add decorative aria-hidden

In App.tsx, the blob background divs (lines 280-298), add aria-hidden="true" to the container:

<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">

In StyleSelector.tsx, the window chrome dots (lines 427-431):

<div className="flex gap-1.5" aria-hidden="true">

In Preview.tsx, visual dividers (lines 390, 392):

<div className="h-4 w-px bg-zinc-800 hidden sm:block" aria-hidden="true" />

Step 5: Add iframe lang attribute

In Preview.tsx line 313, change <html> to <html lang="en">.

In StylePreviewModal.tsx line 48, change <html> to <html lang="en">.

In StyleSelector.tsx line 193, change <html> to <html lang="en">.

Step 6: Verify and commit

Run: npm run build

git add src/App.tsx src/components/StyleSelector.tsx src/components/Preview.tsx src/components/StylePreviewModal.tsx
git commit -m "a11y: add landmarks, semantic structure, skip nav, iframe lang"

Task 6: DOCX Output Accessibility

Files:

  • Modify: src/utils/docxConverter.ts

Step 1: Add document metadata

In docxConverter.ts, modify the generateDocxDocument function signature (line 164) to accept additional options. Add to the ConversionOptions interface (find it near the top):

inputFileName?: string;

In the document options block (lines 1231-1257), add metadata properties:

After const documentOptions: any = { on line 1231, add:

  title: options.inputFileName || 'Document',
  description: 'Generated by TypoGenie',
  creator: 'TypoGenie',

Step 2: Add image placeholder handling

In the processNode function (find where HTML tags are processed), add a case for img elements. Find the section where various tags are handled (around the big switch/if-else on tag names). Add:

if (tagName === 'img') {
  const alt = element.getAttribute('alt') || '';
  const placeholderText = alt ? `[Image: ${alt}]` : '[Image]';
  return [new Paragraph({
    children: [new TextRun({
      text: placeholderText,
      font: body.font,
      size: pt(body.size),
      color: formatColor(body.color || '666666'),
      italics: true,
    })],
    spacing: { before: 120, after: 120 },
  })];
}

Step 3: Fix table header rows

In processTable (line 780), change:

rows.push(new TableRow({ children: cells }));

to:

const hasThCells = Array.from(rowEl.querySelectorAll('th')).length > 0;
rows.push(new TableRow({ children: cells, tableHeader: hasThCells }));

Step 4: Preserve heading level in processHeaderAsTable

In processHeaderAsTable (around line 603-685), find where the Paragraph is created inside the table cell. Add the heading property to the paragraph so it retains heading semantics:

Find the Paragraph constructor call inside the TableCell children. Add:

heading: level === 1 ? HeadingLevel.HEADING_1 :
         level === 2 ? HeadingLevel.HEADING_2 :
         level === 3 ? HeadingLevel.HEADING_3 :
         level === 4 ? HeadingLevel.HEADING_4 :
         level === 5 ? HeadingLevel.HEADING_5 :
         HeadingLevel.HEADING_6,

Step 5: Pass inputFileName from Preview.tsx

In Preview.tsx where generateDocxDocument is called (line 210), add inputFileName to the options:

const blob = await generateDocxDocument(htmlContent, {
  ...existing options,
  inputFileName,
});

Step 6: Verify and commit

Run: npm run build

git add src/utils/docxConverter.ts src/components/Preview.tsx
git commit -m "a11y: add DOCX metadata, image placeholders, table headers"

Task 7: Template Contrast Validation

Files:

  • Create: src/utils/contrastUtils.ts
  • Modify: src/services/templateRenderer.ts

Step 1: Create contrast utility

Create src/utils/contrastUtils.ts:

/**
 * WCAG 2.2 contrast ratio utilities for runtime color validation
 */

export function hexToRgb(hex: string): [number, number, number] {
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;
  const num = parseInt(full, 16);
  return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}

export function relativeLuminance([r, g, b]: [number, number, number]): number {
  const [rs, gs, bs] = [r, g, b].map(c => {
    const s = c / 255;
    return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

export function contrastRatio(color1: string, color2: string): number {
  const l1 = relativeLuminance(hexToRgb(color1));
  const l2 = relativeLuminance(hexToRgb(color2));
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

/**
 * Adjusts foreground color to meet minimum contrast ratio against background.
 * Lightens or darkens the foreground as needed.
 */
export function ensureContrast(fg: string, bg: string, minRatio: number): string {
  if (contrastRatio(fg, bg) >= minRatio) return fg;

  const bgLum = relativeLuminance(hexToRgb(bg));
  const [r, g, b] = hexToRgb(fg);

  // Determine direction: lighten if bg is dark, darken if bg is light
  const lighten = bgLum < 0.5;

  for (let step = 1; step <= 50; step++) {
    const factor = step * 0.02;
    let nr: number, ng: number, nb: number;

    if (lighten) {
      nr = Math.min(255, Math.round(r + (255 - r) * factor));
      ng = Math.min(255, Math.round(g + (255 - g) * factor));
      nb = Math.min(255, Math.round(b + (255 - b) * factor));
    } else {
      nr = Math.max(0, Math.round(r * (1 - factor)));
      ng = Math.max(0, Math.round(g * (1 - factor)));
      nb = Math.max(0, Math.round(b * (1 - factor)));
    }

    const adjusted = '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join('');
    if (contrastRatio(adjusted, bg) >= minRatio) return adjusted;
  }

  // Fallback: return white or black
  return lighten ? '#FFFFFF' : '#000000';
}

/**
 * Determines if text at given size/weight qualifies as "large text" per WCAG.
 * Large text = 18pt+ (24px+) or 14pt+ bold (18.66px+)
 */
export function isLargeText(sizePt: number, bold: boolean): boolean {
  return sizePt >= 18 || (bold && sizePt >= 14);
}

Step 2: Integrate into templateRenderer.ts

At the top of src/services/templateRenderer.ts, add import:

import { ensureContrast, isLargeText } from '../utils/contrastUtils';

Modify the elementToCss function (lines 32-76). After color is resolved but before it's pushed to CSS, validate contrast. Change the color line (line 44):

if (style.color) {
  let resolvedColor = resolveColor(style.color, palette);
  const bgColor = resolveColor('background', palette);
  const sizePt = style.size || 12;
  const isBold = style.bold || false;
  const minRatio = isLargeText(sizePt, isBold) ? 4.5 : 7;
  resolvedColor = ensureContrast(resolvedColor, bgColor, minRatio);
  css.push(`color: ${resolvedColor}`);
}

Step 3: Fix del element color fallback

In generatePreviewCss (line 83+), find where del element CSS is generated. After the line that generates del CSS, add a post-processing step. If the template file structure means del goes through elementToCss generically, the contrast fix in Step 2 already handles it. But for the specific case where del uses border color:

In generatePreviewCss, after all elements are processed, add a special del override check:

// Special handling: if del color uses 'border' palette, check contrast and fallback
const delElement = elements.del;
if (delElement?.color === 'border') {
  const resolvedBorder = resolveColor('border', palette);
  const bgColor = resolveColor('background', palette);
  const { contrastRatio: ratio } = await import('../utils/contrastUtils');
  // contrastUtils is sync, just import at top
}

Actually, simpler: the elementToCss function with the Step 2 change already auto-corrects any color that fails contrast, including del using border. The ensureContrast call will lighten/darken the resolved border color until it meets 7:1. No special del handling needed.

Step 4: Override justified text

In elementToCss, line 50, change:

if (style.align) css.push(`text-align: ${style.align === 'both' ? 'justify' : style.align}`);

to:

if (style.align) {
  const align = style.align === 'both' || style.align === 'justify' ? 'left' : style.align;
  css.push(`text-align: ${align}`);
}

Step 5: Enforce line-height floor

In elementToCss, line 54, change:

if (style.spacing.line) css.push(`line-height: ${style.spacing.line}`);

to:

if (style.spacing.line) {
  // Enforce minimum line-height: 1.5 for body text, 1.0 for headings
  const isHeading = selector.includes(' h1') || selector.includes(' h2') || selector.includes(' h3') || selector.includes(' h4') || selector.includes(' h5') || selector.includes(' h6');
  const minLineHeight = isHeading ? 1.0 : 1.5;
  const lineHeight = Math.max(style.spacing.line, minLineHeight);
  css.push(`line-height: ${lineHeight}`);
}

Step 6: Verify and commit

Run: npm run build

git add src/utils/contrastUtils.ts src/services/templateRenderer.ts
git commit -m "a11y: add runtime contrast validation, justify override, line-height floor"

Task 8: Miscellaneous Remaining Issues

Files:

  • Create: src/components/ErrorBoundary.tsx
  • Modify: src/main.tsx
  • Modify: src/App.tsx
  • Modify: src/components/StyleSelector.tsx

Step 1: Create ErrorBoundary

Create src/components/ErrorBoundary.tsx:

import React from 'react';

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, ErrorBoundaryState> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div
          role="alert"
          className="h-screen w-screen flex items-center justify-center bg-zinc-950 text-zinc-100 p-8"
        >
          <div className="max-w-md text-center">
            <h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
            <p className="text-zinc-400 mb-6">
              TypoGenie encountered an unexpected error. Please reload the application.
            </p>
            <button
              onClick={() => window.location.reload()}
              className="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-xl transition-colors"
            >
              Reload
            </button>
          </div>
        </div>
      );
    }

    return this.props.children;
  }
}

Step 2: Wrap App in ErrorBoundary

In src/main.tsx, add import and wrap:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';

const rootElement = document.getElementById('root');
if (!rootElement) {
  throw new Error("Could not find root element to mount to");
}

const root = ReactDOM.createRoot(rootElement);
root.render(
  <React.StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </React.StrictMode>
);

Step 3: Add Framer Motion reduced motion in App.tsx

Add import at top of App.tsx:

import { useReducedMotion } from 'motion/react';

Inside the App component, add:

const prefersReducedMotion = useReducedMotion();

Change the blob animations (lines 280-298) to respect the preference:

<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
  <motion.div
    className="absolute -top-[20%] -left-[10%] w-[50%] h-[50%] bg-indigo-900/10 rounded-full blur-3xl"
    animate={prefersReducedMotion ? {} : {
      x: [0, 30, 0],
      y: [0, -20, 0],
      scale: [1, 1.1, 1]
    }}
    transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
  />
  <motion.div
    className="absolute top-[20%] -right-[10%] w-[40%] h-[40%] bg-violet-900/10 rounded-full blur-3xl"
    animate={prefersReducedMotion ? {} : {
      x: [0, -20, 0],
      y: [0, 30, 0],
      scale: [1, 1.15, 1]
    }}
    transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
  />
</div>

Change the gradient text animation (lines 325-334):

<motion.span
  className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-violet-400"
  animate={prefersReducedMotion ? {} : {
    backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"]
  }}
  transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
  style={{ backgroundSize: "200% 200%" }}
>

Change the generating spinner (lines 397-406):

<motion.div
  className="relative"
  animate={prefersReducedMotion ? {} : { rotate: 360 }}
  transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
>

Step 4: Add title to truncated descriptions in StyleSelector

In StyleSelector.tsx line 409, add title attribute:

<p className="text-xs text-zinc-400 line-clamp-2 leading-relaxed mb-2" title={style.description}>{style.description}</p>

Step 5: Ensure keyboard shortcut hint visible on small screens

In App.tsx, the shortcuts button area (lines 245-255). The button itself uses hidden sm:flex. After it, add a small-screen-only hint:

<kbd className="sm:hidden px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-400 font-mono text-xs border border-zinc-700 cursor-pointer" onClick={() => setShowShortcuts(true)} role="button" aria-label="Show keyboard shortcuts">?</kbd>

Step 6: Verify and commit

Run: npm run build

git add src/components/ErrorBoundary.tsx src/main.tsx src/App.tsx src/components/StyleSelector.tsx
git commit -m "a11y: add error boundary, reduced motion, remaining fixes"

Execution Checklist

Task Description Files Est.
1 CSS Foundation 7 files ~15 min
2 Modal System 5 files ~20 min
3 Keyboard Access 4 files ~20 min
4 ARIA Live Regions 4 files ~15 min
5 Semantic Structure 4 files ~10 min
6 DOCX Output 2 files ~15 min
7 Contrast Validation 2 files ~15 min
8 Miscellaneous 4 files ~10 min