Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
201 commits
Select commit Hold shift + click to select a range
47ca1ba
chore(a11y): add axe-core scanning with baseline ratchet and VPAT draft
paustint Aug 30, 2026
fc4cb44
fix(ui): resolve all critical axe violations across the app
paustint Aug 30, 2026
5a6b65b
chore(a11y): scan landing/docs surfaces, add manual audit checklist, …
paustint Aug 30, 2026
74bdb18
fix(ui): remediate remaining automated accessibility audit findings
paustint Aug 30, 2026
6325e76
fix(org-groups): drag-handle cursor scoping and screen-reader context…
paustint Aug 30, 2026
5b3ca1c
fix(ui): complete the list/field-list composite keyboard pattern
paustint Aug 30, 2026
36cb4d9
fix(ui): keyboard-accessible tooltips with spoken keyboard shortcuts
paustint Aug 30, 2026
c1256e9
fix(ui): combobox/picklist keyboard navigation, Safari focus visibili…
paustint Aug 30, 2026
228870e
fix(ui): align FormRowButton with its sibling inputs
paustint Aug 30, 2026
01ba670
fix(query): advertise the existing Cmd+Enter reload shortcut
paustint Aug 30, 2026
a019112
fix(ui): make CheckboxToggle work with the keyboard
paustint Aug 30, 2026
47214d3
fix(ui): announce the child-records loading transition
paustint Aug 30, 2026
5bcfc30
fix(data-table): keep the grid a single page tab stop
paustint Aug 30, 2026
4e2a428
fix(ui): move focus to main content on route change
paustint Aug 30, 2026
cc61651
fix(ui): repair radio groups app-wide — shared names, one tab stop, a…
paustint Aug 30, 2026
e7d2e9d
fix(load): keep focus on buttons that disable themselves when clicked
paustint Aug 30, 2026
cc28804
fix(ui): announce keyboard shortcuts on every shortcut-bearing control
paustint Aug 30, 2026
34e0fc0
fix(ui): tab labels truncate in narrow panels and stay full in scroll…
paustint Aug 30, 2026
23695d2
fix(load): table sizing — preview height floor and horizontal scroll …
paustint Aug 30, 2026
abfc405
fix(load): manage focus and announce when a mapping row is removed
paustint Aug 30, 2026
ac8f30b
fix(ui): make field help reachable by keyboard and announced on focus
paustint Aug 30, 2026
36288d1
fix(ui): keep collapsible button labels in the accessible name
paustint Aug 30, 2026
69ed5ea
fix(load): move focus to the new run tab when retrying failed records
paustint Aug 30, 2026
4f44a89
fix(query): query-history inline editing and save-query control acces…
paustint Aug 30, 2026
ca32db9
fix(ui): history org-scope toggle keeps focus and announces the selec…
paustint Aug 30, 2026
e629904
fix(ui): dropdown menus return focus to their trigger, yielding to di…
paustint Aug 30, 2026
af6cfb7
docs(a11y): record the manual audit findings and outcomes (M1-M35)
paustint Aug 30, 2026
9971b96
fix(ui): search inputs — clear-button focus, password-manager suppres…
paustint Aug 30, 2026
19380e2
fix(load): keep focus on multi-object step buttons and hand focus alo…
paustint Aug 30, 2026
ab8ade9
fix(ui): keep the active-filters badge inside the trigger bounds
paustint Aug 30, 2026
6e64fb3
fix(ui): popovers cap to the viewport and the selection summary works…
paustint Aug 30, 2026
1d85dea
fix(ui-core): mass update field rows — focus managed across add and r…
paustint Aug 30, 2026
970a310
fix(ui): combobox drill-in Reset no longer traps focus; arrow positio…
paustint Aug 30, 2026
41dd291
fix(update-records): keyboard-accessible validate/review controls wit…
paustint Aug 30, 2026
0f6fc87
fix(create-records): cmd+enter save shortcut, save-outcome announceme…
paustint Aug 30, 2026
854a066
fix(ui): return focus to the input when its clear button is clicked
paustint Aug 30, 2026
b4ffef0
fix(ui): move combobox role and expanded state onto the focused input…
paustint Aug 30, 2026
ce14ad1
feat(ui): make TimePicker a type-to-filter combobox
paustint Aug 30, 2026
0d7c331
fix(data-history): pin toggle semantics, scoped download names, setti…
paustint Aug 30, 2026
57120f7
fix(ui): keep grid focus state when a portaled dropdown menu opens fr…
paustint Aug 30, 2026
196716a
fix(ui): DatePicker keyboard and layout overhaul
paustint Aug 30, 2026
8322a44
feat(ui): grid focuses a cell's lone widget directly during navigatio…
paustint Aug 30, 2026
37256fd
fix(automation-control): announcements, spacing, and review modal acc…
paustint Aug 30, 2026
9c452af
feat(ui): Popover trapFocus, filter popover keyboard containment, gri…
paustint Aug 30, 2026
78ec3e2
fix(ui): per-row details popovers — out of the tab order but reachabl…
paustint Aug 30, 2026
7befaf5
fix(manage-permissions): name and announce the permission grid's repe…
paustint Aug 30, 2026
d1de128
fix(ui): span-aware grid arrows and frozen-band scroll compensation
paustint Aug 30, 2026
49cc7d7
fix(data-analysis): keyboard access for select-column tooltips, cell …
paustint Aug 30, 2026
13c5287
fix(ui): name readonly boolean grid checkboxes after their column
paustint Aug 30, 2026
66150d4
fix(permission-analysis): finding-cell keyboard access, scoped names,…
paustint Aug 30, 2026
2316bb6
fix(ui): drop the feedback widget below overlay layers
paustint Aug 30, 2026
6f3de24
fix(ui): stop the grid stealing focus from overlays it opened
paustint Aug 30, 2026
4516c1c
fix(ui): Escape closes only the open menu, never the hosting modal
paustint Aug 30, 2026
4062e92
feat(ui): APG keyboard navigation for the Tree component
paustint Aug 30, 2026
77007df
fix(deploy): name the view/compare metadata controls for screen readers
paustint Aug 30, 2026
ef6cebd
fix(record-type-manager): announce picklist field and record type con…
paustint Aug 30, 2026
f31edeb
fix(create-object-and-fields): field card semantics, context, and foc…
paustint Aug 30, 2026
38992e7
fix(ui): Panel focus contract — focus in on open, return on close, Es…
paustint Aug 30, 2026
19f970e
fix(ui): copy-to-clipboard keeps focus, announces success, and stops …
paustint Aug 30, 2026
e40e466
fix(anon-apex): announce execution progress and outcome
paustint Aug 30, 2026
45b84b5
fix(debug-log-viewer): keyboard-accessible Debug Levels popover and l…
paustint Aug 30, 2026
dd7b6c5
fix(salesforce-api): name each copy button for what it copies
paustint Aug 30, 2026
c8760c2
fix(platform-event-monitor): clarify controls and announce outcomes
paustint Aug 30, 2026
3952d7e
feat(ui-core): screen-reader mode setting for the Monaco code editors
paustint Aug 30, 2026
f8b9e99
docs(a11y): verify C8/C10, record C9 fix, log the second manual sweep…
paustint Aug 30, 2026
ceb9361
docs(a11y): credit the list-panel Showing-X-of-Y announcement fix in M25
paustint Aug 30, 2026
fd09679
refactor(ui): centralize re-announce and filter-count live regions
paustint Aug 31, 2026
6c72300
refactor(permission-analysis): share the finding-cell severity/detail…
paustint Aug 31, 2026
c4f39b3
refactor(ui-core): extract the AppMainContent shell container
paustint Aug 31, 2026
7592af6
fix(canvas): adopt the shared app-shell accessibility wiring
paustint Aug 31, 2026
326df5f
refactor(ui): one owner for the aria-disabled-with-guarded-click cont…
paustint Aug 31, 2026
02245a7
refactor(ui): expose TabsRef.focusTab instead of leaking the tab-<id>…
paustint Aug 31, 2026
989c616
refactor(ui): share the grid overlay selector across its three focus …
paustint Aug 31, 2026
07cd670
docs(a11y): update M31 focus mechanism and close out the C3 TimePicke…
paustint Aug 31, 2026
20648db
fix(ui): subquery related-object accordion is one tab stop with arrow…
paustint Sep 1, 2026
305e828
fix(ui): grid ignores key events bubbling through portals from cell-o…
paustint Sep 1, 2026
a35518b
fix(ui): record lookup popover no longer steals focus when the record…
paustint Sep 1, 2026
4aeb5fd
docs(a11y): log the Automation Control review-modal follow-ups for a …
paustint Sep 1, 2026
3c0ffca
fix(apex-tests): run actions in the page header, one-tab-stop class l…
paustint Sep 1, 2026
c16ada2
fix(apex-tests): center status/outcome badges in taller grid rows
paustint Sep 1, 2026
9fa1ddc
fix(apex-tests): make the Code Coverage filter input filter the table
paustint Sep 1, 2026
e613d32
fix(ui): grid navigation survives zero-row filter results and filter-…
paustint Sep 1, 2026
8eff5ba
fix(ui): disabled combobox options no longer activate by pointer or E…
paustint Sep 1, 2026
89d6108
fix(ui): name popover dialogs and announce their async content
paustint Sep 1, 2026
89f1fa1
fix(ui): grid keyboard review fixes — overlay focus return, text-inpu…
paustint Sep 1, 2026
2178c08
fix(ui-core): give the Home page a heading
paustint Sep 1, 2026
945ccc5
fix(ui): ScopedNotification is a live region
paustint Sep 1, 2026
0d2b700
fix(ui): name dropdown triggers that fell back to the literal "action"
paustint Sep 1, 2026
9407e1d
chore(a11y): align jsx-a11y rule options with the plugin's recommende…
paustint Sep 1, 2026
7bff748
fix(ui): docked composer can be re-opened by keyboard
paustint Sep 1, 2026
6048490
fix(ui): name status icons, refresh buttons, nav menus and the action…
paustint Sep 1, 2026
ad97720
fix(ui): self-disabling Save/Verify/Load buttons keep focus
paustint Sep 1, 2026
256af03
fix(ui): associate labels, ids and the progress ring name where they …
paustint Sep 1, 2026
3db6674
fix(ui): Pro badge and upgrade button meet text contrast
paustint Sep 1, 2026
87bb8dd
chore(a11y): harden the axe baseline ratchet
paustint Sep 1, 2026
d0759dd
ci: run the E2E job when only jetstream-e2e changes
paustint Sep 1, 2026
f60d7f7
test(a11y): scan the header avatar dropdown menu open state
paustint Sep 2, 2026
fb55627
fix(apex-tests): keep focus on self-disabling run controls, announce …
paustint Sep 2, 2026
7089c0e
fix(data-history): name and announce the payload tab's copy controls
paustint Sep 2, 2026
f6ea7de
fix(debug-log-viewer): name the viewed-marker cell "View log" instead…
paustint Sep 2, 2026
3f73ff7
fix(debug-log-viewer): keep focus on Delete Logs while the purge runs
paustint Sep 2, 2026
35d8dfb
fix(anon-apex): name the Apex history select
paustint Sep 2, 2026
817e8d7
fix(create-object-and-fields): keep focus on self-disabling submit bu…
paustint Sep 2, 2026
e3fb1c7
fix(create-object-and-fields): name and group the repeated per-field …
paustint Sep 2, 2026
3ea6a64
fix(org-groups): name the page-header org actions menu trigger
paustint Sep 2, 2026
049e7ce
fix(org-groups): scope org card connection controls to the org and ke…
paustint Sep 2, 2026
41bcc1e
fix(org-groups): keep focus on Refresh All Orgs and announce its prog…
paustint Sep 2, 2026
34c8226
fix(org-groups): announce the delete confirmation step and drop mouse…
paustint Sep 2, 2026
b326749
fix(org-groups): report success and failure when deleting a group
paustint Sep 2, 2026
6dbcd0e
fix(record-type-manager): set the Record Type Picklist Manager docume…
paustint Sep 2, 2026
f9705a1
fix(record-type-manager): keep the Deploy buttons focusable while dis…
paustint Sep 2, 2026
6f1c6e4
refactor(record-type-manager): delete the unused RecordTypeManagerEdi…
paustint Sep 2, 2026
0f540de
fix(deploy): read the compare result aloud in the metadata tree inste…
paustint Sep 2, 2026
4012503
fix(platform-event-monitor): render the subscribed-event pills inside…
paustint Sep 2, 2026
3c65878
fix(load): drop the literal aria-describedby on the batch size input …
paustint Sep 2, 2026
84bf4a5
fix(permission-analysis): name the repeated per-group and per-row tre…
paustint Sep 2, 2026
fbe1587
fix(permission-analysis): announce analysis progress and completion
paustint Sep 2, 2026
54396ec
fix(permission-analysis): let the visible "Filters (N)" text name the…
paustint Sep 2, 2026
35d7b01
fix(a11y): drop icon descriptions that doubled control names beside v…
paustint Sep 2, 2026
4fdc9d4
fix(a11y): name copy-to-clipboard buttons that share a view for what …
paustint Sep 2, 2026
9c8e571
docs(a11y): record the code-review outcome, correct stale statements,…
paustint Sep 2, 2026
f833e09
fix(a11y): make the per-type metadata refresh rows real buttons named…
paustint Sep 2, 2026
584861a
fix(teams): expose member-row status warnings to keyboard and screen …
paustint Sep 2, 2026
cb86b11
fix(web-extension): make the floating Jetstream panel a dialog with m…
paustint Sep 2, 2026
56c7e9f
fix(billing): make the Monthly / Annual toggle a keyboard-operable ra…
paustint Sep 2, 2026
74a6c9d
fix(query): name the field-function row controls and keep focus when …
paustint Sep 2, 2026
50cebe9
fix(teams): name per-domain actions, keep focus after deleting a doma…
paustint Sep 2, 2026
2804188
fix(a11y): associate form labels with their controls where the wrappe…
paustint Sep 2, 2026
cedfe71
fix(deploy): label, head and name the download-package modal controls
paustint Sep 2, 2026
950f276
fix(profile): name the profile action menus and per-session controls,…
paustint Sep 2, 2026
d86b10c
fix(teams): keep focus on self-disabling submit buttons and let forms…
paustint Sep 2, 2026
3618ac4
fix(teams): name the team tables and action menus, fix copied column-…
paustint Sep 2, 2026
b78261a
fix(teams): return focus to Edit after saving or cancelling the team …
paustint Sep 2, 2026
1f7897b
fix(teams): name invitation-row actions and make the expiry tooltips …
paustint Sep 2, 2026
b2ebe9c
fix(load): name each saved mapping's Use button and actions menu for …
paustint Sep 2, 2026
17979e4
fix(deploy): name each history row's View Details / Download Package …
paustint Sep 2, 2026
1807a2e
fix(query): label group-by / order-by rows correctly and keep focus w…
paustint Sep 2, 2026
0e50c25
fix(web-extension): make the record quick view a dialog with managed …
paustint Sep 2, 2026
ca8c26d
fix(query): keep focus on the self-disabling attachment download, bul…
paustint Sep 2, 2026
634c133
fix(deploy): name the changeset selection radio group, make "manually…
paustint Sep 2, 2026
0668d99
fix(formula-evaluator): keep focus on the deploy modal's action butto…
paustint Sep 2, 2026
5cc0aeb
fix(load): keep focus on self-disabling load controls and announce lo…
paustint Sep 2, 2026
8e8351b
fix(load): custom metadata step heading levels, status announcements …
paustint Sep 2, 2026
cb2000d
fix(billing): name the plan radio group and keep focus on Subscribe N…
paustint Sep 2, 2026
3a8c6c1
fix(settings): make "Danger Zone" a heading and manage focus through …
paustint Sep 2, 2026
4c375da
fix(web-extension): one stable toggle for the button options, and nam…
paustint Sep 2, 2026
e7f1595
fix(query): keep focus in the subquery panel after "Clear all" remove…
paustint Sep 2, 2026
0dc8126
fix(deploy): announce deployment progress and outcome in the deploy s…
paustint Sep 2, 2026
6c79508
fix(query): give the query walkthrough modal a header and real step h…
paustint Sep 2, 2026
32347f6
fix(query): use the shared copy-to-clipboard button in the record-to-…
paustint Sep 2, 2026
33f2fb4
fix(query): drop the literal aria-describedby on the bulk-update batc…
paustint Sep 2, 2026
ff8d39d
fix(deploy): title the compare-metadata export modal "Export Metadata"
paustint Sep 2, 2026
50822b1
test(e2e): bind the OIDC mock identity providers to free ports
paustint Sep 2, 2026
2d825b5
docs: add a public accessibility page
paustint Sep 2, 2026
8f9a5d2
chore(a11y): promote anchor-ambiguous-text to error
paustint Sep 2, 2026
ff52141
chore(a11y): ratchet warn-tier lint hits, axeScan specs and route bas…
paustint Sep 2, 2026
a1776da
docs: add the /a11y-review skill and a definition of done for UI changes
paustint Sep 2, 2026
703404f
fix(orgs): manage focus through the org info popover's remove confirm…
paustint Sep 2, 2026
36b1400
fix(orgs): expose the Add Org advanced options toggle as a disclosure
paustint Sep 2, 2026
cf65be8
fix(ui): return drawer focus to its opener when the click left nothin…
paustint Sep 2, 2026
dabe22e
feat(ui): describe what Enter does on a focused grid cell
paustint Sep 2, 2026
2297391
fix(ui): stop the picklist reopening after an option is chosen with E…
paustint Sep 2, 2026
cd0e2bd
feat(record): save the record modal with Cmd/Ctrl+Enter and announce …
paustint Sep 2, 2026
c052cc4
fix(query): keyboard access to the recent queries in the Query Record…
paustint Sep 2, 2026
25f4e8f
fix(record): keyboard access to recent records and a clean hand-off t…
paustint Sep 2, 2026
9f9b20c
fix(ui): keep grid rows on whole pixels so row borders render evenly
paustint Sep 2, 2026
3075d91
feat(record): announce a successful save in the record modal
paustint Sep 2, 2026
7c29ce0
fix(org-groups): return focus to the opener after the group modal closes
paustint Sep 2, 2026
1994fa6
fix(org-groups): announce drag and drop with org and group names
paustint Sep 2, 2026
4f85775
fix(ui): flag every form control invalid when it shows an error
paustint Sep 2, 2026
1795af5
fix(cookie-banner): name the consent region and say what the buttons …
paustint Sep 2, 2026
960b694
fix(ui): return modal focus to its opener even when a child autofocuses
paustint Sep 2, 2026
a8560b0
fix(ui): tell link-only grid cells apart from cells with controls
paustint Sep 2, 2026
f9d4624
fix(a11y): say that Salesforce links open a new tab and announce Auto…
paustint Sep 2, 2026
81b680d
feat(load-records): say why Continue is blocked and announce mapping …
paustint Sep 2, 2026
2335def
fix(ui): make grid cell hints match what Enter actually does
paustint Sep 2, 2026
225f809
docs(a11y): log the second VoiceOver pass findings and fixes
paustint Sep 2, 2026
4cba1cd
feat(ui): open a closed combobox with Enter
paustint Sep 7, 2026
a8f43ed
fix(orgs): keep focus on "Clear Cached Data" after it disables itself
paustint Sep 7, 2026
70df2fe
fix(orgs): keep the header focus rings clear of the navbar
paustint Sep 7, 2026
55fa6b5
fix(query): name the SOQL format options gear after what it opens
paustint Sep 7, 2026
e080094
fix(ui): stop a held Enter from picking the first option after choosi…
paustint Sep 7, 2026
7fd1082
fix(ui): open the download modals with Enter without downloading at once
paustint Sep 7, 2026
f050a68
fix(ui): activate menu items with Space and open menus with the arrow…
paustint Sep 7, 2026
9d29f37
fix(query): return focus to Record actions after a bulk update refres…
paustint Sep 7, 2026
91c12ef
docs(a11y): log the route-change scroll offset fix
paustint Sep 7, 2026
766adba
fix(ui-utils): keep page-level Cmd/Ctrl+Enter shortcuts quiet while a…
paustint Sep 8, 2026
1d1b3f7
fix(ui): announce grid cells read-only unless the column has an editor
paustint Sep 8, 2026
3480e8e
chore(a11y): shrink the lint ratchet baseline after the review-round …
paustint Sep 8, 2026
3d9fa74
docs(a11y): log the 2026-09-07 merge-readiness review round
paustint Sep 8, 2026
35761c8
fix(query): keep focus on Add Order By and Add Group By when the fift…
paustint Sep 8, 2026
18d7451
fix(orgs): keep the group switcher's trigger across selections and ma…
paustint Sep 8, 2026
82e75c0
docs(a11y): log the Select All arrow-key hand-off into the field list
paustint Sep 8, 2026
057f985
test: give component specs room under a fully parallel suite
paustint Sep 13, 2026
f882632
feat(ui-core): leave a code editor with the keyboard and hear its sho…
paustint Sep 13, 2026
08794a7
docs(a11y): log the 2026-09-13 manual pass follow-ups
paustint Sep 13, 2026
a793414
fix(ui): give every helper button in the form primitives an explicit …
paustint Sep 13, 2026
5c5d997
test(a11y): make axeScan assert on its own
paustint Sep 13, 2026
1e19fc7
fix(load-records-multi-object): label the date format select
paustint Sep 13, 2026
e7ec098
fix(ui): name progress bars for what is progressing
paustint Sep 13, 2026
d2024da
docs(a11y): log the 2026-09-13 merge-readiness follow-ups
paustint Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
115 changes: 115 additions & 0 deletions .claude/skills/a11y-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: a11y-review
description: Accessibility (WCAG 2.1 AA) review of UI changes - keyboard operation, accessible names/roles/states, focus management, announcements, and the repo's a11y gates. Use before finishing any change that adds or modifies interactive UI, when asked to review a branch or PR for accessibility, or when a user reports a keyboard or screen reader problem.
---

