Accessibility audit: keyboard navigation, screen-reader semantics and WCAG AA contrast - #20
Merged
Merged
Conversation
Audited against WCAG 2.2 AA with axe-core, keyboard-only navigation and the two themes. The landing page now reports zero axe violations; the app is down to two known false positives (CodeMirror's own scroller, and the portalled context menu being outside a landmark). Keyboard and screen-reader behaviour - Add a shared useDialogA11y hook: Escape, a Tab/Shift+Tab focus trap, initial focus inside the panel and focus restoration to the trigger. Every dialog (confirm, shortcuts, preferences, create-snippet, link, search palette) hand-rolled its own Escape listener and none of them trapped or restored focus, so keyboard users tabbed straight out of the modal into the page behind it. The six copies collapse onto the hook. - Add useMenuKeyboardNav for the context menu, which is portalled to document.body and was therefore unreachable by keyboard: it now focuses the first item, moves with the arrow keys / Home / End, and returns focus to the trigger. Its group and separator wrappers get the roles role="menu" requires. - Give the sidebar tree real tree/treeitem/group semantics with expanded, level and selected state. The rows were role="button", which makes their children presentational and hid the expand toggle and actions menu from assistive tech. Drops a no-op nested button that was a dead tab stop. - Expose the search palette as a combobox + listbox with aria-activedescendant and announce the result count, so the arrow-key selection is perceivable. - Add a skip link and complete the landmarks: the landing page had no <main>, and the snippet editor rendered as a plain <div>. Add the missing h1s. - Fix the toasts' live regions — aria-live sat on a conditionally rendered child, so nothing was ever announced. Contrast and visible focus - Add a single :focus-visible ring. Inputs across the app set outline-none with no replacement, leaving keyboard users with no focus indicator at all. - Add --faint and --danger theme tokens, each calibrated per theme to clear 4.5:1 on the lightest surface it lands on, and move the tertiary text (placeholders, hints, empty states) and destructive text onto them. The opacity-based inks (text-ink/20 … /35) bottomed out around 2.5:1, and Tailwind's red-400 fell to 2.9:1 on the light panel. - Honour prefers-reduced-motion globally; only the title shimmer did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148oG4ifKUHvqfgePwyVK2C
Referenced from the pull request body. Safe to drop once the PR is merged — nothing in the app links to them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148oG4ifKUHvqfgePwyVK2C
Contributor
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
klipcode | a3c2f2e | Commit Preview URL Branch Preview URL |
Jul 31 2026, 09:17 AM |
Closing a context menu restored focus to its trigger synchronously, which blurred the rename input that the menu item had just opened. The input commits on blur, so renaming from the context menu ended immediately. Defer the restore and skip it when something else already took focus. Escape still hands focus back to the trigger.
Aside rows are treeitem, not button, since the accessibility pass. Move the shared app-shell locators into e2e/helpers.ts so both suites resolve rows the same way instead of repeating role queries (gotoApp was duplicated too).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audited the app against WCAG 2.2 AA using axe-core, keyboard-only navigation and both themes, then fixed the highest-impact findings.
Result: the landing page now reports 0 axe violations in both themes (was 0 serious but several contrast failures below axe's default threshold). The app is down to two known false positives — CodeMirror's own
.cm-scroller, which manages focus through itscontenteditable, and the portalled context menu being "outside a landmark", which is expected for a popup.Keyboard & screen-reader fixes
Dialogs had no focus management at all
Every dialog — confirm, shortcuts, preferences, create-snippet, link, search palette — hand-rolled its own Escape listener, and none of them trapped or restored focus. Keyboard users tabbed straight out of the modal into the page behind it, and on close focus was dumped back at the top of the document.
Extracted
useDialogA11y(src/hooks/useDialogA11y.ts), which handles Escape, a Tab/Shift+Tab focus trap, initial focus inside the panel, and focus restoration to the trigger. The six duplicated Escape listeners collapse onto it. Dialog titles moved fromaria-labelonto real<h2>elements wired up witharia-labelledby, and the shortcuts/preferences dialogs gained a close button (previously Escape or a backdrop click only).The context menu was unreachable by keyboard
It is portalled to
document.body, so it sat outside the tab order entirely — opening it from the "More options" button left focus stranded on the button.useMenuKeyboardNavnow focuses the first item on open, moves with the arrow keys / Home / End, closes on Tab, and returns focus to the trigger. Its wrappers also got thegroup/separatorroles thatrole="menu"requires.The sidebar tree announced as a flat list of buttons
Rows were
role="button", which makes their children presentational — hiding the expand toggle and the actions menu from assistive tech (axe flagged this asnested-interactive). They are nowtreeitems inside atree, carryingaria-expanded,aria-levelandaria-selected, with child containers asgroup. Also removed a nested<button>that had no handler and was purely a dead tab stop.Other
combobox+listboxwitharia-activedescendant, and announces the result count — the arrow-key selection was previously invisible to screen readers.<main>, and the snippet editor rendered as a plain<div>. Added both, plus the missing<h1>s.aria-livesat on a conditionally rendered child, so a region that doesn't exist yet gets its first content — meaning nothing was ever announced. Moved onto the always-mounted wrapper.prefers-reduced-motionis now honoured globally (only the title shimmer respected it before).Visual changes
Visible focus indicator
Inputs across the app set
outline-nonewith no replacement, leaving keyboard users with no focus indicator at all. Added a single:focus-visiblering driven by a--focus-ringtoken, so pointer users see nothing change. The editors (CodeMirror, TipTap) keep their own caret and opt out.Contrast
The opacity-based inks used for tertiary text (
text-ink/20…text-ink/35) bottomed out around 2.5:1 — placeholders, empty states, hints and the search palette's footer were all well under AA. Tailwind'sred-400dropped to 2.9:1 on the light panel.Rather than hand-tuning dozens of opacities, added two theme tokens —
--faintand--danger— each calibrated per theme to clear 4.5:1 against the lightest surface it actually lands on (not just--background, but panels, chips and the editor gutter too), and moved the affected text onto them. Also fixed the account toast, which had no colour or background of its own and inherited whatever was behind it.Before / after
New close buttons
Verification
pnpm lint— cleanpnpm test— 146/146 passingtsc --noEmit— clean (the two pre-existinggenerate-titleerrors come from the un-generatedcloudflare-env.d.ts, unrelated to this branch)aria-activedescendanttracks the palette selection, andprefers-reduced-motioncollapses the animationsNotes
docs/accessibility-audit/in a separate commit so they're easy to drop after review — nothing in the app references them..cm-scrollerfinding is genuinely reachable-but-unlabelled for read-only snippet previews; a roving-tabindex pass on the sidebar tree (arrow-key navigation between rows) would complete the tree pattern; and drag-and-drop still has no single-pointer alternative (WCAG 2.5.7), though the context menu's cut/paste covers most of it.Generated by Claude Code