specweave 1.0.239 → 1.0.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/CLAUDE.md +31 -30
  2. package/README.md +1 -1
  3. package/bin/specweave.js +16 -0
  4. package/dist/plugins/specweave-ado/lib/ado-permission-gate.d.ts.map +1 -1
  5. package/dist/plugins/specweave-ado/lib/ado-permission-gate.js +17 -2
  6. package/dist/plugins/specweave-ado/lib/ado-permission-gate.js.map +1 -1
  7. package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts +7 -0
  8. package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
  9. package/dist/plugins/specweave-github/lib/github-feature-sync.js +53 -0
  10. package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
  11. package/dist/plugins/specweave-jira/lib/jira-permission-gate.d.ts.map +1 -1
  12. package/dist/plugins/specweave-jira/lib/jira-permission-gate.js +17 -2
  13. package/dist/plugins/specweave-jira/lib/jira-permission-gate.js.map +1 -1
  14. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts +1 -0
  15. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.d.ts.map +1 -1
  16. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js +7 -3
  17. package/dist/plugins/specweave-testing/lib/playwright-cli-detector.js.map +1 -1
  18. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.d.ts.map +1 -1
  19. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js +27 -19
  20. package/dist/plugins/specweave-testing/lib/playwright-cli-runner.js.map +1 -1
  21. package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts +8 -0
  22. package/dist/plugins/specweave-testing/lib/playwright-routing.d.ts.map +1 -1
  23. package/dist/plugins/specweave-testing/lib/playwright-routing.js +10 -7
  24. package/dist/plugins/specweave-testing/lib/playwright-routing.js.map +1 -1
  25. package/dist/src/adapters/agents-md-generator.js +1 -1
  26. package/dist/src/adapters/agents-md-generator.js.map +1 -1
  27. package/dist/src/adapters/claude/README.md +1 -1
  28. package/dist/src/adapters/claude-md-generator.js +1 -1
  29. package/dist/src/adapters/claude-md-generator.js.map +1 -1
  30. package/dist/src/cli/commands/init.d.ts.map +1 -1
  31. package/dist/src/cli/commands/init.js +10 -1
  32. package/dist/src/cli/commands/init.js.map +1 -1
  33. package/dist/src/cli/commands/refresh-marketplace.d.ts.map +1 -1
  34. package/dist/src/cli/commands/refresh-marketplace.js +7 -67
  35. package/dist/src/cli/commands/refresh-marketplace.js.map +1 -1
  36. package/dist/src/cli/commands/team.d.ts +20 -0
  37. package/dist/src/cli/commands/team.d.ts.map +1 -0
  38. package/dist/src/cli/commands/team.js +101 -0
  39. package/dist/src/cli/commands/team.js.map +1 -0
  40. package/dist/src/cli/helpers/init/claude-settings-env.d.ts +16 -0
  41. package/dist/src/cli/helpers/init/claude-settings-env.d.ts.map +1 -0
  42. package/dist/src/cli/helpers/init/claude-settings-env.js +44 -0
  43. package/dist/src/cli/helpers/init/claude-settings-env.js.map +1 -0
  44. package/dist/src/cli/helpers/init/plugin-installer.d.ts.map +1 -1
  45. package/dist/src/cli/helpers/init/plugin-installer.js +9 -13
  46. package/dist/src/cli/helpers/init/plugin-installer.js.map +1 -1
  47. package/dist/src/cli/helpers/issue-tracker/index.d.ts.map +1 -1
  48. package/dist/src/cli/helpers/issue-tracker/index.js +12 -6
  49. package/dist/src/cli/helpers/issue-tracker/index.js.map +1 -1
  50. package/dist/src/cli/helpers/issue-tracker/types.d.ts +2 -0
  51. package/dist/src/cli/helpers/issue-tracker/types.d.ts.map +1 -1
  52. package/dist/src/cli/helpers/issue-tracker/types.js.map +1 -1
  53. package/dist/src/core/increment/discipline-checker.js +1 -1
  54. package/dist/src/core/increment/discipline-checker.js.map +1 -1
  55. package/dist/src/core/increment/status-commands.d.ts.map +1 -1
  56. package/dist/src/core/increment/status-commands.js +7 -0
  57. package/dist/src/core/increment/status-commands.js.map +1 -1
  58. package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts +2 -2
  59. package/dist/src/core/lazy-loading/llm-plugin-detector.d.ts.map +1 -1
  60. package/dist/src/core/lazy-loading/llm-plugin-detector.js +63 -25
  61. package/dist/src/core/lazy-loading/llm-plugin-detector.js.map +1 -1
  62. package/dist/src/core/reflection/reflect-handler.js +2 -2
  63. package/dist/src/core/reflection/reflect-handler.js.map +1 -1
  64. package/dist/src/core/session/handoff-context.js +2 -2
  65. package/dist/src/core/session/handoff-context.js.map +1 -1
  66. package/dist/src/sync/ado-reconciler.d.ts.map +1 -1
  67. package/dist/src/sync/ado-reconciler.js +21 -2
  68. package/dist/src/sync/ado-reconciler.js.map +1 -1
  69. package/dist/src/sync/engine.d.ts.map +1 -1
  70. package/dist/src/sync/engine.js +2 -0
  71. package/dist/src/sync/engine.js.map +1 -1
  72. package/dist/src/sync/github-reconciler.d.ts.map +1 -1
  73. package/dist/src/sync/github-reconciler.js +52 -26
  74. package/dist/src/sync/github-reconciler.js.map +1 -1
  75. package/dist/src/sync/jira-reconciler.d.ts.map +1 -1
  76. package/dist/src/sync/jira-reconciler.js +16 -3
  77. package/dist/src/sync/jira-reconciler.js.map +1 -1
  78. package/dist/src/sync/providers/ado.d.ts.map +1 -1
  79. package/dist/src/sync/providers/ado.js +4 -2
  80. package/dist/src/sync/providers/ado.js.map +1 -1
  81. package/dist/src/sync/providers/github.d.ts.map +1 -1
  82. package/dist/src/sync/providers/github.js +11 -0
  83. package/dist/src/sync/providers/github.js.map +1 -1
  84. package/dist/src/sync/providers/jira.d.ts.map +1 -1
  85. package/dist/src/sync/providers/jira.js +14 -2
  86. package/dist/src/sync/providers/jira.js.map +1 -1
  87. package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
  88. package/dist/src/sync/sync-coordinator.js +31 -6
  89. package/dist/src/sync/sync-coordinator.js.map +1 -1
  90. package/dist/src/utils/auto-install.js +4 -4
  91. package/dist/src/utils/auto-install.js.map +1 -1
  92. package/package.json +2 -2
  93. package/plugins/FINAL-AUDIT-RECOMMENDATIONS.md +3 -3
  94. package/plugins/SKILLS-VS-AGENTS.md +1 -1
  95. package/plugins/specweave/PLUGIN.md +0 -2
  96. package/plugins/specweave/commands/export-skills.md +1 -1
  97. package/plugins/specweave/commands/role-orchestrator.md +1 -1
  98. package/plugins/specweave/hooks/log-decision.sh +6 -0
  99. package/plugins/specweave/hooks/stop-auto-v5.sh +17 -1
  100. package/plugins/specweave/hooks/stop-reflect.sh +16 -2
  101. package/plugins/specweave/hooks/stop-sync.sh +17 -9
  102. package/plugins/specweave/hooks/user-prompt-submit.sh +119 -35
  103. package/plugins/specweave/lib/vendor/sync/github-reconciler.js +52 -26
  104. package/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -1
  105. package/plugins/specweave/scripts/read-grill-context.sh +149 -0
  106. package/plugins/specweave/skills/code-review/SKILL.md +608 -0
  107. package/plugins/specweave/skills/done/SKILL.md +1 -1
  108. package/plugins/specweave/skills/grill/SKILL.md +91 -0
  109. package/plugins/specweave/skills/performance/SKILL.md +6 -0
  110. package/plugins/specweave/skills/security/SKILL.md +7 -0
  111. package/plugins/specweave/skills/security-patterns/SKILL.md +6 -0
  112. package/plugins/specweave/skills/tdd-orchestrator/SKILL.md +1 -1
  113. package/plugins/specweave/skills/team-build/SKILL.md +1 -1
  114. package/plugins/specweave/skills/team-orchestrate/SKILL.md +1 -1
  115. package/plugins/specweave/skills/tech-lead/SKILL.md +7 -0
  116. package/plugins/specweave-ado/lib/ado-permission-gate.js +18 -2
  117. package/plugins/specweave-ado/lib/ado-permission-gate.ts +19 -2
  118. package/plugins/specweave-frontend/skills/frontend/SKILL.md +138 -2
  119. package/plugins/specweave-frontend/skills/i18n-expert/SKILL.md +989 -0
  120. package/plugins/specweave-github/hooks/github-auto-create-handler.sh +23 -1
  121. package/plugins/specweave-github/lib/github-feature-sync.js +41 -0
  122. package/plugins/specweave-github/lib/github-feature-sync.ts +62 -0
  123. package/plugins/specweave-infrastructure/PLUGIN.md +2 -1
  124. package/plugins/specweave-infrastructure/skills/gcp-deep-dive/SKILL.md +1172 -0
  125. package/plugins/specweave-infrastructure/skills/observability/SKILL.md +6 -0
  126. package/plugins/specweave-infrastructure/skills/opentelemetry/SKILL.md +6 -0
  127. package/plugins/specweave-jira/lib/jira-permission-gate.js +18 -2
  128. package/plugins/specweave-jira/lib/jira-permission-gate.ts +19 -2
  129. package/plugins/specweave-mobile/PLUGIN.md +1 -2
  130. package/plugins/specweave-mobile/README.md +13 -12
  131. package/plugins/specweave-mobile/skills/capacitor-ionic/SKILL.md +4 -18
  132. package/plugins/specweave-mobile/skills/deep-linking-push/SKILL.md +4 -22
  133. package/plugins/specweave-mobile/skills/expo/SKILL.md +4 -24
  134. package/plugins/specweave-mobile/skills/mobile-testing/SKILL.md +4 -22
  135. package/plugins/specweave-mobile/skills/react-native-expert/SKILL.md +404 -47
  136. package/plugins/specweave-testing/PLUGIN.md +3 -11
  137. package/plugins/specweave-testing/lib/playwright-cli-detector.js +8 -3
  138. package/plugins/specweave-testing/lib/playwright-cli-detector.ts +8 -3
  139. package/plugins/specweave-testing/lib/playwright-cli-runner.js +25 -20
  140. package/plugins/specweave-testing/lib/playwright-cli-runner.ts +24 -19
  141. package/plugins/specweave-testing/lib/playwright-routing.js +1 -6
  142. package/plugins/specweave-testing/lib/playwright-routing.ts +11 -8
  143. package/plugins/specweave-testing/skills/accessibility-testing/SKILL.md +998 -0
  144. package/plugins/specweave-testing/skills/e2e-testing/SKILL.md +29 -28
  145. package/plugins/specweave-testing/skills/mutation-testing/SKILL.md +769 -0
  146. package/plugins/specweave-testing/skills/performance-testing/SKILL.md +961 -0
  147. package/plugins/specweave-testing/skills/qa-engineer/SKILL.md +2 -0
  148. package/plugins/specweave/.specweave/logs/decisions.jsonl +0 -12
  149. package/plugins/specweave/.specweave/logs/reflect/reflect.log +0 -8
  150. package/plugins/specweave/.specweave/logs/stop-auto.log +0 -6
  151. package/plugins/specweave/.specweave/logs/stop-sync.log +0 -10
  152. package/plugins/specweave/.specweave/state/dashboard.json +0 -43
  153. package/plugins/specweave/skills/infrastructure/SKILL.md +0 -86
  154. package/plugins/specweave/skills/qa-lead/SKILL.md +0 -77
  155. package/plugins/specweave-mobile/skills/mobile-architect/SKILL.md +0 -30
  156. package/plugins/specweave-testing/commands/e2e-setup.md +0 -1103
  157. package/plugins/specweave-testing/commands/test-coverage.md +0 -983
  158. package/plugins/specweave-testing/commands/test-generate.md +0 -1160
  159. package/plugins/specweave-testing/commands/test-init.md +0 -413
  160. package/plugins/specweave-testing/commands/ui-automate.md +0 -182
  161. package/plugins/specweave-testing/commands/ui-inspect.md +0 -82