# Accessibility review

Jetstream targets WCAG 2.1 AA (program docs in `docs/accessibility/`). This skill is the review
pass that the 2026 audit and code review used; run it on the current diff before calling UI work
done, or on the files the user names.

## Scope

- Default scope is `git diff --name-only main...HEAD` plus uncommitted changes; the user may
narrow it to files or an area. Only `.tsx` files that render UI (and their specs) matter.
- **Read each file in full**, not just the hunk: the defect is usually in how the changed element
interacts with the rest of the component (a role on the parent, a handler on the row).
- Check `docs/accessibility/audit-2026/findings.md` ("Still open after the review") before
reporting so known, deliberately-open items are not re-raised.

## Checklist

Walk every interactive element in scope and answer each question. "Interactive" includes anything
with an `onClick`, `onKeyDown`, `href`, `tabIndex`, or an ARIA widget role.

1. **Name, role, state.** Does it have an accessible name that matches the visible text (icon-only
buttons need `title` or `aria-label`; the name must include the visible label - WCAG 2.5.3)?
Is the role native (`<button>`, `<a href>`) or a correct ARIA widget role on a focusable element?
Are `aria-expanded`, `aria-selected`, `aria-checked`, `aria-pressed`, `aria-current`,
`aria-invalid`, `aria-describedby` present where the state exists visually? Is anything
decorative (`Icon` next to text) hidden rather than named?
2. **Keyboard.** Can it be reached with Tab (or with arrow keys inside a composite) and operated
with Enter/Space? Do lists, grids, trees, tabs, menus, and radio groups use a single tab stop
with arrow keys, Home/End, and type-ahead where the APG pattern has it? Is there any trap
(Monaco editors are the deliberate exception, with the documented Ctrl+M escape)?
3. **Focus management.** When a control unmounts on click (Remove -> Keep/Confirm, Save/Undo,
delete row, close panel), where does focus go? It must land on the replacement control, the
nearest sensible sibling, or the trigger - never on `body`. Dialogs and popovers must trap and
return focus (`Modal`, `Popover`, `FloatingFocusManager` do this; custom panels must too).
Route changes must not steal focus except after a user-initiated navigation.
4. **Announcements.** Are status changes a screen reader must hear (load progress, results count,
save confirmation, validation summary, "copied") rendered inside a live region? Are errors
announced assertively and success/progress politely? Does the live region exist before the
message arrives (a region mounted with text is not announced)?
5. **Disabled and busy.** A control that disables itself while its own action runs must stay
focusable (`ariaDisabledButtonProps`) so focus is not dropped and the reason is announced;
native `disabled` is fine for controls gated by other state. Long operations expose
`aria-busy` or a status message, not just a spinner.
6. **Forms.** Every input has a `<label for>` or `aria-labelledby` that points at an existing id;
help text and errors are linked with `aria-describedby`; required and invalid states are in
ARIA, not only in colour; error summaries move focus or are announced.
7. **Layering.** Escape closes only the top-most layer (popover inside a modal, editor inside a
grid cell). Anything that handles Escape goes through `useEscapeToCloseLayer`.
8. **Visual (what code can show).** Text and focus rings are not removed with `outline: none`;
information is not carried by colour alone; the layout tolerates 200% zoom / 320px reflow
(no fixed pixel heights around text).
9. **Tests.** Interactive `libs/ui` components have an `axeScan()` assertion in their spec;
keyboard behaviour that was hand-rolled has a unit test; new routes have an
`a11y-baseline.json` entry.

