.agents/skills/frontend-a11y-check/SKILL.md
Use this skill for frontend work under src/frontend when the change affects:
Use alongside:
frontend-testing for Jest/React Testing Library patterns.e2e-testing for Playwright patterns.frontend-i18n when adding or changing user-facing labels, accessible names, aria-label, tooltips, or visible strings.ibm-a11y-automation to run the Python IBM route scanner and produce Markdown/HTML reports.Do not stop at "run axe on default render." Inspect the changed UI and cover every meaningful user-visible state, for both keyboard and screen-reader users, that can expose accessibility bugs.
Automated a11y is not one tool. Run BOTH — they catch different classes of bug:
axe() (@/utils/a11y-test), jsdom-only. Fast; strong on contrast, labels, roles, ARIA basics.aChecker):
page.runA11yScan(label) — runs aChecker.getCompliance in-browser on the live DOM, so it scans open modals / menus / selected / editing states. This is IBM, not axe (despite the name).scripts/a11y/a11y_scan.py (see ibm-a11y-automation) — route scanner for the default-loaded page only.A page that is green on axe is NOT done. When the change touches a table/tree/listbox/menu/composite widget, IBM is mandatory. Prefer page.runA11yScan for stateful surfaces (it sees the DOM after your interactions); use the Python scanner for a quick default-load route check.
Verify commands:
cd src/frontend
RUN_A11Y=true RUN_A11Y_ASSERT=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
# The Python scanner's playwright deps are NOT in the default `uv sync`.
# One-time: `uv run --with playwright playwright install chromium`
uv run --with playwright python scripts/a11y/a11y_scan.py \
--url http://localhost:3000 \
--routes /settings/<route> \
--out /tmp/a11y.json --markdown /tmp/a11y.md --timeout-ms 45000
RUN_A11Y=true runs the scan; add RUN_A11Y_ASSERT=true to actually fail on new violations (without it the scan is informational). Both engines must report zero. After changing a shared component, re-scan every page that uses it, not just the one you touched.
tabindex; arrows move within.<body>.rowgroup owns row, list owns listitem).presentation/none).aria-hidden="true" never wraps a focusable element.Things axe passes but IBM flags (all real WCAG Level 1):
| IBM rule | WCAG | Typical cause |
|---|---|---|
element_tabbable_role_valid | 4.1.2 | tabbable element with role="presentation" / no widget role (focus sentinels, wrappers) |
aria_child_valid | 1.3.1 | rowgroup/list/tablist owning no valid child role |
aria_child_tabbable | 2.1.1 / 4.1.2 | composite widget with no tabbable descendant (needs roving tabindex) |
aria_hidden_focus_misuse | 4.1.2 | focusable element inside aria-hidden="true" |
aria_accessiblename_exists | 4.1.2 | element with a widget role but no accessible name — e.g. an AG Grid columnheader with neither field nor headerName (icon-only action column) |
aria_content_in_landmark | 1.3.1 | content outside any landmark — e.g. a Radix menu/popover portaled to <body>. role="menu" is NOT landmark-exempt; only aria-modal dialogs are. Often unfixable app-wide → baseline it (see below) |
Rule of thumb: touch a table/tree/listbox/menu → scan with IBM.
TableComponent): the grid's own tab guards + pagination are the fragile part.
tabindex="-1", but NEVER inert/disabled — that breaks AG Grid's tab guards and traps reverse (Shift+Tab) entry (WCAG 2.1.2).tabToNextCell hook plus a container-scoped focusin redirect (defer the redirect to requestAnimationFrame — focus changes inside a focusin handler are ignored, and AG Grid restores the last-focused cell on the next tick).role="presentation"; the grid needs one roving-tabindex row.columnheader accessible name from field; a column with neither field nor headerName is nameless (aria_accessiblename_exists, 4.1.2). Give it a headerName and hide it visually with a headerClass (sr-only clip on .ag-header-cell-text) so the header stays blank but named.onCellKeyDown on Enter/Space (give the column a colId to target it). A Radix trigger opens on keydown/pointerdown, not a synthetic .click() — focus the trigger and re-dispatch the key (new KeyboardEvent("keydown", {key, bubbles:true})), guarding against re-entry (skip when target is already the button). TableComponent spreads {...props} to AG Grid, so pass onCellKeyDown from the page — no shared-component change..ag-no-border): the theme suppresses the cell focus outline (outline:none), hiding keyboard focus (2.4.7). Restore it with a :focus-visible ring scoped to .ag-no-border .ag-cell:focus-visible (+ header/row). :focus-visible distinguishes modality on AG cells — mouse click resolves to :focus, keyboard nav to :focus-visible — so the ring shows for keyboard only and mouse stays quiet. CAVEAT when verifying: probe with a clean, first-interaction page; a session that already used the keyboard makes :focus-visible report true for a later mouse click (heuristic contamination).ensureDomOrder: true so DOM order matches visual order for tab navigation.TableComponent is shared across settings/traces — fix once in the grid patch, then re-scan every grid page. Its pagination/tab-out rework lives in LE-1720 / PR #13953; don't duplicate it per-page.Trigger asChild double tab stop: a DialogTrigger/DropdownMenuTrigger wrapping a real <button> without asChild makes Radix render its own <button> around it → nested buttons → two consecutive tab stops (2.4.3). Always pass asChild when the child is already an interactive element. (Watch the trigger wrapper's own asChild={cond} default — DeleteConfirmationModal needs an explicit asChild.)startEditingCell opens an inline editor), Radix steals it back. Re-assert focus on your target across a few requestAnimationFrames to outlast the one-time restore. For rename-style flows, .select() the input too.DropdownMenu/Popover content portals to <body>, tripping aria_content_in_landmark. Portaling into <main> is not an option when <main> is overflow-hidden (clips the menu). Treat as app-wide debt → baseline it (below); the menu's real a11y (role, named trigger, keyboard open/close, focus restore) is covered by a keyboard test instead.aria-disabled (instead of native disabled) when the control must stay discoverable, and keep it non-operable.<button> with an aria-label.onClick.Use Jest when the changed surface is a primitive or component that renders in jsdom without full app routing.
import { render, screen } from "@testing-library/react";
import { axe } from "@/utils/a11y-test";
it("should_have_no_axe_violations", async () => {
const { container } = render(<Component />);
expect(await axe(container)).toHaveNoViolations();
});
Notes: a11y-test.ts disables color-contrast (jsdom can't measure layout). Prefer role/label/text queries. Assert accessible names explicitly for icon-only / visually-hidden / generated labels. Put tests at __tests__/<name>.a11y.test.tsx.
cd src/frontend
npx jest path/to/<name>.a11y.test.tsx --runInBand
page.runA11yScanUse Playwright when the surface needs real browser layout, routing, app state, mocking, modals, tables, focus order, keyboard behavior, or full-page interactions.
import { expect, test } from "../fixtures";
import { awaitBootstrapTest } from "../utils/await-bootstrap-test";
test("scans populated state", { tag: ["@release", "@api"] }, async ({ page }) => {
await awaitBootstrapTest(page, { skipModal: true });
await page.goto("/settings/example");
await expect(page.getByText("Example")).toBeVisible();
await page.runA11yScan("settings-example-populated");
});
Rules: put specs under tests/a11y/<feature>.a11y.spec.ts; import test/expect from ../fixtures; tag every test @release plus a domain tag (@workspace/@api/@database/@components/@starter-projects); stable scan names (lowercase, feature-first, state-specific); mock API for deterministic states; disable animations for focus assertions; use explicit interactions (no random clicking of destructive controls). If the route belongs in static coverage, update scripts/a11y/a11y_routes.json.
cd src/frontend
RUN_A11Y=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
RUN_A11Y=true RUN_A11Y_ASSERT=true npx playwright test tests/a11y/<feature>.a11y.spec.ts --project=chromium --workers=5
npm run a11y:html-report --silent
Some IBM violations are real but framework-level and can't be fixed per-page (e.g. aria_content_in_landmark on a Radix menu portal). Track them with an IBM baseline so the scan stays green while the debt is recorded — do NOT silently drop the scan or the assertion.
How it works (fixtures.ts + .achecker.yml):
baselineFolder: tests/a11y/baselines. The baseline file name is the scan label: {project}__{label}.json, e.g. chromium__assets-files-actions-menu.json (dashes preserved; the first scan in a test has no index suffix).filterReport loads that file and marks any result matching a baseline entry by path.dom + ruleId + reasonId as ignored: true. countNewA11yViolations counts violation && !ignored, so baselined issues don't fail.Create one:
coverage/accessibility-reports/{label}.json.tests/a11y/baselines/{label}.json as { "results": [ { "ruleId", "reasonId", "path": { "dom": … } } ] }. A minimal baseline (only the entries to ignore) is clearest; add a description field noting why and how to resurface it.Matching is exact-DOM-path, so baselines can go stale if unrelated <body>-level portals (toasts) shift the portal index — scan the state with no other overlays open.
For route/page changes, cover the smallest matrix representing real user states:
Don't force states the page can't enter; briefly explain any skipped state.
Use tests/a11y/api-keys.a11y.spec.ts and tests/a11y/files.a11y.spec.ts as the models for route-level work: mock API data; scan populated table; scan empty table; open create modal and scan; submit to the generated-result modal and scan; open a text-cell modal and scan; select a row and scan the selected state; set a mobile viewport and scan. Add keyboard-interaction tests (not scans) for tab-out, reverse re-entry, Enter-to-open, focus restore, and focus-visible where the surface has custom keyboard behavior. This is the expected bar for data-rich routes.
Driving grid row states: client-rendered states (upload progress %, upload-failed/error, disabled rows) are usually driven by fields the cell renderer reads off the row (params.data.*). If the list query returns the response untransformed, just inject the field into the mocked rows (e.g. { ...file, progress: -1 }) to scan the error/progress state — no need to simulate the real flow.
When done, state: