FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(a11y): WCAG 2.1 AA scanning, baseline ratchet, and remediation by paustint · Pull Request #2011 · jetstreamapp/jetstream · GitHub

feat(a11y): WCAG 2.1 AA scanning, baseline ratchet, and remediation - #2011

Open
paustint wants to merge 17 commits into
mainfrom
chore/wcag-a11y-scanning
Open

feat(a11y): WCAG 2.1 AA scanning, baseline ratchet, and remediation#2011
paustint wants to merge 17 commits into
mainfrom
chore/wcag-a11y-scanning

Conversation

Copy link
Copy Markdown
Contributor

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

  • Playwright axe scans of every app route + key interactive states, gated by a
    ratchet baseline (a11y-baseline.json): existing violations pass, anything
    new 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-urls for
    landing/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):

  • Combobox options were announced as nothing: arrow keys move real focus to the
    li, which had role="presentation" while role="option" sat on an inner div.
  • Icon-only buttons had no accessible names: generated icon svgs hardcode
    aria-hidden="true", which swallowed the label Icon was already setting.
  • Tabs implement the WAI-ARIA pattern (roving tabindex, Arrow/Home/End).
  • Skip links, aria-invalid wiring in Input/Select, labeled listboxes, dialog
    semantics on DatePicker, <html lang> on landing, contrast fixes.

Behavior changes to eyeball

  • Org cards drag from a dedicated grip handle; card-body dragging no longer
    initiates a drag (dnd-kit can't split pointer activation from the a11y role,
    and the card body is full of buttons).
  • Landing CTAs on pricing/desktop-app/goodbye darkened cyan-500 → cyan-700
    (white-on-cyan-500 is 2.3:1; unfixable without darkening).
  • Error toasts are now role="alert" (assertive); others stay polite.
  • Checkbox lists dropped listbox/option roles (options can't contain interactive
    children) — E2E page objects now assert on the row checkbox instead.

Deliberately not done: nested-interactive on list rows with trailing
popover 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 remains
before 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.

A customer agreement requires WCAG 2.1 AA conformance efforts and an
accessibility conformance report (VPAT) on request. Playwright specs now
axe-scan every app route plus key interactive states, component specs can
assert via axeScan() from @jetstream/test-utils, and docs/accessibility/
holds the findings log and draft report.

Scans gate on a11y-baseline.json as a ratchet seeded from a full local
run: violations already in the baseline pass, anything new fails, and the
file should only ever shrink as findings are remediated
(pnpm a11y:merge-baseline regenerates it). axeScan() filters Floating
UI's focus-guard sentinels, which trip axe's button-name rule despite
never holding focus.
First remediation pass from the WCAG 2.1 AA audit — the a11y baseline
drops from 89 serious/critical entries to 10, with zero critical rules
remaining. Three shared-component defects accounted for most of it:

- Combobox/Picklist/FormGroupDropdown set aria-controls while the
  listbox popover is unmounted (invalid reference on every page);
  it is now set only while open, per the ARIA 1.2 combobox pattern.
- Icon with omitContainer + description produced an svg that kept its
  baked-in aria-hidden="true", so icon-only buttons had no accessible
  name; the clone now strips aria-hidden and adds role="img". Header
  popovers, grid row actions, and list refresh/filter buttons also get
  explicit labels.
- List rendered role="listbox" with no name and ListItemCheckbox passed
  label="" — listboxes now take ariaLabel and checkboxes are labeled
  from their heading.

Remaining baselined rules (nested-interactive, definition-list,
link-in-text-block) need markup restructuring and are tracked in
docs/accessibility/audit-2026/findings.md.
…nding lang

axe scans of the secondary surfaces (17 landing/auth pages, ~210 docs
pages) via scripts/a11y-scan-urls.mjs, which needed the @playwright/test
import and an explicit browser context. Docs content pages came back
clean; landing was missing <html lang> everywhere (fixed) and shares a
footer contrast issue with the docs homepage (tracked in the findings
log).