## Shared primitives to reach for

| Need | Use |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Keep a self-disabling button focusable | `ariaDisabledButtonProps` (`libs/ui/src/lib/form/button/aria-disabled-button.utils.ts`) |
| Announce a status string | `AssistiveStatus` (polite region), `useAnnouncer` (imperative), `ScopedNotification` (banner) |
| Escape closes this layer only | `useEscapeToCloseLayer` / `EscapeLayerPropagationContext` |
| Single-tab-stop list with row-level extras | `List` (roving tabindex, `focusListEntryRow`), `Tabs`, `Accordion`, `Tree` |
| Filter input that hands off to the list with ArrowDown | `SearchInput` + `List` (see `SobjectFieldList`) |
| Combobox / picklist | `Combobox`, `Picklist` (ARIA 1.2 pattern; focus moves to the option) |
| Modal / popover / dropdown menu | `Modal`, `Popover`, `DropDown` (focus trap, return focus, Escape) |
| Data grid keyboard model | `data-table/grid/keyboard/useGridKeyboardNavigation.ts` |
| Skip link, focus main on navigation | `SkipToContent`, `FocusMainContentOnRouteChange` |
| Unit-level axe assertion | `axeScan(container)` from `@jetstream/test-utils` |

Reference implementations to copy from: `libs/ui/src/lib/modal/Modal.tsx`, `popover/Popover.tsx`,
`form/dropdown/DropDown.tsx`, `list/List.tsx`, `tabs/Tabs.tsx`, and the grid under `data-table/grid/`.

