feat(a11y): WCAG 2.1 AA scanning, baseline ratchet, and remediation - #2011
feat(a11y): WCAG 2.1 AA scanning, baseline ratchet, and remediation#2011paustint wants to merge 201 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR establishes an end-to-end WCAG 2.1 AA accessibility program for Jetstream: automated scanning (Playwright + axe-core) with a “baseline ratchet” that prevents regressions, plus targeted UI remediations and supporting program documentation (findings log + draft ACR/VPAT).
Changes:
- Added Playwright axe-core page sweep + interactive-state scans that write evidence artifacts and gate against
a11y-baseline.json(ratchet behavior). - Added component-test axe scanning via
axeScan()(@jetstream/test-utils) and began applying it to interactivelibs/uicomponents. - Remediated common accessibility issues across UI (tabs keyboard pattern, icon-only button names, list/checkbox semantics, skip links, form error ARIA wiring, dialog semantics, contrast/link distinguishability).
Reviewed changes
Copilot reviewed 78 out of 80 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/a11y-scan-urls.mjs | Ad-hoc URL scanning for non-authenticated/static surfaces (landing/docs). |
| scripts/a11y-merge-baseline.mjs | Utility to regenerate/merge the committed baseline ratchet from scan results. |
| package.json | Adds a11y scripts and dev deps for axe + vitest-axe. |
| pnpm-lock.yaml | Locks new axe / vitest-axe dependencies. |
| libs/ui/tsconfig.lib.json | Adds project refs needed for new UI test utilities usage. |
| libs/ui/src/lib/widgets/Icon.tsx | Ensures icon-only controls can expose an accessible name (remove baked-in aria-hidden). |
| libs/ui/src/lib/toast/Toast.tsx | Error toasts become assertive alerts; adds aria-live/aria-atomic. |
| libs/ui/src/lib/toast/tests/Toast.spec.tsx | Updates role assertions for error toasts. |
| libs/ui/src/lib/tabs/Tabs.tsx | Implements WAI-ARIA tabs keyboard behavior (Arrow/Home/End + activation). |
| libs/ui/src/lib/tabs/Tab.tsx | Implements roving tabindex (only active tab in tab order). |
| libs/ui/src/lib/tabs/tests/Tabs.spec.tsx | Adds keyboard behavior tests + axeScan assertion. |
| libs/ui/src/lib/sobject-list/SobjectListMultiSelect.tsx | Names the objects list for screen readers. |
| libs/ui/src/lib/sobject-list/SobjectListFilter.tsx | Adds accessible name for filter trigger button. |
| libs/ui/src/lib/sobject-list/SobjectList.tsx | Names the objects list for screen readers. |
| libs/ui/src/lib/sobject-field-list/SobjectFieldList.tsx | Adds icon descriptions + names the fields list. |
| libs/ui/src/lib/progress-indicator/ProgressStepIndicator.tsx | Adds aria-label for progressbar. |
| libs/ui/src/lib/progress-indicator/ProgressIndicator.tsx | Adds aria-label for progressbar. |
| libs/ui/src/lib/modal/tests/Modal.spec.tsx | Adds axeScan assertion and refactors render helper. |
| libs/ui/src/lib/list/ListWithFilterMultiSelect.tsx | Adds accessible names for reload button and list. |
| libs/ui/src/lib/list/ListItemCheckbox.tsx | Removes listbox/option semantics for checkbox rows; ensures checkbox has a label. |
| libs/ui/src/lib/list/List.tsx | Adds ariaLabel prop; adjusts semantics for checkbox lists vs listbox lists. |
| libs/ui/src/lib/layout/SkipToContent.tsx | New skip-link component for bypassing header/navigation. |
| libs/ui/src/index.ts | Exports SkipToContent from the UI library. |
| libs/ui/src/lib/form/select/Select.tsx | Automatically wires aria-invalid / error aria-describedby onto native select children. |
| libs/ui/src/lib/form/input/Input.tsx | Automatically wires aria-invalid / error aria-describedby onto native input children. |
| libs/ui/src/lib/form/form-a11y.utils.tsx | Shared helper for stamping error semantics onto native controls. |
| libs/ui/src/lib/form/picklist/Picklist.tsx | Fixes aria-controls to only reference mounted listbox while open. |
| libs/ui/src/lib/form/formGroupDropDown/FormGroupDropdown.tsx | Fixes aria-controls to only reference mounted listbox while open. |
| libs/ui/src/lib/form/date/DatePicker.tsx | Improves input descriptions + dialog semantics for the date popup. |
| libs/ui/src/lib/form/combobox/ComboboxListItem.tsx | Moves option semantics to the focused element to ensure SR announcement. |
| libs/ui/src/lib/form/combobox/Combobox.tsx | Fixes aria-controls to only reference mounted listbox while open. |
| libs/ui/src/lib/data-table/grid/renderers/CellRenderers.tsx | Adds aria-labels for icon-only row action buttons. |
| libs/test/e2e-utils/src/lib/pageObjectModels/QueryPage.model.ts | Updates selectors/assertions for checkbox-based selection semantics. |
| libs/test/e2e-utils/src/lib/pageObjectModels/OrgGroupPage.model.ts | Updates drag initiation to use dedicated drag handle. |
| libs/test-utils/src/lib/a11y-test-utils.ts | Introduces axeScan() helper and filters library-internal focus guards. |
| libs/test-utils/src/index.ts | Exports axeScan() from @jetstream/test-utils. |
| libs/shared/ui-record-form/src/lib/UiRecordForm.tsx | Adds label for columns select control. |
| libs/shared/ui-core/src/settings/SoqlQueryFormatConfig.tsx | Ensures numeric inputs are properly label-associated via ids. |
| libs/shared/ui-core/src/record/UserSearchPopover.tsx | Improves button title for clarity. |
| libs/shared/ui-core/src/query/QueryHistory/QueryHistoryModal.tsx | Names list for screen readers. |
| libs/shared/ui-core/src/orgs/OrgInfoPopover.tsx | Adds accessible name to org details trigger. |
| libs/shared/ui-core/src/formula-evaluator/FormulaEvaluatorRecordSearch.tsx | Adds icon description for accessible name. |
| libs/shared/ui-core/src/app/HeaderHelpPopover.tsx | Adds accessible name to help trigger. |
| libs/shared/ui-core/src/app/AppHome/AppHomeBillingUser.tsx | Fixes invalid <dl> structure by nesting link inside <dd>. |
| libs/shared/ui-core/src/app/AppHome/AppHome.tsx | Fixes invalid <dl> structure by nesting link inside <dd>. |
| libs/shared/ui-core/src/analysis/PermissionAnalysisHistoryModal.tsx | Names list for screen readers. |
| libs/features/salesforce-api/src/SalesforceApiHistoryModal.tsx | Names list for screen readers. |
| libs/features/platform-event-monitor/src/PlatformEventMonitorPublisherCard.tsx | Adds icon description for accessible name. |
| libs/features/platform-event-monitor/src/PlatformEventMonitorListenerCard.tsx | Adds icon descriptions for accessible names. |
| libs/features/org-groups/src/lib/SalesforceOrgCardDraggable.tsx | Adds dedicated drag handle button for accessible DnD initiation. |
| libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx | Underlines link for non-color-only distinguishability. |
| libs/features/deploy/src/selection-components/UserSelection.tsx | Names list for screen readers. |
| libs/features/debug-log-viewer/src/DebugLogViewerTable.tsx | Ensures button-like cells have an accessible name when content is empty. |
| libs/features/debug-log-viewer/src/DebugLogViewer.tsx | Adds icon descriptions for accessible names on controls. |
| libs/connected/connected-ui/src/lib/DescribeMetadataList.tsx | Names list for screen readers. |
| apps/landing/pages/pricing/index.tsx | Adjusts CTA contrast colors to meet WCAG contrast. |
| apps/landing/pages/goodbye/index.tsx | Adjusts contrast + ensures link is distinguishable without color alone. |
| apps/landing/pages/desktop-app/index.tsx | Adjusts CTA contrast colors to meet WCAG contrast. |
| apps/landing/pages/_document.js | Adds <html lang="en">. |
| apps/landing/components/layouts/Layout.tsx | Adds skip link + focusable target container. |
| apps/landing/components/Footer.tsx | Improves footer text contrast. |
| apps/jetstream/src/app/components/billing/EnhancedBillingCard.tsx | Adds aria-label for radio input. |
| apps/jetstream/src/app/app.tsx | Adds SkipToContent and main-content focus target in web app shell. |
| apps/jetstream-desktop-client/src/app/app.tsx | Adds SkipToContent and main-content focus target in desktop app shell. |
| apps/jetstream-e2e/tsconfig.json | Adds project ref needed by new a11y tests import(s). |
| apps/jetstream-e2e/src/tests/query/query-results.spec.ts | Updates assertions to checkbox checked state. |
| apps/jetstream-e2e/src/tests/query/query-builder.spec.ts | Updates assertions to checkbox checked state. |
| apps/jetstream-e2e/src/tests/a11y/page-sweep.spec.ts | New route-wide a11y sweep spec using APP_ROUTES. |
| apps/jetstream-e2e/src/tests/a11y/interactive-states.spec.ts | New a11y scans for critical interactive UI states. |
| apps/jetstream-e2e/src/tests/a11y/a11y.utils.ts | Shared scan harness: evidence output + baseline ratchet gating. |
| apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json | Adds initial committed baseline ratchet entries. |
| docs/accessibility/README.md | Documents the accessibility program, tooling, and ratchet rules. |
| docs/accessibility/audit-2026/manual-checklist.md | Adds a repeatable manual audit runbook/checklist. |
| docs/accessibility/audit-2026/findings.md | Adds findings log + lint census + scan summary & status tracking. |
| docs/accessibility/vpat/jetstream-acr-DRAFT.md | Adds draft ACR/VPAT structure for customer requests. |
| .oxlintrc.json | Documents a11y lint ratchet and promotes no-redundant-roles to error. |
| .gitignore | Ignores generated a11y-results evidence directories. |
| .github/workflows/ci.yml | Uploads a11y scan results as CI artifacts per shard. |
| CLAUDE.md | Adds explicit repo guidance for accessibility + a11y testing expectations. |
| .github/copilot-instructions.md | Adds accessibility review/testing expectations to Copilot instructions. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 79 out of 81 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
libs/ui/src/lib/list/List.tsx:193
- In checkbox mode (
useCheckbox), the list intentionally moves focus into the individual checkboxes (per the comment), but the<ul>is still alwaystabIndex={0}. That creates an extra (and usually undesired) tab stop before the first checkbox and makes the "plain list" container focusable even though it’s no longer a listbox.
libs/ui/src/lib/form/combobox/ComboboxListItem.tsx:131 ComboboxListItemnow marks the focused element (<li role="option">) asaria-disabled={disabled}, but it still always callsonSelection(id)on click. If an option is disabled, it should not be selectable via pointer activation; otherwise assistive tech will announce it as disabled but it still behaves enabled.
8608644 to
6d67931
Compare
a9a4b6b to
82bdd47
Compare
82bdd47 to
800e8da
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 248 out of 251 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
libs/ui/src/lib/widgets/Tooltip.tsx:212
onKeyDowninvokes theonClickhandler by casting aKeyboardEventto aMouseEvent, which can break callers that rely on mouse-specific properties (orcurrentTargettyping) and is unsafe at runtime. Prefer triggering an actual click from the focused element soonClickreceives the correct event shape.
800e8da to
1b23f5b
Compare
|
Went through Copilot's suppressed (low-confidence) comments across the reviews — two were valid:
|
There was a problem hiding this comment.
🟡 Changes recommended
The baseline merge script can overwrite the ratchet output even on missing/empty inputs, and CopyToClipboard’s in-button live region can destabilize the control’s accessible name.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
- Files reviewed: 249/252 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
The skip-link focus targets suppress the focus outline, which can make focus invisible for keyboard users and undermine the accessibility goals of the PR.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
- Files reviewed: 293/296 changed files
- Comments generated: 2
- Review effort level: Lite
Enter on a focused, closed combobox did nothing (or submitted a wrapping form), while Picklist, DropDown and Lightning's own combobox all open on it. The list opens on keydown, and that press's keyup is skipped so it does not fall into the "Enter picks the first option" branch and undo the open. A modified Enter is left alone for page-level shortcuts.
The button disabled itself on click through the native attribute, which drops focus to <body>. aria-disabled keeps it focusable, and the result is announced since nothing visible confirms the cache was cleared.
With org groups configured, the "Choose Group" label's 1.5 line box pushed the stacked org switcher past the 3.125rem global header, and the navbar painted over the bottom 2px of the org combobox and "Add Org" focus rings. A 1.2 line height on the selector keeps the whole stack inside the header; 1 was too tight for the 14px group name in the selected state.
The icon-only gear beside "Format" was announced as "Settings", which says nothing about what it configures and collides with the app's Settings page.
…ng from a combobox list The list selects on keydown and hands focus back to the input, so the same press's keyup arrived at the input's "Enter picks the first option" branch. Drill-in items made it worst: they keep the list open and refocus the input after 50ms, so any normal key release drilled into the first child as well. The input now acts on an Enter keyup only when it saw the keydown itself.
The modals open with the filename input focused and that input ran the download on Enter keyup, so the keyup of the Enter that activated the Download button downloaded and closed the modal immediately. Enter is now handled on keydown, which the opening press never delivers to the input.
… keys Space on a focused menu item fell through to type-ahead and jumped focus to the first item; the trigger now also opens on ArrowDown (first or selected item) and ArrowUp (last item), per the APG menu button pattern. The trigger button is exposed through triggerRef for callers that must refocus it.
…hes the results Closing the modal after a deploy re-runs the query, which disables the Record actions trigger while it loads, and a disabled button cannot take the focus the modal hands back. The hand-off now completes once the trigger is enabled again.
… modal is open The primary-action and go-back shortcuts listened on window with no notion of where the action lived, so Cmd+Enter while reading a test result or coverage source launched an Apex test run from behind the modal. Page-scope actions (the default) now stay quiet while an aria-modal dialog is open; the two shortcuts that belong to a modal (record modal Save, suite membership Save) declare scope: 'dialog' and keep firing.
aria-readonly was derived from the column's `editable` flag alone, but that flag also covers paste/clear eligibility on checkbox columns that have no editor (every permission manager table), so those cells were hinted "Press Enter to edit" while Enter toggles the checkbox. Read-only now follows the same predicate as the editor path (editable AND an editor), and summary-row cells, which only ever host filter and bulk-action controls, are read-only.
Findings V23-V43 with their fixes, the corrected lint census (71 hits in 39 files), the honest VPAT status (14 evidence-missing criteria still TBD), the package version the draft was evaluated against, and the V rows in the checklist's regression rule.
…h row disables them The click that adds the fifth clause natively disabled the button under the pointer or keyboard, which drops focus to <body>. Both buttons now disable through aria-disabled (ariaDisabledButtonProps) so focus stays put and the click guard still refuses a sixth row.
…ke its list one tab stop Choosing a group rendered a different tree (Choose Group vs. name + Switch), which re-mounted the popover and its trigger, so the popover had nothing to return focus to and keyboard focus fell to <body>. The selector now renders one stable tree, so closing the popover lands back on the trigger, which is named "Switch group" when a group is active. The group rows were each a Tab stop selected through an li click handler; they are now the shared roving List: one tab stop, ArrowUp/Down, Home/End, Enter/Space to choose. The heading also loses a copy-pasted "Refresh Metadata" tooltip.
Finding V50 (owner-reported) with the fix, and the V range in the checklist's regression rule and the VPAT's evaluation-methods list.
CI runs every project's Vitest concurrently through `nx run-many -t test`, so specs that render real widgets (floating-ui popovers, 96-option comboboxes, virtualized grids) and finish with an axe scan contend for CPU and overran Vitest's 5s default. They surfaced as an intermittent timeout in a different spec on every run, which reads as an unrelated failure rather than as load. Raise the floor once in the shared jsdom setup instead of per test. 20s is still short enough to catch a genuine hang.
…rtcuts Monaco captures Tab as indentation, so a keyboard user who focused a code editor had no way out of it. Ctrl+M was the only escape and it was documented in Settings, which is not where anyone stuck in an editor looks (WCAG 2.1.2). Escape now moves focus to the next control after the editor, and Shift+Escape to the previous one. Both directions are needed: leaving only forwards makes the editor a one-way valve, because shift-tabbing back from the next control lands inside it again and Monaco captures Shift+Tab as outdent, stranding everything before the editor. The handler runs in the capture phase, above the editor, so it acts before Monaco sees the key. Monaco reclaims its textarea while handling the same keydown, so a move made from inside a Monaco command was silently bounced back — which made every candidate look unfocusable and walked the search off the end of the page. For the same reason the widget and multi-cursor guards are a DOM and `getSelections()` check rather than context keys. Each editor also announces its own Cmd/Ctrl+Enter action. The button that runs it sits in the card header, before the editor in the tab order, so a keyboard user who has just finished typing cannot reach it by tabbing forward. Announcing it on the editor puts it where the user is standing, rather than bending the tab order: a positive `tabindex` hoists a control ahead of every natural tab stop document-wide.
Team Dashboard, Platform Events and Anonymous Apex, as V51-V57. Also supersedes C9: it was closed on the strength of Ctrl+M documented in Settings, which this pass showed nobody finds while stuck in an editor.
…type A button without a `type` inside a `<form>` is a submit button. The Platform Events publisher now wraps its record form in a real form, so its field clear "x" and the inline date picker's Clear / Today / Previous / Next Month buttons submitted the form and published an event. Every helper button in the shared form primitives is now `type="button"`; none of them is meant to submit anything.
`axeScan()` returned the filtered results and left the assertion to the caller, while the lint ratchet counted a spec as covered as soon as it called `axeScan(`. Eight specs called it bare, so they scanned and asserted nothing. The helper now fails the test on any violation and still returns the results. A spec that expects a logged, still-open violation (nested-interactive, X4) names it through `knownViolations`, which keeps that rule out of the assertion but in the returned results so the spec can pin exactly what it expects.
`Select` labelled `date-format`, but the control's id was `date-format-select`, so the select had no accessible name; its `aria-describedby` also pointed at nothing. The select now carries the labelled id and is described by the help text.
`ProgressIndicator` and `ProgressStepIndicator` were always named "Progress", which tells a screen reader user nothing when several bars share a surface (the Jobs popover). Both take a `label`, and the five callers name theirs.
Findings V58–V71 from the rebased-branch review: the fixes, which are fixups and which stand alone. The VPAT's keyboard-trap row and evaluation date, the public accessibility page and the Settings page now describe the Escape / Shift+Escape way out of code editors and the screen reader editor setting.
2fcaa09 to
fcb46e4
Compare
A customer agreement requires commercially reasonable efforts toward WCAG 2.1 AA
and an accessibility conformance report (VPAT) on written request. This branch
adds the scanning infrastructure to know where we stand, remediates what it
found across the web app, desktop app, browser extension, landing site and docs,
and guards against regressions. Program docs, the findings log (the canonical
tracker: C#, X#, L#, M#, R#, V# references below) and the draft VPAT live in
docs/accessibility/; a public summary is at/accessibilityon the docs site.Infrastructure and gates
a ratchet baseline (
a11y-baseline.json): existing violations pass, anythingnew fails, and the file only shrinks as findings are fixed. Scan evidence
uploads as CI artifacts (VPAT inputs).
pnpm a11y:scan-urlscovers landing/docs.axeScan()for component specs (asserts on its own; filters Floating UI'sfocus-guard noise), with an
axeScan()requirement for newlibs/uispecs.pnpm a11y:lint-ratchet(pre-commit + CI lint job): per-file baseline ofwarn-tier
jsx-a11yhits that may only shrink, every sweptAPP_ROUTESentry must be baselined; five
jsx-a11yrules promoted toerror./a11y-reviewskill and a definition of done for interactive UI in CLAUDE.md.Remediation — axe baseline went from 89 serious/critical entries to 3
(the one open
nested-interactivepattern), zero critical violations across49 scans, plus two full keyboard/VoiceOver manual passes over every surface.
The recurring fixes, all through shared primitives in
@jetstream/ui:element (silent arrowing), icon-only buttons had no names, radio groups
shared no name, form controls now carry
aria-invalidand described-by help.trees, accordions, menus and the grid (one page tab stop each, arrows inside);
ARIA 1.2 combobox; TimePicker rebuilt as a type-to-filter combobox.
self-disabling buttons keep focus (
ariaDisabledButtonProps), controls thatremove themselves hand focus to a neighbour, route changes move focus to main.
AssistiveStatus/useAnnouncerfor long-running actions,counts, save/deploy/test outcomes; toasts are live regions (errors assertive).
useEscapeToCloseLayer): one press closes only the innermostlayer; code editors keep Escape while autocomplete/find/hover is open.
says so; Settings gains "Optimize code editors for screen readers".
Behavior changes to eyeball
initiates a drag (the card body is full of buttons).
record-actions column header now reads "Actions".
with Enter/Space; menus activate with Space and open with ArrowDown; the
download modals open on Enter without downloading at once.
stay quiet while a modal is open.
rows are real buttons; the feedback widget sits below overlay layers.
interactive children) — E2E page objects assert on the row checkbox instead.
Deliberately not done (all logged in the findings "Still open" table):
nested-interactiveon list rows with trailing popover triggers (3 pages) staysbaselined pending a design decision; 14 VPAT criteria remain
TBDuntil theirevidence passes are recorded; the route sweep covers top-level routes only.
Review history: three code-review rounds (2026-09-01, 09-07, 09-13) with
every finding either fixed as a fixup of the commit that introduced it or
logged; the 09-13 round added browser-verified fixes for popover clipping of
filter dropdowns and Escape-vs-autocomplete in the SOQL popovers.