Skip to content

Accessibility audit: keyboard, focus, tab semantics, contrast, live regions#94

Open
thalida wants to merge 13 commits into
mainfrom
feat/issue-79-a11y-audit
Open

Accessibility audit: keyboard, focus, tab semantics, contrast, live regions#94
thalida wants to merge 13 commits into
mainfrom
feat/issue-79-a11y-audit

Conversation

@thalida

@thalida thalida commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Closes #79.

Full accessibility audit across the app, then fixes prioritizing keyboard, focus, contrast, and tab semantics. Audited against current main (so this reflects what #78/#83/#87 already handled, not the pre-#78 state).

What the audit found

Verified with rigorous tooling, not eyeballing:

  • axe-core (Chrome's audit engine) run against the real composed surfaces (settings, source picker, modals, tree), now committed as tests/a11y/audit.test.tsx so it can't regress.
  • WCAG contrast math on the OKLCH tokens across every Theme switcher: pick accent + surface colors #87 accent + surface preset (OKLCH → sRGB luminance → ratio).

The recent PRs had already handled a lot (global :focus-visible ring, landmarks, most icon-button labels, and contrast — which passes AA across all presets). The real gaps were keyboard operability, focus management, and status announcements.

Fixes

Keyboard + focus

  • File tree was <div onClick> only (mouse-dead). Rebuilt as a real ARIA tree: role=tree/treeitem/group, aria-expanded/aria-selected, roving tabindex, and full arrow / Home / End / Enter / Space navigation.
  • Modals (Debug, Shortcuts, Projects) had no focus trap, restore, or background inert. New useDialogFocus hook adds all three; ProjectsView gains role=dialog/aria-modal in modal mode.
  • Skip-to-content link + sr-only utility; <main> is focus-targetable.
  • Focus rings: RangePair thumb and the almanac nav rows no longer suppress their keyboard ring.

Contrast

  • Bumped --cc-text-muted so placeholders + segmented-select labels clear 4.5:1 on the sidebar surface (was 4.45).

Tab / control semantics

  • PaneTabs is now keyboard-navigable (roving tabindex + arrow keys, automatic activation) with aria-controls/role=tabpanel wiring (Info + Controls panes).
  • SegmentedSelect is a real radiogroup/radio with aria-checked + arrow-key selection (was styled buttons with CSS-only state).
  • The 47 interactive-controls-inside-<summary> (settings section/subgroup reset buttons) are gone: <details>/<summary> replaced with a button-driven disclosure (aria-expanded toggle + reset button as siblings). Structure preserved; .is-open handles collapse.

Labels + landmarks + live regions

  • All 271 settings form fields get an id + htmlFor label association (fixes the "form field needs id/name" and the stacked-row case where the label bound to the reset button).
  • Sidebars get distinct aria-labels; the activity bar is a labelled <nav>.
  • Search results are real <button>s + a result-count live region; the canvas gets a text alternative (role=img) and city selection is announced via a SelectionAnnouncer live region; footer build-status, branch resolving, commit/file-preview loads, and CopyButton "Copied" all announce via role=status.

Verification

  • just test (2518 frontend tests, incl. new tree keyboard, dialog focus, tab keyboard, segmented radio, selection-announcer, and axe audit tests), just lint (eslint + tsc + prettier) all green. Backend untouched.
  • Verified live in the running app: tabbed the whole UI, checked focus rings, disclosure behavior, and contrast on multiple presets.

Split out as a follow-up: keyboard-resizable sidebars (#93, nice-to-have). Full 3D keyboard navigation of the city is intentionally out of scope — the canvas has a text alternative and the tree + search give a keyboard path to select files.

🤖 Generated with Claude Code

thalida and others added 13 commits July 12, 2026 23:05
- RangePair: keyboard focus-visible ring now shows on the thumb instead of
  being suppressed entirely (was outline:none on the whole overlay input).
- almanac-fact nav rows: keep the global :focus-visible ring instead of
  replacing it with only a faint hover wash.
- Bump --cc-text-muted lightness so placeholders and segmented-select
  inactive labels clear 4.5:1 on the sidebar surface (was 4.45:1).

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Explore file tree rows were <div onClick> only: unreachable and
unusable by keyboard. Rebuild them as a real ARIA tree:

- role=tree/treeitem/group, aria-expanded on dirs, aria-selected on the
  active node, and an aria-label on the tree.
- Roving tabindex (the selected item, or the first when none) plus full
  keyboard nav: Up/Down move, Left/Right collapse/expand or step in/out,
  Home/End jump, Enter/Space activate.
- Keyboard focus ring drawn on the row band only, not around the wrapping
  subtree.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keydown bubbled to ancestor <li role=treeitem> handlers, which re-ran and
pulled focus back to a folder's first child, so arrow-down couldn't move
past the first item inside any nested folder. Only the focused item (the
event target) now handles its own keys.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New useDialogFocus hook: on open it moves focus into the dialog, traps
  Tab within it, marks background siblings inert (out of tab order + a11y
  tree), and restores focus to the opener on close. Applied to Debug,
  Shortcuts, and (in dismissible/modal mode) Projects views.
- ProjectsView gains role=dialog + aria-modal when shown over a city.
- Shortcuts dialog now labelled by its visible title (fixes Label-in-Name:
  aria-label 'Keyboard shortcuts' vs visible 'Keyboard & Mouse').
- Add a skip-to-content link + sr-only utility; main is focus-targetable.

Adds axe-core as a dev dep for the a11y test harness.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An interactive control (the reset button) nested inside <summary> is
unreliable for keyboard/AT (Chrome flags all 47). Rebuild Section and
Subgroup as button-driven disclosures: the header is a flex row with a real
<button aria-expanded> toggle and the reset button as a sibling, not nested.

- Outer element keeps its class (.controls-section / .theme-subgroup) as a
  <div>, and children stay direct children, so every structural selector
  (dividers, nested-subgroup indent, first-of-type) is unchanged; an .is-open
  class hides the body when collapsed, mirroring a closed <details>.
- Chevron rotates off aria-expanded; ControlsPane collapses sections by
  remounting them (nonce) instead of toggling details.open.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Every settings control (color/number/slider/toggle/range-pair, plus the
  HueMap and TierWidths per-row sliders) now takes an id, and its ThemeRow
  label associates via htmlFor. Fixes the 271 'form field needs id or name'
  and the stacked-row case where the label bound to the reset button, not the
  control. RangePair thumbs get distinct 'X minimum'/'X maximum' names.
- NewProjectForm: associate the source label (htmlFor+id), wire the validation
  error via aria-describedby + role=alert, drop the redundant aria-label.
- ThemeRow tooltip uses a colon, not an em-dash.
- New tests/a11y/audit.test.tsx: runs axe-core across ControlsPane,
  ProjectsView, both modals, and TreePane, plus structural guards
  (no interactive-in-summary, no id-less fields, no orphan labels, no
  positive tabindex) so these can't silently regress.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The subgroup toggle button carries font:inherit, which overrode the
.text-label font-size when those classes sat on the button — leaving the
labels uppercase (text-transform isn't part of the font shorthand) but blown
up to body size. Move text-label onto the label span, matching Section, so
subgroup headers render at the small label size again.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PaneTabs: roving tabindex (only the active tab is a Tab stop) plus arrow-key
navigation with automatic activation, Home/End, and focus following selection
- the interaction the tablist role advertises. A panelId prop wires
aria-controls on each tab and stable tab ids; InfoPane and ControlsPane mark
their body as the matching role=tabpanel (aria-labelledby the active tab, via
a new Pane bodyProps passthrough).

SegmentedSelect: was styled buttons in a role=group with the selection only in
CSS. Now a real role=radiogroup / role=radio with aria-checked, a group name
(the field label), roving tabindex, and arrow-key selection.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The paneRef only existed to query <details> for collapse; that path is gone
(nonce remount), so remove the ref and its Pane wiring, and de-stale the
Pane paneRef doc comment.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The city <canvas> gets role=img + a description of what it shows (WCAG
  1.1.1), since it's otherwise a nameless graphic to assistive tech.
- New SelectionAnnouncer: a visually-hidden polite live region (mounted in
  App) that mirrors the picker and speaks the selected file/dir/commit/gem, so
  selecting in the 3D scene is announced (WCAG 4.1.3).

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The two sidebars get distinct aria-labels (Explore / Selection details) so
  the complementary landmarks are distinguishable; the activity bar is a
  labelled <nav>.
- Search results are real <button>s (native Enter/Space, button role) instead
  of tabindex li's, and a polite live region announces the result count / no-
  match as you type.
- Footer build-status is a role=status live region (rebuild/idle/error now
  announced); its decorative dot is aria-hidden.
- Breadcrumb separator is aria-hidden.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CopyButton announces 'Copied' via a visually-hidden live region (was a
  color-only flash).
- BranchSelect: associate the visible Branch label with the select (htmlFor +
  id, dropping the duplicate aria-label), and announce 'Resolving branches'
  via role=status.
- CommitPane message load/error and FilePreviewPane load are announced via
  role=status (the file body is left off the live region so it isn't read out
  wholesale).

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The <embed> PDF preview had no accessible name; give it a title, and label
the video/audio elements by filename.

Refs #79

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thalida thalida linked an issue Jul 13, 2026 that may be closed by this pull request
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.

Accessibility audit

1 participant