## Gates to run

```bash
pnpm lint # jsx-a11y rules at `error` (never demote one)
pnpm a11y:lint-ratchet # no new warn-tier jsx-a11y hits, axeScan() in new libs/ui specs, every route baselined
pnpm nx run ui:test -- <spec> # unit specs with axeScan()
pnpm e2e:local a11y # axe sweep of every route + interactive states against a11y-baseline.json
```

When a fix drops a lint hit, run `pnpm a11y:lint-ratchet --update` so the baseline shrinks with it.
Growth of either baseline needs `--allow-growth` and a line in the findings log.

## Report format

One line per finding, most severe first, then the verified-OK list and anything not exercised:

```
[P1|P2|P3] path/to/File.tsx:123 - claim - who hits it and how (keyboard / VoiceOver / zoom) - fix
```

- **P1**: cannot be completed with a keyboard or a screen reader (trap, unnamed control, lost focus,
unannounced error, inaccessible required step).
- **P2**: works but wrong or confusing (misleading name, missing state, duplicate announcement,
focus lands somewhere surprising).
- **P3**: polish and consistency.

Only report what the code proves. If a behaviour depends on runtime layout or a screen reader
quirk, say it is untested rather than guessing.

## Landing fixes

Fix P1 and P2 findings in the same change unless the user asks for a report only. On a branch
with atomic commits, land a fix as `git commit --fixup=<sha>` against the commit that introduced
the defect and leave the autosquash to the author; a fix for a pre-existing gap is a normal commit
with a `fix(<scope>):` subject that says what a user could not do before. Add or extend a spec for
every keyboard or focus behaviour you change, and run `pnpm format` and `pnpm organize-imports`
on touched files.
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ Jetstream is a private Nx monorepo (React 19 + Vite web app, Express/Prisma API,
- UI is hand-built — this repo does NOT use `@salesforce/design-system-react`. Prefer SLDS CSS classes; use Emotion `css` where needed. State is jotai atoms.
- Always use curly braces on `if` statements. Prefer verbose variable names (except `i` for index); avoid single-letter names.
- Tests are Vitest, co-located in `__tests__/*.spec.ts`. Migrations are created with the Prisma CLI only.
- The product targets **WCAG 2.1 AA** (see `docs/accessibility/`). In UI changes, flag missing accessible names, keyboard operability gaps, focus-management regressions, and missing `aria-live` for async status. Interactive `libs/ui` components should carry an `axeScan()` assertion (`@jetstream/test-utils`) in their spec — reference pattern: `libs/ui/src/lib/modal/__tests__/Modal.spec.tsx`.
39 changes: 38 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ jobs:
- name: Lint
run: pnpm lint

# Fails on new warn-tier jsx-a11y hits, new libs/ui specs without axeScan(), and new routes
# without an axe baseline entry (scripts/a11y-lint-ratchet.mjs).
- name: Accessibility lint ratchet
run: pnpm a11y:lint-ratchet

# Build application
build-and-test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -245,7 +250,9 @@ jobs:
id: setup
uses: ./.github/actions/setup-e2e
with:
affected-projects: api|jetstream
# jetstream-e2e is listed so a change that only touches the suite itself (a spec, the a11y
# baseline, the shared page objects) still runs it — api/jetstream alone would skip those.
affected-projects: api|jetstream|jetstream-e2e
suite-name: E2E

# build:ci compiles all nine apps; E2E only reaches three of them. api/src/main.ts statically
Expand Down Expand Up @@ -288,6 +295,19 @@ jobs:
retention-days: 1
if-no-files-found: ignore

# Raw axe-core scan results from the a11y specs — evidence artifacts for the accessibility
# conformance report (VPAT) and inputs to scripts/a11y-merge-baseline.mjs.
- name: Upload a11y scan results
if: always() && steps.setup.outputs.should_run == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: a11y-results-${{ matrix.shardIndex }}
path: apps/jetstream-e2e/a11y-results
retention-days: 30
# Only the shard(s) that ran an a11y spec produce results, so an empty shard is normal here;
# merge-e2e-reports fails the run if NO shard produced any.
if-no-files-found: ignore

# Playwright's _electron support needs a real BrowserWindow, which a headless GitHub-hosted
# runner has no display server for — xvfb-run provides a virtual one. Single job, single worker
# (no sharding, unlike e2e-shard): Electron launches are heavier than browser contexts and there's
Expand Down Expand Up @@ -419,6 +439,23 @@ jobs:
pattern: blob-report-*
merge-multiple: true

- name: Download a11y scan results
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: all-a11y-results
pattern: a11y-results-*
merge-multiple: true

# The a11y specs land on whichever shards Playwright assigns them to, so a shard without results
# is normal. What must never pass silently is the suite running with NO shard producing any —
# that means the a11y specs were skipped or broken and the ratchet did not run.
- name: Check a11y scan results exist
run: |
if [ -n "$(ls -A all-blob-reports 2>/dev/null)" ] && [ -z "$(ls -A all-a11y-results 2>/dev/null)" ]; then
echo "::error::E2E ran but no a11y scan results were produced — the a11y specs did not run"
exit 1
fi

# The E2E job exits successfully without producing reports when the affected-guard skips it,
# so an empty download here is expected rather than an error.
#
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ package-lock.json
**/playwright-report
**/playwright-summary.json
**/playwright/.cache
# axe-core scan evidence (VPAT inputs) — generated by the a11y E2E specs and scripts/a11y-scan-urls.mjs
**/a11y-results

.nx/cache
.nx/workspace-data
Expand Down
8 changes: 8 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ pnpm exec oxlint --quiet . || {
echo "Lint errors found. Run 'pnpm lint:fix' to auto-fix what can be fixed."
exit 1
}

# Accessibility ratchet: new warn-tier jsx-a11y hits, new libs/ui specs without axeScan(), and new
# routes without an axe baseline entry (scripts/a11y-lint-ratchet.mjs).
pnpm a11y:lint-ratchet || {
echo ""
echo "Accessibility ratchet failed. See the messages above, or use 'git commit --no-verify' to bypass."
exit 1
}
33 changes: 25 additions & 8 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,33 @@
// unicorn/no-new-array, unicorn/no-single-promise-in-promise-methods,
// unicorn/no-useless-fallback-in-spread, unicorn/no-useless-length-check.

// Accessibility was advisory before the migration (Nx's React preset reports these as
// warnings, and only the two React apps opted into the wider set). Keeping that posture.
"jsx-a11y/autocomplete-valid": "warn",
// Accessibility ratchet (docs/accessibility/README.md): each rule is promoted from `warn` to
// `error` once its violation count reaches zero — see the lint census in
// docs/accessibility/audit-2026/findings.md for current counts. Never demote a promoted rule.
"jsx-a11y/anchor-ambiguous-text": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/control-has-associated-label": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"jsx-a11y/label-has-associated-control": "warn",
// depth: SLDS blueprints nest a control's text several levels deep (li > div.slds-media >
// span.slds-media__body > span), beyond the default depth of 2
"jsx-a11y/control-has-associated-label": ["warn", { "depth": 5 }],
"jsx-a11y/interactive-supports-focus": "error",
// depth: the SLDS toggle's second <label> wraps the input plus the on/off state text two levels down
"jsx-a11y/label-has-associated-control": ["error", { "depth": 3 }],
"jsx-a11y/no-noninteractive-element-interactions": "warn",
"jsx-a11y/no-noninteractive-element-to-interactive-role": "warn",
"jsx-a11y/no-redundant-roles": "warn",
// The allowlist mirrors eslint-plugin-jsx-a11y's recommended config: APG composite widgets are
// built from list markup (ul[role=listbox] > li[role=option], role=tree/treeitem, role=tablist/tab)
"jsx-a11y/no-noninteractive-element-to-interactive-role": [
"warn",
{
"ul": ["listbox", "menu", "menubar", "radiogroup", "tablist", "tree", "treegrid"],
"ol": ["listbox", "menu", "menubar", "radiogroup", "tablist", "tree", "treegrid"],
"li": ["menuitem", "menuitemradio", "menuitemcheckbox", "option", "row", "tab", "treeitem"],
"table": ["grid"],
"td": ["gridcell"],
"fieldset": ["radiogroup", "presentation"]
}
],
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/no-static-element-interactions": "warn",

// Focus placement in modals/popovers is deliberate (StepUpAuthModal, grid filter inputs, etc.).
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ This project does NOT use `@salesforce/design-system-react`, all components were

Prefer using Salesforce lightning design system CSS classes when applicable, but can use `import { css } from '@emotion/react'` where needed.

## Accessibility

The product targets WCAG 2.1 AA — program docs, findings log, and the conformance report live in `docs/accessibility/`. Run the `/a11y-review` skill on any change that adds or modifies interactive UI before calling it done.

- New or changed interactive UI must be keyboard operable with correct ARIA (names, roles, states) and managed focus: when a control unmounts on activation, move focus to its replacement or the trigger, never let it fall to `body`. Reference implementations: `libs/ui/src/lib/modal/Modal.tsx`, `popover/Popover.tsx`, `form/dropdown/DropDown.tsx`, `list/List.tsx`, and the grid under `data-table/grid/`.
- Use the shared primitives instead of hand-rolling: `ariaDisabledButtonProps` for a control that disables itself, `AssistiveStatus` / `useAnnouncer` / `ScopedNotification` for status a screen reader must hear, `useEscapeToCloseLayer` for anything Escape closes, and the roving-tabindex composites (`List`, `Tabs`, `Accordion`, `Tree`, the grid) for long collections — never a tab stop per row.
- Add an `axeScan()` assertion (from `@jetstream/test-utils`) to specs for interactive `libs/ui` components — see `libs/ui/src/lib/modal/__tests__/Modal.spec.tsx`.
- `pnpm a11y:lint-ratchet` runs in pre-commit and CI. It fails on any new `warn`-tier `jsx-a11y` hit (per file and rule, baselined in `tools/oxlint/jsx-a11y-baseline.json`), a new `libs/ui` spec without `axeScan()`, and a new `APP_ROUTES` entry without an `a11y-baseline.json` key. `--update` shrinks the baseline; growth needs `--allow-growth` plus a findings-log entry. E2E axe scans in `apps/jetstream-e2e/src/tests/a11y/` ratchet the same way, and `jsx-a11y` rules are never demoted from `error`.

## Testing Approach

- Unit tests with Vitest (co-located with source files, but in a `__tests__` folder example: `__tests__/*.spec.ts`)
Expand Down
Loading
Loading