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.');
|