From 47ca1ba406e34d15a197620c5eafd9a5998fcdad Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 30 Aug 2026 16:45:18 -0500 Subject: [PATCH 001/201] chore(a11y): add axe-core scanning with baseline ratchet and VPAT draft --- .github/copilot-instructions.md | 1 + .github/workflows/ci.yml | 11 +++ .gitignore | 2 + .oxlintrc.json | 7 +- CLAUDE.md | 8 ++ .../src/tests/a11y/a11y-baseline.json | 32 +++++++ .../src/tests/a11y/a11y.utils.ts | 83 +++++++++++++++++ .../src/tests/a11y/interactive-states.spec.ts | 45 ++++++++++ .../src/tests/a11y/page-sweep.spec.ts | 38 ++++++++ apps/jetstream-e2e/tsconfig.json | 3 + docs/accessibility/README.md | 55 ++++++++++++ docs/accessibility/audit-2026/findings.md | 68 ++++++++++++++ .../accessibility/vpat/jetstream-acr-DRAFT.md | 89 +++++++++++++++++++ libs/test-utils/src/index.ts | 1 + libs/test-utils/src/lib/a11y-test-utils.ts | 30 +++++++ .../ui/src/lib/modal/__tests__/Modal.spec.tsx | 49 ++++++---- libs/ui/src/lib/tabs/__tests__/Tabs.spec.tsx | 7 ++ libs/ui/tsconfig.lib.json | 7 +- package.json | 6 ++ pnpm-lock.yaml | 74 +++++++++++++-- scripts/a11y-merge-baseline.mjs | 51 +++++++++++ scripts/a11y-scan-urls.mjs | 74 +++++++++++++++ 22 files changed, 709 insertions(+), 32 deletions(-) create mode 100644 apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json create mode 100644 apps/jetstream-e2e/src/tests/a11y/a11y.utils.ts create mode 100644 apps/jetstream-e2e/src/tests/a11y/interactive-states.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/a11y/page-sweep.spec.ts create mode 100644 docs/accessibility/README.md create mode 100644 docs/accessibility/audit-2026/findings.md create mode 100644 docs/accessibility/vpat/jetstream-acr-DRAFT.md create mode 100644 libs/test-utils/src/lib/a11y-test-utils.ts create mode 100644 scripts/a11y-merge-baseline.mjs create mode 100644 scripts/a11y-scan-urls.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9987c0bdd..4bdf3c635 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d362a07a5..6cb1e3fcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,17 @@ 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 + 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 diff --git a/.gitignore b/.gitignore index 39db98bc4..a7c51bf96 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.oxlintrc.json b/.oxlintrc.json index 57f207530..2d1326226 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -103,8 +103,9 @@ // 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. + // 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/autocomplete-valid": "warn", "jsx-a11y/click-events-have-key-events": "warn", "jsx-a11y/control-has-associated-label": "warn", @@ -112,7 +113,7 @@ "jsx-a11y/label-has-associated-control": "warn", "jsx-a11y/no-noninteractive-element-interactions": "warn", "jsx-a11y/no-noninteractive-element-to-interactive-role": "warn", - "jsx-a11y/no-redundant-roles": "warn", + "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.). diff --git a/CLAUDE.md b/CLAUDE.md index 50c211dbe..682e67880 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,14 @@ 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/`. + +- New or changed interactive UI must be keyboard operable with correct ARIA (names, roles, states) and managed focus. Reference implementations: `libs/ui/src/lib/modal/Modal.tsx`, `popover/Popover.tsx`, `form/dropdown/DropDown.tsx`, and the grid under `data-table/grid/`. +- 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`. +- E2E axe scans in `apps/jetstream-e2e/src/tests/a11y/` gate against `a11y-baseline.json` (ratchet: it only shrinks). The `jsx-a11y` lint rules in `.oxlintrc.json` follow the same ratchet — never demote one from `error`, and don't add new violations to the `warn`-tier rules. + ## Testing Approach - Unit tests with Vitest (co-located with source files, but in a `__tests__` folder example: `__tests__/*.spec.ts`) diff --git a/apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json b/apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json new file mode 100644 index 000000000..d9e7380d7 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json @@ -0,0 +1,32 @@ +{ + "route-ANON_APEX": ["aria-valid-attr-value", "button-name"], + "route-AUTOMATION_CONTROL": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-BILLING": ["aria-valid-attr-value", "button-name", "label"], + "route-CREATE_FIELDS": ["aria-input-field-name", "aria-valid-attr-value", "button-name", "nested-interactive"], + "route-DATA_ANALYSIS": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-DATA_HISTORY": ["aria-valid-attr-value", "button-name"], + "route-DEBUG_LOG_VIEWER": ["aria-command-name", "aria-valid-attr-value", "button-name"], + "route-DEPLOY_METADATA": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-FEEDBACK_SUPPORT": ["aria-valid-attr-value", "button-name"], + "route-FORMULA_EVALUATOR": ["aria-valid-attr-value", "button-name"], + "route-HOME": ["aria-valid-attr-value", "button-name", "definition-list"], + "route-LOAD": ["aria-input-field-name", "aria-progressbar-name", "aria-valid-attr-value", "button-name"], + "route-LOAD_CREATE_RECORD": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-LOAD_MASS_UPDATE": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-LOAD_MULTIPLE": ["aria-valid-attr-value", "button-name", "link-in-text-block"], + "route-OBJECT_EXPORT": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-PERMISSION_ANALYSIS": ["aria-input-field-name", "aria-valid-attr-value", "button-name", "nested-interactive"], + "route-PERMISSION_MANAGER": ["aria-input-field-name", "aria-valid-attr-value", "button-name", "nested-interactive"], + "route-PLATFORM_EVENT_MONITOR": ["aria-valid-attr-value", "button-name", "select-name"], + "route-PROFILE": ["aria-valid-attr-value", "button-name"], + "route-QUERY": ["aria-input-field-name", "aria-valid-attr-value", "button-name"], + "route-RECORD_TYPE_MANAGER": ["aria-valid-attr-value", "button-name"], + "route-SALESFORCE_API": ["aria-valid-attr-value", "button-name"], + "route-SALESFORCE_ORG_GROUPS": ["aria-valid-attr-value", "button-name", "nested-interactive"], + "route-SETTINGS": ["aria-valid-attr-value", "button-name", "label"], + "route-TEAM_DASHBOARD": ["aria-valid-attr-value", "button-name", "definition-list"], + "state-nav-menu-open": ["aria-valid-attr-value", "button-name", "definition-list"], + "state-query-fields-list": ["aria-input-field-name", "aria-valid-attr-value", "button-name", "label", "link-name", "nested-interactive"], + "state-query-results-grid": ["aria-valid-attr-value", "button-name"], + "state-query-sobject-list": ["aria-input-field-name", "aria-valid-attr-value", "button-name"] +} diff --git a/apps/jetstream-e2e/src/tests/a11y/a11y.utils.ts b/apps/jetstream-e2e/src/tests/a11y/a11y.utils.ts new file mode 100644 index 000000000..bfa0c7931 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/a11y/a11y.utils.ts @@ -0,0 +1,83 @@ +import { AxeBuilder } from '@axe-core/playwright'; +import { expect, Page, TestInfo } from '@playwright/test'; +import type { AxeResults } from 'axe-core'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Shared axe-core scan harness for the WCAG 2.1 AA program (see docs/accessibility/README.md). + * + * Every scan writes its full axe results to a11y-results/.json — these are the raw + * evidence artifacts for the VPAT/ACR. Gating works as a ratchet against a committed baseline: + * - A scanKey with no baseline entry is record-only, so brand new scans never break CI. + * - A scanKey with a baseline entry fails only when a serious/critical rule violation appears + * that is not already in the baseline. Shrink the baseline as violations are remediated + * (scripts/a11y-merge-baseline.mjs regenerates it from a results directory). + */ + +const WCAG_21_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; +const GATED_IMPACTS = new Set(['serious', 'critical']); + +const RESULTS_DIR = join(process.cwd(), 'apps/jetstream-e2e/a11y-results'); +const BASELINE_PATH = join(process.cwd(), 'apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json'); + +type A11yBaseline = Record; + +let cachedBaseline: A11yBaseline | null = null; + +function getBaseline(): A11yBaseline { + if (!cachedBaseline) { + cachedBaseline = existsSync(BASELINE_PATH) ? (JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) as A11yBaseline) : {}; + } + return cachedBaseline; +} + +export async function runA11yScan(page: Page, testInfo: TestInfo, scanKey: string): Promise { + const results = await new AxeBuilder({ page }).withTags(WCAG_21_AA_TAGS).analyze(); + + mkdirSync(RESULTS_DIR, { recursive: true }); + writeFileSync( + join(RESULTS_DIR, `${scanKey}.json`), + JSON.stringify( + { + scanKey, + url: page.url(), + timestamp: results.timestamp, + axeVersion: results.testEngine?.version, + violations: results.violations, + incomplete: results.incomplete, + }, + null, + 2, + ), + ); + + const summary = results.violations.map(({ id, impact, description, nodes }) => ({ + id, + impact, + description, + nodeCount: nodes.length, + targets: nodes.slice(0, 5).map(({ target }) => target.join(' ')), + })); + await testInfo.attach(`a11y-${scanKey}`, { body: JSON.stringify(summary, null, 2), contentType: 'application/json' }); + + const gatedRuleIds = results.violations + .filter(({ impact }) => impact && GATED_IMPACTS.has(impact)) + .map(({ id }) => id) + .sort(); + + const baselineRuleIds = getBaseline()[scanKey]; + if (!baselineRuleIds) { + // No baseline yet for this scan — record-only so newly added scans can't break CI. + // Run scripts/a11y-merge-baseline.mjs over the results directory to add it to the ratchet. + console.warn(`[a11y] ${scanKey}: no baseline entry (record-only). serious/critical rules: ${gatedRuleIds.join(', ') || 'none'}`); + return results; + } + + const newRuleIds = gatedRuleIds.filter((ruleId) => !baselineRuleIds.includes(ruleId)); + expect(newRuleIds, `New serious/critical axe violations on "${scanKey}" (not in a11y-baseline.json): ${newRuleIds.join(', ')}`).toEqual( + [], + ); + + return results; +} diff --git a/apps/jetstream-e2e/src/tests/a11y/interactive-states.spec.ts b/apps/jetstream-e2e/src/tests/a11y/interactive-states.spec.ts new file mode 100644 index 000000000..ba603cb45 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/a11y/interactive-states.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '../../fixtures/fixtures'; +import { runA11yScan } from './a11y.utils'; + +test.describe.configure({ mode: 'parallel' }); + +/** + * Axe-core scans of interactive states that the page sweep can't see: open menus, populated + * comboboxes/listboxes and the query results data grid. Page-level scans only evaluate the + * closed/initial DOM, and most of the historically weak patterns (combobox listboxes, menu + * roving focus, grid semantics) only exist in the DOM once opened. + */ +test.describe('a11y interactive states', () => { + test('home page with navigation menu open', async ({ page }, testInfo) => { + await page.goto('/app'); + await page.getByTestId('header').getByRole('button', { name: 'Load Records' }).click(); + await expect(page.getByRole('menuitemcheckbox', { name: 'Load Records', exact: true })).toBeVisible(); + + await runA11yScan(page, testInfo, 'state-nav-menu-open'); + }); + + test('query builder with object list loaded', async ({ page, queryPage }, testInfo) => { + await queryPage.goto(); + await expect(queryPage.sobjectList.getByTestId('Account')).toBeVisible(); + + await runA11yScan(page, testInfo, 'state-query-sobject-list'); + }); + + test('query builder with object selected and fields visible', async ({ page, queryPage }, testInfo) => { + await queryPage.goto(); + await queryPage.selectObject('Account'); + await expect(queryPage.fieldsList.getByText('Account ID', { exact: true })).toBeVisible(); + + await runA11yScan(page, testInfo, 'state-query-fields-list'); + }); + + test('query results data grid', async ({ page, queryPage }, testInfo) => { + await queryPage.gotoResults('SELECT Id, Name, CreatedDate FROM Account LIMIT 10'); + await expect(page.getByRole('grid')).toBeVisible(); + + await runA11yScan(page, testInfo, 'state-query-results-grid'); + }); + + // TODO(a11y): extend with modal-open, date-picker-open and load-wizard step states once the + // baseline for the sweep + these four states is established. +}); diff --git a/apps/jetstream-e2e/src/tests/a11y/page-sweep.spec.ts b/apps/jetstream-e2e/src/tests/a11y/page-sweep.spec.ts new file mode 100644 index 000000000..69b881218 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/a11y/page-sweep.spec.ts @@ -0,0 +1,38 @@ +import { APP_ROUTES } from '@jetstream/shared/ui-router'; +import { expect, test } from '../../fixtures/fixtures'; +import { runA11yScan } from './a11y.utils'; + +test.describe.configure({ mode: 'parallel' }); + +/** + * Axe-core WCAG 2.1 A/AA sweep over every top-level application route. + * Each scan writes evidence JSON to a11y-results/ and ratchets against a11y-baseline.json + * (see a11y.utils.ts for the gating rules). + */ + +// TEAM_INVITE requires an invitation token and external routes live on getjetstream.app. +const EXCLUDED_ROUTES = new Set(['DESKTOP_APPLICATION', 'BROWSER_EXTENSION', 'TEAM_INVITE']); + +const routesToScan = Object.entries(APP_ROUTES).filter( + ([routeKey, { ROUTE }]) => !EXCLUDED_ROUTES.has(routeKey) && !ROUTE.startsWith('http'), +); + +test.describe('a11y page sweep', () => { + for (const [routeKey, { ROUTE, TITLE }] of routesToScan) { + test(`${routeKey} (${ROUTE})`, async ({ page, apiRequestUtils }, testInfo) => { + // Most tool pages render an org-required empty state without an org, which would make the + // scan meaningless — select the default org first so real page content is evaluated. + await apiRequestUtils.selectDefaultOrg(); + await page.goto(`/app${ROUTE}`); + + // Let async page content settle (metadata fetches, lazy chunks) before scanning, but don't + // fail the scan if long-polling keeps the network busy. + await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => undefined); + // The data-testid="header" wrapper is zero-height (hidden to Playwright), so wait on the + // visible banner landmark instead to confirm the app shell rendered. + await expect(page.getByRole('banner')).toBeVisible(); + + await runA11yScan(page, testInfo, `route-${routeKey}`); + }); + } +}); diff --git a/apps/jetstream-e2e/tsconfig.json b/apps/jetstream-e2e/tsconfig.json index 932eebb35..016a77b0e 100644 --- a/apps/jetstream-e2e/tsconfig.json +++ b/apps/jetstream-e2e/tsconfig.json @@ -40,6 +40,9 @@ { "path": "../../libs/api-types" }, + { + "path": "../../libs/shared/ui-router" + }, { "path": "../../libs/api-config" }, diff --git a/docs/accessibility/README.md b/docs/accessibility/README.md new file mode 100644 index 000000000..9c914b65d --- /dev/null +++ b/docs/accessibility/README.md @@ -0,0 +1,55 @@ +# Accessibility Program (WCAG 2.1 AA) + +Jetstream targets **WCAG 2.1 Level AA** using commercially reasonable efforts, and can produce an +Accessibility Conformance Report (ACR, using the ITI **VPAT 2.5 WCAG** template) on request. +This directory holds the audit evidence, findings, and the conformance report. + +## Layout + +- `audit-/findings.md` — the findings log: every audit finding with WCAG criterion, severity, and status. +- `vpat/` — the authored conformance report (`jetstream-acr-.md`) and exported copies delivered to customers. +- Raw axe-core scan evidence is generated into `apps/jetstream-e2e/a11y-results/` (gitignored) and uploaded + as the `a11y-results-*` CI artifacts on every E2E run. + +## Scope + +The primary scope is the **Jetstream web application**, which includes the Electron **desktop app** +(same React components from `libs/ui` + `libs/features`). The landing/auth site, docs site, and +browser extension are secondary surfaces covered by spot scans and noted separately in the ACR. + +## Automated checks + +| Layer | What | How to run | +| --------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| E2E page sweep | axe-core WCAG 2.1 A/AA scan of every app route + key interactive states | `pnpm playwright:test:a11y` (needs the E2E server; runs in CI inside the e2e shards) | +| Static surfaces | axe scan of arbitrary URLs (landing, docs) | `pnpm a11y:scan-urls ` or `--sitemap` | +| Component tests | `axeScan()` from `@jetstream/test-utils` in Vitest specs (see `libs/ui/src/lib/modal/__tests__/Modal.spec.tsx`) | `pnpm nx run ui:test` | +| Lint | oxlint `jsx-a11y` plugin (`.oxlintrc.json`) | `pnpm lint` | + +## The baseline ratchet + +E2E scans gate against `apps/jetstream-e2e/src/tests/a11y/a11y-baseline.json`: + +- A scan key **not** in the baseline is record-only (never fails CI) — new scans are safe to add. +- A scan key **in** the baseline fails CI only when a **new** serious/critical axe rule violation + appears that isn't already baselined. +- After a full run, `pnpm a11y:merge-baseline [resultsDirs...]` regenerates the baseline from the + results. The baseline should only ever shrink as findings are remediated — review the diff. + +Lint follows the same ratchet idea: `jsx-a11y` rules currently at `warn` in `.oxlintrc.json` are +promoted to `error` once their violation count reaches zero (see the census in the findings log). + +## Manual audit + +Automated tooling covers roughly a third of WCAG. Each audit cycle also includes, per representative flow: + +1. **Keyboard-only**: complete the task with no pointer; no traps, visible focus, sane order, Esc closes overlays. +2. **Screen reader**: VoiceOver (macOS) pass; names/roles/values, live-region announcements, form errors. +3. **Visual**: 200% zoom, 320px reflow (1.4.10), text-spacing override (1.4.12), contrast spot checks, `prefers-reduced-motion`. + +Record everything in the findings log, then update the VPAT. + +## Cadence + +Refresh the ACR annually and after major UI changes. The CI artifacts make the automated half of a +refresh nearly free; the manual pass is the real work. diff --git a/docs/accessibility/audit-2026/findings.md b/docs/accessibility/audit-2026/findings.md new file mode 100644 index 000000000..e1ea1d21a --- /dev/null +++ b/docs/accessibility/audit-2026/findings.md @@ -0,0 +1,68 @@ +# Accessibility Audit Findings — 2026 + +Status of every known WCAG 2.1 AA finding. Severity definitions: + +- **P1 (material)**: blocks or substantially impairs task completion for assistive-technology or keyboard users. These are the "material accessibility defects" in customer agreements. +- **P2**: degrades the experience but a workaround exists. +- **P3**: best-practice / polish; no significant user impact. + +Statuses: `open` → `in-progress` → `fixed (PR #)` | `accepted` (documented rationale, revisit annually). + +## Component findings (from code survey, 2026-08-26 — each needs manual verification before the VPAT is finalized) + +| # | Finding | WCAG | Severity | Where | Status | +| --- | ----------------------------------------------------------------------------------------------------------- | ------------ | -------- | -------------------------------------------------------------------------------- | ------ | +| C1 | Combobox never sets `aria-activedescendant`; active option is not announced during arrow-key navigation | 4.1.2 | P1 | `libs/ui/src/lib/form/combobox/Combobox.tsx` | open | +| C2 | Tabs have correct roles but no arrow-key roving tabindex | 2.1.1 | P1 | `libs/ui/src/lib/tabs/Tabs.tsx` | open | +| C3 | TimePicker has no ARIA attributes and no keyboard handling | 2.1.1, 4.1.2 | P1 | `libs/ui/src/lib/form/time-picker/TimePicker.tsx` | open | +| C4 | No skip link in any user-facing app | 2.4.1 | P1 | app shells (web, desktop, landing, extension) | open | +| C5 | Input/Select don't set `aria-invalid`; error association depends on the caller | 3.3.1, 4.1.2 | P1 | `libs/ui/src/lib/form/input/Input.tsx`, `form/select/Select.tsx` | open | +| C6 | Toast uses bare `role="status"`; no `aria-atomic`, no dismissal-timing consideration | 4.1.3, 2.2.1 | P2 | `libs/ui/src/lib/toast/Toast.tsx` | open | +| C7 | DatePicker popup/trigger ARIA contract is thin (DateGrid itself has roving focus) | 4.1.2 | P2 | `libs/ui/src/lib/form/date/DatePicker.tsx` | open | +| C8 | Expression builder drag-and-drop (dnd-kit) keyboard alternative unverified | 2.1.1 | P2 | `libs/ui/src/lib/expression-group/` | open | +| C9 | Monaco editor needs `accessibilitySupport` configuration and a documented Esc-to-escape-tab-trap affordance | 2.1.2 | P2 | `libs/shared/ui-core/src/app/MonacoEditor.tsx` | open | +| C10 | Virtualized lists/grid: verify aria-rowcount vs rendered rows and off-screen focus targets | 4.1.2 | P2 | `libs/ui/src/lib/form/combobox/ComboboxWithItemsVirtual.tsx`, `data-table/grid/` | open | + +## Accepted findings + +| # | Finding | Rationale | Status | +| --- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| A1 | Floating UI focus-guard sentinels (`` with no accessible name) trip axe `button-name`/`aria-command-name` | Library-internal focus redirectors; focus never rests on them. Filtered in `axeScan()` (`libs/test-utils/src/lib/a11y-test-utils.ts`). Revisit on @floating-ui/react upgrades. | accepted | + +## Lint census (oxlint jsx-a11y warn-tier rules, 2026-08-26) + +137 warnings across 67 files. Promote each rule to `error` in `.oxlintrc.json` when its count hits zero. + +| Rule | Count | +| --------------------------------------------- | ------------------- | +| click-events-have-key-events | 47 | +| no-static-element-interactions | 39 | +| control-has-associated-label | 18 | +| no-noninteractive-element-interactions | 14 | +| no-noninteractive-element-to-interactive-role | 12 | +| interactive-supports-focus | 3 | +| autocomplete-valid | 3 | +| label-has-associated-control | 1 | +| no-redundant-roles | 0 — **promote now** | + +## Automated scan findings (axe, 2026-08-26 — 30 scans: 26 routes + 4 interactive states) + +89 baselined serious/critical rule entries across 30 scans, but only 11 distinct rules, and the two +critical ones that appear on **every** page trace to a handful of shared components — fixing those +clears most of the baseline at once. + +| # | axe rule | Impact | Where seen | Root cause | Severity | +| --- | ------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| X1 | `aria-valid-attr-value` | critical | all 30 scans | Combobox input `aria-controls` references the listbox id while the listbox is closed/not in the DOM (7 variants, all `libs/ui` Combobox — incl. the header org selector). Set `aria-controls` only when open, or keep the listbox rendered. | P1 | +| X2 | `button-name` | critical | all 30 scans | Icon-only `slds-button_icon` buttons with no accessible name — 13 distinct elements (org info popover trigger, dropdown triggers, row action buttons). Add `aria-label`/assistive text; audit the shared Icon-button call sites. | P1 | +| X3 | `aria-input-field-name` | serious | 13 scans | `