diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 589943f208..acd638b235 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -39,6 +39,14 @@ jobs: uses: ./.github/actions/setup-node - name: Check Types run: pnpm run check + check-a11y: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout and Setup Node + uses: ./.github/actions/setup-node + - name: Assert no Svelte a11y warnings + run: pnpm run check:a11y unit-tests: runs-on: ubuntu-latest steps: diff --git a/CLAUDE.md b/CLAUDE.md index e96847edf5..6830c8c3cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,18 @@ const { form, errors, enhance } = $derived( 3. **Test execution**: Run appropriate test suites based on changes 4. **Follow patterns**: Use existing component patterns and utility functions 5. **Design system**: Prefer Holocene components over custom implementations -6. **Accessibility**: Ensure proper ARIA attributes and semantic HTML +6. **Accessibility**: Enforced, not optional — see the Accessibility section below; run `pnpm check:a11y` before pushing UI changes. + +## Accessibility + +Svelte compiler accessibility warnings are gated as **CI errors** via `pnpm check:a11y` (the `check-a11y` job) — a PR that introduces an `a11y_*` warning fails. Run it locally before pushing. + +- **Compose Holocene primitives; don't hand-roll interactive elements.** `Button`, `Input`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Tooltip`, `Modal`, `Drawer`, `Menu`, `Table`, `Accordion` own the a11y contract (name, role, keyboard, focus). A `
`, ``, or CSS `:after` "tooltip" is the top source of audit findings. +- **Every interactive control needs** an accessible name, a keyboard path (Enter/Space, Escape to dismiss), and a visible focus indicator. Icon-only buttons get an `aria-label`. Form controls get a real (non-empty) label; forward native `required`; set `aria-invalid`/`aria-describedby` on the focusable control. +- **Don't signal state by color alone; live regions must pre-exist empty; `` needs `scope`; DOM order must equal visual order (no `flex-*-reverse`).** +- axe (Storybook `addon-a11y`, Playwright `tests/accessibility/`) is a floor — it can't see keyboard operability, focus order, or name quality. Keyboard-walk changes and verify announcements with a screen reader. + +Audit/review conventions (manifest, PR labels, per-SC authoring & review guidance) live in the `winston-claude-skills` a11y skills. ## Code Generation diff --git a/package.json b/package.json index 1664d178d8..126cc1cf67 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "format": "pnpm prettier:fix; pnpm eslint:fix; pnpm stylelint:fix", "check": "VITE_TEMPORAL_UI_BUILD_TARGET=local svelte-check --tsconfig ./tsconfig.json --tsgo --incremental", "check:watch": "VITE_TEMPORAL_UI_BUILD_TARGET=local svelte-check --tsconfig ./tsconfig.json --tsgo --incremental --watch", + "check:a11y": "node ./scripts/a11y/assert-no-a11y-warnings.mjs", "prettier": "prettier --check --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss .", "prettier:fix": "prettier --write --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss .", "preview:local": "VITE_TEMPORAL_UI_BUILD_TARGET=local vite preview", diff --git a/scripts/a11y/assert-no-a11y-warnings.mjs b/scripts/a11y/assert-no-a11y-warnings.mjs new file mode 100644 index 0000000000..ca455a700e --- /dev/null +++ b/scripts/a11y/assert-no-a11y-warnings.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Gate: fail if svelte-check surfaces any Svelte accessibility warning (code `a11y_*`). +// +// Svelte emits a11y issues as compiler *warnings*, which svelte-check does not +// fail on by default. `--fail-on-warnings` can't be used here because the repo +// carries unrelated non-a11y warnings, and Svelte 5's `compilerOptions.warningFilter` +// only suppresses warnings, it can't escalate them. So this script runs svelte-check, +// keeps every other warning as-is, and fails *only* on `a11y_*` codes — turning +// accessibility warnings into a CI error without touching anything else. +import { spawnSync } from 'node:child_process'; + +const result = spawnSync( + 'svelte-check', + ['--tsconfig', './tsconfig.json', '--output', 'machine'], + { + encoding: 'utf8', + shell: true, + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env, VITE_TEMPORAL_UI_BUILD_TARGET: 'local' }, + }, +); + +const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + +// Svelte 5 a11y warning codes are all `a11y_*` (e.g. a11y_missing_attribute) and +// appear in the diagnostic message / doc URL. Match the code specifically so a path +// segment like `scripts/a11y/` never produces a false positive. +const a11yDiagnostics = output + .split('\n') + .filter((line) => /\b(ERROR|WARNING)\b/.test(line) && /a11y_/.test(line)); + +if (a11yDiagnostics.length > 0) { + console.error( + `\n✖ ${a11yDiagnostics.length} Svelte accessibility warning(s) — a11y warnings are gated as errors:\n`, + ); + for (const line of a11yDiagnostics) console.error(` ${line.trim()}`); + console.error( + '\nFix the accessibility issues above (prefer a Holocene primitive; see CLAUDE.md → Accessibility).\n', + ); + process.exit(1); +} + +if (result.status !== 0 && output.trim() === '') { + console.error( + 'svelte-check produced no output and exited non-zero — cannot verify a11y state.', + ); + process.exit(result.status ?? 1); +} + +console.log('✓ svelte-check surfaced no Svelte a11y warnings.');