Skip to main content

Accessibility automation & component audit

Version: 0.2.0 · Type: ✨ Feature

Spec: .specs/2026-06-16/a11y-automation/

Linear: parent DES-117; this batch falls under Phase 1/3/4 (DES-118 / DES-120 / DES-121)

Background

Upgrade accessibility in @plaud/design from "rely on Radix fallbacks + manual review" to "automatically verifiable (axe) + quantified standard (WCAG 2.1 AA) + existing inventory audited and fixed".

Toolchain / Infrastructure (Phase 1, no component behavior change)

  • package.json: added devDependencies vitest-axe@0.1.0, @axe-core/playwright@4.11.3, axe-core@4.12.1; added test:a11y and a11y:audit scripts
  • Added src/test/axe-config.ts: shared WCAG_AA_TAGS / AXE_RUN_OPTIONS, used in all three places (vitest / Playwright / audit script)
  • test/setup.ts: registers the vitest-axe matcher; type augmentation in the sibling test/vitest-axe.d.ts
  • vitest.config.ts / tsconfig.json / tsconfig.build.json: added the browser-test glob src/**/*.ct.spec.tsx to exclude
  • Unified browser tests under playwright-ct.config.ts (testMatch *.ct.spec.tsx); test:visual / test:a11y are distinguished by the @visual / @a11y tags
  • Added analyzeA11y in playwright/axe-utils.ts
  • PoC: appended axe assertions to the describe('accessibility') blocks of the Button / Dialog / Input unit tests, plus @a11y blocks in the sibling *.ct.spec.tsx files
  • Confirmed eslint-plugin-jsx-a11y is already active in eslint.config.js

Inventory Audit (Phase 3, offline, does not touch test:run)

  • Added scripts/a11y/a11y-audit-config.ts (49 public components → render fixtures), scripts/a11y/a11y-audit.ts (batch run with happy-dom + axe), scripts/a11y/a11y-dom-setup.ts
  • Full audit: all 49 components rendered successfully, 47 compliant / 2 to fix; the report is output to a11y-report/ (gitignore)

Component Fixes (Phase 4)

Slider — thumb missing accessible name (aria-input-field-name, serious)

Problem: aria-label was passed through to the Radix Slider root (roleless) rather than the role="slider" Thumb, so the thumb had no accessible name (WCAG 4.1.2).

Changed Files: src/components/ui/slider.tsx, src/components/Slider/Slider.test.tsx, new src/components/Slider/Slider.ct.spec.tsx (@a11y)

Changes: ui/slider destructures aria-label / aria-labelledby and lands them on SliderPrimitive.Thumb; updated the two original assertions (root → thumb) and added an axe assertion.

Problem: the role="menuitem" elements inside <nav> had no role="menu"/group parent.

Changed Files: src/components/Menu/Menu.tsx, src/components/Menu/Menu.test.tsx, new src/components/Menu/Menu.ct.spec.tsx (@a11y)

Changes:

  • Added role="menu" + aria-orientation="vertical" to the top-level item container
  • Added aria-haspopup="menu" to submenu parent items
  • Added role="none" to the MenuSubItem collapse container (so the parent menu sees through it), and role="group" to its collapsed-content container and the MenuGroup collapsed-content container (satisfying aria-required-children and providing a compliant parent for inner menuitems)
  • Added axe assertions (including nested submenus)

Documentation (Phase 2)

  • Anchored the "Accessibility" chapter of docs/constitution.md to WCAG 2.1 AA + a three-layer automation baseline
  • Added a11y automation testing conventions to CLAUDE.md §9.2
  • Added docs/a11y-checklist.md (an acceptance checklist by component category) and indexed it in docs/README.md

Test File Layout (final convention, no component behavior change)

To reduce per-component file count and directory sprawl, this batch also relocated test files (existing *.visual.spec.tsx files were migrated along with them):

  • Single browser-test file: each component's browser tests are unified into [Name].ct.spec.tsx, with internal test.describe(..., { tag: '@visual' | '@a11y' }, …) blocks (11 components total, deduped from the original 8 visual + 5 a11y). Unit tests *.test.tsx (vitest / happy-dom) remain separate (cannot be merged across runners).
  • Single Playwright config: playwright-ct.config.ts (testMatch *.ct.spec.tsx); test:visual = --grep @visual, test:a11y = --grep @a11y.
  • Scripts split by domain: scripts/visual/ (visual-diff-config / generate-visual-gallery / ai-visual-review / api), scripts/a11y/ (a11y-audit / a11y-audit-config / a11y-dom-setup).
  • Test support moved out of src: src/test/ → top-level test/ (src/ reverts to pure production source); the two icon registries are renamed to self-describing names unit-icon-registry.tsx (unit-test stub) / ct-icon-registry.tsx (CT real Figma). Knock-on: vitest.config setupFiles, tsconfig.json include adds test, tsconfig.build drops the now-invalid src/test exclude, the eslint.config strict block includes test/**, and importer relative paths.
  • Further split by runner ownership: ct-icon-registry.tsx (consumed only by playwright/index.tsx) moves into playwright/, co-located with the CT harness; test/ keeps only the vitest global + cross-runner-shared setup.ts / unit-icon-registry.tsx / axe-config.ts (these three are referenced by unit tests / CT / audit scripts, so they are neutral shared assets and do not belong to any single runner directory). The entire playwright/ directory is excluded from tsc/eslint (it imports @playwright/test), so after ct-icon-registry moves in, it is transpiled by the CT build and no longer type-checked separately.
  • All tests go into __tests__/: all 74 *.test.tsx / *.ct.spec.tsx / *Fixture.visual.tsx files were moved from the component root into their respective __tests__/ (full separation of tests from source), with relative imports adjusted +1 level accordingly. The __ai-review__/ screenshot artifacts remain at the component root (visual:gallery path unchanged, the ct.spec REVIEW_DIR moved up one level). The src/** globs of vitest / tsconfig / eslint recurse, so they automatically cover __tests__/ with no gate-rule changes needed.
  • A layout cheat sheet is in CLAUDE.md §9.