@@ -0,0 +1,998 @@
1
+ ---
2
+ description: Accessibility testing expert for WCAG compliance, axe-core, pa11y, Lighthouse, screen reader testing, keyboard navigation, and CI/CD a11y gates. Use for accessibility audits, a11y test automation, WCAG compliance, screen reader testing, keyboard navigation, color contrast, ARIA patterns, or form accessibility.
3
+ allowed-tools: Read, Write, Edit, Bash
4
+ model: opus
5
+ context: fork
6
+ ---
7
+
8
+ # Accessibility Testing Expert
9
+
10
+ You are an accessibility testing expert with deep knowledge of WCAG standards, automated a11y tooling, assistive technology testing, and inclusive design validation.
11
+
12
+ ## When to Use
13
+
14
+ Trigger this skill for: "accessibility", "a11y", "WCAG", "screen reader", "axe", "axe-core", "pa11y", "keyboard navigation", "color contrast", "ARIA", "focus management", "touch target", "Lighthouse accessibility", "VoiceOver", "NVDA", "TalkBack", "skip navigation", "focus trap", "reduced motion", "form accessibility", "label association".
15
+
16
+ ---
17
+
18
+ ## 1. Automated Accessibility Testing
19
+
20
+ ### axe-core + Playwright
21
+
22
+ ```bash
23
+ npm install -D @axe-core/playwright axe-core
24
+ ```
25
+
26
+ #### Full Page Audit
27
+
28
+ ```typescript
29
+ import { test, expect } from '@playwright/test';
30
+ import AxeBuilder from '@axe-core/playwright';
31
+
32
+ test.describe('Accessibility', () => {
33
+ test('homepage has no WCAG 2.1 AA violations', async ({ page }) => {
34
+ await page.goto('/');
35
+
36
+ const results = await new AxeBuilder({ page })
37
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
38
+ .analyze();
39
+
40
+ expect(results.violations).toEqual([]);
41
+ });
42
+
43
+ test('login page is accessible', async ({ page }) => {
44
+ await page.goto('/login');
45
+
46
+ const results = await new AxeBuilder({ page })
47
+ .withTags(['wcag2a', 'wcag2aa'])
48
+ .exclude('.third-party-widget')
49
+ .analyze();
50
+
51
+ expect(results.violations).toEqual([]);
52
+ });
53
+ });
54
+ ```
55
+
56
+ #### Scoped and Dynamic Content Audits
57
+
58
+ ```typescript
59
+ // Scoped to a specific region
60
+ test('navigation is accessible', async ({ page }) => {
61
+ await page.goto('/');
62
+ const results = await new AxeBuilder({ page })
63
+ .include('[role="navigation"]')
64
+ .withTags(['wcag2a', 'wcag2aa'])
65
+ .analyze();
66
+ expect(results.violations).toEqual([]);
67
+ });
68
+
69
+ // After interaction (modals, dropdowns)
70
+ test('modal is accessible after opening', async ({ page }) => {
71
+ await page.goto('/dashboard');
72
+ await page.getByRole('button', { name: 'Settings' }).click();
73
+ await page.waitForSelector('[role="dialog"]');
74
+
75
+ const results = await new AxeBuilder({ page })
76
+ .include('[role="dialog"]')
77
+ .withTags(['wcag2a', 'wcag2aa'])
78
+ .analyze();
79
+ expect(results.violations).toEqual([]);
80
+ });
81
+ ```
82
+
83
+ #### Custom Rules and Violation Reporting
84
+
85
+ ```typescript
86
+ // Disable specific rules when justified
87
+ const results = await new AxeBuilder({ page })
88
+ .withTags(['wcag2a', 'wcag2aa'])
89
+ .disableRules(['color-contrast'])
90
+ .options({ rules: { 'region': { enabled: true } } })
91
+ .analyze();
92
+
93
+ // Detailed violation logging
94
+ if (results.violations.length > 0) {
95
+ const report = results.violations.map((v) => ({
96
+ rule: v.id, impact: v.impact, description: v.description,
97
+ helpUrl: v.helpUrl,
98
+ nodes: v.nodes.map((n) => ({ html: n.html, target: n.target, failureSummary: n.failureSummary })),
99
+ }));
100
+ console.error('A11y violations:', JSON.stringify(report, null, 2));
101
+ }
102
+ ```
103
+
104
+ ### axe-core + Jest / Vitest (Component Testing)
105
+
106
+ ```bash
107
+ npm install -D jest-axe @types/jest-axe
108
+ ```
109
+
110
+ ```typescript
111
+ import { render } from '@testing-library/react';
112
+ import { axe, toHaveNoViolations } from 'jest-axe';
113
+
114
+ expect.extend(toHaveNoViolations);
115
+
116
+ describe('Button component', () => {
117
+ it('should have no accessibility violations', async () => {
118
+ const { container } = render(
119
+ <button type="button" aria-label="Close dialog">X</button>
120
+ );
121
+ const results = await axe(container);
122
+ expect(results).toHaveNoViolations();
123
+ });
124
+ });
125
+ ```
126
+
127
+ ### pa11y CLI and CI Integration
128
+
129
+ ```bash
130
+ npm install -D pa11y pa11y-ci
131
+
132
+ # Single page audit
133
+ npx pa11y --standard WCAG2AA https://example.com
134
+
135
+ # JSON output for CI
136
+ npx pa11y --reporter json https://example.com > a11y-report.json
137
+ ```
138
+
139
+ #### pa11y-ci Configuration
140
+
141
+ ```json
142
+ // .pa11yci.json
143
+ {
144
+ "defaults": {
145
+ "standard": "WCAG2AA",
146
+ "timeout": 30000,
147
+ "chromeLaunchConfig": { "args": ["--no-sandbox"] }
148
+ },
149
+ "urls": [
150
+ "http://localhost:3000/",
151
+ "http://localhost:3000/login",
152
+ {
153
+ "url": "http://localhost:3000/settings",
154
+ "actions": ["wait for element #settings-form to be visible"]
155
+ }
156
+ ]
157
+ }
158
+ ```
159
+
160
+ ### Lighthouse Accessibility Scoring
161
+
162
+ ```bash
163
+ # CLI audit
164
+ npx lighthouse https://example.com \
165
+ --only-categories=accessibility \
166
+ --chrome-flags="--headless --no-sandbox" \
167
+ --output=html --output-path=./a11y-report.html
168
+ ```
169
+
170
+ #### Lighthouse CI Configuration
171
+
172
+ ```json
173
+ // lighthouserc.json
174
+ {
175
+ "ci": {
176
+ "collect": {
177
+ "url": ["http://localhost:3000/", "http://localhost:3000/login"],
178
+ "startServerCommand": "npm run start",
179
+ "numberOfRuns": 3
180
+ },
181
+ "assert": {
182
+ "assertions": {
183
+ "categories:accessibility": ["error", { "minScore": 0.9 }]
184
+ }
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ ---
191
+
192
+ ## 2. WCAG 2.1 Compliance Testing
193
+
194
+ ### Level AA Requirements Checklist
195
+
196
+ #### Perceivable
197
+
198
+ | Criterion | ID | Test Method |
199
+ |---|---|---|
200
+ | Non-text content has text alternatives | 1.1.1 | axe: `image-alt`, `input-image-alt`, `area-alt` |
201
+ | Captions for prerecorded audio/video | 1.2.1-1.2.5 | Manual review |
202
+ | Audio description for video | 1.2.3, 1.2.5 | Manual review |
203
+ | Info not conveyed by color alone | 1.3.1 | axe: `color-contrast`, manual review |
204
+ | Meaningful sequence preserved | 1.3.2 | Manual: linearize page, verify reading order |
205
+ | Sensory characteristics not sole instruction | 1.3.3 | Manual review |
206
+ | Content orientation not restricted | 1.3.4 | Rotate device/viewport, verify layout |
207
+ | Input purpose identifiable | 1.3.5 | Check `autocomplete` attributes on form fields |
208
+ | Contrast ratio >= 4.5:1 (text) | 1.4.3 | axe: `color-contrast` |
209
+ | Text resizable to 200% without loss | 1.4.4 | Browser zoom to 200%, verify no content loss |
210
+ | No images of text | 1.4.5 | Manual review |
211
+ | Reflow at 320px width | 1.4.10 | Set viewport to 320px, verify no horizontal scroll |
212
+ | Non-text contrast >= 3:1 | 1.4.11 | Manual: check UI component and graphic borders |
213
+ | Text spacing adjustable | 1.4.12 | Apply text spacing override, verify no content loss |
214
+ | Hover/focus content dismissible | 1.4.13 | Manual: test tooltips, popovers |
215
+
216
+ #### Operable
217
+
218
+ | Criterion | ID | Test Method |
219
+ |---|---|---|
220
+ | All functionality via keyboard | 2.1.1 | Tab through entire page |
221
+ | No keyboard traps | 2.1.2 | Verify Escape/Tab can leave all components |
222
+ | Timing adjustable or removable | 2.2.1 | Check for session timeouts |
223
+ | Pause, stop, hide moving content | 2.2.2 | Verify animations can be paused |
224
+ | No content flashes > 3/sec | 2.3.1 | Visual inspection |
225
+ | Skip navigation mechanism | 2.4.1 | Tab from top, verify skip link |
226
+ | Pages have descriptive titles | 2.4.2 | Check `<title>` per page |
227
+ | Logical focus order | 2.4.3 | Tab through and verify order |
228
+ | Link purpose clear from text | 2.4.4 | Review link text (no "click here") |
229
+ | Multiple ways to find pages | 2.4.5 | Verify sitemap, search, or nav |
230
+ | Headings and labels descriptive | 2.4.6 | Review heading hierarchy |
231
+ | Focus indicator visible | 2.4.7 | Tab through, verify focus rings |
232
+ | Pointer gestures have alternatives | 2.5.1 | Test without multi-touch |
233
+ | Pointer cancellation supported | 2.5.2 | Verify action on up-event |
234
+ | Label in name matches visible text | 2.5.3 | Compare `aria-label` with visible text |
235
+ | Motion actuation has alternatives | 2.5.4 | Test without device motion |
236
+
237
+ #### Understandable
238
+
239
+ | Criterion | ID | Test Method |
240
+ |---|---|---|
241
+ | Page language defined | 3.1.1 | Check `<html lang="...">`, axe: `html-has-lang` |
242
+ | Parts in different language marked | 3.1.2 | Check `lang` on foreign-language elements |
243
+ | Consistent navigation | 3.2.3 | Compare nav across pages |
244
+ | Consistent identification | 3.2.4 | Same function = same label across pages |
245
+ | Error identified and described | 3.3.1 | Submit invalid form, verify error text |
246
+ | Labels or instructions provided | 3.3.2 | Check form labels, axe: `label` |
247
+ | Error suggestion provided | 3.3.3 | Submit invalid input, check suggestions |
248
+ | Error prevention on legal/financial | 3.3.4 | Verify confirm/review step |
249
+ | On focus: no context change | 3.2.1 | Tab to elements, verify stability |
250
+ | On input: no context change | 3.2.2 | Change inputs, verify no unexpected navigation |
251
+
252
+ #### Robust
253
+
254
+ | Criterion | ID | Test Method |
255
+ |---|---|---|
256
+ | Valid HTML parsing | 4.1.1 | HTML validator (W3C), axe: `duplicate-id` |
257
+ | Name, role, value for components | 4.1.2 | axe: `aria-roles`, `aria-valid-attr` |
258
+ | Status messages programmatically exposed | 4.1.3 | Test `role="status"`, `role="alert"` |
259
+
260
+ ### Level AAA Notable Criteria
261
+
262
+ Not typically required, but worth targeting for enhanced accessibility:
263
+
264
+ - **1.4.6** Enhanced contrast (7:1)
265
+ - **1.4.8** Visual presentation (line length, spacing)
266
+ - **2.4.9** Link purpose from link text alone
267
+ - **2.4.10** Section headings used
268
+ - **2.5.5** Touch target size 44x44px
269
+
270
+ ```typescript
271
+ test('enhanced contrast AAA', async ({ page }) => {
272
+ await page.goto('/');
273
+ const results = await new AxeBuilder({ page }).withTags(['wcag2aaa']).analyze();
274
+ if (results.violations.length > 0) {
275
+ console.warn('AAA violations:', results.violations.map((v) => v.id));
276
+ }
277
+ });
278
+ ```
279
+
280
+ ### Testing by WCAG Principle
281
+
282
+ ```typescript
283
+ // Perceivable: images have alt text
284
+ test('images have alt text', async ({ page }) => {
285
+ await page.goto('/');
286
+ const images = await page.locator('img').all();
287
+ for (const img of images) {
288
+ const alt = await img.getAttribute('alt');
289
+ const role = await img.getAttribute('role');
290
+ expect(alt !== null || role === 'presentation').toBeTruthy();
291
+ }
292
+ });
293
+
294
+ // Understandable: page language defined
295
+ test('page language defined', async ({ page }) => {
296
+ await page.goto('/');
297
+ const lang = await page.locator('html').getAttribute('lang');
298
+ expect(lang).toBeTruthy();
299
+ expect(lang!.length).toBeGreaterThanOrEqual(2);
300
+ });
301
+
302
+ // Robust: no duplicate IDs
303
+ test('no duplicate element IDs', async ({ page }) => {
304
+ await page.goto('/');
305
+ const duplicates = await page.evaluate(() => {
306
+ const ids = Array.from(document.querySelectorAll('[id]')).map((el) => el.id);
307
+ return ids.filter((id, i) => ids.indexOf(id) !== i);
308
+ });
309
+ expect(duplicates).toEqual([]);
310
+ });
311
+ ```
312
+
313
+ ---
314
+
315
+ ## 3. Screen Reader Testing Patterns
316
+
317
+ ### VoiceOver (macOS)
318
+
319
+ **Activation**: Cmd + F5
320
+
321
+ | Action | Keys |
322
+ |---|---|
323
+ | Start/stop VoiceOver | Cmd + F5 |
324
+ | Read next item | VO + Right Arrow (VO = Ctrl + Option) |
325
+ | Read previous item | VO + Left Arrow |
326
+ | Activate element | VO + Space |
327
+ | Heading list (rotor) | VO + U, then left/right to headings |
328
+ | Landmark list (rotor) | VO + U, navigate to landmarks |
329
+
330
+ **Manual testing checklist**: Navigate in Safari, verify headings/landmarks in rotor, check form label announcements, verify alt text on images, confirm live region updates, verify focus returns after dialog close.
331
+
332
+ ### NVDA (Windows)
333
+
334
+ | Action | Keys |
335
+ |---|---|
336
+ | Start/stop NVDA | Ctrl + Alt + N |
337
+ | Read next item | Down Arrow (browse mode) |
338
+ | Heading list | NVDA + F7 |
339
+ | Next heading | H |
340
+ | Next landmark | D |
341
+ | Forms mode | Enter (on form field) |
342
+ | Browse mode | Escape |
343
+
344
+ ### TalkBack (Android)
345
+
346
+ | Action | Gesture |
347
+ |---|---|
348
+ | Read next item | Swipe right |
349
+ | Read previous item | Swipe left |
350
+ | Activate | Double tap |
351
+ | Scroll | Two-finger swipe |
352
+
353
+ ### Testing Live Regions
354
+
355
+ ```typescript
356
+ test('status updates announced via live region', async ({ page }) => {
357
+ await page.goto('/form');
358
+ const liveRegion = page.locator('[aria-live="polite"], [role="status"]');
359
+ await expect(liveRegion).toBeAttached();
360
+
361
+ await page.getByRole('button', { name: 'Save' }).click();
362
+ await expect(liveRegion).toHaveText(/saved successfully/i);
363
+ });
364
+
365
+ test('error alerts use assertive live region', async ({ page }) => {
366
+ await page.goto('/form');
367
+ const alertRegion = page.locator('[aria-live="assertive"], [role="alert"]');
368
+ await page.getByRole('button', { name: 'Submit' }).click();
369
+ await expect(alertRegion).toContainText(/required/i);
370
+ });
371
+ ```
372
+
373
+ ### Testing ARIA Labels
374
+
375
+ ```typescript
376
+ test('interactive elements have accessible names', async ({ page }) => {
377
+ await page.goto('/');
378
+
379
+ for (const role of ['button', 'link'] as const) {
380
+ const elements = await page.getByRole(role).all();
381
+ for (const el of elements) {
382
+ const name = await el.evaluate((e) =>
383
+ e.getAttribute('aria-label') || e.getAttribute('aria-labelledby') || e.textContent?.trim()
384
+ );
385
+ expect(name).toBeTruthy();
386
+ }
387
+ }
388
+ });
389
+ ```
390
+
391
+ ### Common ARIA Mistakes and Fixes
392
+
393
+ | Mistake | Fix |
394
+ |---|---|
395
+ | `<div onclick="...">` | Use `<button>` or add `role="button"` + `tabindex="0"` + keydown handler |
396
+ | `aria-label` on non-interactive `<div>` | Use `aria-label` only on interactive or landmark elements |
397
+ | `role="button"` without keyboard support | Add `tabindex="0"` and keydown handler for Enter/Space |
398
+ | `aria-hidden="true"` on focusable elements | Remove from focusable elements or add `tabindex="-1"` |
399
+ | Redundant `role="navigation"` on `<nav>` | Remove explicit role; `<nav>` has implicit navigation role |
400
+ | Missing `aria-expanded` on toggles | Add `aria-expanded="true/false"` to disclosure buttons |
401
+ | `aria-labelledby` pointing to missing ID | Ensure referenced ID exists in the DOM |
402
+ | `aria-label` differs from visible text | Match `aria-label` with visible label (WCAG 2.5.3) |
403
+
404
+ ```typescript
405
+ test('no common ARIA mistakes', async ({ page }) => {
406
+ await page.goto('/');
407
+
408
+ // Clickable divs without proper roles
409
+ const clickableDivs = await page.evaluate(() =>
410
+ Array.from(document.querySelectorAll('div[onclick], span[onclick]'))
411
+ .filter((el) => !el.getAttribute('role') && !el.getAttribute('tabindex'))
412
+ .map((el) => el.outerHTML.substring(0, 100))
413
+ );
414
+ expect(clickableDivs).toEqual([]);
415
+
416
+ // aria-hidden on focusable elements
417
+ const hiddenFocusable = await page.evaluate(() =>
418
+ Array.from(document.querySelectorAll('[aria-hidden="true"] a, [aria-hidden="true"] button, [aria-hidden="true"] input'))
419
+ .filter((el) => el.getAttribute('tabindex') !== '-1')
420
+ .map((el) => el.outerHTML.substring(0, 100))
421
+ );
422
+ expect(hiddenFocusable).toEqual([]);
423
+ });
424
+ ```
425
+
426
+ ---
427
+
428
+ ## 4. Keyboard Navigation Testing
429
+
430
+ ### Tab Order Verification
431
+
432
+ ```typescript
433
+ test('tab order follows logical reading order', async ({ page }) => {
434
+ await page.goto('/');
435
+
436
+ const expectedOrder = ['Skip to main content', 'Home', 'Products', 'About', 'Contact', 'Search'];
437
+
438
+ for (const expectedLabel of expectedOrder) {
439
+ await page.keyboard.press('Tab');
440
+ const focused = await page.evaluate(() => {
441
+ const el = document.activeElement;
442
+ return el?.getAttribute('aria-label') || el?.textContent?.trim() || '';
443
+ });
444
+ expect(focused).toContain(expectedLabel);
445
+ }
446
+ });
447
+
448
+ test('shift+tab navigates backwards', async ({ page }) => {
449
+ await page.goto('/');
450
+ for (let i = 0; i < 5; i++) await page.keyboard.press('Tab');
451
+ const fifth = await page.evaluate(() => document.activeElement?.textContent?.trim());
452
+ await page.keyboard.press('Shift+Tab');
453
+ const fourth = await page.evaluate(() => document.activeElement?.textContent?.trim());
454
+ expect(fourth).not.toBe(fifth);
455
+ });
456
+ ```
457
+
458
+ ### Focus Trap Testing
459
+
460
+ ```typescript
461
+ test('modal traps focus within dialog', async ({ page }) => {
462
+ await page.goto('/');
463
+ await page.getByRole('button', { name: 'Open modal' }).click();
464
+ await page.waitForSelector('[role="dialog"]');
465
+
466
+ const dialog = page.locator('[role="dialog"]');
467
+ const focusableInModal = await dialog.locator(
468
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
469
+ ).all();
470
+
471
+ expect(focusableInModal.length).toBeGreaterThan(0);
472
+
473
+ // Tab through and verify focus stays in modal
474
+ for (let i = 0; i < focusableInModal.length + 2; i++) {
475
+ await page.keyboard.press('Tab');
476
+ const inside = await page.evaluate(() => {
477
+ const dialog = document.querySelector('[role="dialog"]');
478
+ return dialog?.contains(document.activeElement) ?? false;
479
+ });
480
+ expect(inside).toBe(true);
481
+ }
482
+
483
+ // Escape closes modal, focus returns to trigger
484
+ await page.keyboard.press('Escape');
485
+ await expect(dialog).not.toBeVisible();
486
+ const triggerFocused = await page.getByRole('button', { name: 'Open modal' }).evaluate(
487
+ (el) => el === document.activeElement
488
+ );
489
+ expect(triggerFocused).toBe(true);
490
+ });
491
+ ```
492
+
493
+ ### Skip Navigation Links
494
+
495
+ ```typescript
496
+ test('skip navigation link works', async ({ page }) => {
497
+ await page.goto('/');
498
+ await page.keyboard.press('Tab');
499
+
500
+ const skipLink = page.locator('a[href="#main-content"], a[href="#main"]');
501
+ await expect(skipLink).toBeFocused();
502
+ await expect(skipLink).toBeVisible();
503
+
504
+ await page.keyboard.press('Enter');
505
+ const mainFocused = await page.evaluate(() => {
506
+ const active = document.activeElement;
507
+ return active?.id === 'main-content' || active?.id === 'main' || active?.closest('main') !== null;
508
+ });
509
+ expect(mainFocused).toBe(true);
510
+ });
511
+ ```
512
+
513
+ ### Custom Keyboard Interactions
514
+
515
+ ```typescript
516
+ test('dropdown menu supports keyboard navigation', async ({ page }) => {
517
+ await page.goto('/');
518
+ const menuButton = page.getByRole('button', { name: 'Menu' });
519
+ await menuButton.focus();
520
+
521
+ await page.keyboard.press('Enter');
522
+ const menu = page.getByRole('menu');
523
+ await expect(menu).toBeVisible();
524
+
525
+ await page.keyboard.press('ArrowDown');
526
+ await expect(page.getByRole('menuitem').first()).toBeFocused();
527
+
528
+ await page.keyboard.press('ArrowDown');
529
+ await expect(page.getByRole('menuitem').nth(1)).toBeFocused();
530
+
531
+ await page.keyboard.press('Escape');
532
+ await expect(menu).not.toBeVisible();
533
+ await expect(menuButton).toBeFocused();
534
+ });
535
+
536
+ test('tabs support arrow key navigation', async ({ page }) => {
537
+ await page.goto('/tabs-page');
538
+ const tabs = page.getByRole('tablist').getByRole('tab');
539
+
540
+ await tabs.first().focus();
541
+ await expect(tabs.first()).toHaveAttribute('aria-selected', 'true');
542
+
543
+ await page.keyboard.press('ArrowRight');
544
+ await expect(tabs.nth(1)).toBeFocused();
545
+ await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
546
+ await expect(page.getByRole('tabpanel')).toBeVisible();
547
+ });
548
+ ```
549
+
550
+ ---
551
+
552
+ ## 5. Visual Accessibility
553
+
554
+ ### Color Contrast Testing
555
+
556
+ WCAG contrast requirements:
557
+ - **AA normal text** (< 18pt / < 14pt bold): 4.5:1
558
+ - **AA large text** (>= 18pt / >= 14pt bold): 3:1
559
+ - **AAA normal text**: 7:1
560
+ - **AAA large text**: 4.5:1
561
+ - **Non-text UI components**: 3:1
562
+
563
+ ```typescript
564
+ test('color contrast meets WCAG AA', async ({ page }) => {
565
+ await page.goto('/');
566
+ const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
567
+ expect(results.violations).toEqual([]);
568
+ });
569
+ ```
570
+
571
+ ### prefers-reduced-motion Testing
572
+
573
+ ```typescript
574
+ test('animations respect prefers-reduced-motion', async ({ page }) => {
575
+ await page.emulateMedia({ reducedMotion: 'reduce' });
576
+ await page.goto('/');
577
+
578
+ const hasAnimations = await page.evaluate(() => {
579
+ for (const el of document.querySelectorAll('*')) {
580
+ const styles = window.getComputedStyle(el);
581
+ if (parseFloat(styles.animationDuration) > 0 || parseFloat(styles.transitionDuration) > 0) {
582
+ if (!el.hasAttribute('data-essential-animation')) return true;
583
+ }
584
+ }
585
+ return false;
586
+ });
587
+ expect(hasAnimations).toBe(false);
588
+ });
589
+ ```
590
+
591
+ ### prefers-color-scheme Testing
592
+
593
+ ```typescript
594
+ test.describe('Dark mode accessibility', () => {
595
+ test('dark mode maintains contrast ratios', async ({ page }) => {
596
+ await page.emulateMedia({ colorScheme: 'dark' });
597
+ await page.goto('/');
598
+ const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
599
+ expect(results.violations).toEqual([]);
600
+ });
601
+
602
+ test('light mode maintains contrast ratios', async ({ page }) => {
603
+ await page.emulateMedia({ colorScheme: 'light' });
604
+ await page.goto('/');
605
+ const results = await new AxeBuilder({ page }).withRules(['color-contrast']).analyze();
606
+ expect(results.violations).toEqual([]);
607
+ });
608
+ });
609
+ ```
610
+
611
+ ### Touch Target Size Validation
612
+
613
+ WCAG 2.5.8 (AA): Minimum 24x24px. Best practice (AAA 2.5.5): 44x44px.
614
+
615
+ ```typescript
616
+ test('touch targets meet minimum size', async ({ page }) => {
617
+ await page.goto('/');
618
+
619
+ const smallTargets = await page.evaluate(() => {
620
+ const elements = document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]');
621
+ const violations: Array<{ html: string; width: number; height: number }> = [];
622
+
623
+ elements.forEach((el) => {
624
+ const rect = el.getBoundingClientRect();
625
+ if (rect.width === 0 || rect.height === 0) return;
626
+ if (el.tagName === 'A' && el.closest('p')) return; // Skip inline text links
627
+
628
+ if (rect.width < 44 || rect.height < 44) {
629
+ violations.push({
630
+ html: (el as HTMLElement).outerHTML.substring(0, 120),
631
+ width: Math.round(rect.width),
632
+ height: Math.round(rect.height),
633
+ });
634
+ }
635
+ });
636
+ return violations;
637
+ });
638
+
639
+ expect(smallTargets).toEqual([]);
640
+ });
641
+ ```
642
+
643
+ ---
644
+
645
+ ## 6. Form Accessibility
646
+
647
+ ### Label Association Testing
648
+
649
+ ```typescript
650
+ test('all form inputs have associated labels', async ({ page }) => {
651
+ await page.goto('/form');
652
+
653
+ const unlabeledInputs = await page.evaluate(() => {
654
+ const inputs = document.querySelectorAll(
655
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), select, textarea'
656
+ );
657
+ const violations: string[] = [];
658
+
659
+ inputs.forEach((input) => {
660
+ const id = input.getAttribute('id');
661
+ const hasExplicitLabel = id ? document.querySelector(`label[for="${id}"]`) !== null : false;
662
+ const hasImplicitLabel = input.closest('label') !== null;
663
+ const hasAria = input.getAttribute('aria-label') || input.getAttribute('aria-labelledby') || input.getAttribute('title');
664
+
665
+ if (!hasExplicitLabel && !hasImplicitLabel && !hasAria) {
666
+ violations.push((input as HTMLElement).outerHTML.substring(0, 100));
667
+ }
668
+ });
669
+ return violations;
670
+ });
671
+
672
+ expect(unlabeledInputs).toEqual([]);
673
+ });
674
+ ```
675
+
676
+ ### Error Message Announcement
677
+
678
+ ```typescript
679
+ test('form errors are announced to screen readers', async ({ page }) => {
680
+ await page.goto('/form');
681
+ await page.getByRole('button', { name: 'Submit' }).click();
682
+
683
+ // Error messages should be in alert or live region
684
+ const errorAnnouncement = await page.evaluate(() => {
685
+ const errors = document.querySelectorAll('[role="alert"], [aria-live="assertive"], [aria-live="polite"]');
686
+ return Array.from(errors).map((el) => el.textContent?.trim()).filter(Boolean);
687
+ });
688
+ expect(errorAnnouncement.length).toBeGreaterThan(0);
689
+
690
+ // Each invalid field should have aria-describedby pointing to error
691
+ const invalidFields = await page.locator('[aria-invalid="true"]').all();
692
+ expect(invalidFields.length).toBeGreaterThan(0);
693
+
694
+ for (const field of invalidFields) {
695
+ const describedBy = await field.getAttribute('aria-describedby');
696
+ expect(describedBy).toBeTruthy();
697
+ const errorEl = page.locator(`#${describedBy}`);
698
+ await expect(errorEl).toBeAttached();
699
+ expect((await errorEl.textContent())?.trim().length).toBeGreaterThan(0);
700
+ }
701
+ });
702
+ ```
703
+
704
+ ### Required Field Indication
705
+
706
+ ```typescript
707
+ test('required fields are properly indicated', async ({ page }) => {
708
+ await page.goto('/form');
709
+
710
+ const requiredFields = await page.locator('[required], [aria-required="true"]').all();
711
+ expect(requiredFields.length).toBeGreaterThan(0);
712
+
713
+ for (const field of requiredFields) {
714
+ const id = await field.getAttribute('id');
715
+ const label = id ? page.locator(`label[for="${id}"]`) : page.locator('label').filter({ has: field });
716
+
717
+ if (await label.count() > 0) {
718
+ const labelText = await label.first().textContent();
719
+ const hasIndicator = labelText?.includes('*') || labelText?.toLowerCase().includes('required');
720
+ const hasAriaRequired = await field.getAttribute('aria-required') === 'true';
721
+ const hasRequired = await field.getAttribute('required') !== null;
722
+ expect(hasIndicator || hasAriaRequired || hasRequired).toBe(true);
723
+ }
724
+ }
725
+ });
726
+ ```
727
+
728
+ ### Validation Message Patterns
729
+
730
+ ```typescript
731
+ test('inline validation messages are accessible', async ({ page }) => {
732
+ await page.goto('/form');
733
+
734
+ const emailField = page.getByLabel('Email');
735
+ await emailField.fill('invalid-email');
736
+ await emailField.blur();
737
+ await page.waitForSelector('[role="alert"], .error-message');
738
+
739
+ await expect(emailField).toHaveAttribute('aria-invalid', 'true');
740
+ const describedBy = await emailField.getAttribute('aria-describedby');
741
+ expect(describedBy).toBeTruthy();
742
+
743
+ // Fix value and verify error clears
744
+ await emailField.fill('valid@example.com');
745
+ await emailField.blur();
746
+ const invalidAttr = await emailField.getAttribute('aria-invalid');
747
+ expect(invalidAttr === null || invalidAttr === 'false').toBe(true);
748
+ });
749
+ ```
750
+
751
+ ### Reference: Accessible Form Pattern
752
+
753
+ ```html
754
+ <form novalidate aria-labelledby="form-title">
755
+ <h2 id="form-title">Contact Us</h2>
756
+ <p>Fields marked with * are required.</p>
757
+
758
+ <div class="field">
759
+ <label for="name">Full Name *</label>
760
+ <input id="name" type="text" required aria-required="true"
761
+ aria-describedby="name-hint name-error" autocomplete="name" />
762
+ <span id="name-hint" class="hint">Enter your first and last name</span>
763
+ <span id="name-error" class="error" role="alert" aria-live="assertive"></span>
764
+ </div>
765
+
766
+ <div class="field">
767
+ <label for="email">Email Address *</label>
768
+ <input id="email" type="email" required aria-required="true"
769
+ aria-describedby="email-error" autocomplete="email" />
770
+ <span id="email-error" class="error" role="alert" aria-live="assertive"></span>
771
+ </div>
772
+
773
+ <fieldset>
774
+ <legend>Preferred Contact Method</legend>
775
+ <div>
776
+ <input type="radio" id="contact-email" name="contact" value="email" />
777
+ <label for="contact-email">Email</label>
778
+ </div>
779
+ <div>
780
+ <input type="radio" id="contact-phone" name="contact" value="phone" />
781
+ <label for="contact-phone">Phone</label>
782
+ </div>
783
+ </fieldset>
784
+
785
+ <button type="submit">Send Message</button>
786
+ </form>
787
+ ```
788
+
789
+ ---
790
+
791
+ ## 7. CI/CD Integration
792
+
793
+ ### GitHub Actions Accessibility Gate
794
+
795
+ ```yaml
796
+ # .github/workflows/accessibility.yml
797
+ name: Accessibility Tests
798
+
799
+ on:
800
+ pull_request:
801
+ branches: [main, develop]
802
+ push:
803
+ branches: [main]
804
+
805
+ jobs:
806
+ a11y:
807
+ runs-on: ubuntu-latest
808
+ steps:
809
+ - uses: actions/checkout@v4
810
+ - uses: actions/setup-node@v4
811
+ with:
812
+ node-version: 20
813
+ cache: 'npm'
814
+ - run: npm ci
815
+ - run: npx playwright install --with-deps chromium
816
+ - run: npm run build
817
+ - name: Start app
818
+ run: npm run start &
819
+ env:
820
+ PORT: 3000
821
+ - run: npx wait-on http://localhost:3000 --timeout 30000
822
+ - name: axe-core tests
823
+ run: npx playwright test --project=accessibility
824
+ - name: pa11y-ci
825
+ run: npx pa11y-ci --config .pa11yci.json
826
+ - name: Lighthouse
827
+ uses: treosh/lighthouse-ci-action@v12
828
+ with:
829
+ configPath: ./lighthouserc.json
830
+ uploadArtifacts: true
831
+ - name: Upload report
832
+ if: always()
833
+ uses: actions/upload-artifact@v4
834
+ with:
835
+ name: accessibility-report
836
+ path: playwright-report/
837
+ retention-days: 14
838
+ ```
839
+
840
+ ### Blocking on Critical/Serious Violations
841
+
842
+ ```typescript
843
+ // tests/accessibility/a11y-gate.spec.ts
844
+ import { test, expect } from '@playwright/test';
845
+ import AxeBuilder from '@axe-core/playwright';
846
+
847
+ const pages = ['/', '/login', '/dashboard', '/settings', '/form'];
848
+
849
+ for (const pagePath of pages) {
850
+ test(`${pagePath} has no critical/serious a11y violations`, async ({ page }) => {
851
+ await page.goto(pagePath);
852
+
853
+ const results = await new AxeBuilder({ page })
854
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
855
+ .analyze();
856
+
857
+ const critical = results.violations.filter(
858
+ (v) => v.impact === 'critical' || v.impact === 'serious'
859
+ );
860
+
861
+ if (critical.length > 0) {
862
+ console.error(`Violations on ${pagePath}:`, JSON.stringify(
863
+ critical.map((v) => ({ rule: v.id, impact: v.impact, count: v.nodes.length })), null, 2
864
+ ));
865
+ }
866
+
867
+ // Block on critical/serious; warn on moderate/minor
868
+ expect(critical).toEqual([]);
869
+
870
+ const moderate = results.violations.filter((v) => v.impact === 'moderate');
871
+ if (moderate.length > 0) {
872
+ console.warn(`Moderate on ${pagePath}:`, moderate.map((v) => v.id));
873
+ }
874
+ });
875
+ }
876
+ ```
877
+
878
+ ### Playwright Project Configuration
879
+
880
+ ```typescript
881
+ // playwright.config.ts
882
+ import { defineConfig } from '@playwright/test';
883
+
884
+ export default defineConfig({
885
+ projects: [
886
+ {
887
+ name: 'accessibility',
888
+ testDir: './tests/accessibility',
889
+ use: { browserName: 'chromium', baseURL: 'http://localhost:3000' },
890
+ },
891
+ ],
892
+ reporter: [
893
+ ['html', { outputFolder: 'playwright-report' }],
894
+ ['json', { outputFile: 'a11y-report.json' }],
895
+ ],
896
+ });
897
+ ```
898
+
899
+ ---
900
+
901
+ ## 8. Reusable A11y Fixture
902
+
903
+ ```typescript
904
+ // fixtures/a11y.fixture.ts
905
+ import { test as base, expect } from '@playwright/test';
906
+ import AxeBuilder from '@axe-core/playwright';
907
+
908
+ export const test = base.extend<{
909
+ a11y: {
910
+ assertNoViolations: (opts?: { tags?: string[]; exclude?: string[] }) => Promise<void>;
911
+ assertNoCritical: () => Promise<void>;
912
+ checkContrast: () => Promise<void>;
913
+ checkForms: () => Promise<void>;
914
+ };
915
+ }>({
916
+ a11y: async ({ page }, use) => {
917
+ await use({
918
+ assertNoViolations: async (opts) => {
919
+ let b = new AxeBuilder({ page }).withTags(opts?.tags ?? ['wcag2a', 'wcag2aa']);
920
+ for (const s of opts?.exclude ?? []) b = b.exclude(s);
921
+ expect((await b.analyze()).violations).toEqual([]);
922
+ },
923
+ assertNoCritical: async () => {
924
+ const r = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
925
+ expect(r.violations.filter((v) => v.impact === 'critical' || v.impact === 'serious')).toEqual([]);
926
+ },
927
+ checkContrast: async () => {
928
+ expect((await new AxeBuilder({ page }).withRules(['color-contrast']).analyze()).violations).toEqual([]);
929
+ },
930
+ checkForms: async () => {
931
+ expect((await new AxeBuilder({ page }).withRules(['label', 'select-name', 'autocomplete-valid']).analyze()).violations).toEqual([]);
932
+ },
933
+ });
934
+ },
935
+ });
936
+
937
+ export { expect } from '@playwright/test';
938
+ ```
939
+
940
+ **Usage**:
941
+
942
+ ```typescript
943
+ import { test } from '../fixtures/a11y.fixture';
944
+
945
+ test('homepage is accessible', async ({ page, a11y }) => {
946
+ await page.goto('/');
947
+ await a11y.assertNoViolations();
948
+ });
949
+
950
+ test('form inputs are labeled', async ({ page, a11y }) => {
951
+ await page.goto('/form');
952
+ await a11y.checkForms();
953
+ });
954
+ ```
955
+
956
+ ---
957
+
958
+ ## 9. Quick Reference
959
+
960
+ ### axe-core Rule Tags
961
+
962
+ | Tag | Meaning |
963
+ |---|---|
964
+ | `wcag2a` | WCAG 2.0 Level A |
965
+ | `wcag2aa` | WCAG 2.0 Level AA |
966
+ | `wcag2aaa` | WCAG 2.0 Level AAA |
967
+ | `wcag21a` | WCAG 2.1 Level A |
968
+ | `wcag21aa` | WCAG 2.1 Level AA |
969
+ | `wcag22aa` | WCAG 2.2 Level AA |
970
+ | `best-practice` | Non-WCAG best practices |
971
+ | `section508` | Section 508 requirements |
972
+
973
+ ### Common axe-core Rules
974
+
975
+ | Rule | What It Checks |
976
+ |---|---|
977
+ | `color-contrast` | Text contrast ratio |
978
+ | `image-alt` | Images have alt text |
979
+ | `label` | Form inputs have labels |
980
+ | `button-name` | Buttons have accessible names |
981
+ | `link-name` | Links have accessible names |
982
+ | `html-has-lang` | HTML has lang attribute |
983
+ | `landmark-one-main` | Page has one main landmark |
984
+ | `region` | Content is in landmarks |
985
+ | `duplicate-id` | No duplicate IDs |
986
+ | `aria-roles` | Valid ARIA roles |
987
+ | `aria-valid-attr` | Valid ARIA attributes |
988
+ | `aria-hidden-focus` | No focusable elements inside aria-hidden |
989
+ | `tabindex` | tabindex values not greater than 0 |
990
+ | `bypass` | Page has skip nav mechanism |
991
+
992
+ ---
993
+
994
+ ## Related Skills
995
+
996
+ - `e2e-testing` - E2E testing with Playwright and visual regression
997
+ - `unit-testing` - Unit testing and TDD for component-level a11y
998
+ - `qa-engineer` - Overall test strategy including accessibility