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 182 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
| {/* The success checkmark is visual-only — announce the outcome (focus stays on this button) */} | ||
| <span role="status" aria-live="polite" className="slds-assistive-text"> | ||
| {isCopied ? 'Copied to clipboard' : ''} | ||
| </span> |
| let fileCount = 0; | ||
|
|
||
| for (const dir of resultsDirs) { | ||
| if (!existsSync(dir)) { | ||
| console.error(`Results directory not found: ${dir}`); | ||
| process.exitCode = 1; | ||
| continue; | ||
| } | ||
| for (const file of readdirSync(dir).filter((name) => name.endsWith('.json'))) { | ||
| const { scanKey, violations } = JSON.parse(readFileSync(join(dir, file), 'utf8')); | ||
| if (!scanKey) { | ||
| continue; | ||
| } | ||
| fileCount++; | ||
| const ruleIds = (violations || []).filter(({ impact }) => GATED_IMPACTS.has(impact)).map(({ id }) => id); | ||
| baseline[scanKey] = Array.from(new Set([...(baseline[scanKey] || []), ...ruleIds])).sort(); | ||
| } | ||
| } | ||
|
|
||
| const sortedBaseline = Object.fromEntries(Object.entries(baseline).sort(([a], [b]) => a.localeCompare(b))); | ||
| writeFileSync(BASELINE_PATH, `${JSON.stringify(sortedBaseline, null, 2)}\n`); |
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
| {userHeaderWithoutNavigation && <HeaderNoNavigation />} | ||
| {children} | ||
| {/* Skip-link target only — several pages render their own <main> landmark, so this must stay a plain div */} | ||
| <div id="main-content" tabIndex={-1} className="outline-none"> |
| return ( | ||
| <> | ||
| <FocusMainContentOnRouteChange /> | ||
| <main id={MAIN_CONTENT_ID} tabIndex={-1} style={{ outline: 'none' }} className={className} data-testid="content"> |
There was a problem hiding this comment.
🟡 Changes recommended
There are a few correctness/a11y issues in the new interactions (notably keyboard activation handling and skip-link focus visibility) that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
libs/shared/ui-core/src/app/AppMainContent.tsx:32
- The skip-link / route-change focus target is explicitly suppressing its focus outline. Since this element is intentionally focused for keyboard users, removing the outline can make focus location invisible (WCAG 2.4.7). Prefer keeping the default outline (or styling
:focus-visible) rather than forcingoutline: none.
apps/landing/components/layouts/Layout.tsx:44 - This skip-link target explicitly removes the focus outline (
outline-none). Because it’s the element that receives focus after activating the skip link, hiding the outline can make focus hard to locate for keyboard users (WCAG 2.4.7). Consider removingoutline-none(or replacing it with a visiblefocus-visiblestyle).
- Files reviewed: 294/402 changed files
- Comments generated: 1
- Review effort level: Lite
| if (isEnterOrSpace(event)) { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
| onActionClick && onActionClick(); | ||
| } |
Both "Learn more" links now carry their subject in the link name, so the rule has zero hits and joins the error tier.
…elines oxlint cannot fail on the warn-tier jsx-a11y rules without failing on the 75 legacy sites, so scripts/a11y-lint-ratchet.mjs baselines the allowed hits per file and rule and fails on growth. The same run requires axeScan() in any libs/ui spec not grandfathered in and an a11y-baseline.json entry for every swept APP_ROUTES key, so a new route is gated at commit time instead of in a 20-minute E2E job. Runs in pre-commit and the CI lint job.
…ation "Remove Org" and the keep/confirm pair replace each other in the DOM, so focus fell to <body> on both transitions. Focus now lands on "Keep Org" (with the warning attached as its description) and returns to "Remove Org" on keep. Removing the org unmounts the popover with its trigger, so the org switcher takes focus once the selection has moved off the removed org.
The toggle reveals content below it, so it now carries aria-expanded and aria-controls (both valid on a checkbox in ARIA 1.2) and a label that says what it shows; the revealed region stays in the DOM while collapsed so the reference always resolves.
…g focused Panel remembered document.activeElement at open time, which is <body> after a mouse click in Safari, so closing the SOQL Query drawer left focus nowhere. Panel now ignores <body> and falls back to an explicit returnFocusTo target; the query results toolbar button is wired up as that target.
Screen readers announce a gridcell's content but nothing about the editor or
the popover trigger behind it. GridBody now points the focused cell's
aria-describedby at hidden hints ("Editable. Press Enter to edit." /
"Contains controls. Press Enter to interact...") right before focusing it,
so the hint is read with the cell. Applied to the single focused cell only.
…nter or Space The input handles keys on keyup, so the release of the Enter/Space that selected an option (keydown, in the list) landed on the freshly focused input and hit its "closed: open the list" branch. The keyup that completes a selection is now skipped.
…saving Enter deliberately never submits the long record form; the app-wide primary action shortcut now saves instead and is announced on the Save button. The spinner shown while loading or saving is mirrored to a status region.
…s popover The recent queries were click-only rows. They are now the shared roving listbox: Enter/Space loads a query, Cmd/Ctrl+Enter executes it and Shift+Enter restores it (mirroring the click modifiers), ArrowDown from "View All History" enters the list, and Tab leaves the compact SOQL editor instead of indenting.
…o the record modal Recent records in the View Record Details popover were click-only rows and a natively disabled Submit made Tab leave (and close) the popover; the list is now the shared roving listbox, ArrowDown from the id input enters it and Submit stays focusable while aria-disabled. Both this popover and Search Users close before opening the record modal, so the dialog is announced and takes focus instead of the popover keeping it.
The default row height was 28.5px, so every other row was translated to a half-pixel offset and its 1px border straddled two device pixels on 1x displays (faint, uneven lines, most visible in Safari). Row heights are now whole pixels: the default is 29 and callback/measured heights round up.
The modal remounts in view mode after saving, so a screen reader only heard the new title; a polite "Record saved" is now announced.
autoFocus on the group name input ran before floating-ui recorded which element opened the modal, so the modal "returned" focus to its own detached input and keyboard focus fell to <body>. Modal now takes an initialFocus ref (floating-ui's supported way to pick the first focused element) and the group modal uses it; deleting a group or the last org hands focus to "Create New Group" since their menus unmount with the card.
dnd-kit's default announcements interpolate the draggable and droppable ids,
which are UUIDs on this page. The cards now carry their labels in the drag
data and a custom Accessibility plugin reads them ("Acme Sandbox is over
Production Orgs"); the drag handle is named "Move <org>". @dnd-kit/dom
becomes a direct dependency because it owns the Accessibility plugin.
Input, Select and Textarea already stamp aria-invalid through associateErrorsWithControls, but Combobox, Picklist, Checkbox, DatePicker, RadioGroup, Slider, the file selectors and the manual record-id input only linked their error text — a screen reader read the message without ever saying the field was invalid (the load records field mapping rows use the Combobox).
…accept or reject The banner was an anonymous div with "Accept" and "Reject" buttons, so a screen reader user heard two bare verbs with no context. It is now a region named by its heading, and the buttons read "Accept cookies" / "Reject cookies" with the explanation as their description.
floating-ui records the return-focus target one render after the modal mounts, so a child with autoFocus became that target and, unmounting with the modal, left focus on <body>. Modal now captures the opener during its first render and, two frames after closing, focuses it if nothing else took focus. Covers the remaining autoFocus modals (email change, API history, query history) without touching them.
A cell whose only interactive content is links (open in Salesforce) is now described as "Contains a link. Press Enter to move to it" instead of the generic controls hint; the Automation Control quick filter also gets an accessible name that says what it filters.
…mation Control's loaded count SalesforceLogin links only showed a decorative new-window icon, so screen readers never heard that they leave the app. Automation Control mounts its table with rows already loaded, which bypasses the grid's filter-count announcement, so the page announces the loaded item count itself.
…errors
The next-step button was disabled with no explanation. Its gate is now the
same pure function that produces the reason ("Select an object and upload a
file to continue", "Resolve 2 mapping errors to continue", ...), shown as a
tooltip on hover and focus and mirrored as the button's accessible
description while it stays focusable via aria-disabled. The field-mapping
step also announces its error count as it changes.
The hint said "press Enter to interact" for every cell with a control, but Enter toggles a lone expander, opens a lone link, activates a lone control, enters a lone text field, and only switches to Actionable mode for cells with several controls. The hint now follows the same decision tree.
There was a problem hiding this comment.
🟡 Changes recommended
OrgsCombobox’s new ref handling is incorrect for React function components (won’t work at runtime and will warn), and the web-extension popup select introduces an invalid self-referential aria-describedby.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 294/443 changed files
- Comments generated: 2
- Review effort level: Lite
| <select | ||
| id="color-scheme-select" | ||
| id="color-scheme" | ||
| aria-describedby="color-scheme" | ||
| className="slds-select" |
| export interface OrgsComboboxProps { | ||
| /** Lets a parent move focus into the org switcher, e.g. after the selected org (and its info popover) is removed */ | ||
| ref?: Ref<ComboboxWithGroupedItemsRef>; | ||
| orgs: SalesforceOrgUi[]; | ||
| selectedOrg: Maybe<SalesforceOrgUi>; |
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, and guards against regressions. Program docs, findings log, and the
draft VPAT live in
docs/accessibility/.Infrastructure
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).
axeScan()component assertions in Vitest,pnpm a11y:scan-urlsforlanding/docs, and one jsx-a11y lint rule promoted to error (ratchet documented
in
.oxlintrc.json).Remediation — baseline went 89 serious/critical entries → 3, zero critical
violations across 47 scans (app, landing, docs):
li, which had
role="presentation"whilerole="option"sat on an inner div.aria-hidden="true", which swallowed the labelIconwas already setting.aria-invalidwiring in Input/Select, labeled listboxes, dialogsemantics on DatePicker,
<html lang>on landing, contrast fixes.Behavior changes to eyeball
initiates a drag (dnd-kit can't split pointer activation from the a11y role,
and the card body is full of buttons).
(white-on-cyan-500 is 2.3:1; unfixable without darkening).
role="alert"(assertive); others stay polite.children) — E2E page objects now assert on the row checkbox instead.
Deliberately not done:
nested-interactiveon list rows with trailingpopover triggers (3 pages) needs a design decision and stays baselined with
rationale in the findings log. The manual audit (keyboard/VoiceOver/visual;
runbook in
docs/accessibility/audit-2026/manual-checklist.md) is what remainsbefore the VPAT draft can be finalized.
CI note: the query-builder subquery "Filter child objects" flake is
pre-existing — verified by a control run with these changes stashed, which
failed at the same rate.