Skip to content

Accessibility audit: keyboard navigation, screen-reader semantics and WCAG AA contrast - #20

Merged
martinezharo merged 4 commits into
mainfrom
claude/accessibility-audit-lqo3ak
Jul 31, 2026
Merged

Accessibility audit: keyboard navigation, screen-reader semantics and WCAG AA contrast#20
martinezharo merged 4 commits into
mainfrom
claude/accessibility-audit-lqo3ak

Conversation

@martinezharo

Copy link
Copy Markdown
Owner

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 its contenteditable, 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 from aria-label onto real <h2> elements wired up with aria-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. useMenuKeyboardNav now 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 the group / separator roles that role="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 as nested-interactive). They are now treeitems inside a tree, carrying aria-expanded, aria-level and aria-selected, with child containers as group. Also removed a nested <button> that had no handler and was purely a dead tab stop.

Other

  • Search palette is now a proper combobox + listbox with aria-activedescendant, and announces the result count — the arrow-key selection was previously invisible to screen readers.
  • Skip link + landmarks: the landing page had no <main>, and the snippet editor rendered as a plain <div>. Added both, plus the missing <h1>s.
  • Toast live regions: aria-live sat 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-motion is now honoured globally (only the title shimmer respected it before).

Visual changes

Visible focus indicator

Inputs across the app set outline-none with no replacement, leaving keyboard users with no focus indicator at all. Added a single :focus-visible ring driven by a --focus-ring token, so pointer users see nothing change. The editors (CodeMirror, TipTap) keep their own caret and opt out.

Skip link (first Tab) Focus ring
Skip link Focus ring

Contrast

The opacity-based inks used for tertiary text (text-ink/20text-ink/35) bottomed out around 2.5:1 — placeholders, empty states, hints and the search palette's footer were all well under AA. Tailwind's red-400 dropped to 2.9:1 on the light panel.

Rather than hand-tuning dozens of opacities, added two theme tokens — --faint and --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

Landing footer, dark
Landing footer, light
App home, dark
App home, light
Search palette, dark
Search palette, light
Context menu, dark
Context menu, light

New close buttons

Shortcuts dialog

Verification

  • pnpm lint — clean
  • pnpm test — 146/146 passing
  • tsc --noEmit — clean (the two pre-existing generate-title errors come from the un-generated cloudflare-env.d.ts, unrelated to this branch)
  • axe-core + keyboard walkthrough in Chromium, both themes: focus trap holds, Escape restores focus to the trigger, context menu arrow keys work, aria-activedescendant tracks the palette selection, and prefers-reduced-motion collapses the animations

Notes

  • The screenshots live in docs/accessibility-audit/ in a separate commit so they're easy to drop after review — nothing in the app references them.
  • Not covered, and worth a follow-up: the .cm-scroller finding 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

claude added 2 commits July 31, 2026 02:10
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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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).
@martinezharo
martinezharo merged commit 2fff096 into main Jul 31, 2026
3 checks passed
@martinezharo
martinezharo deleted the claude/accessibility-audit-lqo3ak branch July 31, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants