feat: WCAG 2.1 AA accessibility audit and fixes#74
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comprehensive accessibility improvements: - Add skip navigation link and main landmark - Implement ARIA tab pattern (tablist/tab/tabpanel) for nav - Add role="dialog", aria-modal, aria-labelledby to all 6 modals - Add focus trap and Escape key handling for modals - Add aria-labels to icon-only buttons (arrows, PiP) - Add sr-only labels for timer inputs (name, type, notes) - Add aria-live region for timer status announcements - Add aria-hidden to decorative SVG timer ring - Add role="timer" with aria-label to timer display - Add role="img" with aria-label to chart canvases - Add role="status" to storage indicator - Add keyboard arrow key support for timer dial (slider role) - Add keyboard arrow navigation within tablist - Fix color contrast: --text-secondary #a0a0a0 → #b0b0b0 (4.8:1+) - Replace outline:none with focus-visible ring on all elements - Fix label associations for Start Time fields in modals - Increase footer link size from 0.625rem to 0.75rem - Add main landmark to privacy.html and terms.html Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive accessibility overhaul based on a WCAG 2.1 AA audit. Key changes include the introduction of semantic HTML landmarks, ARIA roles for navigation and status updates, improved color contrast, and robust keyboard navigation features like modal focus trapping and arrow-key controls for the timer. Review feedback identified a few remaining accessibility issues, specifically the lack of a visual focus indicator for the timer dial, a mismatch in ARIA value attributes, and a logic bug where keyboard increments could exceed the visual timer's maximum duration.
| button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | ||
| a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
There was a problem hiding this comment.
The .timer-display element is focusable via keyboard (tabindex="0") but lacks a visual focus indicator. This violates WCAG 2.1 Success Criterion 2.4.7 (Focus Visible). Adding a focus ring ensures keyboard users can identify when the timer dial is currently focused.
| button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| button:focus-visible, .timer-display:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } | |
| a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| timerDisplay.setAttribute('role', 'slider'); | ||
| timerDisplay.setAttribute('aria-label', 'Timer duration'); | ||
| timerDisplay.setAttribute('aria-valuemin', '0'); | ||
| timerDisplay.setAttribute('aria-valuemax', '60'); |
There was a problem hiding this comment.
The aria-valuemax attribute is set to 60, but the visual timer dial and interaction logic are constrained by maxDragSeconds (currently 25 minutes). This value should be updated to match the actual maximum allowed by the component to provide accurate information to screen reader users.
| timerDisplay.setAttribute('aria-valuemax', '60'); | |
| timerDisplay.setAttribute('aria-valuemax', maxDragSeconds / 60); |
| if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { | ||
| e.preventDefault(); | ||
| remainingSeconds = Math.min((remainingSeconds || 0) + step, 60 * 60); | ||
| updateTimerDisplay(); | ||
| const mins = Math.ceil(remainingSeconds / 60); | ||
| timerDisplay.setAttribute('aria-valuenow', mins); | ||
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | ||
| } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { | ||
| e.preventDefault(); | ||
| remainingSeconds = Math.max((remainingSeconds || 0) - step, 60); | ||
| updateTimerDisplay(); | ||
| const mins = Math.ceil(remainingSeconds / 60); | ||
| timerDisplay.setAttribute('aria-valuenow', mins); | ||
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | ||
| } |
There was a problem hiding this comment.
The keyboard handler allows setting the timer duration up to 60 minutes (60 * 60), but the visual timer ring logic in updateTimerDisplay is hardcoded to a maximum of 25 minutes (maxDragSeconds). Setting a value higher than 25 minutes will cause the ring to render incorrectly. The keyboard increments should be clamped to maxDragSeconds to maintain consistency with the visual representation and the mouse/touch interaction logic.
| if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| remainingSeconds = Math.min((remainingSeconds || 0) + step, 60 * 60); | |
| updateTimerDisplay(); | |
| const mins = Math.ceil(remainingSeconds / 60); | |
| timerDisplay.setAttribute('aria-valuenow', mins); | |
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | |
| } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| remainingSeconds = Math.max((remainingSeconds || 0) - step, 60); | |
| updateTimerDisplay(); | |
| const mins = Math.ceil(remainingSeconds / 60); | |
| timerDisplay.setAttribute('aria-valuenow', mins); | |
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | |
| } | |
| if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { | |
| e.preventDefault(); | |
| remainingSeconds = Math.min((remainingSeconds || 0) + step, maxDragSeconds); | |
| updateTimerDisplay(); | |
| const mins = Math.ceil(remainingSeconds / 60); | |
| timerDisplay.setAttribute('aria-valuenow', mins); | |
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | |
| } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { | |
| e.preventDefault(); | |
| remainingSeconds = Math.max((remainingSeconds || 0) - step, step); | |
| updateTimerDisplay(); | |
| const mins = Math.ceil(remainingSeconds / 60); | |
| timerDisplay.setAttribute('aria-valuenow', mins); | |
| timerDisplay.setAttribute('aria-valuetext', mins + ' minutes'); | |
| } |
Only the active tab is in the Tab order (tabindex="0"), inactive tabs are removed from Tab order (tabindex="-1"). Arrow keys move between tabs per WAI-ARIA Authoring Practices. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move aria-selected/tabindex updates into original nav click handler instead of relying on separate IIFE listener (fragile ordering) - Scope Escape key handler to only safe-to-dismiss modals (add-modal, edit-modal) — prevents bypassing destructive confirmations and migration flows - Add totalSeconds = remainingSeconds in timer dial keyboard handler so progress ring displays correctly after keyboard adjustment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… add modal - Move keydown listener from nav element to each tab button for more reliable arrow key navigation - Add Enter key handler to add-modal matching existing edit-modal pattern — users can type a name and press Enter to submit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Arrow Down from a focused tab moves focus to the first focusable element in the associated tabpanel, completing the keyboard navigation pattern (Left/Right cycle tabs, Down enters content). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Gourmand CLI changed its interface — --full is no longer a valid flag. Default behavior is already full analysis. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Comprehensive WCAG 2.1 AA accessibility audit and fixes for Acquacotta.
<main>landmark, ARIA tab pattern for navigation,role="dialog"on all 6 modalsaria-liveregion for timer status announcements, sr-only labels for timer inputs,aria-labelon icon-only buttons,aria-hiddenon decorative SVG--text-secondary3.8:1 → 4.8:1+), added:focus-visiblering on all interactive elements, increased footer link sizerole="img"andaria-labelto all canvas elementsCloses #73
Test plan
🤖 Generated with Claude Code