The manual-checklist doc is the beginner-oriented runbook for the
keyboard / VoiceOver / visual passes that automated scanning cannot
cover — its results feed the remaining TBD rows of the VPAT draft.
Second remediation pass — the axe baseline is down to a single rule on 3
pages (nested-interactive on list rows with trailing popover triggers,
which needs a design decision and stays documented in the findings log).
All 47 scans across app, landing, and docs are otherwise clean.

Highlights beyond mechanical labeling:
- Combobox options were announced as nothing: focus lands on the li,
  which had role="presentation" while role="option" sat on an inner
  div. The li now carries the option semantics.
- Tabs implement the WAI-ARIA pattern: roving tabindex plus
  orientation-aware Arrow/Home/End with automatic activation.
- Skip links in web/desktop shells and landing layout; landing wraps
  page content in a focusable #main-content target (a div, not <main> —
  several pages render their own main landmark).
- Checkbox lists drop listbox/option roles: options cannot contain
  interactive children and focus moves to the labeled checkboxes, which
  announce their own state. E2E page objects updated to match.
- Org cards drag from a dedicated handle so dnd-kit's button role no
  longer wraps a card full of interactive controls. This changes mouse
  UX: card-body dragging no longer initiates a drag.
- Error toasts are role="alert"; Input/Select stamp aria-invalid and
  error association onto native control children.
- Landing cyan CTAs/links darkened to cyan-700 to pass contrast.

The remaining subquery E2E flake was verified pre-existing: a control
run with the Tabs changes stashed failed at the same rate.
Copilot AI lite review requested due to automatic review settings August 27, 2026 02:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

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 interactive libs/ui components.
  • 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.

Comment thread scripts/a11y-scan-urls.mjs Outdated
Comment thread libs/ui/src/lib/list/List.tsx Outdated
- Remove unused TITLE binding from the a11y page-sweep route loop (CodeQL)
- Default the a11y URL scan key port to 443 for https so http/https scans
  of the same host cannot collide and evidence reflects the real port
- Skip the axe scan (and fail the run) when navigation fails in
  a11y-scan-urls.mjs instead of scanning the previously loaded page
- Name debug log viewer cells by rendered content, labeling 'View log'
  only when the cell is truly empty (0/false values render content)
- Align List.ariaLabel JSDoc with the optional prop contract
…name

The listener card's clear-all icon button gained an accessible name
("Unsubscribe from all events and clear results") in the a11y pass, and
getByRole name matching is substring by default — so the lax
'Unsubscribe' locator now resolved to two buttons and strict mode failed
the click deterministically in CI shard 4. exact: true, mirroring the
existing Subscribe locators (which had the same problem with
'Subscribe' vs 'Unsubscribe').
Copilot AI review requested due to automatic review settings August 28, 2026 12:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

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 always tabIndex={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
  • ComboboxListItem now marks the focused element (<li role="option">) as aria-disabled={disabled}, but it still always calls onSelection(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.

Dragging now starts from the grip handle only, but the card body kept
its cursor: grabbing from the whole-card-drag era (and slds-box_link's
hover rule shows a pointer). The grab/grabbing cursors now live on the
handle alone; the card shows a default cursor.
The app's <base href="/app"> makes a bare #fragment href resolve against
the base URL, so following the skip link reloaded the page at /app
instead of jumping within the current page. Focus now moves
programmatically with the default navigation prevented.
Every group card renders identical "Make Active"/"Edit"/menu buttons, so
tabbing or listing buttons in a screen reader gave no clue which group
each acted on. Buttons now carry the group name in their accessible name
("Make Active - <group>", keeping the visible label as a prefix per
Label in Name), and Card gained an ariaLabel prop so entering a card
announces its group. E2E locators are card-scoped substring matches and
are unaffected.
Three pre-existing keyboard bugs surfaced by the manual audit:

- SobjectList/SobjectListMultiSelect created ulRef with createRef in the
  render body and never attached it, so ArrowDown from the object filter
  silently did nothing (the fields list wires the same handler correctly).
- Space on a field checkbox toggled twice: the native activation fires
  change AND a bubbled click, and the row's click handler toggled it
  back. The row handler now ignores events originating from the
  checkbox input/label.
- Every field checkbox sat in the page tab order, so tabbing out of a
  500-field list meant 500 presses. Checkboxes are tabIndex=-1; the list
  is one tab stop and arrow keys move between rows (roving tabindex).
Manual audit follow-ups on the query fields list:

- Per-row secondary controls stay out of the page tab order and are
  reached with ArrowRight from the row checkbox (ArrowLeft returns) —
  the "where is this field used" icon on every row was otherwise
  re-flooding the tab order the roving-tabindex fix just cleaned up.
- Field checkboxes announced their raw id ("Account|Rating"): the rows
  pass a ReactNode heading, so the label fallback bottomed out at id.
  getContent now carries an explicit label and the field list provides
  "Label (ApiName)".
SearchInput dispatched Up/Down on keyup but let the keydown bubble; a
nested child-object field list sits inside a parent List row, so the
parent's keydown handler moved focus to a root row before the nested
filter's keyup could focus its own list. The keydown is now consumed
when arrows are handled. The View/Hide fields expand buttons also leave
the page tab order (reached with ArrowRight, like other row controls).
Tooltip triggers were plain spans: with no focusable element inside,
keyboard users could never reveal the tooltip (WCAG 1.4.13), and the
picklist/formula type badges layered a click-to-copy action on top with
no keyboard path at all. Tooltip now accepts triggerTabIndex, and an
interactive trigger (onClick + tabindex) exposes role="button" with
Enter/Space activation. The field rows wire these at tabIndex=-1 so
they join the row-local ArrowRight cycle instead of the page tab order.
KeyboardShortcut rendered only visual kbd glyphs — '⌘' announces as
"place of interest sign" at best, and the tooltip's description attaches
on open, after focus, so it was often never read at all. The widget now
renders assistive text ("Command + Enter") with the visuals aria-hidden,
and exports getAriaKeyshortcuts so triggers can announce the shortcut at
focus time via the aria-keyshortcuts attribute — wired on the query
Execute and Back buttons as the exemplar.
Manual audit findings on the query page:

- Arrow keys closed portaled combobox menus (query filter Operator,
  order-by Nulls) in Chrome: options receive real focus during arrow
  navigation, but with usePortal they live outside the container the
  blur handler checked, so the first arrow press blurred-and-closed.
  The popover ref is now part of the containment check.
- No visible focus indicator on lists and combobox options in Safari
  (and a clipped one in Chrome): these elements get no default focus
  ring in Safari, and an outline drawn outside the element is clipped
  by the scrolling container. Explicit :focus-visible outlines with
  outline-offset: -2px draw inside the element on the list, its rows,
  and combobox options.

The ul keydown lint warning is suppressed with rationale: the ul is the
composite's single tab stop delegating for focusable rows, a pattern
jsx-a11y cannot recognize.
Two more manual-audit findings on the query page:

- PopoverContainer forwarded its ref in a mount-time effect, but a
  non-eager popover renders nothing at mount and the effect deps never
  fire again — the forwarded ref stayed null forever. The blur handler's
  portal containment check therefore never passed and arrow keys still
  closed portaled combobox menus (filter Operator). The ref is now a
  composed callback ref on the floating div.
- PicklistItem (order-by Order/Nulls and friends) had the same
  role-on-the-wrong-element bug as ComboboxListItem: focus lands on a
  role="presentation" li while role="option" sat on an inner div, so
  arrowing announced nothing — and no :focus-visible style meant Safari
  showed no focus ring at all. Option semantics moved to the li with an
  inset focus outline.
The spacer above the button was an empty label with a hardcoded 15px
margin, a couple px short of a real label row, so delete buttons in
order-by/filter rows sat visibly high. The spacer is now an invisible
label with real content, inheriting the exact label metrics.

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
@​axe-core/​playwright@​4.13.0
axe-core@​4.13.0
vitest-axe@​0.1.0

View full report

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

2 participants


Back | FazBrowse Home | New Git URL