@diegovelasquezweb/a11y-engine 0.1.0

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.
@@ -0,0 +1,4166 @@
1
+ {
2
+ "rules": {
3
+ "accesskeys": {
4
+ "category": "keyboard",
5
+ "fix": {
6
+ "description": "Ensure every accesskey attribute value is unique across the page. Duplicate accesskey values create unpredictable keyboard shortcuts — only one element will receive activation, and which one varies by browser.",
7
+ "code": "<!-- Each accesskey must be unique: -->\n<button accesskey=\"s\">Save</button>\n<button accesskey=\"d\">Delete</button>\n<a href=\"/help\" accesskey=\"h\">Help</a>\n\n<!-- Avoid duplicates: -->\n<!-- <button accesskey=\"s\">Save</button> -->\n<!-- <button accesskey=\"s\">Submit</button> — CONFLICT -->"
8
+ },
9
+ "false_positive_risk": "low",
10
+ "framework_notes": {
11
+ "react": "In React, accessKey is a camelCase JSX prop: <button accessKey='s'>. Track assigned keys in a constants file to prevent duplicates across components. Consider avoiding accesskey entirely — it conflicts with browser and screen reader shortcuts on most platforms.",
12
+ "vue": "In Vue, use accesskey as a plain HTML attribute: <button accesskey='s'>. Maintain a centralized mapping of assigned keys to avoid collisions when multiple components declare accesskeys independently.",
13
+ "angular": "In Angular, bind with [attr.accesskey]='key' or use the plain accesskey attribute. Centralize key assignments in a service or constants file to prevent conflicts between lazy-loaded feature modules.",
14
+ "svelte": "In Svelte, use accesskey as a standard HTML attribute: <button accesskey='s'>. Svelte does not transform or validate accesskeys — maintain a centralized key mapping to prevent duplicates across components.",
15
+ "astro": "In .astro files, use the standard HTML accesskey attribute. Since Astro assembles pages from multiple components, track accesskey assignments in a shared constants file to prevent cross-component duplicates."
16
+ },
17
+ "fix_difficulty_notes": "The simplest fix is to remove duplicate accesskey values and assign unique keys. However, accesskey has fundamental usability problems: key combinations vary by OS and browser (Alt+key on Windows, Ctrl+Opt+key on macOS), and they frequently conflict with assistive technology shortcuts. Many accessibility experts recommend avoiding accesskey altogether in favor of skip links and landmark navigation.",
18
+ "related_rules": [
19
+ {
20
+ "id": "bypass",
21
+ "reason": "Skip links and landmark navigation are the recommended keyboard shortcut alternatives to accesskey — evaluate both together."
22
+ }
23
+ ]
24
+ },
25
+ "area-alt": {
26
+ "category": "text-alternatives",
27
+ "fix": {
28
+ "description": "Add a descriptive alt attribute to every <area> element inside the image map, describing the clickable region's destination or function. Without it, screen readers announce the raw href or nothing at all.",
29
+ "code": "<img src=\"floor-plan.png\" alt=\"Office floor plan\" usemap=\"#office-map\">\n<map name=\"office-map\">\n <area shape=\"rect\" coords=\"0,0,100,100\" href=\"/room-a\" alt=\"Conference Room A\">\n <area shape=\"circle\" coords=\"200,200,50\" href=\"/room-b\" alt=\"Break Room B\">\n <area shape=\"poly\" coords=\"300,0,400,100,350,200\" href=\"/room-c\" alt=\"Open Workspace C\">\n</map>"
30
+ },
31
+ "false_positive_risk": "low",
32
+ "framework_notes": {
33
+ "react": "Image maps are rarely used in React apps. If you must use one, pass alt as a prop on each <area> element. Consider replacing the image map with individual clickable components positioned via CSS Grid — this is more maintainable and accessible.",
34
+ "vue": "In Vue, bind alt directly on <area> elements inside <map>. Prefer replacing image maps with positioned <button> or <a> elements overlaid on an image via CSS — image maps are fragile on responsive layouts.",
35
+ "angular": "In Angular, use [attr.alt]='areaLabel' on each <area> element. Image maps do not resize with responsive layouts — consider replacing with an SVG-based interactive graphic using role='img' and embedded links.",
36
+ "svelte": "In Svelte, <area> elements work as standard HTML. Add alt text directly: <area alt='Section description'>. Svelte's compiler warns about missing alt on <img> but not on <area> — validate manually.",
37
+ "astro": "In .astro files, <area> elements render as standard HTML. Add alt text directly. Image maps are rarely used in modern Astro projects — consider replacing with CSS/SVG-based interactive regions."
38
+ },
39
+ "fix_difficulty_notes": "Image maps are a legacy HTML pattern that does not scale well to responsive layouts — coordinates are pixel-based and break on different viewport sizes. The best long-term fix is to replace the image map with an SVG graphic containing <a> elements, or positioned HTML elements over a background image. If you must keep the image map, ensure every <area> has a descriptive alt attribute.",
40
+ "related_rules": [
41
+ {
42
+ "id": "image-alt",
43
+ "reason": "The parent <img> of the image map also needs alt text — fix both together."
44
+ },
45
+ {
46
+ "id": "server-side-image-map",
47
+ "reason": "If replacing with a client-side image map, ensure every <area> has alt text."
48
+ }
49
+ ],
50
+ "guardrails_overrides": {
51
+ "must": [
52
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
53
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
54
+ ],
55
+ "must_not": [
56
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
57
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
58
+ ],
59
+ "verify": [
60
+ "Confirm computed accessible name matches expected spoken phrase.",
61
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
62
+ ]
63
+ }
64
+ },
65
+ "aria-allowed-attr": {
66
+ "category": "aria",
67
+ "managed_by_libraries": [
68
+ "radix",
69
+ "headless-ui",
70
+ "chakra",
71
+ "mantine",
72
+ "material-ui",
73
+ "polaris",
74
+ "react-aria",
75
+ "ariakit",
76
+ "shadcn",
77
+ "primevue",
78
+ "vuetify"
79
+ ],
80
+ "fix": {
81
+ "description": "Remove or replace ARIA attributes that are not supported by the element's role. Each ARIA role defines a specific set of allowed states and properties — using unsupported attributes causes unpredictable screen reader behavior.",
82
+ "code": "<!-- aria-checked is NOT allowed on role='textbox' -->\n<!-- Before: -->\n<div role=\"textbox\" aria-checked=\"true\" contenteditable=\"true\">Text</div>\n<!-- After: remove the unsupported attribute -->\n<div role=\"textbox\" contenteditable=\"true\">Text</div>\n\n<!-- aria-expanded IS allowed on role='button' -->\n<button aria-expanded=\"false\" aria-controls=\"menu-list\">Menu</button>"
83
+ },
84
+ "false_positive_risk": "low",
85
+ "framework_notes": {
86
+ "react": "Use eslint-plugin-jsx-a11y with the role-supports-aria-props rule to catch unsupported ARIA attributes at build time. When spreading props onto elements (e.g., {...rest}), unsupported ARIA attributes from parent components can leak through — filter them explicitly.",
87
+ "vue": "In Vue, ARIA attributes pass through to the DOM without validation. Use eslint-plugin-vuejs-accessibility to flag unsupported role/attribute combinations in templates. When using v-bind='$attrs', unwanted ARIA attributes from parent components can propagate.",
88
+ "angular": "In Angular, no built-in validation catches unsupported ARIA attribute/role combinations. Enable @angular-eslint rules for ARIA validation. When using @HostBinding for ARIA attributes, verify the host element's role supports each bound attribute.",
89
+ "svelte": "Svelte passes ARIA attributes directly to the DOM. Verify that the ARIA attribute is valid for the element's role. Use eslint-plugin-svelte with the a11y rules enabled to catch invalid ARIA attribute pairings at lint time.",
90
+ "astro": "In .astro files, ARIA attributes are passed through to the static HTML output. For ARIA attributes on elements inside framework islands, each framework's rules apply. Validate the rendered HTML output with axe."
91
+ },
92
+ "fix_difficulty_notes": "The fix is straightforward — remove the unsupported attribute or switch to a role that supports it. The WAI-ARIA spec (https://www.w3.org/TR/wai-aria/#role_definitions) lists 'Supported States and Properties' for each role. The most common violation: aria-expanded on an element whose role does not support it (e.g., role='textbox'). If the attribute conveys meaningful state, the role is likely wrong — not the attribute.",
93
+ "related_rules": [
94
+ {
95
+ "id": "aria-prohibited-attr",
96
+ "reason": "Both rules enforce correct ARIA attribute usage per role — fix them together to avoid repeated passes."
97
+ },
98
+ {
99
+ "id": "aria-required-attr",
100
+ "reason": "After removing unsupported attributes, verify the required attributes for the role are still present."
101
+ },
102
+ {
103
+ "id": "aria-roles",
104
+ "reason": "An invalid role makes all attribute checks unreliable — fix the role first."
105
+ },
106
+ {
107
+ "id": "aria-conditional-attr",
108
+ "reason": "aria-allowed-attr checks which attributes a role supports; aria-conditional-attr checks whether the attribute value is semantically valid for that role — fix both in one pass."
109
+ }
110
+ ],
111
+ "guardrails_overrides": {
112
+ "must": [
113
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
114
+ ],
115
+ "must_not": [
116
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
117
+ ],
118
+ "verify": [
119
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
120
+ ]
121
+ }
122
+ },
123
+ "aria-allowed-role": {
124
+ "category": "aria",
125
+ "managed_by_libraries": [
126
+ "radix",
127
+ "headless-ui",
128
+ "chakra",
129
+ "mantine",
130
+ "material-ui",
131
+ "polaris",
132
+ "react-aria",
133
+ "ariakit",
134
+ "shadcn",
135
+ "primevue",
136
+ "vuetify"
137
+ ],
138
+ "fix": {
139
+ "description": "Remove or change the role attribute so it is appropriate for the element. Certain HTML elements restrict which ARIA roles can be applied — for example, <input type='text'> cannot have role='button'. Use a native element that matches the desired role instead.",
140
+ "code": "<!-- Invalid: role='button' on a text input -->\n<!-- <input type=\"text\" role=\"button\"> -->\n\n<!-- Valid: use a <button> for button semantics -->\n<button type=\"button\">Click me</button>\n\n<!-- Valid: role='search' on a <form> is allowed -->\n<form role=\"search\">\n <label for=\"q\">Search</label>\n <input type=\"search\" id=\"q\">\n</form>\n\n<!-- Valid: role='presentation' on a layout <table> -->\n<table role=\"presentation\">\n <tr><td>Layout content</td></tr>\n</table>"
141
+ },
142
+ "false_positive_risk": "medium",
143
+ "framework_notes": {
144
+ "react": "In React, component abstractions may apply roles dynamically via props spread. Audit the rendered DOM to confirm the role is valid for the host element. Libraries like Radix UI choose host elements that match the ARIA role — prefer them over manually assigning roles to arbitrary elements.",
145
+ "vue": "In Vue, dynamic :role bindings may produce invalid pairings at runtime. Ensure computed role values match the host element type. Use browser DevTools to inspect the rendered element and confirm the role is allowed.",
146
+ "angular": "In Angular, avoid [attr.role] bindings that assign roles incompatible with the host element. Angular CDK components select the correct host element for the given role — use them instead of manually overriding roles on arbitrary elements.",
147
+ "svelte": "Svelte does not validate ARIA roles at compile time. Use eslint-plugin-svelte with a11y rules to catch invalid role/element pairings. The role must be appropriate for the host HTML element — e.g., role='button' on a <div> is valid but role='button' on an <a> is not.",
148
+ "astro": "In .astro templates, roles are rendered as-is to static HTML. Astro does not validate ARIA semantics — use axe-core or similar tools to verify role/element compatibility in the build output."
149
+ },
150
+ "fix_difficulty_notes": "The ARIA in HTML specification (https://www.w3.org/TR/html-aria/) defines which roles are allowed on each HTML element. The most common violation is adding a role that conflicts with the element's implicit semantics — for example, role='heading' on a <button>. The fix is usually to change the element to one that naturally supports the desired role, rather than forcing a role onto an incompatible element.",
151
+ "related_rules": [
152
+ {
153
+ "id": "aria-roles",
154
+ "reason": "aria-roles validates the role value itself; aria-allowed-role validates whether that role is appropriate for the host element."
155
+ },
156
+ {
157
+ "id": "aria-required-attr",
158
+ "reason": "After ensuring the role is allowed, verify all required ARIA attributes for that role are present."
159
+ }
160
+ ],
161
+ "guardrails_overrides": {
162
+ "must": [
163
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
164
+ ],
165
+ "must_not": [
166
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
167
+ ],
168
+ "verify": [
169
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
170
+ ]
171
+ }
172
+ },
173
+ "aria-braille-equivalent": {
174
+ "category": "aria",
175
+ "fix": {
176
+ "description": "Add a non-braille equivalent alongside each braille ARIA attribute: pair aria-braillelabel with aria-label, aria-labelledby, or visible text content, and pair aria-brailleroledescription with aria-roledescription. Braille attributes are supplements, not replacements.",
177
+ "code": "<!-- Before: braille label without a non-braille equivalent -->\n<button aria-braillelabel=\"Sv\">Save</button>\n<!-- This is actually valid because the button has visible text 'Save' -->\n\n<!-- Invalid: aria-brailleroledescription without aria-roledescription -->\n<div role=\"region\" aria-brailleroledescription=\"rgn\">...</div>\n<!-- After: add the non-braille equivalent -->\n<div role=\"region\" aria-roledescription=\"content region\" aria-brailleroledescription=\"rgn\">...</div>"
178
+ },
179
+ "false_positive_risk": "low",
180
+ "framework_notes": {
181
+ "react": "Braille ARIA attributes (ariaBraillelabel, ariaBrailleroledescription) are niche — only use them when you have confirmed braille display users need abbreviated labels. Always ensure the non-braille equivalent (aria-label or aria-roledescription) is set first.",
182
+ "vue": "In Vue, use aria-braillelabel and aria-brailleroledescription as standard HTML attributes. They are only useful for braille display optimization — do not add them unless you have a specific braille user need and the non-braille equivalent is already in place.",
183
+ "angular": "In Angular, bind [attr.aria-braillelabel] and [attr.aria-brailleroledescription] only when a non-braille equivalent is already present. These attributes are specialized for braille display users and are rarely needed in typical applications.",
184
+ "svelte": "Svelte passes aria-brailleroledescription and aria-braillelabel to the DOM as-is. Ensure a non-braille equivalent (aria-roledescription or aria-label) is always present alongside the braille variant.",
185
+ "astro": "In .astro files, braille ARIA attributes are passed through to static HTML. This is a rare rule — it only applies to elements that specifically target braille display users."
186
+ },
187
+ "fix_difficulty_notes": "This rule is rarely triggered because aria-braillelabel and aria-brailleroledescription are specialized attributes used almost exclusively by applications targeting braille display users. The fix is simple: ensure a non-braille accessible name or role description exists alongside the braille variant. If you did not intentionally add braille attributes, they may have been introduced by a third-party library — check your dependencies.",
188
+ "related_rules": [
189
+ {
190
+ "id": "aria-roledescription",
191
+ "reason": "aria-brailleroledescription must be paired with aria-roledescription — fix both together to ensure the non-braille equivalent is present."
192
+ }
193
+ ],
194
+ "guardrails_overrides": {
195
+ "must": [
196
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
197
+ ],
198
+ "must_not": [
199
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
200
+ ],
201
+ "verify": [
202
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
203
+ ]
204
+ }
205
+ },
206
+ "aria-command-name": {
207
+ "category": "aria",
208
+ "managed_by_libraries": [
209
+ "radix",
210
+ "headless-ui",
211
+ "chakra",
212
+ "mantine",
213
+ "material-ui",
214
+ "polaris",
215
+ "react-aria",
216
+ "ariakit",
217
+ "shadcn",
218
+ "primevue",
219
+ "vuetify"
220
+ ],
221
+ "fix": {
222
+ "description": "Give every element with role='button', role='link', or role='menuitem' an accessible name via visible text content, aria-label, or aria-labelledby.",
223
+ "code": "<!-- ARIA button with visible text -->\n<div role=\"button\" tabindex=\"0\">Save changes</div>\n\n<!-- ARIA link with aria-label (icon-only) -->\n<span role=\"link\" tabindex=\"0\" aria-label=\"View documentation\">\n <svg aria-hidden=\"true\">...</svg>\n</span>\n\n<!-- ARIA menuitem with text content -->\n<li role=\"menuitem\">Copy to clipboard</li>"
224
+ },
225
+ "false_positive_risk": "low",
226
+ "framework_notes": {
227
+ "react": "In React, custom button components using role='button' instead of native <button> must expose an aria-label prop. Prefer native <button> elements — they have implicit roles and built-in keyboard support. For menu items in Radix UI or Headless UI, ensure the MenuItem component receives text content or an aria-label.",
228
+ "vue": "In Vue, custom interactive components with ARIA roles must pass accessible names to the root element. Use native <button> and <a> elements whenever possible — they provide implicit accessible names from text content.",
229
+ "angular": "In Angular, components using role='button' should accept an ariaLabel input and bind it with [attr.aria-label]. Prefer native HTML elements — Angular CDK's CdkButton directive adds keyboard support to native buttons without requiring custom ARIA roles.",
230
+ "svelte": "Svelte renders ARIA roles and names as standard HTML attributes. For elements with role='button', role='link', or role='menuitem', ensure an accessible name via aria-label or visible text content.",
231
+ "astro": "In .astro files, ARIA command roles render to static HTML. Ensure every element with a command role has visible text or aria-label. For interactive elements inside framework islands, the island framework's rules apply."
232
+ },
233
+ "fix_difficulty_notes": "The most common cause is icon-only buttons or links built with ARIA roles instead of native elements. The simplest fix is to add aria-label. The best fix is to replace the ARIA role with a native element (<button>, <a>) which receives its accessible name from text content automatically. For menu items, the text content of the <li> or <div role='menuitem'> serves as the accessible name.",
234
+ "related_rules": [
235
+ {
236
+ "id": "button-name",
237
+ "reason": "button-name covers native <button> elements — fix all button naming violations together."
238
+ },
239
+ {
240
+ "id": "link-name",
241
+ "reason": "link-name covers native <a> elements — fix all interactive element naming together."
242
+ },
243
+ {
244
+ "id": "aria-tooltip-name",
245
+ "reason": "Tooltip triggers often lack accessible names — fix both to ensure the trigger and tooltip are consistently named."
246
+ }
247
+ ],
248
+ "guardrails_overrides": {
249
+ "must": [
250
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
251
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
252
+ ],
253
+ "must_not": [
254
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
255
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
256
+ ],
257
+ "verify": [
258
+ "Confirm computed accessible name matches expected spoken phrase.",
259
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
260
+ ]
261
+ }
262
+ },
263
+ "aria-conditional-attr": {
264
+ "category": "aria",
265
+ "fix": {
266
+ "description": "Replace mismatched ARIA state attributes with the correct attribute for the element's role. For example, replace aria-checked on a role='option' element with aria-selected, and set aria-expanded to true only when the controlled content is actually visible.",
267
+ "code": "<!-- Before: aria-selected on role='checkbox' (should be aria-checked) -->\n<div role=\"checkbox\" aria-selected=\"true\" tabindex=\"0\">Accept terms</div>\n<!-- After: use the correct attribute for the role -->\n<div role=\"checkbox\" aria-checked=\"true\" tabindex=\"0\">Accept terms</div>\n\n<!-- Before: aria-checked on role='option' (should be aria-selected) -->\n<li role=\"option\" aria-checked=\"true\">Item 1</li>\n<!-- After: -->\n<li role=\"option\" aria-selected=\"true\">Item 1</li>"
268
+ },
269
+ "false_positive_risk": "low",
270
+ "framework_notes": {
271
+ "react": "In React, custom selectable list components often misuse aria-checked on role='option' elements — use aria-selected instead. For toggle buttons, use role='switch' with aria-checked, not role='button' with aria-pressed mixed with aria-checked.",
272
+ "vue": "In Vue, custom dropdown or listbox components may apply aria-checked to options — replace with aria-selected. Use the WAI-ARIA authoring practices as a reference for correct attribute/role pairings.",
273
+ "angular": "In Angular CDK, ListKeyManager-based components should use aria-selected on role='option' items. Custom implementations sometimes incorrectly mix aria-checked with listbox roles — consult the ARIA spec for the correct attribute per role.",
274
+ "svelte": "Svelte passes all ARIA attributes to the DOM without validation. Ensure conditional ARIA attributes (aria-checked, aria-selected, aria-expanded, aria-pressed) are only used on elements with the correct role.",
275
+ "astro": "In .astro files, ARIA attributes are rendered to static HTML. Conditional attributes like aria-expanded must match the element's role — validate against the WAI-ARIA spec."
276
+ },
277
+ "fix_difficulty_notes": "This rule catches mismatches between ARIA attributes and role semantics — the attribute exists in ARIA but is not valid for the specific role. The fix requires consulting the WAI-ARIA spec to find the correct attribute for the element's role. Common mismatches: aria-checked on role='option' (use aria-selected), aria-pressed on role='switch' (use aria-checked), aria-expanded on role='checkbox' (not supported).",
278
+ "related_rules": [
279
+ {
280
+ "id": "aria-allowed-attr",
281
+ "reason": "aria-conditional-attr checks semantic validity of attribute values; aria-allowed-attr checks whether the attribute itself is permitted on the role — fix both in one pass."
282
+ },
283
+ {
284
+ "id": "aria-valid-attr-value",
285
+ "reason": "After fixing the attribute choice, verify the value is a valid token."
286
+ }
287
+ ],
288
+ "guardrails_overrides": {
289
+ "must": [
290
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
291
+ ],
292
+ "must_not": [
293
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
294
+ ],
295
+ "verify": [
296
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
297
+ ]
298
+ }
299
+ },
300
+ "aria-deprecated-role": {
301
+ "category": "aria",
302
+ "fix": {
303
+ "description": "Replace deprecated ARIA roles with their modern equivalents. Deprecated roles may be ignored by assistive technologies or produce unexpected behavior.",
304
+ "code": "<!-- Before: deprecated role='directory' -->\n<ul role=\"directory\">...</ul>\n<!-- After: use role='list' or remove the redundant role -->\n<ul>...</ul>\n\n<!-- Before: deprecated role='doc-biblioentry' used outside DPUB context -->\n<li role=\"doc-biblioentry\">...</li>\n<!-- After: use standard roles -->\n<li role=\"listitem\">...</li>"
305
+ },
306
+ "false_positive_risk": "low",
307
+ "framework_notes": {
308
+ "react": "In React, deprecated roles are passed as string props without any compile-time warning. Add eslint-plugin-jsx-a11y to catch deprecated role values. When upgrading component libraries, check changelogs for ARIA role changes — deprecated roles may have been valid in older ARIA specs.",
309
+ "vue": "In Vue, deprecated roles pass through to the DOM without validation. Use eslint-plugin-vuejs-accessibility to detect deprecated values in templates.",
310
+ "angular": "In Angular, no built-in validation catches deprecated ARIA roles. Enable @angular-eslint ARIA rules to flag deprecated role values at compile time.",
311
+ "svelte": "Svelte does not warn about deprecated ARIA roles at compile time. Roles like 'directory', 'doc-biblioentry', and 'doc-endnote' are deprecated — replace with current equivalents from the ARIA 1.2 spec.",
312
+ "astro": "In .astro files, deprecated roles render to static HTML without warnings. Use axe-core or eslint-plugin-jsx-a11y (for JSX islands) to catch deprecated roles in the build output."
313
+ },
314
+ "fix_difficulty_notes": "The most commonly deprecated role is 'directory' (deprecated in ARIA 1.2, replaced by 'list'). Other deprecated roles include certain DPUB-ARIA roles when used outside digital publishing contexts. The fix is usually straightforward: replace the deprecated role with its modern equivalent or remove it if the native HTML element provides the correct semantics implicitly.",
315
+ "related_rules": [
316
+ {
317
+ "id": "aria-roles",
318
+ "reason": "Both rules validate role attribute values — fix all role violations together."
319
+ }
320
+ ],
321
+ "guardrails_overrides": {
322
+ "must": [
323
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
324
+ ],
325
+ "must_not": [
326
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
327
+ ],
328
+ "verify": [
329
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
330
+ ]
331
+ }
332
+ },
333
+ "aria-dialog-name": {
334
+ "category": "aria",
335
+ "preferred_relationship_checks": [
336
+ "aria-labelledby",
337
+ "aria-label"
338
+ ],
339
+ "managed_by_libraries": [
340
+ "radix",
341
+ "headless-ui",
342
+ "chakra",
343
+ "mantine",
344
+ "material-ui",
345
+ "polaris",
346
+ "react-aria",
347
+ "ariakit",
348
+ "shadcn",
349
+ "primevue",
350
+ "vuetify"
351
+ ],
352
+ "fix": {
353
+ "description": "Ensure every element with role='dialog' or role='alertdialog' has an accessible name via aria-label or aria-labelledby pointing to a visible heading inside the dialog.",
354
+ "code": "<!-- Using aria-labelledby (preferred — references visible heading): -->\n<div role=\"dialog\" aria-labelledby=\"dialog-title\" aria-modal=\"true\">\n <h2 id=\"dialog-title\">Confirm deletion</h2>\n <p>Are you sure you want to delete this item?</p>\n <button>Cancel</button>\n <button>Delete</button>\n</div>\n\n<!-- Using aria-label (when no visible heading exists): -->\n<div role=\"alertdialog\" aria-label=\"Session expiring\" aria-modal=\"true\">\n <p>Your session will expire in 2 minutes.</p>\n <button>Extend session</button>\n</div>"
355
+ },
356
+ "false_positive_risk": "low",
357
+ "framework_notes": {
358
+ "react": "In React, modal libraries (Radix Dialog, Headless UI Dialog, React Aria) require a Title or aria-label prop and wire it to aria-labelledby automatically. If building a custom dialog, ensure the aria-labelledby value matches the id of the heading rendered inside the portal.",
359
+ "vue": "In Vue, Headless UI and PrimeVue dialogs accept a title slot that auto-generates the aria-labelledby association. For custom dialogs using Teleport, verify the aria-labelledby id resolves correctly in the teleported DOM context.",
360
+ "angular": "Angular CDK Dialog and Angular Material MatDialog accept an aria-label or ariaLabelledBy config option. Always provide one. For custom dialogs, bind [attr.aria-labelledby]='titleId' on the role='dialog' element.",
361
+ "svelte": "In Svelte, custom dialog components must include aria-label or aria-labelledby on the element with role='dialog'. Libraries like Svelte Headless UI and Melt UI handle this automatically when a title prop is provided.",
362
+ "astro": "In .astro files, dialog elements need aria-label or aria-labelledby. For modal islands using React/Vue/Svelte, the framework's dialog component rules apply within the island."
363
+ },
364
+ "fix_difficulty_notes": "The preferred approach is aria-labelledby pointing to a visible heading inside the dialog — this keeps the accessible name synchronized with what sighted users see. Use aria-label only when the dialog has no visible title. For alertdialog, also add aria-describedby pointing to the message body so screen readers announce both the title and the alert content.",
365
+ "related_rules": [
366
+ {
367
+ "id": "aria-required-attr",
368
+ "reason": "Dialogs require aria-modal and aria-labelledby as required attributes — verify both are present when fixing dialog naming."
369
+ }
370
+ ],
371
+ "guardrails_overrides": {
372
+ "must": [
373
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
374
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
375
+ ],
376
+ "must_not": [
377
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
378
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
379
+ ],
380
+ "verify": [
381
+ "Confirm computed accessible name matches expected spoken phrase.",
382
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
383
+ ]
384
+ }
385
+ },
386
+ "aria-hidden-body": {
387
+ "category": "aria",
388
+ "fix": {
389
+ "description": "Remove aria-hidden='true' from the <body> element. Setting aria-hidden on the document body hides the entire page from assistive technologies, making it completely inaccessible.",
390
+ "code": "<!-- Before: entire page hidden from AT -->\n<body aria-hidden=\"true\">\n ...\n</body>\n\n<!-- After: remove aria-hidden from body -->\n<body>\n ...\n</body>"
391
+ },
392
+ "false_positive_risk": "low",
393
+ "framework_notes": {
394
+ "react": "This typically occurs when a modal library applies aria-hidden='true' to the body (or the React root) and fails to remove it when the modal closes. Libraries like React Modal (react-modal) manage this automatically via the appElement prop — ensure it is set correctly. If using a custom modal, clean up aria-hidden in the modal's unmount/close lifecycle.",
395
+ "vue": "In Vue, this can happen when a dialog plugin sets aria-hidden='true' on document.body during open and fails to remove it on close (e.g., due to an error during the close transition). Always use a finally block or watch handler to guarantee cleanup.",
396
+ "angular": "In Angular, Angular CDK Dialog and Material Dialog manage aria-hidden on sibling elements, not on <body> itself. If you see aria-hidden on <body>, a third-party library or custom modal implementation is likely the cause — trace it via DOM mutation breakpoints in DevTools.",
397
+ "svelte": "Svelte apps render inside the <body> — never set aria-hidden='true' on <body> or the root container. If hiding content during modal display, apply aria-hidden to a specific wrapper, not the body.",
398
+ "astro": "Astro renders full HTML pages — never set aria-hidden='true' on <body>. For modals that need to hide background content, apply aria-hidden to the main content wrapper, not the body element."
399
+ },
400
+ "fix_difficulty_notes": "This is almost always a bug, not an intentional design choice. The most common cause: a modal dialog sets aria-hidden='true' on the body or app root during open, and the cleanup code fails to run (due to an error, race condition, or missing unmount handler). The fix is to ensure the modal's close/destroy logic always removes aria-hidden from the body. Use the inert attribute (now widely supported) as a modern alternative to aria-hidden for hiding background content behind modals.",
401
+ "related_rules": [
402
+ {
403
+ "id": "aria-hidden-focus",
404
+ "reason": "Both involve aria-hidden misuse — if aria-hidden is on the body, all focusable elements are affected."
405
+ },
406
+ {
407
+ "id": "hidden-content",
408
+ "reason": "Revealing hidden content (hidden-content) may affect body-level aria-hidden state — verify both together."
409
+ }
410
+ ],
411
+ "guardrails_overrides": {
412
+ "must": [
413
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
414
+ ],
415
+ "must_not": [
416
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
417
+ ],
418
+ "verify": [
419
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
420
+ ]
421
+ }
422
+ },
423
+ "aria-hidden-focus": {
424
+ "category": "aria",
425
+ "fix": {
426
+ "description": "Remove aria-hidden=\"true\" from elements that can receive focus, or exclude them from the tab order.",
427
+ "code": "<!-- Option 1: Remove aria-hidden -->\n<button>Visible button</button>\n<!-- Option 2: Keep hidden but remove from tab order -->\n<span aria-hidden=\"true\" tabindex=\"-1\">Decorative text</span>"
428
+ },
429
+ "false_positive_risk": "low",
430
+ "framework_notes": {
431
+ "react": "In React portals (modals, dialogs, tooltips), aria-hidden='true' is commonly applied to the app root during open state. Ensure portals render outside the aria-hidden subtree (e.g., appended to <body>). Libraries like Radix UI handle this correctly via the inert attribute and portal rendering.",
432
+ "vue": "In Vue with Teleport, the teleported content renders outside the component's DOM subtree — verify the Teleport target element (e.g., #teleport-target or body) is not inside an aria-hidden container.",
433
+ "angular": "Angular CDK Overlay (used by Material dialogs and menus) renders at the body level, outside aria-hidden scopes. Verify that aria-hidden is applied to the correct host element — not to a container that includes the CDK overlay outlet.",
434
+ "svelte": "Svelte does not have a built-in portal mechanism. Libraries like svelte-portal or the Svelte <svelte:body> target render content at the body level. Ensure modal/dialog content rendered through portals is outside any aria-hidden='true' ancestor.",
435
+ "astro": "Astro islands hydrate independently — applying aria-hidden='true' to a parent in the base layout will hide all islands within it. For modals, render the dialog island at the body level using a framework portal inside the island component."
436
+ },
437
+ "cms_notes": {
438
+ "shopify": "Shopify themes commonly set aria-hidden='true' on the page content when a drawer menu or cart drawer opens. Ensure the drawer itself is not nested inside the aria-hidden container. Dawn's implementation uses a separate <aside> for drawers outside the main content wrapper.",
439
+ "wordpress": "WordPress lightbox and modal plugins often apply aria-hidden='true' to #page or .site but may leave focusable elements inside. Verify the plugin's implementation removes focus from hidden content or uses the inert attribute.",
440
+ "drupal": "Drupal's off-canvas dialog (used in Layout Builder) applies aria-hidden='true' to the main content. The dialog renders via Drupal.dialog() outside the hidden container. Custom modules using dialog should follow the same pattern via Drupal.behaviors."
441
+ },
442
+ "fix_difficulty_notes": "Setting aria-hidden='true' on a parent hides all its descendants from AT but does not remove their keyboard focusability. Interactive children (buttons, links, inputs) inside an aria-hidden container will still receive Tab focus, creating 'ghost focus'. Apply tabindex='-1' to each interactive descendant, or restructure so no focusable elements exist inside the aria-hidden container.",
443
+ "related_rules": [
444
+ {
445
+ "id": "scrollable-region-focusable",
446
+ "reason": "Both affect keyboard focus — audit all focusability violations together to avoid conflicts."
447
+ },
448
+ {
449
+ "id": "aria-hidden-body",
450
+ "reason": "Both involve aria-hidden misuse — if aria-hidden is on the body, all focusable elements are affected."
451
+ },
452
+ {
453
+ "id": "hidden-content",
454
+ "reason": "Revealing hidden content (hidden-content) can expose focusable elements that need aria-hidden-focus review."
455
+ }
456
+ ],
457
+ "guardrails_overrides": {
458
+ "must": [
459
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
460
+ ],
461
+ "must_not": [
462
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
463
+ ],
464
+ "verify": [
465
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
466
+ ]
467
+ }
468
+ },
469
+ "aria-input-field-name": {
470
+ "category": "aria",
471
+ "preferred_relationship_checks": [
472
+ "aria-labelledby",
473
+ "label",
474
+ "aria-label"
475
+ ],
476
+ "fix": {
477
+ "description": "Add an accessible name to ARIA input fields (role='textbox', role='searchbox', role='spinbutton', role='slider', role='combobox') via aria-label or aria-labelledby.",
478
+ "code": "<!-- ARIA textbox with aria-labelledby: -->\n<span id=\"search-label\">Search</span>\n<div role=\"textbox\" contenteditable=\"true\"\n aria-labelledby=\"search-label\"\n aria-multiline=\"false\">\n</div>\n\n<!-- Prefer native input (simpler, better AT support): -->\n<label for=\"search\">Search</label>\n<input type=\"search\" id=\"search\">"
479
+ },
480
+ "false_positive_risk": "low",
481
+ "framework_notes": {
482
+ "react": "Rich text editors (Draft.js, Slate, Quill) use contenteditable divs with role='textbox'. Ensure the editor component exposes an aria-label prop and applies it to the root contenteditable element. For standard inputs, always use <label htmlFor='id'>.",
483
+ "vue": "WYSIWYG editor components (Tiptap, Quill) wrap role='textbox' elements. Pass aria-label via the component's label prop. For standard inputs, use <label :for='id'>.",
484
+ "angular": "Angular CDK or ProseMirror-based editors use contenteditable with ARIA roles. Bind [attr.aria-label]='editorLabel' on the root element. For standard inputs, use mat-label or <label [for]='id'>.",
485
+ "svelte": "In Svelte, custom input components (combobox, searchbox, spinbutton) must expose an accessible name via aria-label or aria-labelledby. Verify that wrapper components pass through ARIA naming attributes to the underlying input.",
486
+ "astro": "In .astro files, ARIA input field roles are rendered to static HTML. For custom input components inside framework islands, ensure the island component exposes aria-label or aria-labelledby."
487
+ },
488
+ "fix_difficulty_notes": "The best fix is almost always to replace the ARIA input with a native HTML input — native elements have implicit roles, built-in keyboard support, and do not require manual ARIA attribute management. Use ARIA input roles only when a native equivalent is technically impossible (e.g., a rich text editor with contenteditable).",
489
+ "related_rules": [
490
+ {
491
+ "id": "label",
492
+ "reason": "If a native input can replace the ARIA input, the standard label association pattern resolves both."
493
+ },
494
+ {
495
+ "id": "aria-toggle-field-name",
496
+ "reason": "Fix all ARIA field naming violations together — they share the same accessible name patterns."
497
+ }
498
+ ],
499
+ "guardrails_overrides": {
500
+ "must": [
501
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
502
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
503
+ ],
504
+ "must_not": [
505
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
506
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
507
+ ],
508
+ "verify": [
509
+ "Confirm computed accessible name matches expected spoken phrase.",
510
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
511
+ ]
512
+ }
513
+ },
514
+ "aria-meter-name": {
515
+ "category": "aria",
516
+ "fix": {
517
+ "description": "Add an accessible name to every element with role='meter' via aria-label, aria-labelledby, or the title attribute. Without a name, screen readers announce the value but not what is being measured.",
518
+ "code": "<!-- Using aria-label -->\n<div role=\"meter\" aria-valuenow=\"75\" aria-valuemin=\"0\" aria-valuemax=\"100\"\n aria-label=\"Battery level\">\n 75%\n</div>\n\n<!-- Using aria-labelledby -->\n<span id=\"disk-label\">Disk usage</span>\n<div role=\"meter\" aria-valuenow=\"42\" aria-valuemin=\"0\" aria-valuemax=\"100\"\n aria-labelledby=\"disk-label\">\n 42%\n</div>"
519
+ },
520
+ "false_positive_risk": "low",
521
+ "framework_notes": {
522
+ "react": "In React, custom meter components should accept an aria-label or label prop and apply it to the element with role='meter'. Prefer the native <meter> element when possible — it has implicit semantics and is styled via ::-webkit-meter-bar pseudo-elements.",
523
+ "vue": "In Vue, pass aria-label or aria-labelledby to the root element of custom meter components. The native <meter> element works in Vue templates and provides built-in AT support without ARIA attributes.",
524
+ "angular": "In Angular, bind [attr.aria-label]='meterLabel' on custom meter components. Angular Material does not include a meter component — if using MatProgressBar as a meter, ensure role='meter' is set alongside the accessible name.",
525
+ "svelte": "In Svelte, <div role='meter'> must have aria-label or aria-labelledby. Svelte does not validate ARIA naming — add labels explicitly. Consider using the native <meter> element instead, which supports <label>.",
526
+ "astro": "In .astro files, role='meter' elements must have aria-label or aria-labelledby in the static HTML. Prefer the native <meter> element when possible."
527
+ },
528
+ "fix_difficulty_notes": "The native <meter> element is the preferred approach — it provides implicit semantics, does not require role='meter', and has built-in accessible value announcements. Use role='meter' only when the native element cannot be styled to match design requirements. When using role='meter', all three value attributes (aria-valuenow, aria-valuemin, aria-valuemax) are required alongside the accessible name.",
529
+ "related_rules": [
530
+ {
531
+ "id": "aria-progressbar-name",
532
+ "reason": "Meters and progress bars share the same accessible name requirements — fix both together."
533
+ }
534
+ ],
535
+ "guardrails_overrides": {
536
+ "must": [
537
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
538
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
539
+ ],
540
+ "must_not": [
541
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
542
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
543
+ ],
544
+ "verify": [
545
+ "Confirm computed accessible name matches expected spoken phrase.",
546
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
547
+ ]
548
+ }
549
+ },
550
+ "aria-progressbar-name": {
551
+ "category": "aria",
552
+ "managed_by_libraries": [
553
+ "radix",
554
+ "chakra",
555
+ "mantine",
556
+ "material-ui",
557
+ "react-aria",
558
+ "primevue",
559
+ "vuetify"
560
+ ],
561
+ "fix": {
562
+ "description": "Add an accessible name to every element with role='progressbar' via aria-label, aria-labelledby, or the title attribute. Without a name, screen readers announce the progress value but not what process it represents.",
563
+ "code": "<!-- Using aria-label -->\n<div role=\"progressbar\" aria-valuenow=\"60\" aria-valuemin=\"0\" aria-valuemax=\"100\"\n aria-label=\"File upload progress\">\n 60%\n</div>\n\n<!-- Using aria-labelledby -->\n<span id=\"download-label\">Downloading update</span>\n<progress id=\"download\" max=\"100\" value=\"30\" aria-labelledby=\"download-label\">\n 30%\n</progress>"
564
+ },
565
+ "false_positive_risk": "low",
566
+ "framework_notes": {
567
+ "react": "In React, use the native <progress> element when possible — it has implicit role='progressbar'. For custom progress bars (e.g., animated SVG rings), ensure the wrapper has role='progressbar', aria-valuenow, and aria-label. Libraries like Radix UI Progress expose these props automatically.",
568
+ "vue": "In Vue, the native <progress> element works in templates and provides built-in AT support. For custom progress components (e.g., Vuetify's v-progress-linear), pass the label prop or aria-label to ensure the component renders an accessible name.",
569
+ "angular": "Angular Material's MatProgressBar (mat-progress-bar) applies role='progressbar' automatically but does not set an accessible name by default. Add [attr.aria-label]='progressLabel' to the <mat-progress-bar> element.",
570
+ "svelte": "In Svelte, <div role='progressbar'> must have aria-label or aria-labelledby. For progress indicators, consider using the native <progress> element instead, which supports <label> association.",
571
+ "astro": "In .astro files, role='progressbar' elements must have aria-label in the static HTML. For dynamic progress bars inside framework islands, ensure the island component manages aria-valuenow and aria-label."
572
+ },
573
+ "fix_difficulty_notes": "The native <progress> element is the simplest solution — it has an implicit progressbar role and built-in AT value announcements. For indeterminate progress bars (no known completion percentage), omit aria-valuenow and set aria-valuemin and aria-valuemax — the screen reader will announce 'busy' or 'loading'. Custom animated progress indicators (CSS-only or SVG) are the hardest to fix — they require explicit ARIA attributes on a wrapping element.",
574
+ "related_rules": [
575
+ {
576
+ "id": "aria-meter-name",
577
+ "reason": "Meters and progress bars share the same accessible name requirements — fix both together."
578
+ }
579
+ ],
580
+ "guardrails_overrides": {
581
+ "must": [
582
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
583
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
584
+ ],
585
+ "must_not": [
586
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
587
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
588
+ ],
589
+ "verify": [
590
+ "Confirm computed accessible name matches expected spoken phrase.",
591
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
592
+ ]
593
+ }
594
+ },
595
+ "aria-prohibited-attr": {
596
+ "category": "aria",
597
+ "managed_by_libraries": [
598
+ "radix",
599
+ "headless-ui",
600
+ "chakra",
601
+ "mantine",
602
+ "material-ui",
603
+ "polaris",
604
+ "react-aria",
605
+ "ariakit",
606
+ "shadcn",
607
+ "primevue",
608
+ "vuetify",
609
+ "swiper"
610
+ ],
611
+ "fix": {
612
+ "description": "The violation message identifies the exact prohibited combination. Two distinct cases: (1) aria-label on an element with NO role — add a landmark or semantic role that permits aria-label (e.g. role='region', role='navigation', role='complementary'); (2) aria-label on role='none' or role='presentation' — these roles strip all semantics so aria-label has no effect: either assign a real role or remove both attributes.",
613
+ "code": "<!-- Case 1: aria-label on element with NO role → add a semantic role -->\n<!-- Before: -->\n<div aria-label=\"Hero slides\">...</div>\n<!-- After: add a role that permits aria-label -->\n<div role=\"region\" aria-label=\"Hero slides\">...</div>\n\n<!-- Case 2: aria-label on role='none' or role='presentation' → remove aria-label -->\n<!-- Before: -->\n<div role=\"none\" aria-label=\"Decorative wrapper\">...</div>\n<!-- After: -->\n<div role=\"none\">...</div>\n\n<!-- Case 3: wrong ARIA state for a role (e.g. aria-checked on plain button) -->\n<!-- Before: -->\n<button aria-checked=\"true\">Toggle</button>\n<!-- After: use role='switch' which supports aria-checked -->\n<button role=\"switch\" aria-checked=\"true\">Toggle</button>"
614
+ },
615
+ "false_positive_risk": "low",
616
+ "framework_notes": {
617
+ "react": "In React, ARIA attributes are passed as JSX props — there is no compile-time check for prohibited combinations. Use eslint-plugin-jsx-a11y, which includes the role-supports-aria-props rule to catch these at build time.",
618
+ "vue": "In Vue, ARIA attributes are standard HTML attributes — no framework-level check exists. Use eslint-plugin-vue with vue/no-aria-hidden-on-focusable or similar lint rules.",
619
+ "angular": "In Angular, no built-in check prevents prohibited ARIA combinations. The Angular CDK a11y lint rules (via @angular-eslint) flag some cases — enable them in .eslintrc.",
620
+ "svelte": "Svelte does not warn about prohibited ARIA attributes at compile time. Attributes like aria-label on elements with role='presentation' or role='none' are prohibited — remove them. Use eslint-plugin-svelte for static analysis.",
621
+ "astro": "In .astro files, prohibited ARIA attributes render to static HTML without warnings. Validate the build output with axe-core to catch attributes that conflict with the element's role."
622
+ },
623
+ "fix_difficulty_notes": [
624
+ "Read the Observed Violation message — it names the exact prohibited combination.",
625
+ "Pattern 1: `aria-label` on a plain `<div>` or `<span>` with no role — add a semantic role that permits labelling (`role=\"region\"` for sections, `role=\"navigation\"` for nav-like containers, `role=\"complementary\"` for sidebars).",
626
+ "Pattern 2: `aria-label` on `role=\"none\"` or `role=\"presentation\"` — these roles explicitly remove the element from the accessibility tree, so any ARIA attribute is meaningless: remove the `aria-label`, or swap to a real role if labelling is needed.",
627
+ "Pattern 3: wrong ARIA state for a role (e.g. `aria-checked` on a plain button) — change the role to one that supports the attribute (`role=\"switch\"` for `aria-checked`). Consult https://www.w3.org/TR/wai-aria/#role_definitions for Inherited States and Properties per role."
628
+ ],
629
+ "related_rules": [
630
+ {
631
+ "id": "aria-required-attr",
632
+ "reason": "Often co-located — fix the role first, then verify required attributes are present and prohibited ones are removed."
633
+ },
634
+ {
635
+ "id": "aria-valid-attr-value",
636
+ "reason": "After fixing prohibited attrs, check that remaining ARIA attribute values are valid tokens."
637
+ },
638
+ {
639
+ "id": "aria-roles",
640
+ "reason": "Often co-located — fix the role first, then verify required attributes are present and prohibited ones are removed."
641
+ },
642
+ {
643
+ "id": "aria-allowed-attr",
644
+ "reason": "Both rules enforce correct ARIA attribute usage per role — fix them together to avoid repeated passes."
645
+ },
646
+ {
647
+ "id": "presentation-role-conflict",
648
+ "reason": "Both rules address ARIA attributes that should not be present on the element — fix together."
649
+ }
650
+ ],
651
+ "guardrails_overrides": {
652
+ "must": [
653
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
654
+ ],
655
+ "must_not": [
656
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
657
+ ],
658
+ "verify": [
659
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
660
+ ]
661
+ }
662
+ },
663
+ "aria-required-attr": {
664
+ "category": "aria",
665
+ "managed_by_libraries": [
666
+ "radix",
667
+ "headless-ui",
668
+ "chakra",
669
+ "mantine",
670
+ "material-ui",
671
+ "polaris",
672
+ "react-aria",
673
+ "ariakit",
674
+ "shadcn",
675
+ "primevue",
676
+ "vuetify"
677
+ ],
678
+ "fix": {
679
+ "description": "Add the missing required ARIA attribute(s) for the element's role. Common examples: role='combobox' requires aria-expanded and aria-controls; role='slider' requires aria-valuenow, aria-valuemin, and aria-valuemax; role='checkbox' requires aria-checked.",
680
+ "code": "<!-- combobox requires: aria-expanded + aria-controls -->\n<div role=\"combobox\" aria-expanded=\"false\" aria-controls=\"listbox-id\" aria-haspopup=\"listbox\">\n <input type=\"text\" aria-autocomplete=\"list\">\n</div>\n<ul role=\"listbox\" id=\"listbox-id\">...</ul>\n\n<!-- scrollbar requires: aria-controls + aria-valuenow + aria-valuemin + aria-valuemax -->\n<div role=\"scrollbar\" aria-controls=\"scrollable-region\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>"
681
+ },
682
+ "false_positive_risk": "low",
683
+ "framework_notes": {
684
+ "react": "Headless component libraries (Radix UI, Headless UI, Ark UI) handle required ARIA attributes internally — do not duplicate or override them. If building a custom ARIA widget from scratch, consult the WAI-ARIA spec for each role's required attributes before binding props.",
685
+ "vue": "Floating Vue, Headless UI for Vue, and Vuetify manage ARIA attributes internally. Only add ARIA attributes when building custom widgets. Binding incorrect or unsupported attributes can introduce new violations rather than fixing existing ones.",
686
+ "angular": "Angular CDK's a11y module (FocusTrap, ListKeyManager, ActiveDescendantKeyManager) manages required ARIA attributes for composite widgets. Use these utilities before building custom ARIA widgets from scratch.",
687
+ "svelte": "Svelte passes ARIA attributes to the DOM without validation. Missing required attributes (e.g., aria-checked on role='checkbox', aria-expanded on role='combobox') must be added manually. Use eslint-plugin-svelte for compile-time checks.",
688
+ "astro": "In .astro files, ARIA attributes are rendered to static HTML. Ensure all required ARIA attributes are present for the element's role. For dynamic ARIA states inside framework islands, the island framework manages them."
689
+ },
690
+ "fix_difficulty_notes": "Required attributes vary per role — consult https://www.w3.org/TR/wai-aria/#role_definitions. The most frequent mistake: using role='combobox' without aria-expanded, or role='slider' without aria-valuenow/aria-valuemin/aria-valuemax. When in doubt, prefer native HTML elements (e.g., <select> instead of role='listbox') which carry implicit semantics.",
691
+ "related_rules": [
692
+ {
693
+ "id": "aria-roles",
694
+ "reason": "An invalid role is often the root cause of missing required attributes — fix the role first."
695
+ },
696
+ {
697
+ "id": "aria-valid-attr-value",
698
+ "reason": "After adding required attributes, verify their values are valid."
699
+ },
700
+ {
701
+ "id": "aria-prohibited-attr",
702
+ "reason": "After adding required attributes, verify no prohibited attributes remain for the fixed role."
703
+ },
704
+ {
705
+ "id": "aria-toggle-field-name",
706
+ "reason": "role=\"switch\" requires aria-checked — fix accessible name and required attributes together."
707
+ },
708
+ {
709
+ "id": "aria-allowed-attr",
710
+ "reason": "After removing unsupported attributes, verify the required attributes for the role are still present."
711
+ },
712
+ {
713
+ "id": "aria-allowed-role",
714
+ "reason": "After ensuring the role is allowed, verify all required ARIA attributes for that role are present."
715
+ },
716
+ {
717
+ "id": "aria-dialog-name",
718
+ "reason": "Dialogs with aria-modal='true' require proper focus trapping — fix naming and modal attributes together."
719
+ }
720
+ ],
721
+ "guardrails_overrides": {
722
+ "must": [
723
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
724
+ ],
725
+ "must_not": [
726
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
727
+ ],
728
+ "verify": [
729
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
730
+ ]
731
+ }
732
+ },
733
+ "aria-required-children": {
734
+ "category": "aria",
735
+ "managed_by_libraries": [
736
+ "radix",
737
+ "headless-ui",
738
+ "chakra",
739
+ "mantine",
740
+ "material-ui",
741
+ "polaris",
742
+ "react-aria",
743
+ "ariakit",
744
+ "shadcn",
745
+ "primevue",
746
+ "vuetify"
747
+ ],
748
+ "fix": {
749
+ "description": "Add the required child roles to composite ARIA elements. For example, give role='list' children with role='listitem', and give role='tablist' children with role='tab'.",
750
+ "code": "<!-- role='list' requires role='listitem' children -->\n<div role=\"list\">\n <div role=\"listitem\">Item 1</div>\n <div role=\"listitem\">Item 2</div>\n</div>\n\n<!-- role='tablist' requires role='tab' children -->\n<div role=\"tablist\" aria-label=\"Settings\">\n <button role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-1\">General</button>\n <button role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-2\">Advanced</button>\n</div>"
751
+ },
752
+ "false_positive_risk": "medium",
753
+ "framework_notes": {
754
+ "react": "In React, wrapper elements between the parent and child roles (e.g., a <div> between role='list' and role='listitem') break the required parent-child relationship. Use role='presentation' or role='none' on intermediate wrappers to make them semantically transparent. In tabbed interfaces, ensure role='tab' elements are direct children of role='tablist'.",
755
+ "vue": "In Vue, component wrapper elements can introduce extra DOM nodes between required role relationships. Use Vue 3 Fragments or <template> to avoid wrapper elements, or apply role='none' to intermediate wrappers.",
756
+ "angular": "In Angular, host elements of child components create extra DOM layers. Use the host property in @Component to set role='none' on the host element, or use attribute selectors on the host to eliminate extra wrappers.",
757
+ "svelte": "Svelte does not validate ARIA parent-child role relationships. Ensure composite roles (role='list' → role='listitem', role='tablist' → role='tab') have the correct child roles in the rendered DOM.",
758
+ "astro": "In .astro files, ARIA parent-child relationships must be correct in the rendered HTML. When composing components, verify that parent and child roles remain valid across component boundaries."
759
+ },
760
+ "fix_difficulty_notes": "The most common cause in component frameworks is an intermediate wrapper element between the parent and child roles. The wrapper breaks the ARIA relationship because the child role is no longer a direct descendant of the parent role in the accessibility tree. Solutions: (1) apply role='none' or role='presentation' to the wrapper, (2) restructure to eliminate the wrapper, or (3) use owned children via aria-owns (last resort). Reference: https://www.w3.org/TR/wai-aria/#mustContain.",
761
+ "related_rules": [
762
+ {
763
+ "id": "aria-required-parent",
764
+ "reason": "Required children and required parent are complementary checks — fix both together to ensure complete ARIA relationships."
765
+ },
766
+ {
767
+ "id": "aria-treeitem-name",
768
+ "reason": "Tree structures require correct nesting of treeitem within tree and group roles — fix hierarchy and naming together."
769
+ }
770
+ ],
771
+ "guardrails_overrides": {
772
+ "must": [
773
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
774
+ ],
775
+ "must_not": [
776
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
777
+ ],
778
+ "verify": [
779
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
780
+ ]
781
+ }
782
+ },
783
+ "aria-required-parent": {
784
+ "category": "aria",
785
+ "managed_by_libraries": [
786
+ "radix",
787
+ "headless-ui",
788
+ "chakra",
789
+ "mantine",
790
+ "material-ui",
791
+ "polaris",
792
+ "react-aria",
793
+ "ariakit",
794
+ "shadcn",
795
+ "primevue",
796
+ "vuetify"
797
+ ],
798
+ "fix": {
799
+ "description": "Nest each ARIA role inside its required parent role. For example, wrap role='listitem' inside role='list', and wrap role='tab' inside role='tablist'.",
800
+ "code": "<!-- role='listitem' must be inside role='list' -->\n<div role=\"list\">\n <div role=\"listitem\">Item 1</div>\n <div role=\"listitem\">Item 2</div>\n</div>\n\n<!-- role='tab' must be inside role='tablist' -->\n<div role=\"tablist\" aria-label=\"Account settings\">\n <button role=\"tab\" aria-selected=\"true\">Profile</button>\n <button role=\"tab\" aria-selected=\"false\">Security</button>\n</div>"
801
+ },
802
+ "false_positive_risk": "medium",
803
+ "framework_notes": {
804
+ "react": "In React, rendering role='listitem' elements via a component that does not render inside a role='list' parent is a common source. Ensure the parent component applies role='list' to its container. For Portalled content (e.g., menu items in a dropdown), the portal must render inside the role='menu' container, not at the body level.",
805
+ "vue": "In Vue, Teleport can break required parent-child ARIA relationships by moving child content to a different DOM location. If teleporting role='menuitem' elements, ensure the Teleport target is inside the role='menu' parent.",
806
+ "angular": "In Angular, CDK Overlay renders content at the body level — menu items in an overlay may lose their role='menu' parent. Use cdkConnectedOverlayOrigin or restructure so the overlay container has the required parent role.",
807
+ "svelte": "Svelte does not validate ARIA parent-child requirements. Elements with roles like 'listitem', 'tab', 'menuitem' must be direct children of their required parent role. Component wrappers that add extra <div>s can break this relationship.",
808
+ "astro": "In .astro files, ARIA parent requirements must be met in the rendered HTML. Astro component boundaries can insert wrapper elements — verify the final DOM structure preserves the required parent-child role hierarchy."
809
+ },
810
+ "fix_difficulty_notes": "The fix is to ensure the DOM hierarchy matches the required ARIA role hierarchy. In SPAs, this is complicated by portals, overlays, and dynamic rendering that can detach child roles from their required parents. If restructuring the DOM is not feasible, aria-owns on the parent can establish the relationship regardless of DOM position — but this is a last resort and has inconsistent screen reader support.",
811
+ "related_rules": [
812
+ {
813
+ "id": "aria-required-children",
814
+ "reason": "Required parent and required children are complementary checks — fix both together to ensure complete ARIA relationships."
815
+ },
816
+ {
817
+ "id": "aria-treeitem-name",
818
+ "reason": "Each treeitem must be a child of a tree or group role — fix parent role requirements alongside naming."
819
+ }
820
+ ],
821
+ "guardrails_overrides": {
822
+ "must": [
823
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
824
+ ],
825
+ "must_not": [
826
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
827
+ ],
828
+ "verify": [
829
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
830
+ ]
831
+ }
832
+ },
833
+ "aria-roledescription": {
834
+ "category": "aria",
835
+ "fix": {
836
+ "description": "Apply aria-roledescription only to elements that have an explicit or implicit ARIA role. Do not use it on elements with no role or with role='generic' (the implicit role of <div> and <span>).",
837
+ "code": "<!-- Before: aria-roledescription on a generic div -->\n<div aria-roledescription=\"slide\">Slide content</div>\n<!-- After: add an appropriate role -->\n<div role=\"group\" aria-roledescription=\"slide\" aria-label=\"Slide 1 of 5\">\n Slide content\n</div>\n\n<!-- Valid: aria-roledescription on an element with an implicit role -->\n<button aria-roledescription=\"toggle switch\">Dark mode</button>"
838
+ },
839
+ "false_positive_risk": "low",
840
+ "framework_notes": {
841
+ "react": "In React, carousel or slider components often add aria-roledescription='slide' to plain <div> children. Add role='group' or role='region' to the div first, then apply aria-roledescription. Libraries like Embla Carousel may need a custom render function to inject the role.",
842
+ "vue": "In Vue, ensure the element receiving aria-roledescription has an explicit role. Swiper.js and similar carousel libraries may apply aria-roledescription to wrapper <div> elements without adding a semantic role — override via slot props or custom wrappers.",
843
+ "angular": "In Angular, bind [attr.aria-roledescription] only on elements that already have a role. If using a carousel CDK, ensure the slide container has role='group' or role='region' before applying the roledescription.",
844
+ "svelte": "Svelte passes aria-roledescription to the DOM as-is. This attribute must only be used on elements with an explicit or implicit ARIA role. It customizes how AT announces the role — use only when the default role announcement is insufficient.",
845
+ "astro": "In .astro files, aria-roledescription renders to static HTML. Ensure it is paired with a valid role. This is a specialized attribute — most elements do not need it."
846
+ },
847
+ "fix_difficulty_notes": "aria-roledescription customizes the role announcement for screen readers (e.g., 'slide' instead of 'group'). It only works on elements that have a role in the accessibility tree. Elements with no role or role='generic' (implicit for <div>/<span>) are ignored by AT, so the roledescription has no effect. The fix: add an appropriate semantic role to the element before applying aria-roledescription.",
848
+ "related_rules": [
849
+ {
850
+ "id": "aria-roles",
851
+ "reason": "The host element needs a valid ARIA role before aria-roledescription has any effect — fix the role first."
852
+ },
853
+ {
854
+ "id": "aria-braille-equivalent",
855
+ "reason": "aria-brailleroledescription must pair with aria-roledescription — fix both when either is present."
856
+ }
857
+ ],
858
+ "guardrails_overrides": {
859
+ "must": [
860
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
861
+ ],
862
+ "must_not": [
863
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
864
+ ],
865
+ "verify": [
866
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
867
+ ]
868
+ }
869
+ },
870
+ "aria-roles": {
871
+ "category": "aria",
872
+ "fix": {
873
+ "description": "Replace invalid ARIA role values with valid roles from the WAI-ARIA specification, or remove the role attribute if it is not needed.",
874
+ "code": "<!-- Invalid roles: -->\n<!-- role=\"text\" — not a valid ARIA role -->\n<!-- role=\"container\" — not a valid ARIA role -->\n<!-- role=\"button\" on a <button> — redundant, not a violation but unnecessary -->\n\n<!-- Valid replacements: -->\n<div role=\"region\" aria-labelledby=\"section-heading\">...</div>\n<span role=\"status\" aria-live=\"polite\">Saved successfully</span>\n<div role=\"alert\">Error: field is required</div>"
875
+ },
876
+ "false_positive_risk": "low",
877
+ "framework_notes": {
878
+ "react": "In React, ARIA roles are passed as the role prop. Use eslint-plugin-jsx-a11y (aria-role rule) to catch invalid role values at build time — there is no runtime validation.",
879
+ "vue": "In Vue, invalid role values are passed through to the DOM without validation. Use eslint-plugin-vuejs-accessibility to catch invalid role attributes in templates.",
880
+ "angular": "In Angular, @angular-eslint includes aria-role lint rules. Enable them in .eslintrc to validate role attribute values at compile time.",
881
+ "svelte": "Svelte does not validate ARIA role values at compile time. Invalid roles (typos, deprecated values) render silently. Use eslint-plugin-svelte or axe-core to catch invalid role values before deployment.",
882
+ "astro": "In .astro files, ARIA roles render to static HTML without validation. Typos in role values (e.g., role='buton' instead of role='button') will not be caught at build time — use axe-core."
883
+ },
884
+ "fix_difficulty_notes": "Common invalid roles: 'text' (not valid — use role='paragraph' or remove the role), 'input' (not valid — use native <input>), 'container' (not valid — use role='group'). Also watch for typos: 'dialouge' instead of 'dialog'. Adding a role that matches the element's implicit role (e.g., role='button' on <button>) is not a violation but is unnecessary — remove it for cleaner markup.",
885
+ "related_rules": [
886
+ {
887
+ "id": "aria-required-attr",
888
+ "reason": "After fixing an invalid role, verify the replacement role has all required ARIA attributes."
889
+ },
890
+ {
891
+ "id": "aria-prohibited-attr",
892
+ "reason": "After fixing the role, verify no ARIA attributes are prohibited for the new role."
893
+ },
894
+ {
895
+ "id": "aria-valid-attr-value",
896
+ "reason": "After fixing the role, verify attribute values are valid tokens for the corrected role."
897
+ },
898
+ {
899
+ "id": "aria-allowed-attr",
900
+ "reason": "An invalid role makes all attribute checks unreliable — fix the role first."
901
+ },
902
+ {
903
+ "id": "aria-allowed-role",
904
+ "reason": "aria-roles validates the role value itself; aria-allowed-role validates whether that role is appropriate for the host element."
905
+ },
906
+ {
907
+ "id": "aria-deprecated-role",
908
+ "reason": "Both rules validate role attribute values — fix all role violations together."
909
+ },
910
+ {
911
+ "id": "aria-roledescription",
912
+ "reason": "A valid role must exist before adding a custom roledescription — fix aria-roles first."
913
+ }
914
+ ],
915
+ "guardrails_overrides": {
916
+ "must": [
917
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
918
+ ],
919
+ "must_not": [
920
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
921
+ ],
922
+ "verify": [
923
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
924
+ ]
925
+ }
926
+ },
927
+ "aria-text": {
928
+ "category": "aria",
929
+ "fix": {
930
+ "description": "Ensure role='text' is used only on elements with no focusable descendants. role='text' tells assistive technology to treat the element's content as a single text string — if focusable children exist inside, they become unreachable.",
931
+ "code": "<!-- Valid: role='text' with no focusable descendants -->\n<p role=\"text\">\n On sale: <span class=\"price\">$9.99</span>\n</p>\n\n<!-- Invalid: role='text' with a focusable link inside -->\n<!-- <p role=\"text\">Visit <a href=\"/store\">our store</a></p> -->\n\n<!-- Fix: remove role='text' when interactive children exist -->\n<p>Visit <a href=\"/store\">our store</a></p>"
932
+ },
933
+ "false_positive_risk": "low",
934
+ "framework_notes": {
935
+ "react": "In React, role='text' is occasionally used on price or status components to prevent screen readers from splitting text across child spans. Ensure no <a>, <button>, or <input> elements exist inside the subtree. If interactive children are needed, remove role='text' and let the default content model apply.",
936
+ "vue": "In Vue, avoid using role='text' on wrapper components that accept slots — slot content may include interactive elements injected by consuming components. Validate the slot content at the template level or remove role='text' entirely.",
937
+ "angular": "In Angular, avoid role='text' on components with ng-content projections. Projected content may include interactive elements from parent templates, breaking the role='text' contract.",
938
+ "svelte": "In Svelte, role='text' can be used to prevent AT from splitting text interrupted by semantic elements. Ensure the element with role='text' contains only text content and no interactive children.",
939
+ "astro": "In .astro files, role='text' renders to static HTML. This role is used to prevent screen readers from splitting text content at semantic boundaries — use sparingly."
940
+ },
941
+ "fix_difficulty_notes": "role='text' is a convenience for preventing AT from splitting visually contiguous text into separate announcements (e.g., prices with styled currency symbols). It is not a WCAG requirement — if removing it causes no usability regression, removal is the safest fix. If role='text' is needed, audit all descendants to ensure none are focusable (no links, buttons, inputs, or elements with tabindex >= 0).",
942
+ "related_rules": [
943
+ {
944
+ "id": "nested-interactive",
945
+ "reason": "role='text' forbids focusable descendants — check for nested-interactive violations in the same element."
946
+ }
947
+ ],
948
+ "guardrails_overrides": {
949
+ "must": [
950
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
951
+ ],
952
+ "must_not": [
953
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
954
+ ],
955
+ "verify": [
956
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
957
+ ]
958
+ }
959
+ },
960
+ "aria-toggle-field-name": {
961
+ "category": "aria",
962
+ "preferred_relationship_checks": [
963
+ "aria-labelledby",
964
+ "explicit-label",
965
+ "label",
966
+ "aria-label",
967
+ "implicit-label"
968
+ ],
969
+ "managed_by_libraries": [
970
+ "radix",
971
+ "headless-ui",
972
+ "chakra",
973
+ "mantine",
974
+ "material-ui",
975
+ "polaris",
976
+ "react-aria",
977
+ "ariakit",
978
+ "shadcn",
979
+ "primevue",
980
+ "vuetify"
981
+ ],
982
+ "fix": {
983
+ "description": "Add an accessible name to toggle input fields (checkbox, radio, switch) via a <label>, aria-label, or aria-labelledby.",
984
+ "code": "<!-- Checkbox with associated label: -->\n<label for=\"subscribe\">\n <input type=\"checkbox\" id=\"subscribe\"> Subscribe to newsletter\n</label>\n\n<!-- Custom ARIA switch with aria-label: -->\n<button role=\"switch\" aria-checked=\"false\" aria-label=\"Enable dark mode\">\n <span class=\"toggle-knob\" aria-hidden=\"true\"></span>\n</button>"
985
+ },
986
+ "false_positive_risk": "low",
987
+ "framework_notes": {
988
+ "react": "In React, custom toggle components must spread aria-label and aria-checked to the underlying element. For native checkboxes, use htmlFor on <label>. For switches: <button role='switch' aria-checked={isOn} aria-label='Enable notifications'>.",
989
+ "vue": "In Vue, custom toggle components must pass aria-label and aria-checked to the root element. For native checkboxes, use <label :for='id'> with matching :id on the <input>.",
990
+ "angular": "In Angular, use <label [for]='checkboxId'> for native checkboxes. For custom switches, bind [attr.aria-label]='label' and [attr.aria-checked]='isChecked' on the role='switch' element.",
991
+ "svelte": "In Svelte, custom toggle components (checkboxes, switches, radio buttons) must have aria-label or aria-labelledby. Ensure wrapper components pass accessible names through to the underlying interactive element.",
992
+ "astro": "In .astro files, toggle roles (checkbox, switch, radio) need aria-label or visible labels in the static HTML. For dynamic toggles inside framework islands, the island component manages the accessible name."
993
+ },
994
+ "fix_difficulty_notes": "Custom toggle switches built with role='switch' require both aria-label AND aria-checked. Missing either triggers a violation. For checkbox inputs, a label must be associated via for/id or by wrapping — visually adjacent text without a <label> element is not sufficient for AT.",
995
+ "related_rules": [
996
+ {
997
+ "id": "label",
998
+ "reason": "The same label association pattern resolves both label and aria-toggle-field-name violations."
999
+ },
1000
+ {
1001
+ "id": "aria-required-attr",
1002
+ "reason": "role='switch' requires aria-checked — fix the accessible name and required attributes together."
1003
+ },
1004
+ {
1005
+ "id": "aria-input-field-name",
1006
+ "reason": "Fix all ARIA field naming violations together — they share the same accessible name patterns."
1007
+ }
1008
+ ],
1009
+ "guardrails_overrides": {
1010
+ "must": [
1011
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
1012
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1013
+ ],
1014
+ "must_not": [
1015
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
1016
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1017
+ ],
1018
+ "verify": [
1019
+ "Confirm computed accessible name matches expected spoken phrase.",
1020
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1021
+ ]
1022
+ }
1023
+ },
1024
+ "aria-tooltip-name": {
1025
+ "category": "aria",
1026
+ "managed_by_libraries": [
1027
+ "radix",
1028
+ "headless-ui",
1029
+ "chakra",
1030
+ "mantine",
1031
+ "material-ui",
1032
+ "polaris",
1033
+ "react-aria",
1034
+ "ariakit",
1035
+ "floating-ui",
1036
+ "primevue",
1037
+ "vuetify"
1038
+ ],
1039
+ "fix": {
1040
+ "description": "Give every element with role='tooltip' an accessible name via text content, aria-label, or aria-labelledby. Without a name, the tooltip is invisible to screen reader users.",
1041
+ "code": "<!-- Tooltip with text content (accessible name from content) -->\n<span role=\"tooltip\" id=\"password-hint\">\n Password must be at least 8 characters\n</span>\n<input type=\"password\" aria-describedby=\"password-hint\">\n\n<!-- Tooltip with aria-label (for icon-based tooltips) -->\n<div role=\"tooltip\" aria-label=\"Copy to clipboard shortcut: Ctrl+C\">\n <kbd>Ctrl</kbd>+<kbd>C</kbd>\n</div>"
1042
+ },
1043
+ "false_positive_risk": "low",
1044
+ "framework_notes": {
1045
+ "react": "In React, tooltip libraries (Radix Tooltip, Floating UI, React Tooltip) manage role='tooltip' and accessible names internally. If building a custom tooltip, ensure the tooltip element has text content or aria-label, and the trigger element references it via aria-describedby.",
1046
+ "vue": "In Vue, Floating Vue (v-tooltip) and Headless UI manage tooltip semantics. For custom tooltips, bind role='tooltip' and ensure text content or :aria-label is present on the tooltip element.",
1047
+ "angular": "In Angular, Angular CDK Overlay with MatTooltip handles role='tooltip' and accessible naming. For custom tooltips, ensure the overlay element has role='tooltip' and contains text content or [attr.aria-label].",
1048
+ "svelte": "In Svelte, elements with role='tooltip' must have accessible text content or aria-label. Tooltip libraries for Svelte (like Tippy.js/svelte) should expose a label prop for the tooltip trigger.",
1049
+ "astro": "In .astro files, role='tooltip' elements need text content or aria-label in the static HTML. For dynamic tooltips inside framework islands, the island framework's tooltip component rules apply."
1050
+ },
1051
+ "fix_difficulty_notes": "Tooltips should contain plain text that describes or supplements the trigger element. Complex interactive content inside a tooltip is an anti-pattern — use a dialog or popover instead. The tooltip's accessible name comes from its text content; aria-label is only needed when the tooltip contains non-text content (icons, formatted markup).",
1052
+ "related_rules": [
1053
+ {
1054
+ "id": "aria-command-name",
1055
+ "reason": "The trigger element for a tooltip often also needs an accessible name — fix both the tooltip and its trigger together."
1056
+ }
1057
+ ],
1058
+ "guardrails_overrides": {
1059
+ "must": [
1060
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
1061
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1062
+ ],
1063
+ "must_not": [
1064
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
1065
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1066
+ ],
1067
+ "verify": [
1068
+ "Confirm computed accessible name matches expected spoken phrase.",
1069
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1070
+ ]
1071
+ }
1072
+ },
1073
+ "aria-treeitem-name": {
1074
+ "category": "aria",
1075
+ "fix": {
1076
+ "description": "Ensure every element with role='treeitem' has an accessible name via its text content, aria-label, or aria-labelledby.",
1077
+ "code": "<!-- Treeitem with visible text content (preferred): -->\n<ul role=\"tree\" aria-label=\"File browser\">\n <li role=\"treeitem\" aria-expanded=\"false\">\n Documents\n <ul role=\"group\">\n <li role=\"treeitem\">Resume.pdf</li>\n <li role=\"treeitem\">Cover letter.docx</li>\n </ul>\n </li>\n</ul>\n\n<!-- Treeitem with aria-label (when icon-only): -->\n<li role=\"treeitem\" aria-label=\"Inbox (3 unread)\">\n <svg aria-hidden=\"true\">...</svg>\n</li>"
1078
+ },
1079
+ "false_positive_risk": "low",
1080
+ "framework_notes": {
1081
+ "react": "In React, tree components (React Aria TreeView, MUI TreeView, Ant Design Tree) manage treeitem names from a data prop. Ensure the label or name field is never empty. For custom tree implementations, pass aria-label to icon-only tree items.",
1082
+ "vue": "In Vue, PrimeVue Tree and Vuetify Treeview derive treeitem names from the data model label field. Verify that all tree data nodes have a non-empty label. For custom trees, add aria-label to icon-only items.",
1083
+ "angular": "In Angular, Angular Material Tree (mat-tree-node) does not automatically set an accessible name — the template must include visible text or an aria-label. Use [attr.aria-label]='node.name' on mat-tree-node elements.",
1084
+ "svelte": "In Svelte, tree view components must ensure every role='treeitem' has visible text or aria-label. Custom tree implementations should follow the WAI-ARIA Tree View pattern for keyboard navigation and naming.",
1085
+ "astro": "In .astro files, role='treeitem' elements need visible text or aria-label. Tree views are complex widgets — consider rendering them inside a framework island for proper keyboard and state management."
1086
+ },
1087
+ "fix_difficulty_notes": "Tree views are complex ARIA widgets requiring correct role hierarchy (tree > treeitem > group > treeitem), aria-expanded on parent nodes, and an accessible name on every treeitem. The most common violation is icon-only tree nodes (file browser icons, folder icons) missing aria-label. Add aria-label to every treeitem that lacks visible text content.",
1088
+ "related_rules": [
1089
+ {
1090
+ "id": "aria-required-children",
1091
+ "reason": "Tree structures require correct nesting of treeitem within tree and group roles — fix hierarchy and naming together."
1092
+ },
1093
+ {
1094
+ "id": "aria-required-parent",
1095
+ "reason": "Each treeitem must be a child of a tree or group role — fix parent role requirements alongside naming."
1096
+ }
1097
+ ],
1098
+ "guardrails_overrides": {
1099
+ "must": [
1100
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
1101
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1102
+ ],
1103
+ "must_not": [
1104
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
1105
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1106
+ ],
1107
+ "verify": [
1108
+ "Confirm computed accessible name matches expected spoken phrase.",
1109
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1110
+ ]
1111
+ }
1112
+ },
1113
+ "aria-valid-attr": {
1114
+ "category": "aria",
1115
+ "managed_by_libraries": [
1116
+ "radix",
1117
+ "headless-ui",
1118
+ "chakra",
1119
+ "mantine",
1120
+ "material-ui",
1121
+ "polaris",
1122
+ "react-aria",
1123
+ "ariakit",
1124
+ "shadcn",
1125
+ "primevue",
1126
+ "vuetify"
1127
+ ],
1128
+ "fix": {
1129
+ "description": "Replace any misspelled or invented aria-* attribute names with valid ARIA attribute names from the WAI-ARIA specification. Invalid aria-* attributes are silently ignored by assistive technologies.",
1130
+ "code": "<!-- Before: misspelled or invalid ARIA attributes -->\n<button aria-labelled=\"Save\">Save</button> <!-- should be aria-label -->\n<div aria-role=\"alert\">Error</div> <!-- aria-role is not valid; use role=\"alert\" -->\n\n<!-- After: corrected attributes -->\n<button aria-label=\"Save\">Save</button>\n<div role=\"alert\">Error</div>"
1131
+ },
1132
+ "false_positive_risk": "low",
1133
+ "framework_notes": {
1134
+ "react": "React does not validate aria-* attribute names at compile time — misspelled attributes pass through to the DOM silently. Use eslint-plugin-jsx-a11y which includes the aria-props rule to catch invalid attribute names. Common typos: aria-labelled (should be aria-label), aria-role (should be role).",
1135
+ "vue": "Vue does not validate aria-* attributes. Use eslint-plugin-vuejs-accessibility to catch misspelled ARIA attributes in templates. The most common mistake is aria-labelled instead of aria-label or aria-labelledby.",
1136
+ "angular": "Angular does not validate aria-* attributes at compile time. Enable @angular-eslint ARIA rules to detect invalid attribute names. TypeScript does not type-check HTML attribute strings, so aria-* typos pass through uncaught.",
1137
+ "svelte": "Svelte does not validate ARIA attribute names at compile time. Typos (e.g., aria-lable instead of aria-label) render silently to the DOM. Use eslint-plugin-svelte's a11y rules to catch invalid ARIA attribute names.",
1138
+ "astro": "In .astro files, ARIA attributes render to static HTML without name validation. Typos in ARIA attribute names are invisible at build time — use axe-core or lint tools to catch them."
1139
+ },
1140
+ "fix_difficulty_notes": "This rule catches typos and invented attribute names. The fix is almost always correcting a misspelled attribute. Common mistakes: aria-labelled (should be aria-label or aria-labelledby), aria-role (not an ARIA attribute — use the role HTML attribute), aria-description (valid in ARIA 1.3 but not yet widely supported — use aria-describedby with a referenced element instead). Consult https://www.w3.org/TR/wai-aria/#state_prop_def for the complete list of valid aria-* attributes.",
1141
+ "related_rules": [
1142
+ {
1143
+ "id": "aria-valid-attr-value",
1144
+ "reason": "After fixing the attribute name, verify the value is also valid."
1145
+ }
1146
+ ],
1147
+ "guardrails_overrides": {
1148
+ "must": [
1149
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1150
+ ],
1151
+ "must_not": [
1152
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1153
+ ],
1154
+ "verify": [
1155
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1156
+ ]
1157
+ }
1158
+ },
1159
+ "aria-valid-attr-value": {
1160
+ "category": "aria",
1161
+ "managed_by_libraries": [
1162
+ "radix",
1163
+ "headless-ui",
1164
+ "chakra",
1165
+ "mantine",
1166
+ "material-ui",
1167
+ "polaris",
1168
+ "react-aria",
1169
+ "ariakit",
1170
+ "shadcn",
1171
+ "primevue",
1172
+ "vuetify"
1173
+ ],
1174
+ "fix": {
1175
+ "description": "Set a valid value for the ARIA attribute. Consult the WAI-ARIA spec for the list of allowed values.",
1176
+ "code": "<!-- Common invalid → valid corrections: -->\n\n<!-- aria-live: must be 'off' | 'polite' | 'assertive' -->\n<div aria-live=\"polite\">...</div> <!-- not aria-live=\"yes\" -->\n\n<!-- aria-expanded: must be 'true' | 'false' -->\n<button aria-expanded=\"false\">Menu</button> <!-- not aria-expanded=\"0\" -->\n\n<!-- aria-haspopup: must be 'true' | 'false' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' -->\n<button aria-haspopup=\"menu\">Options</button> <!-- not aria-haspopup=\"dropdown\" -->"
1177
+ },
1178
+ "false_positive_risk": "low",
1179
+ "framework_notes": {
1180
+ "react": "React coerces aria-expanded={isOpen} to the string 'true'/'false' correctly. Do not use aria-expanded={isOpen ? '1' : '0'} or other non-token values. Use aria-expanded={undefined} to omit the attribute entirely when the role does not require it.",
1181
+ "vue": "Use :aria-expanded='isOpen' — Vue renders boolean true/false as the strings 'true'/'false'. Do not use :aria-expanded='isOpen ? 1 : 0'. To omit the attribute, bind to null: :aria-expanded='null'.",
1182
+ "angular": "Use [attr.aria-expanded]='isOpen' binding — Angular renders the boolean as the correct string. Setting [attr.aria-expanded]='null' removes the attribute entirely when not applicable.",
1183
+ "svelte": "Svelte passes ARIA attribute values to the DOM without validation. Invalid values (e.g., aria-hidden='yes' instead of 'true', aria-live='aggressive' instead of 'assertive') render silently. Validate against the WAI-ARIA spec.",
1184
+ "astro": "In .astro files, ARIA attribute values render to static HTML without validation. Use axe-core to catch invalid ARIA values in the rendered output."
1185
+ },
1186
+ "fix_difficulty_notes": "The most common mistake is using incorrect types for boolean attributes: aria-expanded='0' or aria-expanded='yes' instead of 'true'/'false'. In template engines, ensure dynamic state is coerced to the string 'true' or 'false', not a JavaScript boolean. React spreads boolean props correctly — aria-expanded={isOpen} renders 'true'/'false' — but aria-expanded={isOpen ? 1 : 0} does not.",
1187
+ "related_rules": [
1188
+ {
1189
+ "id": "aria-required-attr",
1190
+ "reason": "Required attributes must be present before their values can be validated — fix both together."
1191
+ },
1192
+ {
1193
+ "id": "aria-roles",
1194
+ "reason": "An invalid role produces invalid attribute values — fix the role before the attribute values."
1195
+ },
1196
+ {
1197
+ "id": "aria-prohibited-attr",
1198
+ "reason": "After removing prohibited attrs, verify remaining attribute values are valid tokens."
1199
+ },
1200
+ {
1201
+ "id": "aria-conditional-attr",
1202
+ "reason": "After fixing the attribute choice, verify the value is a valid token."
1203
+ },
1204
+ {
1205
+ "id": "aria-valid-attr",
1206
+ "reason": "After fixing the attribute name, verify the value is also valid."
1207
+ }
1208
+ ],
1209
+ "guardrails_overrides": {
1210
+ "must": [
1211
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1212
+ ],
1213
+ "must_not": [
1214
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1215
+ ],
1216
+ "verify": [
1217
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1218
+ ]
1219
+ }
1220
+ },
1221
+ "audio-caption": {
1222
+ "category": "text-alternatives",
1223
+ "fix": {
1224
+ "description": "Add a <track kind='captions'> element inside every <audio> element, or provide a text transcript linked adjacent to the player.",
1225
+ "code": "<audio controls>\n <source src=\"podcast.mp3\" type=\"audio/mpeg\">\n <track kind=\"captions\" src=\"podcast.vtt\" srclang=\"en\" label=\"English captions\" default>\n</audio>\n\n<!-- Alternative: linked transcript -->\n<audio controls src=\"podcast.mp3\"></audio>\n<p><a href=\"podcast-transcript.html\">Read the full transcript</a></p>"
1226
+ },
1227
+ "false_positive_risk": "low",
1228
+ "framework_notes": {
1229
+ "react": "In React, pass <track> as a child of the <audio> JSX element. React requires a key prop on <track> when rendered in a list. Use the default prop (boolean) to set the default caption track.",
1230
+ "vue": "In Vue, nest <track> inside <audio> in the template. For dynamic caption src, bind :src='captionsSrc'. The default boolean attribute is passed as a plain HTML attribute without binding.",
1231
+ "angular": "In Angular, the native <audio> element with nested <track> works in component templates. For dynamic caption URLs, use [attr.src]='captionsSrc' on the <track> element.",
1232
+ "svelte": "In Svelte, add <track kind='captions'> inside <audio> elements. Svelte does not warn about missing captions on media elements — validate manually or with axe-core.",
1233
+ "astro": "In .astro files, <audio> elements need <track kind='captions'> in the static HTML. For audio players inside framework islands, add tracks within the island component."
1234
+ },
1235
+ "fix_difficulty_notes": "Captions must be synchronized with the audio — a plain text page is a transcript, not captions. VTT (WebVTT) is the standard format for <track> elements and is supported in all modern browsers. Generating captions retroactively is effort-intensive; tools like Whisper (OpenAI), Adobe Premiere, or Rev.com can auto-generate VTT files that require human review before publishing.",
1236
+ "related_rules": [
1237
+ {
1238
+ "id": "video-caption",
1239
+ "reason": "Both require media captions — fix all media accessibility violations together."
1240
+ },
1241
+ {
1242
+ "id": "no-autoplay-audio",
1243
+ "reason": "Auto-playing audio also needs captions if it conveys information — fix both together."
1244
+ }
1245
+ ],
1246
+ "guardrails_overrides": {
1247
+ "must": [
1248
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
1249
+ ],
1250
+ "must_not": [
1251
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
1252
+ ],
1253
+ "verify": [
1254
+ "Confirm computed accessible name matches expected spoken phrase."
1255
+ ]
1256
+ }
1257
+ },
1258
+ "autocomplete-valid": {
1259
+ "category": "forms",
1260
+ "fix": {
1261
+ "description": "Add a valid autocomplete token to form inputs to assist users with autofill.",
1262
+ "code": "<input type=\"email\" name=\"email\" autocomplete=\"email\">\n<input type=\"text\" name=\"name\" autocomplete=\"name\">\n<input type=\"tel\" name=\"phone\" autocomplete=\"tel\">"
1263
+ },
1264
+ "false_positive_risk": "low",
1265
+ "framework_notes": {
1266
+ "react": "Pass autocomplete as a string prop in JSX: <input autoComplete='email' />. Note: React uses camelCase autoComplete, which renders as the autocomplete HTML attribute. React Hook Form does not intercept it — set it directly on the input element.",
1267
+ "vue": "Use autocomplete='email' as a plain HTML attribute in Vue templates. Vee-Validate passes the autocomplete attribute through to the native input via v-bind on the field component.",
1268
+ "angular": "Use the autocomplete attribute in Angular Reactive Forms or Template-driven forms — Angular does not strip or override it. For Angular Material inputs, add autocomplete='email' to the <input> element inside <mat-form-field>.",
1269
+ "svelte": "Use the standard HTML autocomplete attribute: <input autocomplete='email'>. Svelte passes all attributes through to the rendered element. For form libraries like Superforms, set autocomplete on the <input> directly, not through the library's config.",
1270
+ "astro": "In .astro files, use the standard HTML autocomplete attribute. For form elements inside framework islands, each framework's syntax applies (e.g., autoComplete in React islands)."
1271
+ },
1272
+ "cms_notes": {
1273
+ "shopify": "Shopify checkout forms handle autocomplete automatically. For custom theme forms (newsletter signup, contact), add autocomplete tokens to each input: autocomplete='email' for email, autocomplete='given-name' for first name. Dawn's forms include these by default.",
1274
+ "wordpress": "WordPress login and comment forms include autocomplete by default. Form plugins (Gravity Forms, WPForms) may not set autocomplete tokens — check the plugin's field settings or add them via a custom filter. Use woocommerce_form_field_args to add autocomplete to WooCommerce checkout fields.",
1275
+ "drupal": "Drupal's Form API supports #attributes['autocomplete'] on form elements. For login forms, Drupal core sets autocomplete='username' and autocomplete='current-password'. Custom forms built with Form API should explicitly set the autocomplete token via #attributes."
1276
+ },
1277
+ "fix_difficulty_notes": "Acceptable autocomplete tokens are strictly defined by the HTML Living Standard. Custom or invented values (e.g., autocomplete='my-app-email') are invalid even if they help password managers. Use only the tokens listed at https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute. Verify you are applying the token to the right field type — autocomplete='email' on a phone number field is invalid.",
1278
+ "guardrails_overrides": {
1279
+ "must_not": [
1280
+ "Do not use custom or invented autocomplete values — only use the tokens defined in the HTML Living Standard.",
1281
+ "Do not apply autocomplete='off' as a fix — it disables autofill and harms usability for users who rely on password managers."
1282
+ ],
1283
+ "verify": [
1284
+ "Confirm the autocomplete token matches the field's expected input type (e.g., 'email' on an email field, 'tel' on a phone field)."
1285
+ ]
1286
+ },
1287
+ "related_rules": [
1288
+ {
1289
+ "id": "label",
1290
+ "reason": "Properly labeled inputs should also have correct autocomplete tokens — fix label association and autocomplete together."
1291
+ }
1292
+ ]
1293
+ },
1294
+ "avoid-inline-spacing": {
1295
+ "category": "sensory",
1296
+ "fix": {
1297
+ "description": "Move text spacing properties (letter-spacing, word-spacing, line-height) from inline style attributes into a stylesheet so user CSS can override them. Hard-coded inline styles with !important prevent users with low vision from adjusting text spacing (WCAG 1.4.12).",
1298
+ "code": "<!-- Before: inline spacing that cannot be overridden -->\n<p style=\"letter-spacing: 0.1em !important; line-height: 1.2 !important;\">Text content</p>\n\n<!-- After: move spacing to a stylesheet class (overridable by user CSS) -->\n<p class=\"body-text\">Text content</p>\n<!-- In CSS: -->\n<!-- .body-text { letter-spacing: 0.1em; line-height: 1.5; } -->"
1299
+ },
1300
+ "false_positive_risk": "medium",
1301
+ "framework_notes": {
1302
+ "react": "In React, inline styles via the style prop (e.g., style={{ letterSpacing: '0.1em' }}) render as inline style attributes. Move these to CSS classes or CSS Modules. CSS-in-JS solutions (styled-components, Emotion) generate stylesheet rules that are overridable — they are preferable to inline styles for spacing properties.",
1303
+ "vue": "In Vue, :style bindings render as inline styles and have the same issue. Move letter-spacing, word-spacing, and line-height to <style scoped> classes. Scoped styles generate class-based selectors that user stylesheets can override.",
1304
+ "angular": "In Angular, [ngStyle] and [style.*] bindings render as inline styles. Move spacing properties to component stylesheets. Angular's ViewEncapsulation.Emulated generates scoped class selectors that are overridable by user CSS.",
1305
+ "svelte": "In Svelte, style directives (style:letter-spacing='0.1em') render as inline styles. Move spacing properties to the component's <style> block or CSS classes instead. Scoped styles are overridable by user stylesheets.",
1306
+ "astro": "In .astro files, inline style attributes render to static HTML. Move text spacing properties (letter-spacing, word-spacing, line-height) to the component's <style> block or external CSS."
1307
+ },
1308
+ "fix_difficulty_notes": "Inline styles with !important on spacing properties are the primary trigger. Inline styles without !important are technically overridable but still flagged by axe as a risk. The safest fix: move all text spacing (letter-spacing, word-spacing, line-height) to stylesheet classes. CMS-generated content and WYSIWYG editors are common sources of inline spacing styles — sanitize the output to remove inline spacing declarations.",
1309
+ "cms_notes": {
1310
+ "shopify": "Shopify's WYSIWYG rich text editor (theme editor and Metaobject editor) can inject inline letter-spacing and line-height via the Shopify HTML sanitizer. In Liquid, sanitize rich text output using the strip_html filter if spacing styles are injected. For sections with typed content, prevent inline styles by restricting the rich text schema to text blocks only.",
1311
+ "wordpress": "WordPress's Classic Editor (TinyMCE) and Gutenberg's Typography panel both inject inline letter-spacing, word-spacing, and line-height via style attributes. In Gutenberg, these are set per-block. To fix at scale, use the remove_filter hook to strip inline style attributes from wp_kses_post output, or configure Gutenberg's theme.json to disable per-block typography controls.",
1312
+ "drupal": "Drupal's CKEditor (4 and 5) can inject inline style attributes when authors use the Format or Style dropdowns. Restrict allowed HTML attributes in the text format's 'Limit allowed HTML tags' filter to exclude the style attribute. Use the 'Styles' plugin configuration to apply classes instead of inline styles."
1313
+ },
1314
+ "related_rules": [
1315
+ {
1316
+ "id": "css-orientation-lock",
1317
+ "reason": "Both rules address WCAG 1.3.x/1.4.x adaptability — user ability to override visual presentation. Audit display adaptation constraints together."
1318
+ },
1319
+ {
1320
+ "id": "meta-viewport-large",
1321
+ "reason": "Restricting zoom (meta-viewport-large) and blocking text spacing overrides (avoid-inline-spacing) are both WCAG 1.4.x adaptability failures — fix as a group."
1322
+ }
1323
+ ],
1324
+ "guardrails_overrides": {
1325
+ "must_not": [
1326
+ "Do not strip ALL inline styles from the element — only remove the specific text spacing properties (letter-spacing, word-spacing, line-height). Other inline styles (color, background) are unrelated to this rule.",
1327
+ "Do not move spacing into a !important declaration in CSS — user stylesheets must be able to override them."
1328
+ ],
1329
+ "verify": [
1330
+ "Confirm the moved spacing properties in the stylesheet are not marked !important and can be overridden by a user stylesheet."
1331
+ ]
1332
+ }
1333
+ },
1334
+ "blink": {
1335
+ "category": "sensory",
1336
+ "fix": {
1337
+ "description": "Remove all <blink> elements from the page. The <blink> element is deprecated in HTML and causes content to flash, which is inaccessible to users with cognitive disabilities and can trigger seizures in users with photosensitive epilepsy.",
1338
+ "code": "<!-- Before: blinking text -->\n<blink>Important announcement!</blink>\n\n<!-- After: use CSS animation with user preference respect -->\n<p class=\"announcement\">Important announcement!</p>\n<!-- In CSS: -->\n<!-- .announcement { animation: fade 2s ease-in-out; } -->\n<!-- @media (prefers-reduced-motion: reduce) { .announcement { animation: none; } } -->"
1339
+ },
1340
+ "false_positive_risk": "low",
1341
+ "framework_notes": {
1342
+ "react": "The <blink> element is not a valid JSX element — React will render it as an unknown HTML tag. If encountered in legacy code, replace it with a styled <span> or <p>. If animation is needed, use CSS with prefers-reduced-motion support.",
1343
+ "vue": "In Vue, <blink> renders as an unknown HTML element. Replace with a standard element and CSS animation that respects prefers-reduced-motion.",
1344
+ "angular": "In Angular, <blink> is treated as an unknown element. Replace it with a standard element. If attention-grabbing styling is needed, use Angular animations with a prefers-reduced-motion media query check.",
1345
+ "svelte": "The <blink> element is deprecated HTML — Svelte will render it but it should never be used. Replace with CSS animation that respects prefers-reduced-motion.",
1346
+ "astro": "The <blink> element should never appear in .astro files. Replace with CSS animation that uses @media (prefers-reduced-motion: reduce) { animation: none; }."
1347
+ },
1348
+ "fix_difficulty_notes": "This is extremely easy to fix — simply replace <blink> with a standard HTML element. The <blink> element is obsolete in the HTML spec and is not supported by any modern browser. If you encounter it, it is in legacy code that needs updating. No modern browser renders the blink effect, but the element is still flagged because it indicates outdated, inaccessible intent.",
1349
+ "related_rules": [
1350
+ {
1351
+ "id": "marquee",
1352
+ "reason": "Both <blink> and <marquee> are deprecated elements that cause inaccessible animations — remove both together."
1353
+ }
1354
+ ],
1355
+ "guardrails_overrides": {
1356
+ "must": [
1357
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
1358
+ ],
1359
+ "must_not": [
1360
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
1361
+ ],
1362
+ "verify": [
1363
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
1364
+ ]
1365
+ }
1366
+ },
1367
+ "button-name": {
1368
+ "category": "name-role-value",
1369
+ "preferred_relationship_checks": [
1370
+ "aria-labelledby",
1371
+ "aria-label",
1372
+ "explicit-label",
1373
+ "implicit-label",
1374
+ "presentational-role"
1375
+ ],
1376
+ "managed_by_libraries": [
1377
+ "radix",
1378
+ "headless-ui",
1379
+ "chakra",
1380
+ "mantine",
1381
+ "material-ui",
1382
+ "polaris",
1383
+ "react-aria",
1384
+ "ariakit",
1385
+ "shadcn",
1386
+ "primevue",
1387
+ "vuetify"
1388
+ ],
1389
+ "fix": {
1390
+ "description": "Give every button an accessible name via visible text or aria-label.",
1391
+ "code": "<!-- Via visible text: -->\n<button>Submit form</button>\n<!-- Icon button via aria-label: -->\n<button aria-label=\"Close dialog\"><svg aria-hidden=\"true\">...</svg></button>"
1392
+ },
1393
+ "false_positive_risk": "low",
1394
+ "framework_notes": {
1395
+ "react": "Use aria-label prop: <button aria-label=\"Close dialog\">. Prefer visible text children whenever possible.",
1396
+ "vue": "Use :aria-label or aria-label attribute — standard HTML semantics apply.",
1397
+ "angular": "Use [attr.aria-label] binding or the CDK a11y AccessibilityModule for managed focus.",
1398
+ "svelte": "Svelte's compiler warns about missing button names at build time. For icon buttons, use aria-label directly: <button aria-label='Close'>. If using an on:click on a non-button element, Svelte will also warn — prefer native <button> elements.",
1399
+ "astro": "Buttons in Astro's static HTML render without JS — ensure the accessible name is in the HTML, not added via client-side scripts. For interactive island buttons (React/Vue/Svelte inside Astro), the framework's rules apply within the island."
1400
+ },
1401
+ "cms_notes": {
1402
+ "shopify": "Icon buttons in Dawn and other themes often use {% render 'icon-close' %} snippets without aria-label. Add aria-label to the <button> in the section template, not the snippet — snippets lack context about the button's purpose.",
1403
+ "wordpress": "In Gutenberg custom blocks, use the RichText component for button text or set aria-label in the block's save() function. Avoid adding labels only in edit() — they must be in the rendered HTML.",
1404
+ "drupal": "In Twig templates, add aria-label via the Attribute object: <button{{ attributes.setAttribute('aria-label', 'Close'|t) }}>. Use Drupal's |t filter for translatable labels."
1405
+ },
1406
+ "fix_difficulty_notes": [
1407
+ "Using `aria-label` on a button with visible text creates a label mismatch — voice control users speak the visible text, not the `aria-label`. If visible text exists, keep it and remove `aria-label`.",
1408
+ "Only use `aria-label` for icon-only buttons with no visible text."
1409
+ ],
1410
+ "related_rules": [
1411
+ {
1412
+ "id": "input-button-name",
1413
+ "reason": "The same accessible name requirement applies to <input type='button'/'submit'/'reset'> — fix all button naming violations together."
1414
+ },
1415
+ {
1416
+ "id": "link-name",
1417
+ "reason": "The same accessible name requirement applies to links — fix all interactive element naming together."
1418
+ },
1419
+ {
1420
+ "id": "aria-command-name",
1421
+ "reason": "button-name covers native <button> elements — fix all button naming violations together."
1422
+ },
1423
+ {
1424
+ "id": "summary-name",
1425
+ "reason": "summary acts as a toggle button — both must have accessible names for the disclosure widget to be operable."
1426
+ }
1427
+ ],
1428
+ "guardrails_overrides": {
1429
+ "must": [
1430
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
1431
+ ],
1432
+ "must_not": [
1433
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
1434
+ ],
1435
+ "verify": [
1436
+ "Confirm computed accessible name matches expected spoken phrase."
1437
+ ]
1438
+ }
1439
+ },
1440
+ "bypass": {
1441
+ "category": "keyboard",
1442
+ "fix": {
1443
+ "description": "Add a skip link at the very top of each page so keyboard users can bypass repetitive navigation.",
1444
+ "code": "<a class=\"skip-link\" href=\"#main-content\">Skip to main content</a>\n<nav aria-label=\"Primary\">\n <a href=\"/\">Home</a>\n <a href=\"/shop\">Shop</a>\n <a href=\"/about\">About</a>\n</nav>\n<main id=\"main-content\">\n <h1>Welcome to our store</h1>\n</main>"
1445
+ },
1446
+ "false_positive_risk": "medium",
1447
+ "framework_notes": {
1448
+ "react": "In Next.js, add the skip link as the first element in the root layout (app/layout.tsx), before the <header> or navigation components. Style it with CSS to appear only on :focus. The target #main-content must reference the <main id='main-content'> in the same layout.",
1449
+ "vue": "In Nuxt, add the skip link at the very top of layouts/default.vue, before the <header> component. The target #main-content must be on the <main> element wrapping <slot />.",
1450
+ "angular": "Add the skip link as the first element inside app.component.html, before <app-header> or navigation. The target #main-content must match the id on the <main> element rendered inside the page — typically adjacent to <router-outlet>.",
1451
+ "svelte": "In SvelteKit, add the skip link as the first element in +layout.svelte, before any <header> or navigation. SvelteKit handles page transitions client-side — use afterNavigate() to reset focus to the skip link target on route changes.",
1452
+ "astro": "Place the skip link in your base layout (layouts/BaseLayout.astro) as the first element in <body>. Since Astro pre-renders static HTML, the skip link and its target are always in the DOM without hydration delays."
1453
+ },
1454
+ "cms_notes": {
1455
+ "shopify": "Dawn theme includes a skip link in layout/theme.liquid. If building a custom theme, add <a href='#MainContent' class='skip-to-content-link'>Skip to content</a> before the header section. The target id='MainContent' should be on the <main> element wrapping {{ content_for_layout }}.",
1456
+ "wordpress": "Most WordPress themes include a skip link in header.php. If missing, add it as the first element inside <body>. The Underscores (_s) starter theme includes a skip link by default. Block themes should include it in the header template part.",
1457
+ "drupal": "Olivero includes a skip link by default. Custom themes should add it in page.html.twig as the first element inside <body>. Drupal's #skip-link region is designed for this purpose — ensure it is rendered before the page header region."
1458
+ },
1459
+ "fix_difficulty_notes": "The skip link must be visible on focus — hiding it with display:none or visibility:hidden prevents keyboard users from seeing or activating it. The correct pattern is to position it off-screen by default (position:absolute; left:-9999px) and bring it on-screen on :focus (left:0; top:0). The target anchor (#main-content) must be a real element — if it's missing or the href is broken, the skip link appears to work but focus doesn't actually move.",
1460
+ "related_rules": [
1461
+ {
1462
+ "id": "landmark-one-main",
1463
+ "reason": "The skip link target (#main-content) must point to the <main> landmark."
1464
+ },
1465
+ {
1466
+ "id": "landmark-unique",
1467
+ "reason": "Unique landmark labels enhance skip navigation — users can jump directly to a specific landmark."
1468
+ },
1469
+ {
1470
+ "id": "skip-link",
1471
+ "reason": "Skip links are the primary mechanism for satisfying the bypass blocks requirement (WCAG 2.4.1)."
1472
+ },
1473
+ {
1474
+ "id": "accesskeys",
1475
+ "reason": "Both are keyboard enhancement mechanisms — accesskey conflicts with the shortcuts bypass provides."
1476
+ }
1477
+ ],
1478
+ "guardrails_overrides": {
1479
+ "must": [
1480
+ "Place the skip link as the very first focusable element in the DOM, before any navigation or header markup."
1481
+ ],
1482
+ "must_not": [
1483
+ "Do not hide the skip link with display:none or visibility:hidden — use the off-screen CSS pattern (position:absolute; left:-9999px) and bring on-screen on :focus.",
1484
+ "Do not point the href to a non-existent or unmatched id."
1485
+ ],
1486
+ "verify": [
1487
+ "Confirm the skip link is the first focusable element in keyboard tab order.",
1488
+ "Confirm activating the skip link moves focus to the <main> content target."
1489
+ ]
1490
+ }
1491
+ },
1492
+ "color-contrast": {
1493
+ "category": "color",
1494
+ "fix": {
1495
+ "description": "The Observed Violation contains the exact foreground hex, background hex, and failing ratio. Use those values as your starting point: input both into https://webaim.org/resources/contrastchecker/, darken the foreground (or lighten the background) until the ratio reaches ≥4.5:1 for normal text or ≥3:1 for large text (≥18pt / ≥14pt bold). Then locate the CSS token or utility class that sets that foreground color in the source and replace it with the compliant value.",
1496
+ "code": "/* Step 1 — identify the failing pair from Observed Violation, e.g.: */\n/* foreground: #8e8a86 / background: #f8f8f8 / ratio: 3.22 — target ≥4.5:1 */\n\n/* Step 2 — find the CSS token or Tailwind class that sets the color */\n/* e.g. text-cloud → look up --color-cloud in CSS vars or tailwind.config.js */\n\n/* Step 3 — replace with a compliant value */\n.element {\n color: #5e5b58; /* example: darkened value — verify ratio before committing */\n}\n/* Or update the CSS custom property at its definition point: */\n:root {\n --color-cloud: #5e5b58; /* was #8e8a86 (3.22:1) → now verify ≥4.5:1 */\n}"
1497
+ },
1498
+ "false_positive_risk": "high",
1499
+ "framework_notes": {
1500
+ "react": "In Tailwind (including shadcn/ui), verify that HSL CSS custom properties resolve to accessible values in both light and dark themes. CSS-in-JS libraries (styled-components, Emotion) compute colors at runtime — axe audits the computed value, not the source variable.",
1501
+ "vue": "In Vue with Tailwind or CSS Modules, verify contrast in the computed styles, not just the source CSS variables. Use DevTools color picker to confirm the rendered ratio.",
1502
+ "angular": "In Angular Material, override theme colors via mat.define-theme() using contrast-checked color pairs. Avoid raw CSS overrides that bypass the Angular Material theming system's built-in contrast checks.",
1503
+ "svelte": "Svelte's scoped styles compile to unique class names — contrast violations must be fixed in the component's <style> block, not global CSS. When using Tailwind with SvelteKit, verify that dark mode colors (dark:text-*) also meet contrast requirements.",
1504
+ "astro": "Astro renders static HTML with inlined styles — contrast issues are baked into the build output. Fix colors in the source .astro component's <style> block or at the CSS variable definition level (e.g., :root in global.css)."
1505
+ },
1506
+ "cms_notes": {
1507
+ "shopify": "Theme color settings are defined in settings_schema.json and applied via Liquid ({{ settings.color_text }}). Verify that the default color values meet 4.5:1 contrast. Merchants can override these — add contrast guidance in the setting description field.",
1508
+ "wordpress": "Classic themes use the Customizer's color settings; block themes use theme.json's color.palette. Verify default palette contrast pairs. If using wp_add_inline_style(), ensure injected colors are contrast-checked.",
1509
+ "drupal": "Olivero (Drupal's default theme) ships with WCAG 2.1 AA contrast. Custom themes should use CSS custom properties for colors and validate pairs in the color scheme configuration. Check sub-theme overrides in the .theme file."
1510
+ },
1511
+ "fix_difficulty_notes": [
1512
+ "Each instance in Evidence from DOM is an INDEPENDENT element — fix every instance separately.",
1513
+ "False positives are common on text rendered over gradient backgrounds, images, or when color is set dynamically via JavaScript or CSS variables resolved at runtime.",
1514
+ "axe-core samples a single point of the background — text on gradients may appear to fail even when contrast is sufficient.",
1515
+ "Verify each flagged instance visually using the browser DevTools color picker before fixing."
1516
+ ],
1517
+ "related_rules": [
1518
+ {
1519
+ "id": "link-in-text-block",
1520
+ "reason": "Links distinguished only by color need sufficient contrast — both rules enforce color-based visual distinction."
1521
+ }
1522
+ ],
1523
+ "guardrails_overrides": {
1524
+ "must_not": [
1525
+ "Do not rely solely on adjusting opacity to fix contrast — opacity affects both foreground and background.",
1526
+ "Do not apply a fix without verifying the computed contrast ratio using browser DevTools or https://webaim.org/resources/contrastchecker/"
1527
+ ],
1528
+ "verify": [
1529
+ "Confirm ratio is ≥4.5:1 for normal text and ≥3:1 for large text (≥18pt or ≥14pt bold).",
1530
+ "Re-check contrast under keyboard :focus state — focus indicators may reduce background contrast."
1531
+ ]
1532
+ }
1533
+ },
1534
+ "css-orientation-lock": {
1535
+ "category": "keyboard",
1536
+ "fix": {
1537
+ "description": "Remove CSS, manifest, or JavaScript rules that lock the display to a specific orientation (portrait or landscape) unless that orientation is essential. Users with motor disabilities may mount their device in a fixed position (WCAG 1.3.4).",
1538
+ "code": "<!-- Avoid orientation-locking CSS like: -->\n<!-- @media (orientation: portrait) { body { transform: rotate(90deg); } } -->\n\n<!-- Instead, ensure content adapts to both orientations: -->\n<style>\n .layout {\n display: grid;\n grid-template-columns: 1fr;\n }\n @media (orientation: landscape) {\n .layout {\n grid-template-columns: 1fr 1fr;\n }\n }\n</style>"
1539
+ },
1540
+ "false_positive_risk": "medium",
1541
+ "framework_notes": {
1542
+ "react": "In React Native or PWAs, avoid using the Screen Orientation API to lock orientation programmatically (screen.orientation.lock('portrait')). In web.manifest, do not set 'orientation': 'portrait' unless the app genuinely requires it (e.g., a camera viewfinder). In Next.js or Remix, check the manifest.json if present.",
1543
+ "vue": "In Vue PWAs (e.g., with @vite-pwa/nuxt), check the manifest for orientation locking. Do not use CSS transforms to force rotation on orientation change — this locks the visual layout even though the device is in a different orientation.",
1544
+ "angular": "In Angular PWAs, check angular.json or manifest.webmanifest for the 'orientation' field. Remove it or set it to 'any'. Avoid using @media (orientation: portrait) with transforms that force landscape rendering.",
1545
+ "svelte": "In SvelteKit, avoid CSS that locks orientation via @media (orientation: portrait) { display: none; } or similar. Content must be accessible in both orientations unless a specific orientation is essential (e.g., a piano app).",
1546
+ "astro": "In .astro files, CSS orientation locks are baked into the static build. Ensure no CSS rules hide content based on orientation unless the orientation is essential for the content."
1547
+ },
1548
+ "fix_difficulty_notes": "Orientation locking can come from three sources: (1) CSS @media (orientation) rules with transforms, (2) the web app manifest 'orientation' field, (3) JavaScript Screen Orientation API calls. The WCAG exception applies when a specific orientation is 'essential' — e.g., a piano keyboard app, a bank check scanning interface. Most web content does not qualify for this exception. axe detects CSS-based locking; manifest and JavaScript-based locking require manual checks.",
1549
+ "guardrails_overrides": {
1550
+ "must_not": [
1551
+ "Do not remove orientation-related CSS without verifying the content does not have an 'essential orientation' use case (e.g., piano keyboard, check scanner, video fullscreen interface).",
1552
+ "Do not assume axe has detected all orientation locking — also check the web app manifest 'orientation' field and any Screen Orientation API calls manually."
1553
+ ],
1554
+ "verify": [
1555
+ "Confirm the layout is fully usable in both portrait and landscape orientations after removing the orientation lock."
1556
+ ]
1557
+ },
1558
+ "related_rules": [
1559
+ {
1560
+ "id": "meta-viewport",
1561
+ "reason": "Both rules restrict user control of the display — orientation locking and zoom blocking are the same class of WCAG 1.3.4/1.4.4 failure. Fix together."
1562
+ },
1563
+ {
1564
+ "id": "avoid-inline-spacing",
1565
+ "reason": "Both rules address WCAG adaptability requirements — users must be able to control their viewing environment. Audit together."
1566
+ }
1567
+ ]
1568
+ },
1569
+ "definition-list": {
1570
+ "category": "semantics",
1571
+ "fix": {
1572
+ "description": "Ensure <dl> elements contain only properly-ordered <dt> and <dd> elements (or wrapping <div> elements per HTML5). No other elements should be direct children of <dl>.",
1573
+ "code": "<!-- Correct structure -->\n<dl>\n <dt>Term 1</dt>\n <dd>Definition 1</dd>\n <dt>Term 2</dt>\n <dd>Definition 2</dd>\n</dl>\n\n<!-- Also valid: wrapping in <div> (HTML5) -->\n<dl>\n <div>\n <dt>Term 1</dt>\n <dd>Definition 1</dd>\n </div>\n <div>\n <dt>Term 2</dt>\n <dd>Definition 2</dd>\n </div>\n</dl>"
1574
+ },
1575
+ "false_positive_risk": "low",
1576
+ "framework_notes": {
1577
+ "react": "In React, rendering a list of definition items via .map() may introduce wrapper <div> elements at the wrong level. Ensure the <div> wraps a <dt>/<dd> pair, not individual items. Use React.Fragment (<></>) only when the fragment renders as a child of <dl> directly — fragments do not produce DOM nodes.",
1578
+ "vue": "In Vue, v-for on <dl> children must produce <dt>/<dd> pairs or wrapping <div> elements. Use <template v-for> to loop without introducing extra wrapper elements at the wrong nesting level.",
1579
+ "angular": "In Angular, *ngFor on definition list items must produce valid <dt>/<dd> children. Use <ng-container> to loop without adding extra DOM elements between <dl> and its children.",
1580
+ "svelte": "In Svelte, <dl> elements must only contain <dt>, <dd>, and <div> children. Component wrappers that render extra elements inside <dl> will break the definition list structure.",
1581
+ "astro": "In .astro files, <dl> structure must be correct in the rendered HTML. Astro component boundaries may insert wrapper elements — verify the final DOM preserves the <dl>/<dt>/<dd> hierarchy."
1582
+ },
1583
+ "fix_difficulty_notes": "The most common violation: a <span>, <p>, or other element placed as a direct child of <dl>. Only <dt>, <dd>, and <div> (as a grouping wrapper) are valid direct children. In component frameworks, extra wrapper elements from component hosts can break the structure — use role='none' or host element elimination to fix.",
1584
+ "related_rules": [
1585
+ {
1586
+ "id": "dlitem",
1587
+ "reason": "definition-list validates the parent; dlitem validates the children — fix both together."
1588
+ },
1589
+ {
1590
+ "id": "list",
1591
+ "reason": "Both validate list structures — audit all list semantics violations together."
1592
+ }
1593
+ ]
1594
+ },
1595
+ "dlitem": {
1596
+ "category": "semantics",
1597
+ "fix": {
1598
+ "description": "Ensure <dt> and <dd> elements are contained within a <dl> element. These elements have no semantic meaning outside of a definition list.",
1599
+ "code": "<!-- Before: orphaned dt/dd -->\n<dt>Name</dt>\n<dd>Jane Doe</dd>\n\n<!-- After: wrapped in dl -->\n<dl>\n <dt>Name</dt>\n <dd>Jane Doe</dd>\n</dl>"
1600
+ },
1601
+ "false_positive_risk": "low",
1602
+ "framework_notes": {
1603
+ "react": "In React, a component rendering <dt> and <dd> elements must be placed inside a parent component rendering <dl>. If the component is reused outside a <dl> context, the dt/dd elements will be orphaned. Add a runtime check or documentation noting the required parent.",
1604
+ "vue": "In Vue, components rendering <dt>/<dd> must be used inside a <dl> wrapper. Vue does not validate HTML nesting — the orphaned dt/dd will render without error but fail accessibility audits.",
1605
+ "angular": "In Angular, components rendering <dt>/<dd> must be placed inside a <dl>. Angular host elements between <dl> and <dt>/<dd> break the relationship — use attribute selectors or role='none' on the host.",
1606
+ "svelte": "In Svelte, <dt> and <dd> elements must be inside a <dl> parent. Component composition that separates definition terms from their list container will trigger this violation.",
1607
+ "astro": "In .astro files, <dt>/<dd> elements must be direct children of <dl> in the rendered HTML. When splitting definition list items across components, verify the parent-child relationship is preserved."
1608
+ },
1609
+ "fix_difficulty_notes": "The fix is straightforward: wrap orphaned <dt>/<dd> elements in a <dl>. In component frameworks, the issue often occurs when a definition list item component is rendered without its parent definition list component. Ensure the component API enforces the parent-child relationship.",
1610
+ "related_rules": [
1611
+ {
1612
+ "id": "definition-list",
1613
+ "reason": "dlitem validates children placement; definition-list validates parent structure — fix both together."
1614
+ }
1615
+ ]
1616
+ },
1617
+ "document-title": {
1618
+ "category": "name-role-value",
1619
+ "fix": {
1620
+ "description": "Add a descriptive, unique <title> element to every page.",
1621
+ "code": "<title>Product Details — My Store</title>"
1622
+ },
1623
+ "false_positive_risk": "low",
1624
+ "framework_notes": {
1625
+ "react": "Use document.title in a useEffect, or react-helmet for declarative title management. In Next.js, use <title> inside <Head> from 'next/head' — it is server-side rendered.",
1626
+ "vue": "Update document.title in vue-router's afterEach navigation guard, or use vue-meta / @vueuse/head for declarative management.",
1627
+ "angular": "Inject the Title service from @angular/platform-browser and call this.title.setTitle() inside route guards or component ngOnInit.",
1628
+ "svelte": "In SvelteKit, use <svelte:head><title>Page Title</title></svelte:head> in each +page.svelte. SvelteKit updates the <title> automatically on client-side navigation. For dynamic titles, bind the title to a reactive variable.",
1629
+ "astro": "Pass a title prop to your base layout and render it in <head>: <title>{title}</title>. Since Astro generates static HTML per page, each page gets a unique pre-rendered title without JavaScript."
1630
+ },
1631
+ "cms_notes": {
1632
+ "shopify": "Shopify themes set the title in layout/theme.liquid via <title>{{ page_title }} — {{ shop.name }}</title>. The {{ page_title }} Liquid variable is set automatically per page type (product, collection, etc.). Verify it renders meaningful titles, not just the shop name.",
1633
+ "wordpress": "WordPress generates titles via wp_get_document_title() and the document_title_parts filter. Yoast SEO and similar plugins override the default. Block themes use <title>{{ wp_title }}</title> in html.html. Never hardcode the title in header.php.",
1634
+ "drupal": "Drupal sets the page title via the title_resolver service and renders it in html.html.twig: <title>{{ head_title|safe_join(' | ') }}</title>. Custom modules can alter titles via hook_preprocess_html(). Verify that Views pages set proper titles."
1635
+ },
1636
+ "fix_difficulty_notes": "In SPAs, the <title> is often set only on initial load. After client-side route changes, the title must be updated programmatically — the browser tab title stays stale otherwise. Each page title should follow the pattern 'Page Name — Site Name' so users understand context without reading page content.",
1637
+ "related_rules": [
1638
+ {
1639
+ "id": "page-has-heading-one",
1640
+ "reason": "The page title and h1 should describe the same page — fix both together to ensure consistent context for screen reader users."
1641
+ }
1642
+ ],
1643
+ "guardrails_overrides": {
1644
+ "must": [
1645
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
1646
+ ],
1647
+ "must_not": [
1648
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
1649
+ ],
1650
+ "verify": [
1651
+ "Confirm computed accessible name matches expected spoken phrase."
1652
+ ]
1653
+ }
1654
+ },
1655
+ "duplicate-id": {
1656
+ "category": "parsing",
1657
+ "fix": {
1658
+ "description": "Search for duplicate id attribute values and rename them so every id is unique within the page. Update any referencing aria-labelledby, aria-controls, or href='#' attributes accordingly.",
1659
+ "code": "<!-- Before: duplicate ids -->\n<nav id=\"main-nav\">...</nav>\n<footer id=\"main-nav\">...</footer>\n\n<!-- After: unique ids -->\n<nav id=\"primary-nav\">...</nav>\n<footer id=\"footer-nav\">...</footer>\n\n<!-- Update any referencing aria-labelledby / aria-controls / href=\"#\" accordingly -->"
1660
+ },
1661
+ "false_positive_risk": "low",
1662
+ "framework_notes": {
1663
+ "react": "Use the useId() hook (React 18+) to generate unique IDs per component instance: const id = useId(); return <label htmlFor={id}>.",
1664
+ "vue": "Bind a unique key to IDs: <label :for=\"`field-${uid}`\"> where uid is a prop or generated value (e.g., Math.random().toString(36)).",
1665
+ "angular": "Inject a counter service or use the CDK's uniqueId utility to generate stable, unique IDs per component instance.",
1666
+ "svelte": "Svelte does not have a built-in useId() equivalent. Generate unique IDs using a module-level counter or crypto.randomUUID(). For SSR with SvelteKit, ensure IDs are deterministic by accepting them as props rather than generating at render time.",
1667
+ "astro": "Since Astro pre-renders each page to static HTML, duplicate IDs typically come from reusing the same component multiple times. Pass unique id props to each instance, or use Astro.slots with unique naming."
1668
+ },
1669
+ "cms_notes": {
1670
+ "shopify": "Shopify sections can be added multiple times to the same page, causing duplicate IDs. Use the {{ section.id }} Liquid variable to namespace all IDs within a section: id='heading-{{ section.id }}'. This is the most common cause of duplicate-id in Shopify themes.",
1671
+ "wordpress": "WordPress widgets and reusable blocks can render the same HTML multiple times. Use wp_unique_id() to generate unique IDs in PHP. Gutenberg blocks should use useInstanceId() from @wordpress/compose for block-level unique IDs.",
1672
+ "drupal": "Drupal's Html::getUniqueId() generates unique IDs per element. In Twig templates, use {{ attributes.setAttribute('id', 'block-' ~ block.id) }} instead of hardcoding IDs. Views that render the same twig template multiple times are a common source of duplicates."
1673
+ },
1674
+ "fix_difficulty_notes": "In component-based frameworks, duplicate IDs typically occur when the same component renders multiple times on the same page. Use framework-native ID generation: useId() in React 18+, a unique :id binding per instance in Vue, or a service-based ID generator in Angular.",
1675
+ "guardrails_overrides": {
1676
+ "must_not": [
1677
+ "Do not fix duplicate IDs by appending '-1', '-2', '-3' suffixes manually — this approach will fail when a third instance renders. Use a programmatic ID generator instead.",
1678
+ "Do not update the ID without also updating all elements that reference it (aria-labelledby, aria-describedby, aria-controls, href='#id', htmlFor)."
1679
+ ],
1680
+ "verify": [
1681
+ "Confirm all referencing attributes (aria-labelledby, aria-controls, htmlFor, href='#') point to the updated unique ID after the fix."
1682
+ ]
1683
+ },
1684
+ "related_rules": [
1685
+ {
1686
+ "id": "duplicate-id-aria",
1687
+ "reason": "Duplicate IDs break both element references and ARIA relationships — fix all ID uniqueness violations together."
1688
+ }
1689
+ ]
1690
+ },
1691
+ "duplicate-id-aria": {
1692
+ "category": "parsing",
1693
+ "managed_by_libraries": [
1694
+ "radix",
1695
+ "headless-ui",
1696
+ "chakra",
1697
+ "mantine",
1698
+ "material-ui",
1699
+ "polaris",
1700
+ "react-aria",
1701
+ "ariakit",
1702
+ "shadcn",
1703
+ "primevue",
1704
+ "vuetify"
1705
+ ],
1706
+ "fix": {
1707
+ "description": "Ensure all IDs referenced by ARIA attributes (aria-labelledby, aria-describedby, aria-controls, aria-owns) are unique. Duplicate IDs cause screen readers to reference the wrong element.",
1708
+ "code": "<!-- Before: duplicate IDs break aria-labelledby -->\n<span id=\"label\">Username</span>\n<input aria-labelledby=\"label\" type=\"text\">\n<!-- ...later on same page... -->\n<span id=\"label\">Email</span> <!-- duplicate! -->\n\n<!-- After: unique IDs -->\n<span id=\"username-label\">Username</span>\n<input aria-labelledby=\"username-label\" type=\"text\">\n<span id=\"email-label\">Email</span>\n<input aria-labelledby=\"email-label\" type=\"email\">"
1709
+ },
1710
+ "false_positive_risk": "low",
1711
+ "framework_notes": {
1712
+ "react": "Use React 18's useId() hook to generate unique IDs per component instance: const id = useId(); return <><span id={id}>Label</span><input aria-labelledby={id} /></>. Never hardcode IDs in components that render more than once.",
1713
+ "vue": "In Vue, generate a unique ID per instance using a composable: const id = `field-${Math.random().toString(36).slice(2)}`; or use the useId() utility from VueUse. Bind with :id='id' and :aria-labelledby='id'.",
1714
+ "angular": "In Angular, inject a counter service or use a module-level incrementing variable to generate stable unique IDs: private static idCounter = 0; readonly fieldId = `field-${++FieldComponent.idCounter}`;. Bind with [attr.id]='fieldId'.",
1715
+ "svelte": "In Svelte, duplicate IDs referenced by aria-labelledby, aria-describedby, or aria-controls break ARIA associations. Use unique IDs per component instance — generate them with a module-level counter or accept them as props.",
1716
+ "astro": "In .astro files, duplicate IDs across components break ARIA references. Pass unique id props to each component instance. Since Astro pre-renders to static HTML, verify IDs are unique in the build output."
1717
+ },
1718
+ "fix_difficulty_notes": "Unlike duplicate-id (which flags all duplicate IDs), this rule specifically targets IDs that are referenced by ARIA attributes — making it higher severity because the duplicate directly breaks an accessibility relationship. In component-based apps, this almost always occurs when the same component renders multiple times with a hardcoded ID in its template. Use framework ID generation utilities to ensure uniqueness per instance.",
1719
+ "related_rules": [
1720
+ {
1721
+ "id": "duplicate-id",
1722
+ "reason": "Duplicate IDs break both element references and ARIA relationships — fix all ID uniqueness violations together."
1723
+ }
1724
+ ],
1725
+ "guardrails_overrides": {
1726
+ "must": [
1727
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
1728
+ ],
1729
+ "must_not": [
1730
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
1731
+ ],
1732
+ "verify": [
1733
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
1734
+ ]
1735
+ }
1736
+ },
1737
+ "empty-heading": {
1738
+ "category": "semantics",
1739
+ "fix": {
1740
+ "description": "Add meaningful text content to the heading element, or remove it if it serves no structural purpose.",
1741
+ "code": "<!-- Before: empty heading -->\n<h2></h2>\n\n<!-- After: heading with content -->\n<h2>Customer Reviews</h2>\n\n<!-- Or remove entirely if structural-only -->\n<!-- Use CSS pseudo-content for decorative dividers instead -->"
1742
+ },
1743
+ "false_positive_risk": "low",
1744
+ "framework_notes": {
1745
+ "react": "In React, a heading with dynamic content like <h2>{title}</h2> renders an empty heading if title is undefined, null, or an empty string. Guard with: {title && <h2>{title}</h2>} or provide a fallback string.",
1746
+ "vue": "In Vue, <h2>{{ title }}</h2> renders an empty heading if title is falsy. Use a v-if guard: <h2 v-if='title'>{{ title }}</h2> or a computed fallback.",
1747
+ "angular": "In Angular, <h2>{{ title }}</h2> renders empty if title is undefined. Use *ngIf='title' on the heading element, or provide a default value in the component.",
1748
+ "svelte": "In Svelte, <h2>{title}</h2> renders an empty heading if title is undefined or empty. Use {#if title}<h2>{title}</h2>{/if} to conditionally render. Svelte does not warn about empty headings at compile time.",
1749
+ "astro": "In .astro files, headings with empty props render empty tags in static HTML. Use conditional rendering: {title && <h2>{title}</h2>}. This is baked into the build output — axe catches it in the rendered page."
1750
+ },
1751
+ "cms_notes": {
1752
+ "shopify": "Empty headings in Shopify commonly occur when a section schema includes a heading field but the merchant leaves it blank. In Liquid, guard with: {% if section.settings.heading != blank %}<h2>{{ section.settings.heading }}</h2>{% endif %}. Dawn theme already does this — custom themes often miss it.",
1753
+ "wordpress": "WordPress WYSIWYG editors (Classic and Gutenberg) let authors insert empty headings easily. The Heading block prevents empty saves, but the Classic Editor does not. Theme templates that render archive/taxonomy titles should check for empty values before rendering the <h> tag.",
1754
+ "drupal": "Drupal's block title field can be left empty. In block.html.twig, guard with: {% if label %}<h2{{ title_attributes }}>{{ label }}</h2>{% endif %}. Views page titles and exposed filter labels are also common sources of empty headings."
1755
+ },
1756
+ "fix_difficulty_notes": "Empty headings commonly occur in CMS-driven layouts where a heading tag is part of a section template but the author left the field blank. In component libraries, empty headings can also result from a conditional slot that renders nothing. Check both the template and the content source — the fix may be in the CMS content, not the code.",
1757
+ "related_rules": [
1758
+ {
1759
+ "id": "heading-order",
1760
+ "reason": "Empty headings disrupt the heading hierarchy — resolve them together."
1761
+ },
1762
+ {
1763
+ "id": "summary-name",
1764
+ "reason": "An unnamed summary is similar to an empty heading — both leave structural landmarks without labels."
1765
+ }
1766
+ ]
1767
+ },
1768
+ "empty-table-header": {
1769
+ "category": "tables",
1770
+ "fix": {
1771
+ "description": "Ensure all <th> elements contain discernible text. Empty table headers provide no context for the column or row data they describe, making tables unintelligible to screen reader users.",
1772
+ "code": "<!-- Before: empty table header -->\n<table>\n <tr>\n <th></th>\n <th>Q1</th>\n <th>Q2</th>\n </tr>\n <tr>\n <td>Revenue</td>\n <td>$100k</td>\n <td>$120k</td>\n </tr>\n</table>\n\n<!-- After: add descriptive text or use <td> for non-header cells -->\n<table>\n <tr>\n <th>Metric</th>\n <th>Q1</th>\n <th>Q2</th>\n </tr>\n <tr>\n <th scope=\"row\">Revenue</th>\n <td>$100k</td>\n <td>$120k</td>\n </tr>\n</table>"
1773
+ },
1774
+ "false_positive_risk": "low",
1775
+ "framework_notes": {
1776
+ "react": "In React table components, ensure the header row maps over column definitions that always include a non-empty label. If a column is for checkboxes or actions, add a visually hidden label: <th><span className='sr-only'>Select</span></th>.",
1777
+ "vue": "In Vue, data table components (PrimeVue DataTable, Vuetify v-data-table) auto-generate headers from column definitions. Verify the header/text field is defined for every column, including selection or action columns.",
1778
+ "angular": "In Angular Material mat-table, mat-header-cell content is template-driven. Add visible or visually hidden text to every <th mat-header-cell> — do not leave any header cell empty.",
1779
+ "svelte": "In Svelte data table components, ensure every <th> has text content. For checkbox or action columns, add visually hidden text: <th><span class='sr-only'>Select</span></th>.",
1780
+ "astro": "In .astro files, <th> elements must have text content in the rendered HTML. For tables inside framework islands, the island framework's table component rules apply."
1781
+ },
1782
+ "fix_difficulty_notes": "The most common case is a checkbox column or an actions column with an empty <th>. The fix is to add a visually hidden label (e.g., 'Select all' for checkboxes, 'Actions' for action columns) so screen readers can announce the column purpose. If the <th> genuinely has no header role, change it to a <td> instead.",
1783
+ "related_rules": [
1784
+ {
1785
+ "id": "th-has-data-cells",
1786
+ "reason": "Headers without text and headers without associated data cells are related structural issues — fix together."
1787
+ },
1788
+ {
1789
+ "id": "td-headers-attr",
1790
+ "reason": "The headers attribute references <th> elements by id — empty headers make these references meaningless."
1791
+ },
1792
+ {
1793
+ "id": "scope-attr-valid",
1794
+ "reason": "Scope on an empty <th> is meaningless — ensure headers have text content before adding scope."
1795
+ }
1796
+ ]
1797
+ },
1798
+ "focus-order-semantics": {
1799
+ "category": "keyboard",
1800
+ "fix": {
1801
+ "description": "Ensure elements in the focus order have an appropriate role for interactive content. Non-interactive elements (e.g., <div>, <span>, <p>) with tabindex='0' should use a semantic interactive element or an appropriate ARIA role.",
1802
+ "code": "<!-- Before: non-interactive element in the focus order -->\n<div tabindex=\"0\">Click to expand</div>\n\n<!-- After: use a semantic element -->\n<button type=\"button\">Click to expand</button>\n\n<!-- Or add an appropriate ARIA role if a native element is not viable -->\n<div role=\"button\" tabindex=\"0\" aria-expanded=\"false\">\n Click to expand\n</div>"
1803
+ },
1804
+ "false_positive_risk": "medium",
1805
+ "framework_notes": {
1806
+ "react": "In React, custom interactive components often use <div tabIndex={0} onClick={handler}> — replace with <button> or add role='button' with onKeyDown handling for Enter and Space. Libraries like React Aria's useButton hook handle this automatically.",
1807
+ "vue": "In Vue, replace <div tabindex='0' @click='handler'> with <button @click='handler'>. If a custom element is required, add role='button' and handle keydown events for Enter (13) and Space (32).",
1808
+ "angular": "In Angular, replace <div tabindex='0' (click)='handler()'> with <button (click)='handler()'>. Angular CDK's CdkAriaLive and A11yModule provide utilities for making custom elements properly interactive.",
1809
+ "svelte": "In Svelte, tabindex values greater than 0 disrupt natural focus order. Use tabindex='0' for elements that should be in the tab sequence and tabindex='-1' for elements that should only be focusable programmatically.",
1810
+ "astro": "In .astro files, tabindex values render to static HTML. Avoid tabindex > 0 — it creates unpredictable focus order. For dynamic focus management, use framework islands with JavaScript focus control."
1811
+ },
1812
+ "fix_difficulty_notes": "Adding tabindex='0' to a non-interactive element makes it focusable but does not make it operable — keyboard users cannot activate it with Enter or Space unless explicit keydown handlers are added. The simplest fix is to use a native interactive element (<button>, <a>, <input>). If a custom element is required, add the correct ARIA role plus full keyboard event handling.",
1813
+ "guardrails_overrides": {
1814
+ "must_not": [
1815
+ "Do not add tabindex='0' to a non-interactive element without also adding the correct ARIA role and full keyboard event handlers (Enter/Space activation).",
1816
+ "Do not remove a tabindex to fix focus order without verifying the element is still reachable by keyboard."
1817
+ ],
1818
+ "verify": [
1819
+ "Confirm the element can be activated by keyboard (Enter or Space) after any tabindex change.",
1820
+ "Confirm the element's role is correctly exposed in the accessibility tree."
1821
+ ]
1822
+ },
1823
+ "related_rules": [
1824
+ {
1825
+ "id": "tabindex",
1826
+ "reason": "Both rules address focus order issues — fix tabindex values and semantic roles together."
1827
+ },
1828
+ {
1829
+ "id": "scrollable-region-focusable",
1830
+ "reason": "Scrollable regions may need tabindex='0' for keyboard access but also need an appropriate role."
1831
+ },
1832
+ {
1833
+ "id": "nested-interactive",
1834
+ "reason": "Nested interactive elements corrupt focus order — fix nested-interactive first, then verify focus-order-semantics."
1835
+ }
1836
+ ]
1837
+ },
1838
+ "form-field-multiple-labels": {
1839
+ "category": "forms",
1840
+ "fix": {
1841
+ "description": "Remove duplicate label associations so each form field has exactly one associated <label>.",
1842
+ "code": "<!-- One label per input: -->\n<label for=\"name\">Full name</label>\n<input id=\"name\" type=\"text\" name=\"name\">"
1843
+ },
1844
+ "false_positive_risk": "medium",
1845
+ "framework_notes": {
1846
+ "react": "In React, multiple labeling sources are introduced accidentally when a component applies both htmlFor on a <label> and aria-label on the input itself. Choose exactly one labeling strategy per input — prefer a visible <label htmlFor='id'> and remove aria-label.",
1847
+ "vue": "In Vue form component wrappers, ensure the component does not add internal aria-labels that conflict with a parent <label>. A common issue is a UI kit that adds aria-label='Input field' to every input by default — override this with an empty string or use the component's label prop instead.",
1848
+ "angular": "In Angular Material, mat-label inside mat-form-field serves as the accessible label. Do not also add aria-label directly to the input — both will be announced, triggering this violation.",
1849
+ "svelte": "In Svelte, wrapping an input inside a <label> AND also using for='id' creates a double association. Choose one: either wrap the input inside the label (implicit association) or use for/id (explicit association). Svelte's compiler may not warn about this.",
1850
+ "astro": "In .astro files, this issue typically arises when a framework island component adds its own aria-label internally, and the surrounding Astro markup also includes a <label for='id'>. Check the island component's rendered output to identify the duplicate."
1851
+ },
1852
+ "cms_notes": {
1853
+ "shopify": "Shopify themes sometimes apply both a Liquid-generated <label> and an aria-label on the same input (e.g., search forms in Dawn). Remove the aria-label when a visible <label> exists. Check search-related snippets for this pattern.",
1854
+ "wordpress": "WordPress form plugins (WPForms, Gravity Forms) may add both a visible label and an aria-label. Check the plugin's accessibility settings — some have a toggle to prevent double labeling. Custom block forms should use a single labeling strategy.",
1855
+ "drupal": "Drupal's Form API can produce multiple labels when both #title and #attributes['aria-label'] are set. Use only #title for visible labels. Views exposed filters sometimes add both a label and a title attribute, which can trigger this rule."
1856
+ },
1857
+ "fix_difficulty_notes": "aria-labelledby can legitimately reference multiple IDs to concatenate a compound label (e.g., aria-labelledby='label1 label2'). axe may flag this pattern. Review whether both sources are intentional — if so, it is valid and the finding is a false positive. Remove one reference only if the concatenated announcement is redundant.",
1858
+ "related_rules": [
1859
+ {
1860
+ "id": "label",
1861
+ "reason": "Multiple labels on an input conflict with the single-label association requirement — fix both together."
1862
+ },
1863
+ {
1864
+ "id": "label-content-name-mismatch",
1865
+ "reason": "Multiple labels often cause the accessible name to diverge from visible text."
1866
+ },
1867
+ {
1868
+ "id": "label-title-only",
1869
+ "reason": "When adding a visible label, verify you are not creating a duplicate label association."
1870
+ }
1871
+ ],
1872
+ "guardrails_overrides": {
1873
+ "must": [
1874
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
1875
+ ],
1876
+ "must_not": [
1877
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
1878
+ ],
1879
+ "verify": [
1880
+ "Confirm computed accessible name matches expected spoken phrase."
1881
+ ]
1882
+ }
1883
+ },
1884
+ "frame-focusable-content": {
1885
+ "category": "keyboard",
1886
+ "fix": {
1887
+ "description": "Do not set tabindex='-1' on <iframe> or <frame> elements that contain focusable content. This prevents keyboard users from accessing the content inside the frame.",
1888
+ "code": "<!-- Before: iframe with focusable content blocked -->\n<iframe src=\"form.html\" tabindex=\"-1\" title=\"Registration form\"></iframe>\n\n<!-- After: remove tabindex=-1 so keyboard users can access the content -->\n<iframe src=\"form.html\" title=\"Registration form\"></iframe>\n\n<!-- If the iframe is decorative with no interactive content, tabindex=-1 is acceptable -->\n<iframe src=\"animation.html\" tabindex=\"-1\" title=\"Decorative animation\" aria-hidden=\"true\"></iframe>"
1889
+ },
1890
+ "false_positive_risk": "low",
1891
+ "framework_notes": {
1892
+ "react": "In React, third-party embed components (e.g., YouTube, maps, payment forms) sometimes add tabindex='-1' to the iframe. Override by removing the tabindex prop or setting it to 0. If the embed library does not expose a tabindex prop, wrap the iframe and use a ref to remove the attribute after mount.",
1893
+ "vue": "In Vue, check embedded iframe components for tabindex='-1'. Use a mounted hook to remove tabindex from the iframe element if the component does not expose it as a prop.",
1894
+ "angular": "In Angular, check [tabindex] bindings on iframe elements. If using a third-party embed directive, override the tabindex via a host binding or AfterViewInit lifecycle hook.",
1895
+ "svelte": "In Svelte, do not set tabindex='-1' on iframes that contain focusable content. If the iframe embeds interactive elements (forms, links, buttons), remove tabindex='-1' so keyboard users can tab into it. Only use tabindex='-1' on iframes that are purely decorative or have no interactive content.",
1896
+ "astro": "In .astro files, <iframe> elements render to static HTML. Ensure iframes with focusable content have a title attribute. For iframes embedding external content, the external page's accessibility is the source's responsibility."
1897
+ },
1898
+ "fix_difficulty_notes": "tabindex='-1' on an iframe removes it from the sequential tab order but also prevents keyboard users from tabbing into the iframe's content. If the iframe contains any interactive content (forms, links, buttons), removing tabindex='-1' is the correct fix. If the iframe is purely decorative or contains no interactive content, tabindex='-1' combined with aria-hidden='true' is acceptable.",
1899
+ "guardrails_overrides": {
1900
+ "must_not": [
1901
+ "Do not remove tabindex='-1' from an iframe that is decorative (animation, background) with aria-hidden='true' — this combination is intentional and correct.",
1902
+ "Do not remove tabindex='-1' from a cross-origin iframe containing only non-interactive content (e.g., an embedded map with no form fields or links)."
1903
+ ],
1904
+ "verify": [
1905
+ "Confirm the iframe actually contains interactive content (forms, links, buttons) before removing tabindex='-1'.",
1906
+ "Confirm aria-hidden is not also set to 'true' on the iframe — if it is, both tabindex='-1' and aria-hidden='true' may be intentional."
1907
+ ]
1908
+ },
1909
+ "related_rules": [
1910
+ {
1911
+ "id": "frame-title",
1912
+ "reason": "Iframes must be both keyboard-accessible and labeled — fix both together."
1913
+ }
1914
+ ]
1915
+ },
1916
+ "frame-tested": {
1917
+ "category": "keyboard",
1918
+ "fix": {
1919
+ "description": "Ensure <iframe> and <frame> elements contain the axe-core script for complete accessibility testing. This is a testing infrastructure rule — it informs you that content inside frames was not analyzed because axe-core was not injected into them.",
1920
+ "code": "<!-- This is not an HTML fix — it is a testing configuration issue. -->\n<!-- Ensure your axe-core test runner is configured to test iframes: -->\n\n<!-- axe-core configuration: -->\n<!-- axe.run({ iframes: true }) -->\n\n<!-- For cross-origin iframes, axe-core cannot inject automatically. -->\n<!-- Load axe-core inside the iframe's own page: -->\n<iframe src=\"https://third-party.com/widget\" title=\"Chat widget\"></iframe>\n<!-- Inside the iframe page, include: -->\n<!-- <script src=\"axe-core/axe.min.js\"></script> -->"
1921
+ },
1922
+ "false_positive_risk": "high",
1923
+ "framework_notes": {
1924
+ "react": "In React apps using @axe-core/react, iframe testing is limited to same-origin frames. For cross-origin iframes (payment forms, chat widgets, embedded maps), document them as out-of-scope and ensure the third-party provider meets WCAG compliance independently.",
1925
+ "vue": "In Vue apps using vue-axe or axe-core/puppeteer, enable the iframes option in axe configuration. Cross-origin iframes cannot be tested — request a VPAT or accessibility statement from the third-party provider.",
1926
+ "angular": "In Angular apps using axe-core with Protractor or Cypress, configure runOnly with iframes: true for same-origin frames. Cross-origin iframes require separate testing arrangements with the frame content provider.",
1927
+ "svelte": "In Svelte, <iframe> content is not tested by axe-core unless the iframe shares the same origin. This rule is informational — ensure iframe content is independently accessible.",
1928
+ "astro": "In .astro files, <iframe> elements render to static HTML. Axe-core cannot evaluate cross-origin iframe content — verify accessibility of embedded content separately."
1929
+ },
1930
+ "fix_difficulty_notes": "This rule is informational — it means axe-core could not analyze content inside an iframe. Same-origin iframes can be tested by enabling the iframes option in axe configuration. Cross-origin iframes (third-party widgets, payment forms) cannot be injected with axe-core — they must be tested separately by the content provider. Do not treat this as an accessibility violation; treat it as a gap in test coverage.",
1931
+ "guardrails_overrides": {
1932
+ "must_not": [
1933
+ "Do not mark this finding as a violation requiring a code fix — it is a test coverage gap, not an accessibility failure.",
1934
+ "Do not modify the iframe based on this finding alone; address frame-title violations separately."
1935
+ ],
1936
+ "verify": [
1937
+ "Confirm whether the iframe is same-origin (can be tested with axe iframes option) or cross-origin (requires separate testing by the provider)."
1938
+ ]
1939
+ },
1940
+ "related_rules": [
1941
+ {
1942
+ "id": "frame-title",
1943
+ "reason": "Every iframe needs a title attribute regardless of whether its content can be tested — fix frame titles alongside test coverage gaps."
1944
+ }
1945
+ ]
1946
+ },
1947
+ "frame-title": {
1948
+ "category": "name-role-value",
1949
+ "fix": {
1950
+ "description": "Add a descriptive title attribute to every <iframe>.",
1951
+ "code": "<iframe\n title=\"Embedded map showing our store location\"\n src=\"https://maps.example.com/embed\"\n></iframe>"
1952
+ },
1953
+ "false_positive_risk": "low",
1954
+ "framework_notes": {
1955
+ "react": "When embedding iframes (Google Maps, YouTube, Stripe), add the title prop directly: <iframe title='Google Maps — store location' src='...' />. Third-party React wrapper components that embed iframes should expose a title prop passthrough.",
1956
+ "vue": "Use the title attribute directly: <iframe title='Payment form' :src='url' />. For third-party Vue components that embed iframes internally, check the component's props API for a title or label prop.",
1957
+ "angular": "Use [title]='iframeTitle' binding for dynamic titles, or title='Embedded video' for static ones. For hidden tracking iframes generated by third-party scripts, set title='' programmatically to suppress screen reader announcement.",
1958
+ "svelte": "In Svelte, <iframe> elements must have a title attribute: <iframe title='Video player' src='...'></iframe>. Svelte does not warn about missing iframe titles — add them manually.",
1959
+ "astro": "In .astro files, <iframe> elements must include a title attribute in the static HTML. This describes the iframe's purpose for screen reader users."
1960
+ },
1961
+ "fix_difficulty_notes": "Hidden or off-screen iframes used for third-party scripts (analytics, chat SDKs, A/B testing) should use title='' (empty) to suppress screen reader announcement — not a descriptive title. Only visible, interactive iframes that users would encounter need a meaningful title.",
1962
+ "cms_notes": {
1963
+ "shopify": "Shopify apps inject iframes for reviews (Judge.me, Yotpo), live chat (Tidio, Gorgias), and payment UIs. These are typically third-party and cannot be modified directly — contact the app provider. For theme-owned iframes (e.g., YouTube embeds in sections), add title='{{ section.settings.video_description }}' using the section schema.",
1964
+ "wordpress": "WordPress's embed block renders iframes for YouTube, Vimeo, and Google Maps via oEmbed. WordPress core adds the video title as the iframe title for YouTube/Vimeo embeds since 5.6. For Classic Editor embeds and older plugins, the title may be missing — update the embed shortcode or use the block editor.",
1965
+ "drupal": "Drupal's Media module and oEmbed integration renders iframes for remote video. The oEmbed title is used as the iframe title by default. For custom iframe renders in Twig, add title='{{ media.name.value }}' via the template."
1966
+ },
1967
+ "related_rules": [
1968
+ {
1969
+ "id": "frame-focusable-content",
1970
+ "reason": "Iframes must be both keyboard-accessible and labeled — fix both together."
1971
+ },
1972
+ {
1973
+ "id": "frame-tested",
1974
+ "reason": "Every iframe needs a title attribute regardless of whether its content can be tested — fix frame titles alongside test coverage gaps."
1975
+ },
1976
+ {
1977
+ "id": "frame-title-unique",
1978
+ "reason": "frame-title checks for the presence of a title; frame-title-unique checks for uniqueness — fix both together."
1979
+ }
1980
+ ],
1981
+ "guardrails_overrides": {
1982
+ "must": [
1983
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
1984
+ ],
1985
+ "must_not": [
1986
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
1987
+ ],
1988
+ "verify": [
1989
+ "Confirm computed accessible name matches expected spoken phrase."
1990
+ ]
1991
+ }
1992
+ },
1993
+ "frame-title-unique": {
1994
+ "category": "name-role-value",
1995
+ "fix": {
1996
+ "description": "Give every <iframe> and <frame> a unique title attribute that distinguishes it from other frames on the same page. Duplicate titles confuse screen reader users navigating between frames.",
1997
+ "code": "<!-- Before: duplicate iframe titles -->\n<iframe src=\"ad1.html\" title=\"Advertisement\"></iframe>\n<iframe src=\"ad2.html\" title=\"Advertisement\"></iframe>\n\n<!-- After: unique titles -->\n<iframe src=\"ad1.html\" title=\"Advertisement: Summer sale banner\"></iframe>\n<iframe src=\"ad2.html\" title=\"Advertisement: Free shipping promo\"></iframe>"
1998
+ },
1999
+ "false_positive_risk": "low",
2000
+ "framework_notes": {
2001
+ "react": "In React, when rendering multiple iframes via .map(), include a unique identifier in the title prop (e.g., title={`Widget ${index + 1}`} or title={widget.name}). Avoid generic titles like 'iframe' or 'widget' repeated across instances.",
2002
+ "vue": "In Vue, bind :title with unique descriptive text for each iframe rendered via v-for. Use the item's name or purpose in the title, not just a generic label.",
2003
+ "angular": "In Angular, use [title]='uniqueFrameTitle' with a descriptive string per iframe instance. When rendering multiple iframes via *ngFor, include the item's identifier in the title.",
2004
+ "svelte": "In Svelte, each <iframe> on the page must have a unique title. If rendering multiple iframes from a component, pass unique titles as props.",
2005
+ "astro": "In .astro files, each <iframe> must have a distinct title attribute. When reusing iframe components, pass unique title props to each instance."
2006
+ },
2007
+ "fix_difficulty_notes": "The title must describe the purpose or content of the frame, not just its type. 'Advertisement' is valid for one ad iframe but becomes ambiguous when multiple ad iframes exist. Append a distinguishing qualifier (e.g., the ad campaign name, position, or content summary). For dynamically generated iframes (e.g., ad slots), the title should reflect the loaded content when possible.",
2008
+ "related_rules": [
2009
+ {
2010
+ "id": "frame-title",
2011
+ "reason": "frame-title checks for the presence of a title; frame-title-unique checks for uniqueness — fix both together."
2012
+ }
2013
+ ],
2014
+ "guardrails_overrides": {
2015
+ "must": [
2016
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2017
+ ],
2018
+ "must_not": [
2019
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2020
+ ],
2021
+ "verify": [
2022
+ "Confirm computed accessible name matches expected spoken phrase."
2023
+ ]
2024
+ }
2025
+ },
2026
+ "heading-order": {
2027
+ "category": "semantics",
2028
+ "fix": {
2029
+ "description": "Fix the heading hierarchy — no levels may be skipped.",
2030
+ "code": "<!-- Valid heading hierarchy: -->\n<h1>Accessibility Audit Report</h1>\n<h2>Color Contrast Violations</h2>\n<h3>Product listing page</h3>\n<!-- Invalid: jumping from h1 to h3 skips h2 -->"
2031
+ },
2032
+ "false_positive_risk": "low",
2033
+ "framework_notes": {
2034
+ "react": "In component libraries (shadcn/ui, Radix), heading levels are often hardcoded (e.g., CardTitle always renders as h3). Use the 'as' prop to override the level based on context: <CardTitle as='h2'> when the component appears as a top-level section heading.",
2035
+ "vue": "In Vue UI kits (Vuetify, Nuxt UI), check if heading components support a 'tag' prop to override the rendered element. Use semantic levels based on document structure, not visual size.",
2036
+ "angular": "In Angular Material, headings inside mat-card and mat-expansion-panel are often rendered as <div> or <span>. Add the appropriate heading element explicitly inside the component template rather than relying on implicit heading structure.",
2037
+ "svelte": "Svelte does not warn about heading order at compile time. Use an eslint plugin or axe-core integration to catch hierarchy issues. In component libraries (Skeleton UI, Svelte Headless UI), verify that heading levels can be overridden via props.",
2038
+ "astro": "Astro assembles pages from multiple components — heading hierarchy issues arise when components assume a fixed heading level. Pass the heading level as a prop: <Section headingLevel={2}> and render dynamically with <h{headingLevel}> patterns."
2039
+ },
2040
+ "cms_notes": {
2041
+ "shopify": "Shopify sections often hardcode heading levels (e.g., <h2> in featured-collection.liquid). When sections are reordered in the theme editor, the heading hierarchy breaks. Use the section schema to let merchants choose the heading level, or use CSS classes for sizing instead of heading elements.",
2042
+ "wordpress": "Gutenberg's Heading block lets authors pick any level — there is no enforcement of hierarchy. Educate content authors or use a plugin like Flavor that warns about heading order violations in the editor. Theme templates that hardcode <h2> for widget titles also break hierarchy.",
2043
+ "drupal": "Drupal Views titles default to <h2>, and block titles also default to <h2> — when multiple blocks appear on the same page, the hierarchy breaks. Override heading levels in block--*.html.twig or via the Views UI. Use the Block Title Level module to give editors control."
2044
+ },
2045
+ "fix_difficulty_notes": [
2046
+ "Never use heading elements purely for visual sizing (e.g., `<h4>` because it renders smaller) — use CSS classes for font size. Heading level must reflect document structure, not visual hierarchy.",
2047
+ "Resolve `page-has-heading-one` first — a missing `h1` often causes cascading `heading-order` violations."
2048
+ ],
2049
+ "related_rules": [
2050
+ {
2051
+ "id": "page-has-heading-one",
2052
+ "reason": "Fixing the h1 establishes the root of the hierarchy — resolve it first."
2053
+ },
2054
+ {
2055
+ "id": "empty-heading",
2056
+ "reason": "Empty headings disrupt the heading hierarchy — resolve them together."
2057
+ },
2058
+ {
2059
+ "id": "p-as-heading",
2060
+ "reason": "After converting p-as-heading to real headings, verify they fit the existing heading hierarchy."
2061
+ }
2062
+ ],
2063
+ "guardrails_overrides": {
2064
+ "must": [
2065
+ "Resolve page-has-heading-one first — a missing h1 causes cascading heading-order violations."
2066
+ ],
2067
+ "must_not": [
2068
+ "Do not change heading levels purely for visual sizing — use CSS classes for font size.",
2069
+ "Do not adjust heading numbers alone without restructuring content hierarchy — the fix must reflect document structure, not just the numeric sequence."
2070
+ ],
2071
+ "verify": [
2072
+ "Confirm no heading levels are skipped in the final DOM output.",
2073
+ "Confirm the fix does not alter visual design — heading element changes must be purely structural."
2074
+ ]
2075
+ }
2076
+ },
2077
+ "hidden-content": {
2078
+ "category": "semantics",
2079
+ "fix": {
2080
+ "description": "Inform users about hidden content that was not evaluated by the accessibility scanner. Content hidden via display:none, visibility:hidden, or the hidden attribute is excluded from the accessibility tree and from automated testing — it may contain violations that surface only when revealed.",
2081
+ "code": "<!-- Hidden content that axe cannot evaluate: -->\n<div id=\"modal\" hidden>\n <h2>Login</h2>\n <form>...</form>\n</div>\n\n<!-- When revealed, it must be accessible: -->\n<div id=\"modal\" role=\"dialog\" aria-labelledby=\"modal-title\" aria-modal=\"true\">\n <h2 id=\"modal-title\">Login</h2>\n <form>\n <label for=\"email\">Email</label>\n <input type=\"email\" id=\"email\" autocomplete=\"email\">\n </form>\n</div>"
2082
+ },
2083
+ "false_positive_risk": "high",
2084
+ "framework_notes": {
2085
+ "react": "In React, conditionally rendered content ({showModal && <Modal />}) is not in the DOM until rendered — axe cannot test it. Test each UI state separately: run axe after opening modals, expanding accordions, and triggering dropdowns.",
2086
+ "vue": "In Vue, v-if removes content from the DOM entirely, so axe cannot evaluate it. Use v-show for content that should be testable in all states, or run separate axe scans with each visibility state toggled on.",
2087
+ "angular": "In Angular, *ngIf removes content from the DOM. Run axe-core scans after triggering each UI state (dialog open, dropdown expanded, tab panel visible) to ensure hidden content is covered.",
2088
+ "svelte": "In Svelte, {#if condition} removes content from the DOM entirely — axe cannot test it. Test each UI state separately: run axe after opening modals, expanding accordions, and triggering dropdowns. Use {#if} for conditional rendering, not CSS display:none.",
2089
+ "astro": "In .astro files, content hidden via CSS (display:none) is in the static HTML but excluded from axe testing. For dynamic visibility inside framework islands, test each state separately."
2090
+ },
2091
+ "fix_difficulty_notes": "This rule is informational, not a violation. It alerts you that some page content was hidden during the scan and therefore not tested. The fix is not to change the hidden content itself, but to ensure your test suite evaluates every visibility state. Run accessibility scans on all interactive states: modals open, accordions expanded, menus visible, toast notifications active.",
2092
+ "guardrails_overrides": {
2093
+ "must_not": [
2094
+ "Do not treat this finding as a code defect requiring a fix — it is a test coverage indicator.",
2095
+ "Do not make the hidden content visible as a workaround; address actual violations found when re-testing in the visible state."
2096
+ ],
2097
+ "verify": [
2098
+ "Confirm all interactive states (modals, drawers, accordions, tooltips) have been tested separately in visible state."
2099
+ ]
2100
+ },
2101
+ "related_rules": [
2102
+ {
2103
+ "id": "aria-hidden-focus",
2104
+ "reason": "Hidden content that becomes visible may contain focusable elements inside aria-hidden containers — test each state."
2105
+ },
2106
+ {
2107
+ "id": "aria-hidden-body",
2108
+ "reason": "If aria-hidden='true' is applied to the body or root during a hidden state, removing it on reveal must be verified."
2109
+ }
2110
+ ]
2111
+ },
2112
+ "html-has-lang": {
2113
+ "category": "language",
2114
+ "fix": {
2115
+ "description": "Add a lang attribute to the <html> element so screen readers pronounce content correctly.",
2116
+ "code": "<html lang=\"en\">"
2117
+ },
2118
+ "false_positive_risk": "low",
2119
+ "framework_notes": {
2120
+ "react": "In Next.js App Router, set lang in the root layout: export default function RootLayout() { return <html lang='en'>...</html>; } in app/layout.tsx. For i18n, derive the lang from the locale route segment.",
2121
+ "vue": "In Nuxt, set lang in nuxt.config.ts: app: { head: { htmlAttrs: { lang: 'en' } } }. For multilingual Nuxt apps, @nuxtjs/i18n sets the lang attribute per locale automatically.",
2122
+ "angular": "In Angular, set lang in index.html: <html lang='en'>. With Angular Universal (SSR), inject LOCALE_ID and set document.documentElement.lang in a server-side app initializer to support locale-specific rendering.",
2123
+ "svelte": "In SvelteKit, set lang in app.html (the HTML template): <html lang='en'>. For i18n, use hooks.server.ts to dynamically set the lang attribute based on the request locale or route parameter.",
2124
+ "astro": "Set lang in your base layout: <html lang='en'>. For multilingual sites with Astro i18n routing, pass the lang as a prop to the layout: <html lang={Astro.currentLocale ?? 'en'}>."
2125
+ },
2126
+ "cms_notes": {
2127
+ "shopify": "Shopify sets the lang attribute in layout/theme.liquid via <html lang='{{ request.locale.iso_code }}'> for multi-language stores. Single-language stores should hardcode <html lang='en'> (or the appropriate language). The Shopify admin's language settings control {{ request.locale }}.",
2128
+ "wordpress": "WordPress sets the lang attribute via the language_attributes() function in header.php. The site language is configured in Settings > General. Multilingual plugins (WPML, Polylang) override this per page. Never hardcode lang in the theme — always use language_attributes().",
2129
+ "drupal": "Drupal sets the lang attribute in html.html.twig via {{ html_attributes }}, which includes lang from the active language. Multilingual configuration in admin/config/regional/language controls this. Sub-themes overriding html.html.twig must preserve {{ html_attributes }}."
2130
+ },
2131
+ "fix_difficulty_notes": "The lang attribute must be on the <html> element itself, not on a meta tag or the <body>. In framework app templates, the <html> tag is typically in an index.html or document template file outside of component scope — not in a React/Vue/Angular component. For multilingual SPAs, update document.documentElement.lang programmatically on each route change to reflect the current content language.",
2132
+ "related_rules": [
2133
+ {
2134
+ "id": "html-lang-valid",
2135
+ "reason": "Adding a lang attribute must use a valid BCP 47 language tag — fix both together."
2136
+ },
2137
+ {
2138
+ "id": "valid-lang",
2139
+ "reason": "Child elements with lang attributes must also use valid tags — audit all lang values at once."
2140
+ },
2141
+ {
2142
+ "id": "html-xml-lang-mismatch",
2143
+ "reason": "The lang attribute must be present before validating its agreement with xml:lang."
2144
+ }
2145
+ ]
2146
+ },
2147
+ "html-lang-valid": {
2148
+ "category": "language",
2149
+ "fix": {
2150
+ "description": "Use a valid BCP 47 language tag on the <html> element.",
2151
+ "code": "<!-- English -->\n<html lang=\"en\">\n<!-- French Canadian -->\n<html lang=\"fr-CA\">\n<!-- Brazilian Portuguese -->\n<html lang=\"pt-BR\">"
2152
+ },
2153
+ "false_positive_risk": "low",
2154
+ "framework_notes": {
2155
+ "react": "Same fix location as html-has-lang — set lang in app/layout.tsx: <html lang='en-US'>. Ensure the value is a valid BCP 47 tag, not a locale string from your i18n library (e.g., 'en_US' with underscore is invalid).",
2156
+ "vue": "Same as html-has-lang — set in nuxt.config.ts htmlAttrs or in the HTML template. Verify the value against the BCP 47 registry before deploying.",
2157
+ "angular": "Same as html-has-lang — set in index.html or via Angular Universal's locale injection. Confirm the value uses hyphen separators (en-US), not underscore (en_US).",
2158
+ "svelte": "In SvelteKit, set a valid BCP 47 lang tag in app.html: <html lang='en'>. For i18n apps, validate that each locale maps to a valid BCP 47 tag (e.g., 'en-US' not 'english').",
2159
+ "astro": "In .astro layouts, ensure <html lang='en'> uses a valid BCP 47 tag. For multilingual Astro sites, validate each locale's lang value against the BCP 47 standard."
2160
+ },
2161
+ "fix_difficulty_notes": "Common mistakes: using underscore separator ('en_US') instead of hyphen ('en-US'), or spelling out the full language name ('english') instead of the BCP 47 subtag ('en'). Verify against https://www.iana.org/assignments/language-subtag-registry/.",
2162
+ "related_rules": [
2163
+ {
2164
+ "id": "html-has-lang",
2165
+ "reason": "html-has-lang and html-lang-valid are often co-located — fix the lang attribute once to resolve both."
2166
+ },
2167
+ {
2168
+ "id": "valid-lang",
2169
+ "reason": "The same BCP 47 validity requirement applies to lang attributes on child elements."
2170
+ },
2171
+ {
2172
+ "id": "html-xml-lang-mismatch",
2173
+ "reason": "Both lang and xml:lang must contain valid BCP 47 language tags."
2174
+ }
2175
+ ]
2176
+ },
2177
+ "html-xml-lang-mismatch": {
2178
+ "category": "language",
2179
+ "fix": {
2180
+ "description": "Align the lang and xml:lang attributes on the <html> element so both specify the same base language, or remove xml:lang entirely unless serving XHTML. A mismatch confuses assistive technologies about the page language.",
2181
+ "code": "<!-- Before: mismatched language attributes -->\n<html lang=\"en\" xml:lang=\"fr\">\n\n<!-- After: matching language attributes -->\n<html lang=\"en\" xml:lang=\"en\">\n\n<!-- Best: remove xml:lang unless serving XHTML -->\n<html lang=\"en\">"
2182
+ },
2183
+ "false_positive_risk": "low",
2184
+ "framework_notes": {
2185
+ "react": "In Next.js, the lang attribute is set in next.config.js (i18n.defaultLocale) or in the root layout. xml:lang is only needed for XHTML — remove it in standard HTML5. If both exist, ensure they match the same locale.",
2186
+ "vue": "In Nuxt, set the lang attribute in nuxt.config.ts (app.head.htmlAttrs.lang). Do not add xml:lang unless serving XHTML content type. If both are present, ensure they agree on the base language.",
2187
+ "angular": "In Angular, set the lang attribute in index.html on the <html> element. Remove xml:lang unless the app is served as application/xhtml+xml. If both are needed, ensure they specify the same language.",
2188
+ "svelte": "In SvelteKit, app.html typically has only lang (not xml:lang). If both are present (XHTML mode), they must match exactly. This is rare in modern Svelte projects.",
2189
+ "astro": "In .astro layouts, if both lang and xml:lang are present on <html>, they must have identical values. This is rare in modern Astro projects since XHTML mode is uncommon."
2190
+ },
2191
+ "fix_difficulty_notes": "The simplest fix is to remove xml:lang — it is only required for XHTML served with an XML content type. Modern HTML5 documents should use only the lang attribute. If your CMS or framework generates both attributes, ensure the template or configuration sets them to the same value. The 'base language' comparison ignores subtags (e.g., 'en-US' and 'en-GB' have the same base language 'en').",
2192
+ "related_rules": [
2193
+ {
2194
+ "id": "html-has-lang",
2195
+ "reason": "The lang attribute must be present before validating its agreement with xml:lang."
2196
+ },
2197
+ {
2198
+ "id": "html-lang-valid",
2199
+ "reason": "Both lang and xml:lang must contain valid BCP 47 language tags."
2200
+ }
2201
+ ]
2202
+ },
2203
+ "identical-links-same-purpose": {
2204
+ "category": "name-role-value",
2205
+ "fix": {
2206
+ "description": "Ensure links with identical visible text lead to the same destination, or differentiate them with unique text or aria-label to clarify their distinct purposes.",
2207
+ "code": "<!-- Before: two 'Read more' links with different destinations -->\n<a href=\"/article-1\">Read more</a>\n<a href=\"/article-2\">Read more</a>\n\n<!-- After: descriptive link text -->\n<a href=\"/article-1\">Read more about accessibility</a>\n<a href=\"/article-2\">Read more about inclusive design</a>\n\n<!-- Or: extend with aria-label (preserves visual design) -->\n<a href=\"/article-1\" aria-label=\"Read more about accessibility\">Read more</a>\n<a href=\"/article-2\" aria-label=\"Read more about inclusive design\">Read more</a>"
2208
+ },
2209
+ "false_positive_risk": "high",
2210
+ "framework_notes": {
2211
+ "react": "In React, card components with 'Read more' links should accept a linkLabel prop: <a href={href} aria-label={`Read more about ${title}`}>Read more</a>. This preserves visual design while adding screen reader context.",
2212
+ "vue": "In Vue, pass the article or card title into the link: <a :href='url' :aria-label='`Read more about ${title}`'>Read more</a>.",
2213
+ "angular": "In Angular, use [attr.aria-label]='\"Read more about \" + card.title' on the link element within the card component.",
2214
+ "svelte": "In Svelte, links with the same accessible name should navigate to the same URL. If two 'Learn more' links go to different pages, make the link text unique: 'Learn more about pricing', 'Learn more about features'.",
2215
+ "astro": "In .astro files, links with identical text must go to the same destination. Use descriptive, unique link text rather than generic 'Read more' or 'Click here' across different sections."
2216
+ },
2217
+ "fix_difficulty_notes": "axe uses heuristics to detect this — it flags links with identical text pointing to different URLs. False positives are common when 'Read more' links in a card grid are accompanied by a heading that provides context. WCAG 2.4.4 allows disambiguation through programmatic context (e.g., an aria-labelledby relationship between a card heading and its link). Check whether this is already satisfied before fixing. The simplest fix that avoids template changes is aria-label.",
2218
+ "related_rules": [
2219
+ {
2220
+ "id": "link-name",
2221
+ "reason": "Both rules require descriptive link text — fixing generic link names (link-name) also resolves identical-links-same-purpose violations."
2222
+ }
2223
+ ],
2224
+ "guardrails_overrides": {
2225
+ "must": [
2226
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
2227
+ ],
2228
+ "must_not": [
2229
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
2230
+ ],
2231
+ "verify": [
2232
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
2233
+ ]
2234
+ }
2235
+ },
2236
+ "image-alt": {
2237
+ "category": "text-alternatives",
2238
+ "fix": {
2239
+ "description": "Add a descriptive alt attribute to every <img>. Use alt=\"\" for decorative images.",
2240
+ "code": "<img src=\"photo.jpg\" alt=\"Brown leather hiking boots with red laces on a white background\">\n<!-- Decorative image (hidden from AT): -->\n<img src=\"divider.png\" alt=\"\">"
2241
+ },
2242
+ "false_positive_risk": "low",
2243
+ "framework_notes": {
2244
+ "react": "Use the alt prop directly on <img>: <img src={src} alt=\"Description\" />. For decorative images: alt=\"\".",
2245
+ "vue": "Use :alt binding or plain alt attribute — standard HTML semantics apply.",
2246
+ "angular": "Use [attr.alt] binding or plain alt attribute on <img> elements.",
2247
+ "svelte": "Svelte's compiler enforces alt attributes on <img> elements at build time — missing alt triggers a warning (a11y-missing-attribute). Use alt='' for decorative images. This is one of Svelte's strongest built-in accessibility features.",
2248
+ "astro": "Astro's <Image /> component from astro:assets requires the alt prop by default (TypeScript will error without it). For standard <img> tags in .astro files, add alt manually — Astro does not validate HTML attributes at build time."
2249
+ },
2250
+ "cms_notes": {
2251
+ "shopify": "Product images use {{ image | image_url }} with product.featured_image.alt as alt text. If the merchant hasn't set alt text in the admin, it defaults to blank — add a fallback: alt='{{ image.alt | default: product.title }}' in your Liquid template.",
2252
+ "wordpress": "WordPress stores alt text per attachment via update_post_meta($id, '_wp_attachment_image_alt', $value). The image block populates alt from the Media Library. Ensure custom image rendering via wp_get_attachment_image() preserves the stored alt attribute.",
2253
+ "drupal": "Drupal's Image field requires alt text by default (configurable per field instance). In Twig templates, use {{ content.field_image }} which renders with the stored alt. If rendering manually, use {{ file_url(node.field_image.entity.uri.value) }} with alt={{ node.field_image.alt }}."
2254
+ },
2255
+ "fix_difficulty_notes": "axe-core confirms alt presence but cannot evaluate alt quality. An alt='photo.jpg' or alt='image' passes axe but violates 1.1.1. Always verify that alt text is descriptive and conveys the image's purpose. For decorative images, use alt='' (empty, not omitted — omitting alt causes some screen readers to announce the filename).",
2256
+ "related_rules": [
2257
+ {
2258
+ "id": "input-image-alt",
2259
+ "reason": "The same alt text requirement applies to <input type=\"image\"> — fix all alt text violations together."
2260
+ },
2261
+ {
2262
+ "id": "svg-img-alt",
2263
+ "reason": "SVGs used as images need equivalent alt text — audit both together."
2264
+ },
2265
+ {
2266
+ "id": "image-redundant-alt",
2267
+ "reason": "When adding alt text, avoid duplicating visible adjacent text — fixing one can trigger the other."
2268
+ },
2269
+ {
2270
+ "id": "object-alt",
2271
+ "reason": "The same text alternative requirement applies to <object> — audit both together."
2272
+ },
2273
+ {
2274
+ "id": "area-alt",
2275
+ "reason": "The parent <img> of the image map also needs alt text — fix both together."
2276
+ },
2277
+ {
2278
+ "id": "role-img-alt",
2279
+ "reason": "Both rules require images to have accessible names — fix all image alternatives together."
2280
+ }
2281
+ ],
2282
+ "guardrails_overrides": {
2283
+ "must": [
2284
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2285
+ ],
2286
+ "must_not": [
2287
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2288
+ ],
2289
+ "verify": [
2290
+ "Confirm computed accessible name matches expected spoken phrase."
2291
+ ]
2292
+ }
2293
+ },
2294
+ "image-redundant-alt": {
2295
+ "category": "text-alternatives",
2296
+ "fix": {
2297
+ "description": "Remove or shorten the alt text when it duplicates adjacent visible text. Use alt='' to mark the image as decorative when adjacent text fully explains it.",
2298
+ "code": "<!-- Before: alt duplicates adjacent figcaption -->\n<figure>\n <img src=\"chart.png\" alt=\"Bar chart showing Q1 2024 sales by region\">\n <figcaption>Bar chart showing Q1 2024 sales by region</figcaption>\n</figure>\n\n<!-- After: empty alt — caption provides the description -->\n<figure>\n <img src=\"chart.png\" alt=\"\">\n <figcaption>Bar chart showing Q1 2024 sales by region</figcaption>\n</figure>"
2299
+ },
2300
+ "false_positive_risk": "medium",
2301
+ "framework_notes": {
2302
+ "react": "In React, CMS-driven content often auto-populates alt from the image title, which duplicates the adjacent heading. Use conditional alt: alt={caption ? '' : imageAlt} when a caption is present.",
2303
+ "vue": "In Vue, compute the alt conditionally: :alt='caption ? \"\" : imageAlt'. Avoid binding the same string to both alt and a figcaption.",
2304
+ "angular": "In Angular, use conditional binding: [attr.alt]='caption ? \"\" : imageAlt' to suppress redundant alt when a visible caption is present.",
2305
+ "svelte": "In Svelte, avoid duplicating visible text in the alt attribute. If a card has <h2>Product Name</h2><img alt='Product Name'>, the alt is redundant — use alt='' or describe the image differently.",
2306
+ "astro": "In .astro files, alt text that duplicates adjacent visible text is redundant. Use alt='' for decorative images next to their text equivalent, or describe the image's visual content instead."
2307
+ },
2308
+ "fix_difficulty_notes": "axe flags exact or near-exact matches between alt and adjacent text — but verify the adjacent text is truly sufficient before setting alt=''. For charts, the figcaption may describe the title but not the data values — the image alt may still be needed. In <figure>/<figcaption> pairs, the figcaption typically replaces the alt for descriptive images, but for complex diagrams, consider aria-describedby pointing to a detailed description.",
2309
+ "related_rules": [
2310
+ {
2311
+ "id": "image-alt",
2312
+ "reason": "When adding alt text, avoid duplicating visible adjacent text — fixing one can trigger the other."
2313
+ },
2314
+ {
2315
+ "id": "svg-img-alt",
2316
+ "reason": "When adding aria-label to SVGs, avoid duplicating visible adjacent text."
2317
+ }
2318
+ ],
2319
+ "guardrails_overrides": {
2320
+ "must": [
2321
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2322
+ ],
2323
+ "must_not": [
2324
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2325
+ ],
2326
+ "verify": [
2327
+ "Confirm computed accessible name matches expected spoken phrase."
2328
+ ]
2329
+ }
2330
+ },
2331
+ "input-button-name": {
2332
+ "category": "forms",
2333
+ "preferred_relationship_checks": [
2334
+ "aria-labelledby",
2335
+ "aria-label",
2336
+ "explicit-label",
2337
+ "implicit-label"
2338
+ ],
2339
+ "fix": {
2340
+ "description": "Add a value attribute or aria-label to every <input type='button'>, <input type='submit'>, and <input type='reset'>.",
2341
+ "code": "<!-- Input buttons use value as accessible name: -->\n<input type=\"submit\" value=\"Submit order\">\n<input type=\"reset\" value=\"Clear form\">\n<input type=\"button\" value=\"Load more results\">\n\n<!-- If value must stay empty for visual reasons, use aria-label: -->\n<input type=\"submit\" value=\"\" aria-label=\"Submit order\">"
2342
+ },
2343
+ "false_positive_risk": "low",
2344
+ "framework_notes": {
2345
+ "react": "In React, use the value prop: <input type='submit' value='Submit order' />. For modern React, prefer <button type='submit'>Submit order</button> — it supports child elements (icons, spans) and is more styleable.",
2346
+ "vue": "In Vue, use value='Submit order' or :value='submitLabel' for dynamic labels. Prefer <button type='submit'> over <input type='submit'> for greater flexibility.",
2347
+ "angular": "In Angular, use [value]='submitLabel' or a static value attribute. Angular Material's mat-button directives work on <button> — prefer <button> for all interactive controls.",
2348
+ "svelte": "In Svelte, <input type='submit'> and <input type='button'> need a value attribute for their accessible name. For icon-only inputs, use aria-label instead.",
2349
+ "astro": "In .astro files, <input type='button'> and <input type='submit'> must have a value attribute. This renders to static HTML — ensure the value is descriptive."
2350
+ },
2351
+ "fix_difficulty_notes": "For <input type='submit'> and <input type='reset'>, browsers provide default labels ('Submit' and 'Reset') when value is omitted — axe may or may not flag these depending on context. Prefer explicit value attributes over browser defaults, as default labels are not consistently translated across locales. Prefer <button type='submit'> over <input type='submit'> for new code — it is more flexible.",
2352
+ "cms_notes": {
2353
+ "shopify": "Dawn theme's add-to-cart form uses <button type='submit'> not <input type='submit'>. If you see this violation it is likely in a legacy custom section or a third-party app's injected form. In theme forms, add value='{{ 'products.product.add_to_cart' | t }}' to any remaining <input type='submit'> elements.",
2354
+ "wordpress": "Contact Form 7 and older WordPress form plugins historically used <input type='submit'>. CF7 now uses <button> by default. Classic plugin-generated forms may still use <input type='submit' value=''> with value set dynamically via PHP filters — hook into wpcf7_form_tag_data_normalize to set a valid value.",
2355
+ "drupal": "Drupal's Form API renders submit buttons as <input type='submit'> by default with value set from #value. The #value key is required — never set #value to empty. Use 'Save' or a descriptive translatable string: '#value' => $this->t('Save configuration')."
2356
+ },
2357
+ "related_rules": [
2358
+ {
2359
+ "id": "button-name",
2360
+ "reason": "The same accessible name requirement applies to <button> elements — fix all button naming violations together."
2361
+ },
2362
+ {
2363
+ "id": "link-name",
2364
+ "reason": "The same accessible name requirement applies to links — fix all interactive element naming together."
2365
+ }
2366
+ ],
2367
+ "guardrails_overrides": {
2368
+ "must": [
2369
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2370
+ ],
2371
+ "must_not": [
2372
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2373
+ ],
2374
+ "verify": [
2375
+ "Confirm computed accessible name matches expected spoken phrase."
2376
+ ]
2377
+ }
2378
+ },
2379
+ "input-image-alt": {
2380
+ "category": "text-alternatives",
2381
+ "fix": {
2382
+ "description": "Add an alt attribute to every <input type=\"image\"> describing the action it performs.",
2383
+ "code": "<input type=\"image\" src=\"submit.png\" alt=\"Submit form\">"
2384
+ },
2385
+ "false_positive_risk": "low",
2386
+ "framework_notes": {
2387
+ "react": "In React, <input type='image'> is rarely used — most apps use <button> with an <img> or SVG icon instead. If encountered, add the alt prop directly: <input type='image' src={src} alt='Submit form' />.",
2388
+ "vue": "In Vue, bind alt directly: <input type='image' :src='src' alt='Submit form'>. This pattern is uncommon in Vue apps — consider replacing with a <button> containing an icon for better maintainability.",
2389
+ "angular": "In Angular, use [alt]='buttonAction' on the input element. Angular does not validate alt on input[type=image] — this must be caught by axe or manual review.",
2390
+ "svelte": "In Svelte, add alt directly: <input type='image' src={src} alt='Submit form'>. Svelte's a11y warnings do not cover input[type=image] — rely on axe-core for detection.",
2391
+ "astro": "In .astro files, <input type='image'> renders as static HTML. Add the alt attribute directly in the template. This element is uncommon in modern Astro projects — consider replacing with a <button> and an icon."
2392
+ },
2393
+ "fix_difficulty_notes": "The alt must describe the button's action, not the image appearance. 'Submit form' is correct; 'Blue arrow pointing right' is not.",
2394
+ "related_rules": [
2395
+ {
2396
+ "id": "image-alt",
2397
+ "reason": "The same alt text requirement applies to <img> — fix all alt text violations together."
2398
+ },
2399
+ {
2400
+ "id": "svg-img-alt",
2401
+ "reason": "SVGs used as images share the same accessible name requirement."
2402
+ }
2403
+ ],
2404
+ "guardrails_overrides": {
2405
+ "must": [
2406
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2407
+ ],
2408
+ "must_not": [
2409
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2410
+ ],
2411
+ "verify": [
2412
+ "Confirm computed accessible name matches expected spoken phrase."
2413
+ ]
2414
+ }
2415
+ },
2416
+ "label": {
2417
+ "category": "forms",
2418
+ "preferred_relationship_checks": [
2419
+ "aria-labelledby",
2420
+ "explicit-label",
2421
+ "form-field-multiple-labels",
2422
+ "label",
2423
+ "aria-label",
2424
+ "implicit-label"
2425
+ ],
2426
+ "fix": {
2427
+ "description": "Associate every form input with a visible <label> element.",
2428
+ "code": "<label for=\"email\">Email address</label>\n<input id=\"email\" type=\"email\" name=\"email\">"
2429
+ },
2430
+ "false_positive_risk": "low",
2431
+ "framework_notes": {
2432
+ "react": "Use htmlFor prop (not 'for') on <label>: <label htmlFor=\"email\">Email</label>. The 'for' attribute is reserved in JSX.",
2433
+ "vue": "Use the standard for attribute: <label for=\"email\">. Vue renders standard HTML — no special prop needed.",
2434
+ "angular": "Use [for]=\"inputId\" binding or wrap the input inside the label element to avoid explicit ID linking.",
2435
+ "svelte": "Use the standard HTML for attribute: <label for='email'>. Svelte's compiler warns if interactive form elements lack associated labels (a11y-label-has-associated-control). Wrapping the input inside the label also satisfies the check.",
2436
+ "astro": "In .astro files, use standard HTML <label for='id'>. For form inputs inside framework islands (React/Vue/Svelte), each framework's label rules apply within the island boundary — Astro does not validate cross-island label-input associations."
2437
+ },
2438
+ "cms_notes": {
2439
+ "shopify": "Dawn theme hides labels visually with the 'visually-hidden' class. Ensure labels exist in the DOM even when hidden — screen readers need the <label> element. Never remove the label; instead use CSS to hide it if the design requires it.",
2440
+ "wordpress": "Gutenberg's built-in form blocks include labels by default. For custom blocks, use <label> inside the block's save() function. Contact Form 7 and WPForms plugins generate labels automatically — verify they use the 'for' attribute.",
2441
+ "drupal": "Drupal's Form API automatically generates labels when #title is set on form elements. In custom Twig templates for forms, ensure every input has a corresponding <label for='{{ input_id }}'>. Use form_element.html.twig as your reference."
2442
+ },
2443
+ "fix_difficulty_notes": "aria-label works technically but voice control users cannot target the field by speaking the label — they need a visible text match. Prefer a visible <label> over aria-label for all inputs. placeholder is not a substitute for a label — it disappears on input and is not reliably announced by screen readers.",
2444
+ "related_rules": [
2445
+ {
2446
+ "id": "select-name",
2447
+ "reason": "The same label association pattern resolves select-name violations."
2448
+ },
2449
+ {
2450
+ "id": "autocomplete-valid",
2451
+ "reason": "Properly labeled inputs should also have correct autocomplete tokens — fix label association and autocomplete together."
2452
+ },
2453
+ {
2454
+ "id": "form-field-multiple-labels",
2455
+ "reason": "Fixing label association may expose or create multiple-label conflicts — audit both together."
2456
+ },
2457
+ {
2458
+ "id": "label-content-name-mismatch",
2459
+ "reason": "The visible label text must match the accessible name — fix label association and name mismatch together."
2460
+ },
2461
+ {
2462
+ "id": "aria-input-field-name",
2463
+ "reason": "ARIA input field naming and native label association address the same accessible name gap — fix together."
2464
+ },
2465
+ {
2466
+ "id": "aria-toggle-field-name",
2467
+ "reason": "The same label association pattern resolves both label and aria-toggle-field-name violations."
2468
+ },
2469
+ {
2470
+ "id": "label-title-only",
2471
+ "reason": "Both rules enforce proper label association — fix label-title-only and label violations together."
2472
+ }
2473
+ ],
2474
+ "guardrails_overrides": {
2475
+ "must": [
2476
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2477
+ ],
2478
+ "must_not": [
2479
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2480
+ ],
2481
+ "verify": [
2482
+ "Confirm computed accessible name matches expected spoken phrase."
2483
+ ]
2484
+ }
2485
+ },
2486
+ "label-content-name-mismatch": {
2487
+ "category": "forms",
2488
+ "fix": {
2489
+ "description": "Ensure the element's accessible name contains or starts with the visible label text so voice control users can activate it by speaking what they see.",
2490
+ "code": "<!-- Visible label: 'Search products' -->\n<!-- aria-label must contain that text: -->\n<button aria-label=\"Search products\">Search products</button>\n\n<!-- Or remove aria-label and rely on visible text: -->\n<button>Search products</button>\n\n<!-- If icon + text: make aria-label match the visible text -->\n<button aria-label=\"Search products\">\n <svg aria-hidden=\"true\">...</svg>\n Search products\n</button>"
2491
+ },
2492
+ "false_positive_risk": "medium",
2493
+ "framework_notes": {
2494
+ "react": "In React, avoid aria-label props that differ from the button's child text. For icon buttons, if you add visible text alongside the icon, the aria-label becomes unnecessary — remove it.",
2495
+ "vue": "In Vue, check components that accept both a label slot and an aria-label prop — if both are provided with different values, this violation occurs. Prefer deriving the accessible name from the visible slot content.",
2496
+ "angular": "In Angular, aria-label bindings on buttons with text content are a common source of this violation. Remove [attr.aria-label] on buttons that have visible text children unless the label extends the text rather than replacing it.",
2497
+ "svelte": "In Svelte, the accessible name (from aria-label or aria-labelledby) must include the visible text of the element. Voice control users speak the visible text — if it doesn't match the accessible name, activation fails.",
2498
+ "astro": "In .astro files, ensure aria-label includes the visible text content. This rule ensures voice control users can activate elements by speaking the visible label."
2499
+ },
2500
+ "fix_difficulty_notes": "The accessible name must contain (not just match) the visible text — it can have additional context. For example, aria-label='Search products in catalog' on a button with visible text 'Search products' is valid. The violation is when aria-label completely replaces the visible label with different text (e.g., visible 'Buy now', aria-label='Add to cart'). The simplest fix is often to remove the aria-label and let the visible text serve as the accessible name.",
2501
+ "related_rules": [
2502
+ {
2503
+ "id": "label",
2504
+ "reason": "Fixing label association is the primary step before resolving name mismatch."
2505
+ },
2506
+ {
2507
+ "id": "form-field-multiple-labels",
2508
+ "reason": "Multiple labels frequently cause accessible name to differ from visible label text."
2509
+ }
2510
+ ],
2511
+ "guardrails_overrides": {
2512
+ "must": [
2513
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2514
+ ],
2515
+ "must_not": [
2516
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2517
+ ],
2518
+ "verify": [
2519
+ "Confirm computed accessible name matches expected spoken phrase."
2520
+ ]
2521
+ }
2522
+ },
2523
+ "label-title-only": {
2524
+ "category": "forms",
2525
+ "fix": {
2526
+ "description": "Ensure every form element has a visible label and is not solely labeled using the title attribute, aria-describedby, or hidden labels. The title attribute is not consistently exposed by all screen readers and is not visible to sighted users.",
2527
+ "code": "<!-- Before: labeled only by title (not visible, not reliable) -->\n<input type=\"text\" title=\"Enter your name\">\n\n<!-- After: visible <label> element -->\n<label for=\"name\">Full name</label>\n<input type=\"text\" id=\"name\">\n\n<!-- Also valid: floating label pattern with visible label -->\n<div class=\"form-group\">\n <input type=\"email\" id=\"email\" placeholder=\" \" required>\n <label for=\"email\">Email address</label>\n</div>"
2528
+ },
2529
+ "false_positive_risk": "low",
2530
+ "framework_notes": {
2531
+ "react": "In React, avoid using title as the sole labeling mechanism on inputs. Use <label htmlFor='id'> or aria-label only for visually hidden inputs. For floating labels, ensure the <label> is always rendered — not replaced by placeholder text.",
2532
+ "vue": "In Vue, replace title-only labeling with <label :for='id'>. For component libraries (Vuetify, PrimeVue), use the label prop which renders a visible <label> element, not just a title attribute.",
2533
+ "angular": "In Angular, use <label [for]='inputId'> or Angular Material's <mat-label> inside <mat-form-field>. The mat-label renders a visible, persistent label. Do not rely on [attr.title] as the sole accessible name.",
2534
+ "svelte": "In Svelte, prefer <label for='id'> over a title attribute for form inputs. The title attribute is only exposed on hover — screen reader users hear it, but it's invisible to sighted keyboard users.",
2535
+ "astro": "In .astro files, form inputs should have <label> elements, not just title attributes. The title attribute is a fallback, not a substitute for a visible label."
2536
+ },
2537
+ "fix_difficulty_notes": "The title attribute provides a tooltip on hover but is not reliably announced by all screen reader and browser combinations. WCAG requires a visible label for form inputs (1.3.1 Info and Relationships, 3.3.2 Labels or Instructions). The fix is to add a <label> element associated via for/id. For cases where a visible label is not desired (icon-only search fields), use aria-label with a visually hidden <label> as a fallback.",
2538
+ "related_rules": [
2539
+ {
2540
+ "id": "label",
2541
+ "reason": "Both rules enforce proper label association — fix label-title-only and label violations together."
2542
+ },
2543
+ {
2544
+ "id": "form-field-multiple-labels",
2545
+ "reason": "When adding a visible label, verify you are not creating a duplicate label association."
2546
+ }
2547
+ ],
2548
+ "guardrails_overrides": {
2549
+ "must": [
2550
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
2551
+ ],
2552
+ "must_not": [
2553
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
2554
+ ],
2555
+ "verify": [
2556
+ "Confirm computed accessible name matches expected spoken phrase."
2557
+ ]
2558
+ }
2559
+ },
2560
+ "landmark-banner-is-top-level": {
2561
+ "category": "structure",
2562
+ "fix": {
2563
+ "description": "Ensure the banner landmark (<header> or role='banner') is at the top level of the document and not nested inside another landmark such as <main>, <aside>, <nav>, or <section>.",
2564
+ "code": "<!-- Correct: banner at the top level -->\n<body>\n <header>\n <nav aria-label=\"Primary\">\n <a href=\"/\">Home</a>\n </nav>\n </header>\n <main>...</main>\n <footer>...</footer>\n</body>\n\n<!-- Incorrect: banner nested inside main -->\n<!-- <main>\n <header>Site header</header>\n <section>Content</section>\n</main> -->"
2565
+ },
2566
+ "false_positive_risk": "medium",
2567
+ "framework_notes": {
2568
+ "react": "In Next.js, place <header> in the root layout.tsx as a sibling of <main>, not inside it. Page components must not render a top-level <header> — use <section> or <div> for page-specific header areas.",
2569
+ "vue": "In Nuxt, place <header> in layouts/default.vue before the <main> wrapping <slot />. Do not nest <header> inside <main> or <aside>.",
2570
+ "angular": "In Angular, place <header> in app.component.html before the <main> wrapping <router-outlet>. Feature modules must not render a <header> inside the routed content area.",
2571
+ "svelte": "In SvelteKit, <header> elements (which have implicit role='banner') must be top-level — not nested inside <main>, <section>, or <article>. Place <header> as a direct child of <body> in +layout.svelte.",
2572
+ "astro": "In .astro layouts, ensure <header> (role='banner') is a direct child of <body>, not nested inside <main> or <section>. Astro component nesting can accidentally push <header> inside other landmarks."
2573
+ },
2574
+ "fix_difficulty_notes": "A <header> inside <article> or <section> does not create a banner landmark — it is scoped to that sectioning element. The violation occurs when a <header> intended as the site banner is nested inside <main> or another landmark. Move it to be a direct child of <body>. In SPA frameworks, ensure the layout component places <header> before <main>, not inside it.",
2575
+ "guardrails_overrides": {
2576
+ "must_not": [
2577
+ "Do not add role='banner' to a <header> nested inside <main> as a shortcut — move the element to the correct position in the DOM.",
2578
+ "Do not add a second <header role='banner'> at the body level if one already exists — this creates a duplicate-banner violation."
2579
+ ],
2580
+ "verify": [
2581
+ "Confirm the <header> is a direct child of <body> (or is the only banner landmark at body level) in the rendered DOM."
2582
+ ]
2583
+ },
2584
+ "related_rules": [
2585
+ {
2586
+ "id": "landmark-no-duplicate-banner",
2587
+ "reason": "If the banner is duplicated and one copy is nested incorrectly, fix both the nesting and the duplication together."
2588
+ },
2589
+ {
2590
+ "id": "landmark-contentinfo-is-top-level",
2591
+ "reason": "The contentinfo (footer) landmark has the same top-level requirement — fix both together."
2592
+ },
2593
+ {
2594
+ "id": "landmark-main-is-top-level",
2595
+ "reason": "All primary landmarks (banner, main, contentinfo) must be at the top level — fix as a group."
2596
+ },
2597
+ {
2598
+ "id": "landmark-complementary-is-top-level",
2599
+ "reason": "All primary landmarks share the same top-level requirement — fix nesting issues for all landmarks together."
2600
+ }
2601
+ ]
2602
+ },
2603
+ "landmark-complementary-is-top-level": {
2604
+ "category": "structure",
2605
+ "fix": {
2606
+ "description": "Ensure the complementary landmark (<aside> or role='complementary') is at the top level of the document and not nested inside another landmark such as <main>, <nav>, or <header>.",
2607
+ "code": "<!-- Correct: aside at the top level -->\n<body>\n <header>...</header>\n <main>...</main>\n <aside aria-label=\"Related articles\">\n <h2>Related articles</h2>\n <ul>...</ul>\n </aside>\n <footer>...</footer>\n</body>\n\n<!-- Incorrect: aside nested inside main -->\n<!-- <main>\n <article>Content</article>\n <aside>Sidebar</aside>\n</main> -->"
2608
+ },
2609
+ "false_positive_risk": "medium",
2610
+ "framework_notes": {
2611
+ "react": "In Next.js App Router, place <aside> in layout.tsx as a sibling of <main>, not inside it. If sidebar content varies per page, use a slot pattern or React context to project sidebar content into the layout-level <aside>.",
2612
+ "vue": "In Nuxt, use a named slot in layouts/default.vue for sidebar content: <aside><slot name='sidebar' /></aside> placed outside <main>. Page components fill the slot without nesting <aside> inside <main>.",
2613
+ "angular": "In Angular, place <aside> in app.component.html alongside <main>, not inside the <router-outlet>. Use a service or ng-content projection to populate sidebar content from route components.",
2614
+ "svelte": "In Svelte, <aside> elements (role='complementary') must be top-level landmarks — not nested inside <main> unless they are related to the main content specifically.",
2615
+ "astro": "In .astro layouts, <aside> should be a direct child of <body> for top-level complementary content. If related to a specific section, it can be inside <main> with an aria-label."
2616
+ },
2617
+ "fix_difficulty_notes": "The HTML spec allows <aside> inside <article> or <section>, where it is scoped to that sectioning element and does not create a top-level complementary landmark. The violation occurs when an <aside> intended as a page-level sidebar is nested inside <main>. Move it to be a sibling of <main>. Common in blog layouts where the sidebar is mistakenly placed inside the main content area.",
2618
+ "guardrails_overrides": {
2619
+ "must_not": [
2620
+ "Do not add role='complementary' to an <aside> nested inside <main> as a workaround — move the element to be a sibling of <main>.",
2621
+ "Do not flag an <aside> inside <article> or <section> — that placement is intentional and does not create a landmark."
2622
+ ],
2623
+ "verify": [
2624
+ "Confirm the <aside> intended as a page sidebar is a direct child of <body> or a sibling of <main> in the rendered DOM."
2625
+ ]
2626
+ },
2627
+ "related_rules": [
2628
+ {
2629
+ "id": "landmark-banner-is-top-level",
2630
+ "reason": "All primary landmarks share the same top-level requirement — fix nesting issues for all landmarks together."
2631
+ },
2632
+ {
2633
+ "id": "landmark-main-is-top-level",
2634
+ "reason": "Complementary and main landmarks must both be at the top level — fix together."
2635
+ }
2636
+ ]
2637
+ },
2638
+ "landmark-contentinfo-is-top-level": {
2639
+ "category": "structure",
2640
+ "fix": {
2641
+ "description": "Ensure the contentinfo landmark (<footer> or role='contentinfo') is at the top level of the document and not nested inside another landmark such as <main>, <aside>, or <nav>.",
2642
+ "code": "<!-- Correct: footer at the top level -->\n<body>\n <header>...</header>\n <main>...</main>\n <footer>\n <p>&copy; 2026 Company Name</p>\n <nav aria-label=\"Footer\">\n <a href=\"/privacy\">Privacy</a>\n <a href=\"/terms\">Terms</a>\n </nav>\n </footer>\n</body>\n\n<!-- Incorrect: footer nested inside main -->\n<!-- <main>\n <section>Content</section>\n <footer>Copyright info</footer>\n</main> -->"
2643
+ },
2644
+ "false_positive_risk": "medium",
2645
+ "framework_notes": {
2646
+ "react": "In Next.js, place <footer> in the root layout.tsx after <main>, not inside it. Page components should not render a top-level <footer> — the layout owns the site-wide footer.",
2647
+ "vue": "In Nuxt, place <footer> in layouts/default.vue after the <main> wrapping <slot />. Individual page components must not add a <footer> at the landmark level.",
2648
+ "angular": "In Angular, place <footer> in app.component.html after the <main> wrapping <router-outlet>. Route components must not render their own <footer> at the document root level.",
2649
+ "svelte": "In SvelteKit, <footer> (role='contentinfo') must be top-level — not nested inside <main> or <section>. Place it as a direct child of <body> in +layout.svelte, after </main>.",
2650
+ "astro": "In .astro layouts, <footer> (role='contentinfo') must be a direct child of <body>. Astro component composition can accidentally nest <footer> inside other landmarks."
2651
+ },
2652
+ "fix_difficulty_notes": "A <footer> inside <article> or <section> does not create a contentinfo landmark — it is scoped to that sectioning element. The violation occurs when the site-wide <footer> is nested inside <main> or another landmark. Move it to be a direct child of <body>. In SPAs, this is typically a layout architecture issue — the layout component must place <footer> after <main>, not inside it.",
2653
+ "guardrails_overrides": {
2654
+ "must_not": [
2655
+ "Do not add role='contentinfo' to a <footer> inside <main> as a workaround — move the element to be a direct child of <body>.",
2656
+ "Do not flag a <footer> inside <article> or <section> — that placement is scoped to the sectioning element and does not create a landmark."
2657
+ ],
2658
+ "verify": [
2659
+ "Confirm the site-wide <footer> is a direct child of <body> in the rendered DOM, not nested inside any other landmark."
2660
+ ]
2661
+ },
2662
+ "related_rules": [
2663
+ {
2664
+ "id": "landmark-no-duplicate-contentinfo",
2665
+ "reason": "If multiple contentinfo landmarks exist, fix both the nesting and the duplication together."
2666
+ },
2667
+ {
2668
+ "id": "landmark-banner-is-top-level",
2669
+ "reason": "Banner and contentinfo landmarks share the same top-level requirement — fix together."
2670
+ }
2671
+ ]
2672
+ },
2673
+ "landmark-main-is-top-level": {
2674
+ "category": "structure",
2675
+ "fix": {
2676
+ "description": "Ensure the main landmark (<main> or role='main') is at the top level of the document and not nested inside another landmark such as <header>, <aside>, <nav>, or <section>.",
2677
+ "code": "<!-- Correct: main at the top level -->\n<body>\n <header>...</header>\n <main id=\"main-content\">\n <h1>Page title</h1>\n <section>...</section>\n </main>\n <footer>...</footer>\n</body>\n\n<!-- Incorrect: main nested inside a section -->\n<!-- <section>\n <main>Content</main>\n</section> -->"
2678
+ },
2679
+ "false_positive_risk": "low",
2680
+ "framework_notes": {
2681
+ "react": "In Next.js App Router, <main> belongs in layout.tsx as a direct child of <body> (or the root element). Do not wrap <main> inside a <section> or <div role='region'>.",
2682
+ "vue": "In Nuxt, <main> belongs in layouts/default.vue as a top-level element wrapping <slot />. Do not nest it inside another landmark element.",
2683
+ "angular": "In Angular, <main> belongs in app.component.html wrapping <router-outlet>. Ensure no parent component template wraps it in a <section> or other landmark.",
2684
+ "svelte": "In SvelteKit, <main> must be a direct child of <body> — not nested inside another landmark. Place <main> in +layout.svelte wrapping <slot />, alongside (not inside) <header> and <footer>.",
2685
+ "astro": "In .astro layouts, <main> must be a direct child of <body>. Ensure no wrapper <div>, <section>, or other landmark nests the <main> element."
2686
+ },
2687
+ "fix_difficulty_notes": "In SPAs and component-based architectures, <main> is often wrapped by layout shells, provider components, or CSS utility wrappers that may inadvertently introduce a landmark parent. The fix requires understanding which component owns the layout structure — in Next.js it is layout.tsx, in Nuxt layouts/default.vue, in SvelteKit +layout.svelte. Non-landmark <div> wrappers (for CSS Grid/Flexbox) are safe and do not trigger this rule.",
2688
+ "guardrails_overrides": {
2689
+ "must_not": [
2690
+ "Do not flag <main> wrapped in a non-landmark <div> — plain <div> elements are not landmarks and do not violate this rule.",
2691
+ "Do not add role='main' to a <main> element — the implicit role is already 'main' and the attribute is redundant."
2692
+ ],
2693
+ "verify": [
2694
+ "Confirm no ancestor of <main> has an ARIA role that creates a landmark (header, footer, aside, nav, section with aria-label)."
2695
+ ]
2696
+ },
2697
+ "related_rules": [
2698
+ {
2699
+ "id": "landmark-one-main",
2700
+ "reason": "Ensure exactly one main landmark exists and that it is at the top level."
2701
+ },
2702
+ {
2703
+ "id": "landmark-no-duplicate-main",
2704
+ "reason": "If the main landmark is duplicated, fix both the nesting and the duplication."
2705
+ },
2706
+ {
2707
+ "id": "landmark-banner-is-top-level",
2708
+ "reason": "All primary landmarks (banner, main, contentinfo) must be at the top level — fix as a group."
2709
+ },
2710
+ {
2711
+ "id": "landmark-complementary-is-top-level",
2712
+ "reason": "Complementary and main landmarks must both be at the top level — fix together."
2713
+ }
2714
+ ]
2715
+ },
2716
+ "landmark-no-duplicate-banner": {
2717
+ "category": "structure",
2718
+ "fix": {
2719
+ "description": "Ensure only one <header> element (or role='banner') exists at the top level of the page, outside of sectioning elements.",
2720
+ "code": "<body>\n <!-- One top-level header (banner landmark): -->\n <header>\n <a href=\"/\" aria-label=\"Acme Corp home\">\n <img src=\"/logo.svg\" alt=\"Acme Corp\" width=\"120\" height=\"40\">\n </a>\n <nav aria-label=\"Primary\">\n <a href=\"/shop\">Shop</a>\n <a href=\"/about\">About</a>\n </nav>\n </header>\n <main>\n <!-- Nested headers inside article/section do NOT create banner landmarks: -->\n <article>\n <header>Published on March 15, 2026</header>\n </article>\n </main>\n <footer>...</footer>\n</body>"
2721
+ },
2722
+ "false_positive_risk": "medium",
2723
+ "framework_notes": {
2724
+ "react": "In Next.js App Router, the root layout (layout.tsx) should have exactly one <header> at the top level. Page components must not add a second top-level <header> — use <section> or <div> for page-specific header areas.",
2725
+ "vue": "In Nuxt, the global <header> belongs in layouts/default.vue. Individual page components must not render a top-level <header> — use <section aria-labelledby='...'>.",
2726
+ "angular": "In Angular, app.component.html should contain exactly one top-level <header>. Route components must not add a second <header> at the document root level.",
2727
+ "svelte": "In SvelteKit, ensure only one top-level <header> exists per page. If +layout.svelte and +page.svelte both render <header>, one will be a duplicate. Keep <header> only in the layout.",
2728
+ "astro": "In .astro files, ensure only one top-level <header> exists per page. If the base layout includes <header>, individual page components must not add another. Use <section> with aria-label for sub-headers."
2729
+ },
2730
+ "fix_difficulty_notes": "A <header> element only creates a banner landmark when it is a direct child of <body> (not nested inside <article>, <aside>, <main>, <nav>, or <section>). axe may flag this when a layout component renders two <header> tags at body level — one for a skip link area and one for the visible site header. Consolidate them into one top-level <header>.",
2731
+ "guardrails_overrides": {
2732
+ "must_not": [
2733
+ "Do not add aria-label to a second banner landmark as a fix — two labelled banner landmarks still violate this rule. Only one banner is permitted.",
2734
+ "Do not flag a <header> inside <article>, <aside>, <section>, or <nav> — it does not create a banner landmark in that context."
2735
+ ],
2736
+ "verify": [
2737
+ "Confirm exactly one <header> exists as a direct child of <body> (or at body level) in the rendered DOM."
2738
+ ]
2739
+ },
2740
+ "related_rules": [
2741
+ {
2742
+ "id": "landmark-no-duplicate-main",
2743
+ "reason": "Fix all duplicate landmark violations together — they share the same root cause in layout architecture."
2744
+ },
2745
+ {
2746
+ "id": "landmark-one-main",
2747
+ "reason": "Landmark structure issues are often co-located — fix them as a group."
2748
+ },
2749
+ {
2750
+ "id": "landmark-unique",
2751
+ "reason": "Unique landmark labels are the fix for duplicate banner violations — add aria-label to distinguish them."
2752
+ },
2753
+ {
2754
+ "id": "landmark-banner-is-top-level",
2755
+ "reason": "If the banner is duplicated and one copy is nested incorrectly, fix both the nesting and the duplication together."
2756
+ },
2757
+ {
2758
+ "id": "landmark-no-duplicate-contentinfo",
2759
+ "reason": "Fix all duplicate landmark violations together — they share the same root cause in layout architecture."
2760
+ }
2761
+ ]
2762
+ },
2763
+ "landmark-no-duplicate-contentinfo": {
2764
+ "category": "structure",
2765
+ "fix": {
2766
+ "description": "Ensure the document has at most one contentinfo landmark. Multiple top-level <footer> elements or elements with role='contentinfo' create ambiguity for screen reader users navigating by landmarks.",
2767
+ "code": "<!-- Correct: one top-level footer (contentinfo landmark) -->\n<body>\n <header>...</header>\n <main>\n <article>\n <footer>Article footer — not a contentinfo landmark</footer>\n </article>\n </main>\n <footer><!-- Only this one creates a contentinfo landmark -->\n <p>&copy; 2026 Company</p>\n </footer>\n</body>\n\n<!-- Incorrect: two top-level footers -->\n<!-- <body>\n <main>...</main>\n <footer>Site links</footer>\n <footer>Copyright</footer>\n</body> -->"
2768
+ },
2769
+ "false_positive_risk": "medium",
2770
+ "framework_notes": {
2771
+ "react": "In Next.js, the root layout.tsx should contain exactly one top-level <footer>. Page components that need footer-like content should use <section> or <div>, not <footer>, to avoid creating a second contentinfo landmark.",
2772
+ "vue": "In Nuxt, the global <footer> belongs in layouts/default.vue. Individual page components must not render a second top-level <footer>. Use <div> or <section> for page-specific footer content.",
2773
+ "angular": "In Angular, app.component.html should contain exactly one <footer>. Route components must not add a second <footer> at the document root level — use <section> for page-specific bottom content.",
2774
+ "svelte": "In SvelteKit, ensure only one top-level <footer> exists per page. Keep <footer> in +layout.svelte only — individual +page.svelte files should use generic containers for page-specific footer content.",
2775
+ "astro": "In .astro files, ensure only one top-level <footer> exists per page. If the base layout includes <footer>, page components should not add another."
2776
+ },
2777
+ "fix_difficulty_notes": "A <footer> inside <article>, <section>, or <aside> does not create a contentinfo landmark — it is scoped to that sectioning element. The violation only occurs when two or more <footer> elements exist at the body level (not inside sectioning elements). Consolidate them into one <footer> or move the extra ones inside sectioning elements where they become scoped footers.",
2778
+ "guardrails_overrides": {
2779
+ "must_not": [
2780
+ "Do not flag a <footer> inside <article>, <section>, or <aside> — it is scoped to that element and does not create a contentinfo landmark.",
2781
+ "Do not add aria-label to differentiate two body-level footers as a fix — the rule requires at most one contentinfo landmark."
2782
+ ],
2783
+ "verify": [
2784
+ "Confirm only one <footer> exists as a direct child of <body> (or at body level) in the rendered DOM."
2785
+ ]
2786
+ },
2787
+ "related_rules": [
2788
+ {
2789
+ "id": "landmark-contentinfo-is-top-level",
2790
+ "reason": "Duplicate contentinfo landmarks are often caused by incorrect nesting — fix both together."
2791
+ },
2792
+ {
2793
+ "id": "landmark-no-duplicate-banner",
2794
+ "reason": "Fix all duplicate landmark violations together — they share the same root cause in layout architecture."
2795
+ },
2796
+ {
2797
+ "id": "landmark-unique",
2798
+ "reason": "If duplicate contentinfo landmarks cannot be eliminated, give each a unique aria-label."
2799
+ }
2800
+ ]
2801
+ },
2802
+ "landmark-no-duplicate-main": {
2803
+ "category": "structure",
2804
+ "fix": {
2805
+ "description": "Ensure only one <main> element (or role='main') exists on the page. If multiple content areas are needed, use <section> with unique aria-labelledby.",
2806
+ "code": "<main id=\"main-content\">\n <!-- all primary page content -->\n <section aria-labelledby=\"section-a-heading\">\n <h2 id=\"section-a-heading\">Section A</h2>\n </section>\n <section aria-labelledby=\"section-b-heading\">\n <h2 id=\"section-b-heading\">Section B</h2>\n </section>\n</main>"
2807
+ },
2808
+ "false_positive_risk": "low",
2809
+ "framework_notes": {
2810
+ "react": "In Next.js App Router, <main> belongs in layout.tsx, not in page.tsx. Individual page components render content inside the layout's <main> — never add a second <main> inside a page component.",
2811
+ "vue": "In Nuxt, <main> belongs in layouts/default.vue wrapping <slot />. Page components must not add a <main> wrapper.",
2812
+ "angular": "In Angular, <main> belongs in app.component.html wrapping <router-outlet>. Route component templates must not contain a second <main>.",
2813
+ "svelte": "In SvelteKit, ensure only one <main> element exists per page. Place <main> in the root +layout.svelte wrapping <slot />. Individual +page.svelte files must not add their own <main>.",
2814
+ "astro": "In .astro files, ensure only one <main> element exists per page. The base layout should contain the single <main> wrapping <slot />."
2815
+ },
2816
+ "fix_difficulty_notes": "In SPAs, a second <main> commonly appears when a layout component defines one and a page component adds another. The fix is to ensure only the layout component owns the <main> wrapper, and page components render their content inside it without adding a second <main>.",
2817
+ "guardrails_overrides": {
2818
+ "must_not": [
2819
+ "Do not add aria-label to two <main> elements as a workaround — multiple main landmarks are not permitted regardless of labelling.",
2820
+ "Do not use role='main' as a secondary main alongside <main> — both create main landmarks and violate this rule."
2821
+ ],
2822
+ "verify": [
2823
+ "Confirm exactly one <main> element exists in the rendered DOM."
2824
+ ]
2825
+ },
2826
+ "related_rules": [
2827
+ {
2828
+ "id": "landmark-one-main",
2829
+ "reason": "landmark-one-main requires exactly one <main> — duplicate main violations are the inverse of the same requirement."
2830
+ },
2831
+ {
2832
+ "id": "landmark-no-duplicate-banner",
2833
+ "reason": "Fix all duplicate landmark violations together."
2834
+ },
2835
+ {
2836
+ "id": "landmark-main-is-top-level",
2837
+ "reason": "If the main landmark is duplicated, fix both the nesting and the duplication."
2838
+ }
2839
+ ]
2840
+ },
2841
+ "landmark-one-main": {
2842
+ "category": "structure",
2843
+ "fix": {
2844
+ "description": "Add a <main> landmark wrapping your page content.",
2845
+ "code": "<main id=\"main-content\">\n <h1>Products</h1>\n <section aria-labelledby=\"featured-heading\">\n <h2 id=\"featured-heading\">Featured items</h2>\n </section>\n</main>"
2846
+ },
2847
+ "false_positive_risk": "low",
2848
+ "framework_notes": {
2849
+ "react": "Place <main id='main-content'> in the root layout component (app/layout.tsx in Next.js App Router, or the root App component). Do not place <main> in individual page components — it will be missing for routes that render before the layout mounts.",
2850
+ "vue": "Add <main id='main-content'> to the root layout component (App.vue or layouts/default.vue in Nuxt), wrapping <router-view /> or <slot />. Nuxt: set it in layouts/default.vue.",
2851
+ "angular": "Add <main id='main-content'> in app.component.html wrapping <router-outlet>. This ensures the landmark is present for all routes and the skip link target resolves correctly.",
2852
+ "svelte": "In SvelteKit, wrap <slot /> in +layout.svelte with <main id='main-content'>. SvelteKit's layout inheritance ensures every page is wrapped. Never place <main> in individual +page.svelte files.",
2853
+ "astro": "Place <main id='main-content'> in your base layout (e.g., layouts/BaseLayout.astro) wrapping the <slot />. Since Astro pre-renders to static HTML, the <main> landmark is always present without hydration delays."
2854
+ },
2855
+ "cms_notes": {
2856
+ "shopify": "Dawn theme uses <main id='MainContent'> in layout/theme.liquid. If building a custom theme, wrap {{ content_for_layout }} in <main>. Never place <main> inside a section template — it only renders when that section is included.",
2857
+ "wordpress": "Classic themes should wrap the_content() in <main> inside page.php/single.php. Block themes use <main> in templates/index.html. WordPress core does not add <main> automatically — the theme is responsible.",
2858
+ "drupal": "Olivero (the default theme) includes <main> in page.html.twig. Custom themes must ensure page.html.twig wraps {{ page.content }} in <main id='main-content'>. Sub-theme overrides of page.html.twig sometimes lose this landmark."
2859
+ },
2860
+ "fix_difficulty_notes": [
2861
+ "SPAs often render `<main>` conditionally — ensure the root layout component (`layout.tsx` in Next.js, `App.vue` in Vue, `app.component.html` in Angular) always wraps page content in `<main>`, not individual page components.",
2862
+ "axe may flag SPA routes where `<main>` is rendered client-side after the initial snapshot — ensure it is server-side rendered or present in the initial HTML payload."
2863
+ ],
2864
+ "guardrails_overrides": {
2865
+ "must_not": [
2866
+ "Do not add <main> to individual page components if the root layout already wraps content in <main> — this creates a duplicate-main violation.",
2867
+ "Do not use role='main' on a <div> if a <main> element already exists on the page."
2868
+ ],
2869
+ "verify": [
2870
+ "Confirm exactly one <main> element exists in the rendered DOM after adding it."
2871
+ ]
2872
+ },
2873
+ "related_rules": [
2874
+ {
2875
+ "id": "bypass",
2876
+ "reason": "The skip link target (#main-content) should reference the <main> landmark."
2877
+ },
2878
+ {
2879
+ "id": "region",
2880
+ "reason": "Adding <main> may resolve orphan-content region violations."
2881
+ },
2882
+ {
2883
+ "id": "landmark-no-duplicate-banner",
2884
+ "reason": "Landmark structure issues are often co-located — fix them as a group."
2885
+ },
2886
+ {
2887
+ "id": "landmark-no-duplicate-main",
2888
+ "reason": "Duplicate main violations are the inverse of the single <main> requirement — fix together."
2889
+ },
2890
+ {
2891
+ "id": "landmark-main-is-top-level",
2892
+ "reason": "Ensure exactly one main landmark exists and that it is at the top level."
2893
+ },
2894
+ {
2895
+ "id": "skip-link",
2896
+ "reason": "The skip link target should be the <main> landmark — ensure it exists and has the correct id."
2897
+ }
2898
+ ]
2899
+ },
2900
+ "landmark-unique": {
2901
+ "category": "structure",
2902
+ "fix": {
2903
+ "description": "Add unique accessible labels to landmarks of the same type using aria-label or aria-labelledby.",
2904
+ "code": "<nav aria-label=\"Primary navigation\">\n <a href=\"/\">Home</a>\n <a href=\"/shop\">Shop</a>\n <a href=\"/about\">About</a>\n</nav>\n\n<nav aria-label=\"Breadcrumb\">\n <ol>\n <li><a href=\"/\">Home</a></li>\n <li><a href=\"/shop\">Shop</a></li>\n <li>Running Shoes</li>\n </ol>\n</nav>\n\n<nav aria-label=\"Footer links\">\n <a href=\"/privacy\">Privacy Policy</a>\n <a href=\"/terms\">Terms of Service</a>\n</nav>"
2905
+ },
2906
+ "false_positive_risk": "low",
2907
+ "framework_notes": {
2908
+ "react": "In React, pass the aria-label prop to navigation components: <Nav aria-label='Primary navigation'>. Ensure the component forwards the prop to the native <nav> element via spread or explicit prop.",
2909
+ "vue": "In Vue, bind aria-label directly on <nav>: <nav aria-label='Primary navigation'>. Navigation components should accept and forward an aria-label prop to the root <nav>.",
2910
+ "angular": "In Angular, use aria-label='Primary navigation' or [attr.aria-label]='navLabel' on <nav> elements. Shared navigation components should accept an @Input() ariaLabel string and bind it.",
2911
+ "svelte": "In SvelteKit, if multiple <nav> elements exist, each must have a unique aria-label (e.g., 'Primary navigation', 'Footer navigation'). This applies to all landmarks that appear more than once.",
2912
+ "astro": "In .astro files, duplicate landmark types (multiple <nav>, <aside>, etc.) must each have a unique aria-label to distinguish them for screen reader users."
2913
+ },
2914
+ "fix_difficulty_notes": "In component-based apps, duplicate landmarks often come from independently authored components that each render their own <nav> or <aside>. The challenge is coordinating unique labels across components that don't know about each other. Establish a naming convention early (e.g., 'Primary', 'Footer', 'Breadcrumb') and document it in your design system. The label becomes part of the AT announcement: 'Primary navigation, navigation landmark'.",
2915
+ "guardrails_overrides": {
2916
+ "must": [
2917
+ "If visible text exists as a heading inside the landmark, use aria-labelledby pointing to that heading rather than duplicating the text in aria-label."
2918
+ ],
2919
+ "must_not": [
2920
+ "Do not use generic labels like 'navigation 1', 'navigation 2' — labels must be descriptive of the landmark's purpose."
2921
+ ],
2922
+ "verify": [
2923
+ "Confirm all same-type landmarks on the page have unique, descriptive aria-label or aria-labelledby values."
2924
+ ]
2925
+ },
2926
+ "related_rules": [
2927
+ {
2928
+ "id": "landmark-no-duplicate-banner",
2929
+ "reason": "If duplicate banners exist, add aria-label to each — or eliminate the duplicate."
2930
+ },
2931
+ {
2932
+ "id": "bypass",
2933
+ "reason": "Unique landmark labels enhance skip navigation — users can jump directly to a specific landmark."
2934
+ },
2935
+ {
2936
+ "id": "landmark-no-duplicate-contentinfo",
2937
+ "reason": "If duplicate contentinfo landmarks cannot be eliminated, give each a unique aria-label."
2938
+ }
2939
+ ]
2940
+ },
2941
+ "link-in-text-block": {
2942
+ "category": "color",
2943
+ "fix": {
2944
+ "description": "Distinguish links within a block of text from surrounding text using a visual cue beyond color alone (e.g., underline, bold, outline). Users with color vision deficiency cannot rely on color difference alone to identify links (WCAG 1.4.1).",
2945
+ "code": "<!-- Before: link distinguished only by color -->\n<p>Read our <a href=\"/terms\" style=\"color: blue; text-decoration: none;\">terms of service</a>.</p>\n\n<!-- After: underline makes the link identifiable without color -->\n<p>Read our <a href=\"/terms\">terms of service</a>.</p>\n<!-- Browser default underline is sufficient -->\n\n<!-- Or use a visible underline + contrast -->\n<style>\n a { text-decoration: underline; text-underline-offset: 0.2em; }\n</style>"
2946
+ },
2947
+ "false_positive_risk": "medium",
2948
+ "framework_notes": {
2949
+ "react": "In Tailwind CSS projects, the 'no-underline' utility on links inside text blocks violates this rule. Use 'underline' or 'decoration-2' instead. In design systems, ensure the link component applies a visible non-color indicator by default.",
2950
+ "vue": "In Vue, global CSS resets (normalize.css, Tailwind's preflight) often remove link underlines. Re-add underlines to links within text blocks: a:not([class]) { text-decoration: underline; }. Scoped styles in Vue SFCs may not reach deep enough — check computed styles.",
2951
+ "angular": "In Angular Material, links may inherit the Material typography styles that remove underlines. Override in the component stylesheet: a { text-decoration: underline; }. Angular's ViewEncapsulation may prevent global styles from applying — use ::ng-deep cautiously or component-level styles.",
2952
+ "svelte": "In Svelte, links within text blocks must be distinguishable by more than just color — use underlines or other visual indicators. Svelte's scoped styles can target a:not(:hover) for default underline styling.",
2953
+ "astro": "In .astro files, links within paragraphs must be visually distinct from surrounding text via underline or border, not just color contrast alone."
2954
+ },
2955
+ "fix_difficulty_notes": "The 3:1 contrast ratio between link color and surrounding text color is the alternative to underlines per WCAG 1.4.1 — but meeting this ratio is difficult to verify and unreliable for users with color vision deficiency. The safest approach: always underline links within text blocks. Links in navigation menus, buttons, or clearly distinct UI elements are generally exempt because their context makes them identifiable as interactive.",
2956
+ "related_rules": [
2957
+ {
2958
+ "id": "color-contrast",
2959
+ "reason": "Links distinguished only by color need sufficient contrast — both rules enforce color-based visual distinction."
2960
+ }
2961
+ ],
2962
+ "guardrails_overrides": {
2963
+ "must": [
2964
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
2965
+ ],
2966
+ "must_not": [
2967
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
2968
+ ],
2969
+ "verify": [
2970
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
2971
+ ]
2972
+ }
2973
+ },
2974
+ "link-name": {
2975
+ "category": "name-role-value",
2976
+ "preferred_relationship_checks": [
2977
+ "aria-labelledby",
2978
+ "aria-label",
2979
+ "has-visible-text",
2980
+ "presentational-role"
2981
+ ],
2982
+ "managed_by_libraries": [
2983
+ "radix",
2984
+ "react-aria",
2985
+ "ariakit"
2986
+ ],
2987
+ "fix": {
2988
+ "description": "Use descriptive link text that conveys the destination or purpose. Avoid \"click here\" or \"read more\".",
2989
+ "code": "<a href=\"/products\">View our product catalog</a>\n<!-- Avoid: <a href=\"/products\">click here</a> -->"
2990
+ },
2991
+ "false_positive_risk": "low",
2992
+ "framework_notes": {
2993
+ "react": "In React Router, use <Link to='/path'>Descriptive text</Link>. For icon links, add aria-label directly: <Link to='/search' aria-label='Search products'>. Avoid wrapping <Link> around empty or icon-only elements without an aria-label.",
2994
+ "vue": "In Vue Router, use <RouterLink to='/path'>Descriptive text</RouterLink>. For icon links, add :aria-label='...' on the RouterLink component.",
2995
+ "angular": "Use the routerLink directive on native <a> elements: <a routerLink='/path'>Descriptive text</a>. The native <a> carries the correct link role and accessible name automatically.",
2996
+ "svelte": "In SvelteKit, use <a href='/path'>Descriptive text</a> — SvelteKit enhances native links for client-side navigation. For icon links, add aria-label directly. Svelte's a11y warnings will flag <a> elements without discernible text.",
2997
+ "astro": "In .astro files, use standard HTML <a href='/path'>Descriptive text</a>. Astro does not transform links — they render as-is. For icon links inside framework islands, the island framework's rules apply."
2998
+ },
2999
+ "cms_notes": {
3000
+ "shopify": "Social media icon links in Shopify themes often lack aria-label. In the social-icons snippet, add aria-label='{{ service }} (opens in a new window)' to each <a> element. Navigation menus use {{ link.title }} from the menu editor — verify merchants have set descriptive titles.",
3001
+ "wordpress": "WordPress nav menus (wp_nav_menu) use the menu item title as link text. Custom link labels can be empty if the author only sets a URL. Social icon blocks render <a> with SVG — ensure aria-label is set via the block's 'Label' field in the editor.",
3002
+ "drupal": "Drupal's Menu system uses the link title field as the accessible name. In Twig, menu links render via menu.html.twig — verify {{ item.title }} is not empty. For icon links in custom blocks, add aria-label via the Attribute object."
3003
+ },
3004
+ "fix_difficulty_notes": [
3005
+ "Adding `aria-label` to a link that already has visible text creates a mismatch — voice control users speak the visible text, not the `aria-label`, and activation may fail.",
3006
+ "Use `aria-label` only for icon links with no visible text, or use `aria-labelledby` to reference visible content."
3007
+ ],
3008
+ "related_rules": [
3009
+ {
3010
+ "id": "button-name",
3011
+ "reason": "The same accessible name requirement applies to buttons — fix all interactive element naming together."
3012
+ },
3013
+ {
3014
+ "id": "input-button-name",
3015
+ "reason": "The same accessible name requirement applies to <input type=\"button\"> — fix all naming violations together."
3016
+ },
3017
+ {
3018
+ "id": "aria-command-name",
3019
+ "reason": "link-name covers native <a> elements — fix all interactive element naming together."
3020
+ },
3021
+ {
3022
+ "id": "identical-links-same-purpose",
3023
+ "reason": "Generic link names (link-name) are the root cause of identical-links-same-purpose failures — fix link-name first."
3024
+ }
3025
+ ],
3026
+ "guardrails_overrides": {
3027
+ "must": [
3028
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
3029
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
3030
+ ],
3031
+ "must_not": [
3032
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
3033
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
3034
+ ],
3035
+ "verify": [
3036
+ "Confirm computed accessible name matches expected spoken phrase.",
3037
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
3038
+ ]
3039
+ }
3040
+ },
3041
+ "list": {
3042
+ "category": "semantics",
3043
+ "fix": {
3044
+ "description": "Ensure <ul> and <ol> elements contain only <li> children (and optionally <script> or <template>). Remove or rewrap invalid direct children.",
3045
+ "code": "<!-- Before: div inside ul (invalid) -->\n<ul>\n <div class=\"list-item\">Item 1</div>\n <div class=\"list-item\">Item 2</div>\n</ul>\n\n<!-- After: proper li elements -->\n<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>"
3046
+ },
3047
+ "false_positive_risk": "medium",
3048
+ "framework_notes": {
3049
+ "react": "In React, mapping an array to list items is straightforward: {items.map(item => <li key={item.id}>{item.name}</li>)}. Never wrap the map in an extra <div> inside <ul> — use React.Fragment or a flat map.",
3050
+ "vue": "In Vue, use v-for directly on <li>: <li v-for='item in items' :key='item.id'>{{ item.name }}</li>. Avoid adding a wrapper component between <ul> and <li>.",
3051
+ "angular": "In Angular, use *ngFor directly on <li>: <li *ngFor='let item of items'>{{ item.name }}</li>. Component wrappers between <ul> and <li> will introduce invalid children.",
3052
+ "svelte": "In Svelte, <ul> and <ol> must only contain <li>, <script>, and <template> as direct children. Component wrappers that add extra elements inside lists break the list structure.",
3053
+ "astro": "In .astro files, <ul>/<ol> must contain only <li> children in the rendered HTML. Astro component boundaries can insert wrapper elements — verify the final DOM preserves list structure."
3054
+ },
3055
+ "fix_difficulty_notes": "Some CSS frameworks (Bootstrap, Tailwind) output <div> wrappers inside list elements via JavaScript-rendered components. axe will flag these. Before fixing, check whether the violation is in source HTML or in framework-rendered output — the fix location differs. Also note: a <ul> with role='none' on all <li> children (to remove list semantics) is an intentional pattern and not a violation of this rule.",
3056
+ "guardrails_overrides": {
3057
+ "must_not": [
3058
+ "Do not add role='list' to a <ul> that has CSS list-style:none removed — Safari does this intentionally and it is a false positive in many cases.",
3059
+ "Do not restructure the list purely in HTML source without verifying the final rendered DOM — framework-rendered wrappers may re-introduce the violation."
3060
+ ],
3061
+ "verify": [
3062
+ "Confirm the rendered DOM (not just source HTML) has only valid list child elements (<li>, role='presentation' wrappers, or <script>/<template>) directly inside the list."
3063
+ ]
3064
+ },
3065
+ "related_rules": [
3066
+ {
3067
+ "id": "listitem",
3068
+ "reason": "The inverse rule — li elements must be inside a ul/ol. Fix list structure violations together."
3069
+ },
3070
+ {
3071
+ "id": "definition-list",
3072
+ "reason": "Both validate list structures — audit all list semantics violations together."
3073
+ }
3074
+ ]
3075
+ },
3076
+ "listitem": {
3077
+ "category": "semantics",
3078
+ "fix": {
3079
+ "description": "Wrap orphan <li> elements inside a <ul> or <ol> parent, or change the element to a non-list element if list semantics are not intended.",
3080
+ "code": "<!-- Before: li without a list parent -->\n<li>Item 1</li>\n<li>Item 2</li>\n\n<!-- After: wrapped in ul -->\n<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>\n\n<!-- Or if list semantics not intended: -->\n<p>Item 1</p>\n<p>Item 2</p>"
3081
+ },
3082
+ "false_positive_risk": "medium",
3083
+ "framework_notes": {
3084
+ "react": "In React, a component that returns <li> must always be rendered inside a <ul> or <ol>. If the component can be used in contexts without a list parent, consider making it return a generic container and letting the parent decide whether to wrap in <ul>.",
3085
+ "vue": "In Vue, a <li> component must have a <ul> or <ol> as its direct DOM parent. Vue's component wrapper does not count as a list — verify the rendered HTML structure in DevTools, not just the Vue template.",
3086
+ "angular": "In Angular, an <li> component inside <app-list-item> is still a valid <li> in the rendered DOM if the component tag is replaced. Verify the rendered output — Angular component tags are replaced, so check the actual HTML tree.",
3087
+ "svelte": "In Svelte, <li> elements must be inside <ul>, <ol>, or role='list'. Component composition that separates list items from their list container will trigger this violation.",
3088
+ "astro": "In .astro files, <li> elements must be direct children of <ul>/<ol> in the rendered HTML. When splitting list items across components, verify the parent-child relationship is preserved."
3089
+ },
3090
+ "fix_difficulty_notes": "This often occurs in component-based frameworks when a list item component renders <li> at the root but is used outside a parent list component. The fix is either to ensure the parent always provides a <ul>/<ol> context, or to restructure the component so <li> is not the root element when rendered standalone.",
3091
+ "guardrails_overrides": {
3092
+ "must_not": [
3093
+ "Do not add role='listitem' to a <li> as a fix — the element already has an implicit listitem role; the issue is the missing parent list.",
3094
+ "Do not wrap a standalone <li> in a <ul> as a one-off fix without verifying the full list structure is correct."
3095
+ ],
3096
+ "verify": [
3097
+ "Confirm the <li> element is a direct child of a <ul> or <ol> in the rendered DOM."
3098
+ ]
3099
+ },
3100
+ "related_rules": [
3101
+ {
3102
+ "id": "list",
3103
+ "reason": "Inverse rule — fix list/listitem violations together to restore correct list semantics."
3104
+ }
3105
+ ]
3106
+ },
3107
+ "marquee": {
3108
+ "category": "sensory",
3109
+ "fix": {
3110
+ "description": "Remove all <marquee> elements. The <marquee> element is deprecated in HTML and creates moving content that users cannot pause, which is inaccessible to users with cognitive disabilities and fails WCAG 2.2.2.",
3111
+ "code": "<!-- Before: scrolling marquee -->\n<marquee>Breaking news: accessibility matters!</marquee>\n\n<!-- After: static text with optional CSS animation that respects user preferences -->\n<p class=\"news-ticker\" role=\"alert\">Breaking news: accessibility matters!</p>\n<style>\n .news-ticker { /* static by default */ }\n @media (prefers-reduced-motion: no-preference) {\n .news-ticker { animation: scroll-text 10s linear infinite; }\n }\n @media (prefers-reduced-motion: reduce) {\n .news-ticker { animation: none; }\n }\n</style>"
3112
+ },
3113
+ "false_positive_risk": "low",
3114
+ "framework_notes": {
3115
+ "react": "The <marquee> element is not a standard React element and should never be used. If scrolling text is needed, implement it with CSS animations and useReducedMotion() from Framer Motion or a prefers-reduced-motion media query hook.",
3116
+ "vue": "In Vue, <marquee> is treated as an unknown HTML element. Replace with a <p> or <div> and use CSS animations that respect prefers-reduced-motion. Nuxt projects should use the usePreferredReducedMotion() composable from VueUse.",
3117
+ "angular": "In Angular, replace <marquee> with a component that uses CSS animations controlled by a prefers-reduced-motion check. Use the Angular CDK BreakpointObserver or matchMedia to detect and respect the user's motion preference.",
3118
+ "svelte": "The <marquee> element is deprecated HTML — Svelte will render it but it should never be used. Replace with CSS animation that respects @media (prefers-reduced-motion: reduce).",
3119
+ "astro": "The <marquee> element should never appear in .astro files. Replace with CSS animation using @media (prefers-reduced-motion: reduce) { animation: none; }."
3120
+ },
3121
+ "fix_difficulty_notes": "This is one of the easiest fixes — simply remove the <marquee> element and replace with static content. The <marquee> element is fully deprecated and unsupported in the HTML spec. If scrolling/ticker functionality is genuinely needed (e.g., a stock ticker), implement it with CSS animations that respect prefers-reduced-motion and include a pause button for manual control.",
3122
+ "related_rules": [
3123
+ {
3124
+ "id": "blink",
3125
+ "reason": "Both <marquee> and <blink> are deprecated elements causing inaccessible animations — remove both together."
3126
+ }
3127
+ ]
3128
+ },
3129
+ "meta-refresh": {
3130
+ "category": "sensory",
3131
+ "fix": {
3132
+ "description": "Remove automatic page refresh. If redirecting immediately, a delay of 0 is permitted.",
3133
+ "code": "<!-- Remove this: -->\n<!-- <meta http-equiv=\"refresh\" content=\"5; url=...\"> -->\n\n<!-- Instant redirect only (delay=0 is OK): -->\n<meta http-equiv=\"refresh\" content=\"0; url=https://example.com/new-page\">"
3134
+ },
3135
+ "false_positive_risk": "low",
3136
+ "framework_notes": {
3137
+ "react": "In Next.js, use redirect() in server components or middleware for redirects, and useRouter().replace() for client-side navigation. Neither uses <meta http-equiv='refresh'>. Never insert a meta refresh tag.",
3138
+ "vue": "In Nuxt, use navigateTo() or definePageMeta({ redirect: '/' }) — both produce server or router-level redirects without <meta http-equiv='refresh'>.",
3139
+ "angular": "Use the Angular Router service (this.router.navigate() or this.router.navigateByUrl()) for all navigation. Never insert <meta http-equiv='refresh'> into templates or index.html.",
3140
+ "svelte": "In SvelteKit, avoid <meta http-equiv='refresh'>. Use SvelteKit's goto() function for programmatic redirects, or throw redirect(301, '/new-url') in load functions for server-side redirects.",
3141
+ "astro": "In .astro files, avoid <meta http-equiv='refresh'> for redirects. Use Astro.redirect() in the frontmatter for server-side redirects, or configure redirects in astro.config.mjs."
3142
+ },
3143
+ "fix_difficulty_notes": "Server-side HTTP redirects (301/302) do not use <meta http-equiv='refresh'> and are not flagged by this rule. The violation only occurs when a timed redirect is implemented in the HTML <head>. A delay of 0 (instant redirect) is the only permitted use — it is commonly used for canonical URL normalization and is WCAG-compliant.",
3144
+ "guardrails_overrides": {
3145
+ "must_not": [
3146
+ "Do not remove <meta http-equiv='refresh' content='0; url=...'> — a delay of 0 is the only permitted use and is WCAG-compliant.",
3147
+ "Do not add a JavaScript-based redirect as an alternative without also providing a manual navigation option."
3148
+ ],
3149
+ "verify": [
3150
+ "Confirm the refresh delay is greater than 0 before treating this as a violation (delay=0 is allowed)."
3151
+ ]
3152
+ },
3153
+ "related_rules": [
3154
+ {
3155
+ "id": "no-autoplay-audio",
3156
+ "reason": "Both rules involve content that changes without user initiation — timed refresh and autoplay audio are the same class of WCAG 2.2.x timing failure."
3157
+ }
3158
+ ]
3159
+ },
3160
+ "meta-viewport": {
3161
+ "category": "sensory",
3162
+ "fix": {
3163
+ "description": "Remove user-scalable=no from the viewport meta tag to allow users to zoom.",
3164
+ "code": "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
3165
+ },
3166
+ "false_positive_risk": "low",
3167
+ "framework_notes": {
3168
+ "react": "In Next.js App Router, manage the viewport meta via the Metadata API: export const viewport: Viewport = { width: 'device-width', initialScale: 1 }; in layout.tsx. Never set maximumScale: 1 or userScalable: false — these block zoom.",
3169
+ "vue": "In Nuxt, set the viewport in nuxt.config.ts: app.head.meta = [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }]. Omit user-scalable=no entirely.",
3170
+ "angular": "The viewport meta is in index.html. Remove user-scalable=no if present: <meta name='viewport' content='width=device-width, initial-scale=1'>. Do not add maximum-scale=1 either — it has the same effect.",
3171
+ "svelte": "In SvelteKit, the viewport meta tag is in app.html: <meta name='viewport' content='width=device-width, initial-scale=1'>. Never include maximum-scale=1 or user-scalable=no — this prevents pinch-to-zoom.",
3172
+ "astro": "In .astro layouts, set the viewport meta tag in <head> without maximum-scale or user-scalable=no restrictions. Users must be able to zoom to at least 200%."
3173
+ },
3174
+ "fix_difficulty_notes": "iOS Safari (10+) ignores user-scalable=no as an accessibility override, but Android browsers may enforce it. Remove it unconditionally — there is no valid accessibility reason to block zoom, and it is explicitly forbidden by WCAG 1.4.4.",
3175
+ "related_rules": [
3176
+ {
3177
+ "id": "meta-viewport-large",
3178
+ "reason": "meta-viewport checks for user-scalable=no (blocks zoom entirely); meta-viewport-large checks for insufficient maximum-scale."
3179
+ },
3180
+ {
3181
+ "id": "css-orientation-lock",
3182
+ "reason": "Both restrict how users can adapt the viewport — fix together for complete WCAG 1.3.4 / 1.4.4 coverage."
3183
+ }
3184
+ ],
3185
+ "guardrails_overrides": {
3186
+ "must_not": [
3187
+ "Do not use user-scalable=no in any combination — even paired with initial-scale=1.",
3188
+ "Do not add maximum-scale=1 as a substitute — it has the same zoom-blocking effect."
3189
+ ],
3190
+ "verify": [
3191
+ "Confirm the final viewport tag contains neither user-scalable=no nor maximum-scale=1.",
3192
+ "Test pinch-to-zoom on a physical device or Chrome DevTools mobile simulation after the fix."
3193
+ ]
3194
+ }
3195
+ },
3196
+ "meta-viewport-large": {
3197
+ "category": "sensory",
3198
+ "fix": {
3199
+ "description": "Ensure <meta name='viewport'> allows scaling to at least 500% (maximum-scale >= 5 or omit maximum-scale entirely). This is a stricter best-practice check beyond the WCAG AA requirement.",
3200
+ "code": "<!-- Best: no maximum-scale restriction at all -->\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n<!-- Acceptable: maximum-scale is 5 or higher -->\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=10\">\n\n<!-- Violation: maximum-scale below 5 -->\n<!-- <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=2\"> -->"
3201
+ },
3202
+ "false_positive_risk": "low",
3203
+ "framework_notes": {
3204
+ "react": "In Next.js App Router, configure viewport via the Metadata API: export const viewport: Viewport = { width: 'device-width', initialScale: 1 }; — omit maximumScale entirely. The Pages Router uses <Head><meta name='viewport' ... /></Head>.",
3205
+ "vue": "In Nuxt, set viewport in nuxt.config.ts: app.head.meta = [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }]. Do not include maximum-scale unless it is 5 or higher.",
3206
+ "angular": "In Angular, the viewport meta is in index.html. Remove or increase maximum-scale: <meta name='viewport' content='width=device-width, initial-scale=1'>.",
3207
+ "svelte": "In SvelteKit's app.html, ensure the viewport meta tag allows scaling to at least 200%. Do not set maximum-scale below 2.0 or user-scalable=no.",
3208
+ "astro": "In .astro layouts, the viewport meta tag must allow scaling to at least 200%. Users with low vision rely on pinch-to-zoom for readability."
3209
+ },
3210
+ "fix_difficulty_notes": "This is a best-practice rule that goes beyond the WCAG AA requirement (which only requires not blocking zoom entirely via user-scalable=no or maximum-scale=1). The best-practice threshold is maximum-scale >= 5 (500% zoom). The simplest fix is to omit maximum-scale entirely — there is no valid accessibility reason to cap zoom level. iOS Safari ignores maximum-scale restrictions as an accessibility override, but Android browsers may enforce them.",
3211
+ "guardrails_overrides": {
3212
+ "must_not": [
3213
+ "Do not set maximum-scale to exactly 2 or 3 as a compromise — the best-practice threshold is 5 (500%). Set to 5 or remove it entirely.",
3214
+ "Do not confuse this rule with meta-viewport — this rule fires only on low maximum-scale values, not on user-scalable=no."
3215
+ ],
3216
+ "verify": [
3217
+ "Confirm maximum-scale is set to 5.0 or higher, or that the maximum-scale parameter is absent entirely from the viewport meta tag."
3218
+ ]
3219
+ },
3220
+ "related_rules": [
3221
+ {
3222
+ "id": "meta-viewport",
3223
+ "reason": "meta-viewport checks for user-scalable=no (blocks zoom entirely); meta-viewport-large checks for insufficient maximum-scale."
3224
+ },
3225
+ {
3226
+ "id": "avoid-inline-spacing",
3227
+ "reason": "Both address WCAG 1.4.4/1.4.12 adaptability — fixing one without the other leaves the same user group affected."
3228
+ }
3229
+ ]
3230
+ },
3231
+ "nested-interactive": {
3232
+ "category": "keyboard",
3233
+ "managed_by_libraries": [
3234
+ "radix",
3235
+ "headless-ui",
3236
+ "react-aria",
3237
+ "ariakit"
3238
+ ],
3239
+ "fix": {
3240
+ "description": "Remove or restructure nested interactive elements. Interactive controls (buttons, links, inputs) must not be descendants of other interactive controls.",
3241
+ "code": "<!-- Pattern A: <a> wrapping a <button> (card pattern) -->\n<!-- Before: -->\n<a href=\"/product\">\n Product name\n <button onclick=\"addToCart()\">Add to cart</button>\n</a>\n<!-- After: stretched-link keeps card clickable without wrapping the button -->\n<div style=\"position:relative\">\n <a href=\"/product\" style=\"position:absolute;inset:0\" aria-label=\"Product name\"></a>\n <span>Product name</span>\n <button onclick=\"addToCart()\">Add to cart</button>\n</div>\n\n<!-- Pattern B: role=\"option\" / role=\"listitem\" with focusable descendants -->\n<!-- ARIA spec forbids interactive children inside option/listitem roles. -->\n<!-- Before: -->\n<div role=\"option\" aria-selected=\"true\">\n <a href=\"/detail\">View details</a>\n</div>\n<!-- After: remove href/tabIndex from descendants; parent handles keyboard interaction -->\n<div role=\"option\" aria-selected=\"true\" tabindex=\"0\" onkeydown=\"handleSelect(event)\">\n <span>View details</span>\n</div>"
3242
+ },
3243
+ "false_positive_risk": "low",
3244
+ "framework_notes": {
3245
+ "react": "In React card components, avoid wrapping the entire JSX tree in <Link> or <a> when it contains interactive children. Use a CSS stretched-link pattern: position the <a> absolutely with ::after covering the card, while the button stays outside the <a> in DOM order.",
3246
+ "vue": "In Vue, the same pattern applies — <RouterLink> wrapping a card with buttons inside is invalid HTML. Use the stretched-link CSS technique or restructure the component so interactive children are siblings, not descendants, of the link.",
3247
+ "angular": "In Angular, wrapping <mat-card> or a template outlet in [routerLink] creates the same issue. Set [routerLink] on a visible text element or use a CSS overlay approach to preserve DOM structure.",
3248
+ "svelte": "In Svelte, avoid nesting interactive elements (e.g., <button> inside <a>, <a> inside <button>). Svelte does not warn about nested interactives — verify manually. This commonly occurs when wrapping card components in links.",
3249
+ "astro": "In .astro files, nested interactive elements (links inside buttons, buttons inside links) render to static HTML. This is invalid HTML and confuses AT — restructure to avoid nesting."
3250
+ },
3251
+ "fix_difficulty_notes": [
3252
+ "The evidence shows the OUTER container — identify the nested focusable element first. Search the component for `tabIndex`, `href=`, `<button`, `<a `, or `role=\"button\"` inside the flagged selector.",
3253
+ "Most common pattern: a card wrapped in `<a>` containing a `<button>` — the HTML spec forbids interactive content inside `<a>`. Solutions: (1) stretched-link CSS pattern (position `<a>` with `::after` overlay, keep button outside `<a>` in DOM order); (2) keep elements separate and handle card-level clicks via JS.",
3254
+ "COMPOUND CASE — link-as-button co-occurring: if the nested `<a>` calls `e.preventDefault()` and runs a JS action, two violations are stacked.",
3255
+ "Remove only the invalid ARIA attributes from the container (`role`, `aria-selected`); preserve ALL `onClick`/`onKeyDown` handlers that contain non-ARIA conditional logic (drag state, position offsets, gesture thresholds, business flags).",
3256
+ "Fix the link separately: if it never navigates, convert to `<button type=\"button\">`; if it sometimes navigates, keep `<a href>` and remove `e.preventDefault()`. Never delete a handler because the ARIA around it was wrong."
3257
+ ],
3258
+ "related_rules": [
3259
+ {
3260
+ "id": "focus-order-semantics",
3261
+ "reason": "Restructuring nested interactives changes focus order — verify semantics and tab sequence remain logical after the fix."
3262
+ },
3263
+ {
3264
+ "id": "scrollable-region-focusable",
3265
+ "reason": "Scrollable containers inside interactive elements can create compounded focusability issues — audit together."
3266
+ },
3267
+ {
3268
+ "id": "aria-text",
3269
+ "reason": "role=text forbids focusable descendants — aria-text violations often co-occur with nested interactive elements."
3270
+ }
3271
+ ],
3272
+ "guardrails_overrides": {
3273
+ "must": [
3274
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
3275
+ ],
3276
+ "must_not": [
3277
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
3278
+ ],
3279
+ "verify": [
3280
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
3281
+ ]
3282
+ }
3283
+ },
3284
+ "no-autoplay-audio": {
3285
+ "category": "sensory",
3286
+ "fix": {
3287
+ "description": "Remove the autoplay attribute from audio elements, or provide a clearly visible pause/stop control that activates before 3 seconds of audio plays.",
3288
+ "code": "<!-- Remove autoplay: -->\n<audio controls src=\"background.mp3\"></audio>\n\n<!-- Background video: muted is acceptable -->\n<video autoplay muted loop src=\"bg-video.mp4\"></video>\n\n<!-- If autoplay with sound is required, provide an immediate stop control: -->\n<audio autoplay id=\"bg-audio\" src=\"jingle.mp3\"></audio>\n<button onclick=\"document.getElementById('bg-audio').pause()\" style=\"position:fixed;top:1rem;right:1rem\">Stop audio</button>"
3289
+ },
3290
+ "false_positive_risk": "medium",
3291
+ "framework_notes": {
3292
+ "react": "In React, never set the autoPlay prop on <audio> for content with meaningful audio. Background decorative video with autoPlay muted is acceptable. Trigger audio playback programmatically via user interaction events, not in useEffect on mount.",
3293
+ "vue": "In Vue, avoid :autoplay='true' on audio elements. Trigger audio playback via @click handlers. Background video with autoplay muted as static attributes is acceptable.",
3294
+ "angular": "In Angular, do not bind [autoplay]='true' on audio elements. Trigger .play() calls inside (click) event handlers, not in ngOnInit lifecycle hooks.",
3295
+ "svelte": "In Svelte, avoid autoplay on <audio> and <video> elements. If autoplay is needed, provide a visible pause/stop button and ensure the media is muted initially or lasts less than 3 seconds.",
3296
+ "astro": "In .astro files, <audio autoplay> and <video autoplay> render to static HTML. Avoid autoplay, or ensure media is muted and under 3 seconds. For auto-playing media, add a pause control via a framework island."
3297
+ },
3298
+ "fix_difficulty_notes": "axe detects the autoplay attribute but cannot determine whether the audio is muted — muted autoplay is acceptable. A <video autoplay muted loop> for decorative background video does not violate WCAG 1.4.2. The violation is specifically audio content that plays without user interaction and cannot be immediately stopped. Browser autoplay policies (especially Chrome) often block autoplay with sound anyway — verify the audio actually plays in the target browser before treating it as a confirmed violation.",
3299
+ "cms_notes": {
3300
+ "shopify": "Dawn's video section (sections/video.liquid) uses autoplay with muted=true for hero videos — this is intentional and does not violate WCAG 1.4.2. If a merchant-selected video plays audio without muting, add muted to the <video> tag and expose a schema setting for a pause control. Shopify's native video_section block sets autoplay muted by default.",
3301
+ "wordpress": "WordPress's Cover block and Video block allow autoplay. The block editor does not enforce muting on autoplay. If a user enables autoplay and the video has audio, add a pause/stop control using the block's 'Poster image' option and a Play/Pause button via JavaScript. The Advanced Video plugin enforces muted autoplay.",
3302
+ "drupal": "Drupal's Media embed and Video formatter can autoplay via display settings. In the media_video_embed_field module, the 'autoplay' setting should always pair with 'muted'. Override the video formatter template in your theme to ensure autoplay is never set without muted."
3303
+ },
3304
+ "guardrails_overrides": {
3305
+ "must_not": [
3306
+ "Do not flag <video autoplay muted> or <audio autoplay muted> as violations — muted autoplay does not violate WCAG 1.4.2.",
3307
+ "Do not remove autoplay from decorative background video that is muted — verify the muted attribute is absent before treating as a confirmed violation."
3308
+ ],
3309
+ "verify": [
3310
+ "Confirm the media element does not have the muted attribute before treating this as a violation.",
3311
+ "Confirm the audio actually auto-plays in the target browser — browser autoplay policies may block it silently."
3312
+ ]
3313
+ },
3314
+ "related_rules": [
3315
+ {
3316
+ "id": "audio-caption",
3317
+ "reason": "Auto-playing audio also needs captions if it conveys information — fix both together."
3318
+ },
3319
+ {
3320
+ "id": "video-caption",
3321
+ "reason": "Auto-playing video with audio needs captions — address media accessibility holistically."
3322
+ },
3323
+ {
3324
+ "id": "meta-refresh",
3325
+ "reason": "Both are WCAG 2.2.x timing failures that disrupt users without warning — fix together for complete criterion coverage."
3326
+ }
3327
+ ]
3328
+ },
3329
+ "object-alt": {
3330
+ "category": "text-alternatives",
3331
+ "fix": {
3332
+ "description": "Add an aria-label to every <object> element describing its content.",
3333
+ "code": "<object data=\"chart.svg\" type=\"image/svg+xml\" aria-label=\"Bar chart: Q1 2024 sales by region\">\n Fallback text for unsupported browsers\n</object>"
3334
+ },
3335
+ "false_positive_risk": "low",
3336
+ "framework_notes": {
3337
+ "react": "In React, <object> is rarely used. If embedding SVGs, prefer <img> with alt or an inline SVG with role='img' and aria-label. For PDF embeds, consider react-pdf with an accessible fallback link.",
3338
+ "vue": "In Vue, <object> renders as standard HTML. Add aria-label directly. For SVG content, prefer <img :src='svgPath' :alt='description'> or inline SVG with role='img'. Vue does not validate ARIA on <object>.",
3339
+ "angular": "In Angular, use [attr.aria-label]='objectDescription' on <object>. For PDF or media embeds, consider replacing <object> with <iframe title='...'> which has broader AT support.",
3340
+ "svelte": "In Svelte, add aria-label directly: <object data={src} type={type} aria-label='Description'>. Svelte's a11y warnings do not cover <object> — rely on axe-core for detection.",
3341
+ "astro": "In .astro files, <object> renders as static HTML. Add aria-label directly in the template. Consider replacing with <img> or <iframe> for better cross-browser accessibility support."
3342
+ },
3343
+ "fix_difficulty_notes": "The <object> element is rarely used in modern development — most use cases (SVG, PDF, media) are better served by <img>, <video>, <embed>, or <iframe>. If <object> is used, the aria-label must describe what the embedded content communicates to the user (not just the file type or format). If the object is purely decorative, consider replacing it with an accessible equivalent (e.g., an <img> with alt='').",
3344
+ "related_rules": [
3345
+ {
3346
+ "id": "image-alt",
3347
+ "reason": "The same text alternative requirement applies to <img> — audit both together."
3348
+ }
3349
+ ],
3350
+ "guardrails_overrides": {
3351
+ "must": [
3352
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3353
+ ],
3354
+ "must_not": [
3355
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3356
+ ],
3357
+ "verify": [
3358
+ "Confirm computed accessible name matches expected spoken phrase."
3359
+ ]
3360
+ }
3361
+ },
3362
+ "p-as-heading": {
3363
+ "category": "semantics",
3364
+ "fix": {
3365
+ "description": "Replace <p> elements styled to look like headings with actual heading elements (<h1>–<h6>) at the appropriate level in the document hierarchy.",
3366
+ "code": "<!-- Before: paragraph styled as heading -->\n<p class=\"text-2xl font-bold\">Section Title</p>\n<p>Content paragraph...</p>\n\n<!-- After: semantic heading -->\n<h2>Section Title</h2>\n<p>Content paragraph...</p>"
3367
+ },
3368
+ "false_positive_risk": "medium",
3369
+ "framework_notes": {
3370
+ "react": "In component libraries (shadcn/ui, Radix), heading-like components sometimes render as <p> by default. Use the 'as' prop or the 'asChild' pattern to change the rendered element: <Text as='h2' className='text-2xl font-bold'>Section Title</Text>.",
3371
+ "vue": "In Vue UI kits (Vuetify, Nuxt UI), check if the heading-like component supports a 'tag' prop: <VTitle tag='h2'>Section Title</VTitle>. Without it, add the heading element directly in the template.",
3372
+ "angular": "In Angular, heading-like components often render as <div> or <p>. Use the component's selector or a host element attribute to change the rendered tag, or apply the heading element directly in the template rather than relying on the component's default tag.",
3373
+ "svelte": "In Svelte, do not style <p> elements to look like headings — use proper <h1>-<h6> elements. Svelte does not warn about this — it is a content structure issue caught by axe-core.",
3374
+ "astro": "In .astro files, use semantic heading elements instead of styled paragraphs. This is particularly common in CMS-driven content where formatting is applied inline."
3375
+ },
3376
+ "fix_difficulty_notes": "axe detects this heuristically — it flags <p> elements with large, bold, or otherwise heading-like CSS properties. This can produce false positives on intro paragraphs, pull quotes, or callout boxes that are intentionally styled large. Verify visually: does this text introduce a new section of content? If yes, it should be a heading. If it is decorative, a caption, or a lead-in paragraph, it may not need to be a heading despite the styling.",
3377
+ "cms_notes": {
3378
+ "shopify": "Shopify's rich text sections (sections/rich-text.liquid) render WYSIWYG content from the theme editor. Merchants sometimes use paragraph text formatted as bold+large instead of heading elements. In Dawn, educate merchants using the heading type selector in the rich text block. Alternatively, restrict the rich text schema to heading blocks only for section titles.",
3379
+ "wordpress": "The Classic Editor's TinyMCE lets authors select paragraph text and apply visual formatting (bold, font-size) instead of using the heading format. The block editor's Paragraph block similarly allows font-size overrides. Educate authors to use the Heading block. The Accessibility Checker plugin by Equalize Digital flags this pattern in the editor.",
3380
+ "drupal": "Drupal's CKEditor (both CKEditor 4 and 5) allows authors to select paragraph text and apply heading-like styling. Restrict the allowed formats in the text format configuration (admin/config/content/formats) to prevent authors from using <p> with font-size or bold styling. Use CKEditor's Heading plugin to enforce semantic heading use."
3381
+ },
3382
+ "guardrails_overrides": {
3383
+ "must_not": [
3384
+ "Do not convert a <p> to a heading element purely because it is styled large — verify it semantically introduces a new section before changing the element.",
3385
+ "Do not change the heading level of the new element without verifying it fits the existing heading hierarchy."
3386
+ ],
3387
+ "verify": [
3388
+ "Confirm the element introduces a new section of content (not a decorative or stylistic element) before converting it to a heading.",
3389
+ "Confirm the heading level chosen fits the document outline without skipping levels."
3390
+ ]
3391
+ },
3392
+ "related_rules": [
3393
+ {
3394
+ "id": "heading-order",
3395
+ "reason": "After converting p-as-heading to real headings, verify the heading hierarchy is correct — new headings must fit the existing order."
3396
+ },
3397
+ {
3398
+ "id": "page-has-heading-one",
3399
+ "reason": "If converting a p-as-heading introduces a new h1, verify it is the only h1 on the page."
3400
+ }
3401
+ ]
3402
+ },
3403
+ "page-has-heading-one": {
3404
+ "category": "semantics",
3405
+ "fix": {
3406
+ "description": "Search for an `<h1>` in the frontend source files first. If found, fix it there. If no `<h1>` exists in the source — because content is authored in a CMS, database, or backend — mark this finding as **Out of scope — content is not managed in frontend source** and do not propose a code fix. Report it to the user instead.",
3407
+ "code": "<h1>Wireless Noise-Cancelling Headphones</h1>"
3408
+ },
3409
+ "false_positive_risk": "low",
3410
+ "framework_notes": {
3411
+ "react": "In Next.js App Router, place the h1 directly in page.tsx, not in a client component that renders conditionally or inside a Suspense loading state — axe evaluates the initial HTML payload before client hydration fills the loading boundary.",
3412
+ "vue": "In Nuxt, ensure the page-level h1 is in the <template> of the page component so it is included in the server-rendered HTML. Avoid placing the h1 exclusively inside an async component that renders after the initial paint.",
3413
+ "angular": "With Angular Universal (SSR), the h1 must be part of the component template, not inserted via JavaScript after hydration. Verify the h1 is visible in the pre-rendered HTML using View Source.",
3414
+ "svelte": "In SvelteKit, ensure every page has exactly one <h1>. Place it in the +page.svelte component (not +layout.svelte, since the h1 should be page-specific). The layout should define the page structure; individual pages define their h1.",
3415
+ "astro": "In .astro files, each page should have exactly one <h1> that describes the page content. Place it in the page component, not the layout, since the heading should be unique per page."
3416
+ },
3417
+ "fix_difficulty_notes": [
3418
+ "In SPAs and SSR frameworks, the `h1` may be injected client-side after the initial DOM snapshot axe evaluates — ensure it is server-side rendered or present in the initial HTML payload.",
3419
+ "In Next.js, place the `h1` directly in the page component, not behind a loading state."
3420
+ ],
3421
+ "related_rules": [
3422
+ {
3423
+ "id": "heading-order",
3424
+ "reason": "Fixing the missing h1 anchors the heading hierarchy and may cascade-resolve heading-order violations."
3425
+ },
3426
+ {
3427
+ "id": "p-as-heading",
3428
+ "reason": "If converting a p-as-heading introduces a new h1, verify it is the only h1 on the page."
3429
+ },
3430
+ {
3431
+ "id": "document-title",
3432
+ "reason": "Page title and h1 together describe the same page — fix both to give screen reader users consistent context."
3433
+ }
3434
+ ]
3435
+ },
3436
+ "presentation-role-conflict": {
3437
+ "category": "aria",
3438
+ "fix": {
3439
+ "description": "Ensure elements with role='presentation' or role='none' do not have global ARIA attributes or tabindex that would conflict with their presentational purpose. These conflicts cause inconsistent behavior across screen readers.",
3440
+ "code": "<!-- Before: conflicting attributes on a presentational element -->\n<table role=\"presentation\" aria-label=\"Layout table\" tabindex=\"0\">\n <tr><td>Content</td></tr>\n</table>\n\n<!-- After: remove conflicting attributes -->\n<table role=\"presentation\">\n <tr><td>Content</td></tr>\n</table>\n\n<!-- Or remove the presentational role if ARIA/tabindex are needed -->\n<table aria-label=\"Data summary\" tabindex=\"0\">\n <tr><td>Content</td></tr>\n</table>"
3441
+ },
3442
+ "false_positive_risk": "low",
3443
+ "framework_notes": {
3444
+ "react": "In React, components that spread props onto a root element may inadvertently add aria-label, aria-describedby, or tabIndex to an element with role='presentation'. Audit the rendered DOM to ensure presentational elements have no global ARIA attributes.",
3445
+ "vue": "In Vue, attribute inheritance (inheritAttrs: true by default) can pass global ARIA attributes from a parent component down to a child with role='presentation'. Set inheritAttrs: false on presentational wrapper components.",
3446
+ "angular": "In Angular, host bindings on components with role='presentation' may add tabindex or aria-label via @HostBinding. Audit host bindings and remove any that conflict with the presentational role.",
3447
+ "svelte": "In Svelte, role='presentation' or role='none' removes an element's semantics from the accessibility tree. If the element has interactive children or a tabindex, the role creates a conflict — AT ignores the element but its children are still reachable.",
3448
+ "astro": "In .astro files, role='presentation' on an element with focusable children creates an AT conflict. The role hides the element but children remain interactive — restructure to avoid the conflict."
3449
+ },
3450
+ "fix_difficulty_notes": "When role='presentation' or role='none' is set, the element is removed from the accessibility tree. Adding global ARIA attributes (aria-label, aria-describedby, aria-live) or tabindex creates a conflict: the element tries to be both invisible and interactive to AT. The fix is to either remove the conflicting attributes (if the element is truly presentational) or remove role='presentation' (if the element needs to be accessible). Screen readers resolve this conflict differently — some honor the role, others honor the ARIA attributes.",
3451
+ "related_rules": [
3452
+ {
3453
+ "id": "aria-prohibited-attr",
3454
+ "reason": "Both rules address ARIA attributes that should not be present on the element — fix together."
3455
+ }
3456
+ ],
3457
+ "guardrails_overrides": {
3458
+ "must": [
3459
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
3460
+ ],
3461
+ "must_not": [
3462
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
3463
+ ],
3464
+ "verify": [
3465
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
3466
+ ]
3467
+ }
3468
+ },
3469
+ "region": {
3470
+ "category": "structure",
3471
+ "fix": {
3472
+ "description": "Wrap all visible content in semantic landmark elements.",
3473
+ "code": "<header>\n <a href=\"/\">Acme Corp</a>\n <nav aria-label=\"Primary\">...</nav>\n</header>\n<main>\n <h1>Shop all products</h1>\n</main>\n<footer>\n <p>&copy; 2026 Acme Corp</p>\n</footer>"
3474
+ },
3475
+ "false_positive_risk": "medium",
3476
+ "framework_notes": {
3477
+ "react": "Use semantic JSX elements: <header>, <main>, <nav>, <aside>, <footer> instead of <div> wrappers. In Next.js App Router, landmarks naturally fall into layout.tsx (header/footer) and page.tsx (main content).",
3478
+ "vue": "Use semantic HTML5 elements in Vue templates — they work identically to plain HTML. In Nuxt, place <header> and <footer> in the layout component and wrap <slot /> in <main>.",
3479
+ "angular": "Angular adds a custom element wrapper (<app-root>, <app-header>) — ensure the semantic landmark element (<header>, <main>) is inside the component template, not expected to be provided by the custom wrapper.",
3480
+ "svelte": "In SvelteKit, define landmarks in +layout.svelte: <header>, <main> wrapping <slot />, <footer>. Svelte renders standard HTML — landmarks work identically to plain HTML. Ensure components don't add extra wrapper <div>s that orphan content outside landmarks.",
3481
+ "astro": "Define landmarks in your base layout (BaseLayout.astro): <header>, <main> wrapping <slot />, <footer>. Astro islands render inside the layout's landmark structure — island content is automatically inside the correct landmark."
3482
+ },
3483
+ "cms_notes": {
3484
+ "shopify": "Shopify themes define landmarks in layout/theme.liquid. Dawn uses <header>, <main id='MainContent'>, and a footer section. Sections injected via {{ content_for_layout }} land inside <main>. Custom snippets rendered outside the main layout structure can create orphan content.",
3485
+ "wordpress": "Classic themes define landmarks in header.php (<header>), footer.php (<footer>), and wrap the_content() in <main>. Block themes use template parts for header/footer. Sidebar widgets in <aside> are standard. Verify no content renders between </header> and <main> without a landmark.",
3486
+ "drupal": "Olivero defines landmarks in page.html.twig using Drupal's region system. Content outside defined regions (e.g., custom block placements) can create orphan content. Ensure all regions in the .info.yml file map to semantic landmark elements in the page template."
3487
+ },
3488
+ "fix_difficulty_notes": [
3489
+ "Decorative separators, spacer divs, and background containers do not need to be inside landmarks — only meaningful visible content requires landmark wrapping.",
3490
+ "Review each flagged element before adding a landmark."
3491
+ ],
3492
+ "guardrails_overrides": {
3493
+ "must_not": [
3494
+ "Do not wrap decorative elements (spacers, background divs, separator lines) in landmark regions — only meaningful visible content requires landmark wrapping.",
3495
+ "Do not add role='region' without also adding an aria-label or aria-labelledby — an unlabelled role='region' does not create a named landmark."
3496
+ ],
3497
+ "verify": [
3498
+ "Confirm the orphan content is meaningful visible content that users would want to navigate to directly."
3499
+ ]
3500
+ },
3501
+ "related_rules": [
3502
+ {
3503
+ "id": "landmark-one-main",
3504
+ "reason": "Adding a <main> landmark is typically the primary fix for orphan content."
3505
+ },
3506
+ {
3507
+ "id": "skip-link",
3508
+ "reason": "Proper landmark regions reduce the need for skip links but do not replace them — both mechanisms should coexist."
3509
+ }
3510
+ ]
3511
+ },
3512
+ "role-img-alt": {
3513
+ "category": "text-alternatives",
3514
+ "fix": {
3515
+ "description": "Add an accessible name via aria-label or aria-labelledby to every element with role='img', including SVGs, CSS-styled elements, and custom image components that use role='img' to convey visual meaning.",
3516
+ "code": "<!-- SVG with role='img' and aria-label -->\n<svg role=\"img\" aria-label=\"Company logo\" viewBox=\"0 0 100 100\">\n <circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#333\" />\n</svg>\n\n<!-- Decorative SVG: use role='presentation' or aria-hidden -->\n<svg role=\"presentation\" aria-hidden=\"true\" viewBox=\"0 0 100 100\">\n <circle cx=\"50\" cy=\"50\" r=\"40\" fill=\"#333\" />\n</svg>\n\n<!-- Div with role='img' (e.g., CSS background image) -->\n<div role=\"img\" aria-label=\"Sunset over mountains\"\n style=\"background-image: url('sunset.jpg'); width: 300px; height: 200px;\"></div>"
3517
+ },
3518
+ "false_positive_risk": "low",
3519
+ "framework_notes": {
3520
+ "react": "In React, inline SVGs should have role='img' and aria-label on the root <svg> element. For SVG icon components, accept an aria-label prop and apply it to the SVG. Decorative SVGs should have aria-hidden='true'. Libraries like @heroicons/react and lucide-react handle this correctly when passing aria-label.",
3521
+ "vue": "In Vue, SVG components should accept an aria-label prop. Use role='img' on the root <svg> and v-bind:aria-label. Decorative icons should use aria-hidden='true'. Nuxt Image generates <img> tags (not role='img') — this rule applies to custom SVG and CSS image components.",
3522
+ "angular": "In Angular, SVG icon components (including MatIcon) should have role='img' and an accessible name. MatIcon supports [attr.aria-label] directly. For custom SVG components, bind [attr.aria-label]='imageLabel' and set role='img' on the host element.",
3523
+ "svelte": "In Svelte, inline SVG components should accept an aria-label prop: <svg role='img' aria-label={label}>. For decorative SVGs, use aria-hidden='true'. Svelte's compiler warns about missing alt/aria-label on <img> but does not warn about <svg> — enforce this via code review or eslint-plugin-svelte.",
3524
+ "astro": "In .astro files, inline SVGs rendered in the template need role='img' and aria-label manually. Astro's <Image /> component handles <img> tags, not SVGs. For SVG icons inside framework islands, the island framework's rules apply."
3525
+ },
3526
+ "cms_notes": {
3527
+ "shopify": "Shopify themes use SVG icons extensively via {% render 'icon-cart' %} snippets. These snippets typically render <svg> without role='img' or aria-label. Add aria-hidden='true' for decorative icons, or pass an aria-label variable from the including template for meaningful icons.",
3528
+ "wordpress": "WordPress social icons block and navigation block use SVGs. Core blocks include aria-hidden='true' on decorative icons. Custom blocks using inline SVGs must add role='img' and aria-label. The wp_kses_post() sanitizer allows SVG ARIA attributes by default.",
3529
+ "drupal": "Drupal's icon modules and themes render SVGs in Twig. Use {{ attributes.setAttribute('role', 'img').setAttribute('aria-label', label|t) }} on the <svg> element. Decorative icons should use aria-hidden='true' via {{ attributes.setAttribute('aria-hidden', 'true') }}."
3530
+ },
3531
+ "fix_difficulty_notes": "The most common trigger: inline SVGs without an accessible name. Every SVG that conveys meaning should have role='img' and aria-label. Decorative SVGs (icons next to text, dividers, backgrounds) should have aria-hidden='true' instead. CSS background images conveying meaning need a wrapper with role='img' and aria-label — the background-image itself is invisible to AT.",
3532
+ "related_rules": [
3533
+ {
3534
+ "id": "image-alt",
3535
+ "reason": "Both rules require images to have accessible names — fix all image alternatives together."
3536
+ },
3537
+ {
3538
+ "id": "svg-img-alt",
3539
+ "reason": "svg-img-alt specifically targets SVG elements with role='img' — they overlap with this rule."
3540
+ }
3541
+ ],
3542
+ "guardrails_overrides": {
3543
+ "must": [
3544
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label.",
3545
+ "Validate role/attribute combinations against ARIA-in-HTML constraints before applying ARIA fixes."
3546
+ ],
3547
+ "must_not": [
3548
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text).",
3549
+ "Do not add composite widget roles unless the full keyboard interaction model is implemented."
3550
+ ],
3551
+ "verify": [
3552
+ "Confirm computed accessible name matches expected spoken phrase.",
3553
+ "Confirm role, state, and name are exposed correctly in accessibility tooling."
3554
+ ]
3555
+ }
3556
+ },
3557
+ "scope-attr-valid": {
3558
+ "category": "tables",
3559
+ "fix": {
3560
+ "description": "Ensure the scope attribute is used correctly on tables: scope must only appear on <th> elements and its value must be 'row', 'col', 'rowgroup', or 'colgroup'.",
3561
+ "code": "<!-- Correct: scope on <th> elements -->\n<table>\n <thead>\n <tr>\n <th scope=\"col\">Name</th>\n <th scope=\"col\">Email</th>\n <th scope=\"col\">Role</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <th scope=\"row\">Jane Doe</th>\n <td>jane@example.com</td>\n <td>Admin</td>\n </tr>\n </tbody>\n</table>\n\n<!-- Invalid: scope on <td> -->\n<!-- <td scope=\"row\">Jane</td> -->"
3562
+ },
3563
+ "false_positive_risk": "low",
3564
+ "framework_notes": {
3565
+ "react": "In React table components, ensure scope is only passed to <th> elements, not <td>. When using dynamic rendering (mapping over columns), conditionally apply scope based on whether the cell is a header: scope={isHeader ? 'col' : undefined}.",
3566
+ "vue": "In Vue, bind :scope only on <th> elements: <th :scope=\"isRowHeader ? 'row' : 'col'\">. Do not use scope on <td> — it is invalid HTML and ignored by screen readers.",
3567
+ "angular": "In Angular Material mat-table, headers are rendered via mat-header-cell. Add scope='col' to column headers and scope='row' to row headers manually — Angular Material does not set scope automatically.",
3568
+ "svelte": "In Svelte, the scope attribute on <th> must be 'row', 'col', 'rowgroup', or 'colgroup'. Svelte does not validate attribute values — verify manually.",
3569
+ "astro": "In .astro files, scope on <th> must use valid values (row, col, rowgroup, colgroup). This is rendered to static HTML — incorrect values are not caught at build time."
3570
+ },
3571
+ "fix_difficulty_notes": "The scope attribute tells screen readers which data cells a header applies to. scope='col' means the header applies to the cells below it; scope='row' means it applies to cells to its right. Using scope on <td> is invalid HTML — move the scope to the appropriate <th>. For complex tables with multi-level headers, use the headers attribute on <td> elements instead of scope.",
3572
+ "guardrails_overrides": {
3573
+ "must_not": [
3574
+ "Do not add scope to <td> elements — scope is only valid on <th>. Move the scope attribute to the <th> for that column or row.",
3575
+ "Do not use scope='col' on a row header or scope='row' on a column header — verify the header's actual orientation before assigning a scope value."
3576
+ ],
3577
+ "verify": [
3578
+ "Confirm scope is only present on <th> elements, not <td>, in the rendered HTML."
3579
+ ]
3580
+ },
3581
+ "related_rules": [
3582
+ {
3583
+ "id": "td-headers-attr",
3584
+ "reason": "For complex tables where scope is insufficient, the headers attribute on <td> provides explicit header associations."
3585
+ },
3586
+ {
3587
+ "id": "th-has-data-cells",
3588
+ "reason": "Headers must have data cells associated with them — fix scope and header-data relationships together."
3589
+ },
3590
+ {
3591
+ "id": "empty-table-header",
3592
+ "reason": "Scope on an empty <th> is meaningless — ensure headers have text content before adding scope."
3593
+ }
3594
+ ]
3595
+ },
3596
+ "scrollable-region-focusable": {
3597
+ "category": "keyboard",
3598
+ "managed_by_libraries": [
3599
+ "swiper",
3600
+ "embla-carousel",
3601
+ "splide"
3602
+ ],
3603
+ "fix": {
3604
+ "description": "Make scrollable regions keyboard-accessible by adding tabindex=\"0\" and a descriptive label.",
3605
+ "code": "<div tabindex=\"0\" role=\"region\" aria-label=\"Product comparison table\" style=\"overflow: auto;\">\n <table>\n <caption>Feature comparison</caption>\n <thead><tr><th>Feature</th><th>Basic</th><th>Pro</th></tr></thead>\n <tbody><tr><td>Storage</td><td>5 GB</td><td>100 GB</td></tr></tbody>\n </table>\n</div>"
3606
+ },
3607
+ "false_positive_risk": "medium",
3608
+ "framework_notes": {
3609
+ "react": "Use a ref to check if the element actually overflows before adding tabIndex={0}. In React: const ref = useRef(); if (ref.current.scrollHeight > ref.current.clientHeight) add tabIndex. Avoid adding tabIndex to containers managed by virtual scroll libraries (react-window, react-virtual) — they handle keyboard internally.",
3610
+ "vue": "Use a template ref and check scrollHeight > clientHeight in onMounted before binding :tabindex='0'. CSS overflow containers created by v-show or transition wrappers may be flagged falsely — verify the element has genuine scroll content.",
3611
+ "angular": "Use @ViewChild and check nativeElement.scrollHeight > nativeElement.clientHeight in ngAfterViewInit. CDK ScrollingModule (VirtualScrollViewport) handles keyboard natively — do not add tabindex to its host element.",
3612
+ "svelte": "In Svelte, scrollable containers (overflow:auto/scroll) must be keyboard-accessible via tabindex='0' and have an accessible name via aria-label or role='region' with aria-label.",
3613
+ "astro": "In .astro files, scrollable regions are rendered in static HTML. Add tabindex='0' and aria-label to make them keyboard-accessible. For scrollable content inside framework islands, manage focus within the island."
3614
+ },
3615
+ "fix_difficulty_notes": "This rule fires on any element with overflow:auto or overflow:scroll — including CSS clip containers, masked elements, and carousels that overflow visually but have no actual scrollable content. Before adding tabindex='0', confirm the element is genuinely scrollable (has overflow content) and that keyboard users need to scroll it. Adding tabindex='0' to a non-scrollable container adds unnecessary tab stops.",
3616
+ "cms_notes": {
3617
+ "shopify": "Dawn's product media slider and slideshow section use Swiper.js with overflow:hidden on the wrapper and overflow:visible on the slide track. axe may flag the slide wrapper. Swiper renders its own keyboard-accessible navigation via the keyboard option — set keyboard: { enabled: true } in the Swiper config and do not add tabindex='0' to the track element.",
3618
+ "wordpress": "WordPress themes using the SplideJS, Swiper, or Owl Carousel libraries may trigger this rule on carousel wrappers. The Splide and Swiper libraries have built-in keyboard navigation — enable it via the keyboard or a11y configuration options rather than adding manual tabindex. The WooCommerce product gallery uses Flexslider, which needs separate a11y configuration.",
3619
+ "drupal": "Drupal's Views carousels (using Slick or Splide) may render scrollable wrapper elements. Slick Carousel (slick_carousel module) has an accessibility option in its configuration — enable it to add proper keyboard navigation and ARIA attributes rather than manually adding tabindex."
3620
+ },
3621
+ "guardrails_overrides": {
3622
+ "must_not": [
3623
+ "Do not add tabindex='0' to elements that have overflow CSS but no actual scrollable content — verify the element genuinely overflows before making it focusable.",
3624
+ "Do not add tabindex='0' alone without also ensuring the scrollable region has an accessible name (aria-label) so screen reader users understand what they are navigating."
3625
+ ],
3626
+ "verify": [
3627
+ "Confirm the element actually scrolls (has overflow content that exceeds its dimensions) before treating this as a violation requiring tabindex='0'."
3628
+ ]
3629
+ },
3630
+ "related_rules": [
3631
+ {
3632
+ "id": "aria-hidden-focus",
3633
+ "reason": "Both affect keyboard focus — audit all focusability violations together to avoid conflicts."
3634
+ },
3635
+ {
3636
+ "id": "focus-order-semantics",
3637
+ "reason": "Scrollable regions may need tabindex='0' for keyboard access but also need an appropriate role."
3638
+ },
3639
+ {
3640
+ "id": "nested-interactive",
3641
+ "reason": "Nested interactive elements inside scroll containers create keyboard traps — fix both together."
3642
+ }
3643
+ ]
3644
+ },
3645
+ "select-name": {
3646
+ "category": "forms",
3647
+ "preferred_relationship_checks": [
3648
+ "aria-labelledby",
3649
+ "explicit-label",
3650
+ "label",
3651
+ "select-name",
3652
+ "aria-label",
3653
+ "implicit-label"
3654
+ ],
3655
+ "fix": {
3656
+ "description": "Associate every <select> element with a visible <label>.",
3657
+ "code": "<label for=\"country\">Country</label>\n<select id=\"country\" name=\"country\">\n <option value=\"us\">United States</option>\n</select>"
3658
+ },
3659
+ "false_positive_risk": "low",
3660
+ "framework_notes": {
3661
+ "react": "Use htmlFor on <label> pointing to the select's id. For custom select components (react-select, Headless UI Listbox), pass the accessible name via their label prop or aria-labelledby — these components use the combobox/listbox ARIA pattern internally.",
3662
+ "vue": "Native <select> uses standard <label for='id'>. For custom select components (Headless UI Listbox, Floating Vue Select), pass a label via their label prop — the component handles aria-labelledby internally.",
3663
+ "angular": "In Angular Material, <mat-select> is labeled via <mat-label> inside <mat-form-field>. For standalone <select> elements, use <label [for]='selectId'>Label</label>.",
3664
+ "svelte": "Use standard <label for='id'> with native <select>. For custom select components (Svelte Select, Melt UI), verify they expose a label prop or accept aria-labelledby. Svelte's compiler warns about missing labels on interactive elements.",
3665
+ "astro": "In .astro files, use standard HTML <label for='id'> with <select>. For select elements inside framework islands, the island framework's label rules apply."
3666
+ },
3667
+ "cms_notes": {
3668
+ "shopify": "Cart and product variant selectors in Shopify themes often use visually hidden labels. Verify the <label> is present in the DOM (not just a placeholder). In Dawn, variant selectors use <label class='visually-hidden'>. Custom themes must replicate this pattern.",
3669
+ "wordpress": "WordPress form plugins (Contact Form 7, WPForms, Gravity Forms) generate <select> with labels. Verify plugin-generated markup includes proper <label for='...'> associations. The Comment form's sort dropdown often lacks a label.",
3670
+ "drupal": "Drupal's Form API generates labels for select elements when #title is set. In Views exposed filters, select filters automatically get labels from the filter title. Custom Twig form templates must preserve the <label> element — do not remove it for visual reasons."
3671
+ },
3672
+ "fix_difficulty_notes": "Custom dropdown replacements (div/button combinations) are the main challenge — they bypass native label association entirely. If the team uses a custom dropdown, it must implement role='listbox', aria-expanded, and aria-labelledby pointing to the visible label. Migrating to native <select> is the simplest fix but may face design pushback. When that happens, ensure the custom component's ARIA contract is fully implemented.",
3673
+ "related_rules": [
3674
+ {
3675
+ "id": "label",
3676
+ "reason": "The same label association pattern applies — fix label rule to resolve both."
3677
+ }
3678
+ ],
3679
+ "guardrails_overrides": {
3680
+ "must": [
3681
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3682
+ ],
3683
+ "must_not": [
3684
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3685
+ ],
3686
+ "verify": [
3687
+ "Confirm computed accessible name matches expected spoken phrase."
3688
+ ]
3689
+ }
3690
+ },
3691
+ "server-side-image-map": {
3692
+ "category": "text-alternatives",
3693
+ "fix": {
3694
+ "description": "Replace server-side image maps (using ismap on <img>) with client-side image maps (<map> + <area>) or alternative navigation that does not depend on precise pointer coordinates.",
3695
+ "code": "<!-- Before: server-side image map (inaccessible) -->\n<a href=\"/map-handler\">\n <img src=\"navigation.png\" ismap alt=\"Site navigation\">\n</a>\n\n<!-- After: client-side image map with alt text per area -->\n<img src=\"navigation.png\" usemap=\"#nav-map\" alt=\"Site navigation\">\n<map name=\"nav-map\">\n <area shape=\"rect\" coords=\"0,0,100,50\" href=\"/home\" alt=\"Home\">\n <area shape=\"rect\" coords=\"100,0,200,50\" href=\"/about\" alt=\"About\">\n <area shape=\"rect\" coords=\"200,0,300,50\" href=\"/contact\" alt=\"Contact\">\n</map>"
3696
+ },
3697
+ "false_positive_risk": "low",
3698
+ "framework_notes": {
3699
+ "react": "Server-side image maps are extremely rare in React applications. If the ismap attribute is present on an <img>, replace the entire pattern with individual <Link> components styled with CSS Grid or positioned over the image. This is more maintainable and accessible.",
3700
+ "vue": "In Vue, replace server-side image maps with positioned <router-link> components or a client-side <map>. Server-side image maps send cursor coordinates to the server, which is incompatible with SPA routing.",
3701
+ "angular": "In Angular, replace the ismap pattern with Angular Router links positioned over the image, or use a client-side <map> with <area> elements. Server-side coordinate handling is incompatible with Angular's client-side routing.",
3702
+ "svelte": "Server-side image maps (<img ismap>) are inaccessible — replace with client-side image maps (<map>/<area>) or interactive SVGs. Server-side maps are rare in modern Svelte apps.",
3703
+ "astro": "In .astro files, server-side image maps should not be used. Replace with client-side <map>/<area> or interactive SVG elements with proper ARIA labels."
3704
+ },
3705
+ "fix_difficulty_notes": "Server-side image maps send mouse click coordinates to the server, which determines the action. This is completely inaccessible to keyboard users (no coordinates are sent with Enter key) and screen reader users (no alternative text for regions). The fix: replace with a client-side image map (<map>/<area>) with alt text per area, or better yet, replace with individual links positioned via CSS.",
3706
+ "related_rules": [
3707
+ {
3708
+ "id": "area-alt",
3709
+ "reason": "If replacing with a client-side image map, ensure every <area> has alt text."
3710
+ }
3711
+ ]
3712
+ },
3713
+ "skip-link": {
3714
+ "category": "keyboard",
3715
+ "fix": {
3716
+ "description": "Ensure all skip links have a focusable target. A skip link (typically 'Skip to main content') must point to an element that exists in the DOM and can receive focus, usually the <main> element with an id attribute.",
3717
+ "code": "<!-- Skip link in the header: -->\n<body>\n <a href=\"#main-content\" class=\"skip-link\">Skip to main content</a>\n <header>...</header>\n <main id=\"main-content\" tabindex=\"-1\">\n <h1>Page title</h1>\n ...\n </main>\n <footer>...</footer>\n</body>\n\n<!-- CSS for visually hidden skip link (visible on focus): -->\n<!--\n.skip-link {\n position: absolute;\n left: -9999px;\n top: auto;\n width: 1px;\n height: 1px;\n overflow: hidden;\n}\n.skip-link:focus {\n position: static;\n width: auto;\n height: auto;\n overflow: visible;\n padding: 0.5rem 1rem;\n background: #000;\n color: #fff;\n z-index: 9999;\n}\n-->"
3718
+ },
3719
+ "false_positive_risk": "medium",
3720
+ "framework_notes": {
3721
+ "react": "In Next.js App Router, add the skip link in the root layout.tsx before <header>. Set tabIndex={-1} on <main id='main-content'> so it can receive focus when the skip link is activated. Some browsers require tabindex='-1' for non-interactive elements to be focusable via fragment navigation.",
3722
+ "vue": "In Nuxt, add the skip link in layouts/default.vue before <header>. Add tabindex='-1' and id='main-content' to the <main> element. Ensure the skip link is the first focusable element in the DOM.",
3723
+ "angular": "In Angular, add the skip link in app.component.html before <header>. Use [tabIndex]='-1' on <main id='main-content'>. Angular's router may re-render the main content area on navigation — ensure the id persists across route changes.",
3724
+ "svelte": "In SvelteKit, add the skip link in +layout.svelte as the first child element. Set tabindex='-1' on the <main> target. SvelteKit's client-side navigation does not break skip links since the layout persists across routes.",
3725
+ "astro": "Place the skip link in BaseLayout.astro as the first element in <body>. Since Astro renders static HTML, the skip link target is always present. Add tabindex='-1' to <main> for browsers that require it for fragment navigation."
3726
+ },
3727
+ "cms_notes": {
3728
+ "shopify": "Dawn includes a skip link targeting #MainContent. Custom themes must ensure the target element (id='MainContent') exists and has tabindex='-1'. The skip link must be in layout/theme.liquid, not in a section — sections may not render on all pages.",
3729
+ "wordpress": "The Underscores (_s) starter theme includes a working skip link in header.php. Many premium themes remove or break it. Verify the target element (#content or #main) exists, has tabindex='-1', and receives focus on activation. Block themes need the skip link in the header template part.",
3730
+ "drupal": "Drupal renders a skip link via the 'Skip to main content' region. Olivero includes it by default. Custom themes must ensure the skip-link region is rendered in page.html.twig and the target (typically <main>) has id='main-content' and tabindex='-1'."
3731
+ },
3732
+ "fix_difficulty_notes": "The skip link target must meet two conditions: (1) the element with the matching id must exist in the DOM, and (2) it must be focusable. Native interactive elements are focusable by default; non-interactive elements like <main> or <div> require tabindex='-1' to receive programmatic focus. In SPAs, the target element may not exist during initial render if content is loaded asynchronously — ensure the target is rendered before the skip link can be activated.",
3733
+ "related_rules": [
3734
+ {
3735
+ "id": "bypass",
3736
+ "reason": "Skip links are the primary mechanism for satisfying the bypass blocks requirement (WCAG 2.4.1)."
3737
+ },
3738
+ {
3739
+ "id": "landmark-one-main",
3740
+ "reason": "The skip link target should be the <main> landmark — ensure it exists and has the correct id."
3741
+ },
3742
+ {
3743
+ "id": "region",
3744
+ "reason": "Proper landmark regions reduce the need for skip links but do not replace them — both mechanisms should coexist."
3745
+ }
3746
+ ],
3747
+ "guardrails_overrides": {
3748
+ "must": [
3749
+ "If a link uses target=\"_blank\", ensure rel=\"noopener noreferrer\" (or stricter equivalent) is present."
3750
+ ],
3751
+ "must_not": [
3752
+ "Do not mention \"opens in a new tab\" unless target=\"_blank\" is actually present."
3753
+ ],
3754
+ "verify": [
3755
+ "Confirm link purpose remains clear out of context and in the accessibility tree."
3756
+ ]
3757
+ }
3758
+ },
3759
+ "summary-name": {
3760
+ "category": "name-role-value",
3761
+ "fix": {
3762
+ "description": "Add discernible text content to every <summary> element so screen readers can announce its purpose. The <summary> serves as the visible heading and toggle control for a <details> disclosure widget.",
3763
+ "code": "<!-- Before: empty summary -->\n<details>\n <summary></summary>\n <p>Additional information here.</p>\n</details>\n\n<!-- After: summary with descriptive text -->\n<details>\n <summary>More information about shipping</summary>\n <p>We offer free shipping on orders over $50.</p>\n</details>"
3764
+ },
3765
+ "false_positive_risk": "low",
3766
+ "framework_notes": {
3767
+ "react": "In React, <summary> elements with dynamic content like <summary>{title}</summary> render empty if title is undefined. Guard with: {title && <details><summary>{title}</summary>...</details>}. Accordion components (Radix Collapsible, Headless UI Disclosure) manage this automatically.",
3768
+ "vue": "In Vue, <summary>{{ title }}</summary> renders empty if title is falsy. Use v-if='title' on the <details> or provide a fallback string. Headless UI and PrimeVue accordion components handle this internally.",
3769
+ "angular": "In Angular, <summary>{{ title }}</summary> renders empty if title is undefined. Use *ngIf='title' on the <details> element or provide a default string. Angular CDK Accordion manages accessible names automatically.",
3770
+ "svelte": "In Svelte, <summary> elements inside <details> must have visible text content that describes the disclosed section. Svelte does not warn about empty <summary> — add descriptive text.",
3771
+ "astro": "In .astro files, <summary> must contain visible text in the static HTML. This serves as the accessible name for the disclosure widget."
3772
+ },
3773
+ "fix_difficulty_notes": "The fix is to add descriptive text content to the <summary> element. In CMS-driven content, the issue often occurs when an author creates a <details> block but leaves the summary empty. The text should describe what the disclosure widget reveals when expanded — not a generic label like 'Details' or 'More'.",
3774
+ "related_rules": [
3775
+ {
3776
+ "id": "button-name",
3777
+ "reason": "<summary> acts as a toggle button — it requires an accessible name by the same principle as button-name."
3778
+ },
3779
+ {
3780
+ "id": "empty-heading",
3781
+ "reason": "In CMS content, empty <summary> and empty headings often occur together — authors leave both fields blank. Fix both in the same content audit."
3782
+ }
3783
+ ],
3784
+ "guardrails_overrides": {
3785
+ "must": [
3786
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3787
+ ],
3788
+ "must_not": [
3789
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3790
+ ],
3791
+ "verify": [
3792
+ "Confirm computed accessible name matches expected spoken phrase."
3793
+ ]
3794
+ }
3795
+ },
3796
+ "svg-img-alt": {
3797
+ "category": "text-alternatives",
3798
+ "fix": {
3799
+ "description": "Add an accessible name to informative SVGs using role='img' and aria-label. For decorative SVGs, use aria-hidden='true'.",
3800
+ "code": "<!-- Informative SVG (icon conveying meaning): -->\n<svg role=\"img\" aria-label=\"Shopping cart — 3 items\" focusable=\"false\">\n <!-- svg paths -->\n</svg>\n\n<!-- Decorative SVG (visual only, adjacent text explains it): -->\n<svg aria-hidden=\"true\" focusable=\"false\">\n <!-- svg paths -->\n</svg>"
3801
+ },
3802
+ "false_positive_risk": "medium",
3803
+ "framework_notes": {
3804
+ "react": "In React, SVG components commonly strip accessibility attributes. Add role='img' and aria-label directly on the <svg> element: <svg role='img' aria-label='Close' aria-hidden={undefined}>. Icon libraries like Heroicons and Lucide expose aria-hidden and aria-label props.",
3805
+ "vue": "In Vue, pass aria-hidden='true' or aria-label as attributes on the <svg> component. Most Vue icon libraries (unplugin-icons, vue-feather) accept these as standard HTML attributes.",
3806
+ "angular": "In Angular Material, <mat-icon> renders an SVG or ligature — it sets aria-hidden by default. For standalone SVG components, use [attr.aria-label]='label' and [attr.aria-hidden]='isDecorative'.",
3807
+ "svelte": "In Svelte, <svg> elements that convey meaning need role='img' and aria-label or <title> as the first child. Decorative SVGs should have aria-hidden='true'. Svelte's compiler warns about <img> without alt but not <svg> without labels.",
3808
+ "astro": "In .astro files, meaningful <svg> elements need role='img' and an accessible name. Astro's <Image /> component handles <img> tags only — SVGs must be labeled manually."
3809
+ },
3810
+ "fix_difficulty_notes": "Most SVGs in UI are decorative (icons next to visible button text, background illustrations). axe may flag them as missing an alt even when the adjacent text is sufficient. The decision is: does this SVG convey meaning that is NOT expressed in adjacent visible text? If yes → aria-label. If no → aria-hidden='true'. Never add aria-label that duplicates adjacent text — this creates redundant announcements.",
3811
+ "cms_notes": {
3812
+ "shopify": "Shopify themes use {% render 'icon-cart' %}, {% render 'icon-close' %}, and similar snippets that render raw <svg> without aria attributes. For decorative icons (with accompanying visible button text), add aria-hidden='true' to the snippet's <svg> root. For standalone icon buttons (no visible text), pass an aria_label variable from the including template to the snippet.",
3813
+ "wordpress": "WordPress core's Social Icons block (Gutenberg) adds aria-hidden='true' to SVG icons by default. Custom blocks using inline SVG must add role='img' and aria-label for informative icons, or aria-hidden='true' for decorative ones. The SVG Sanitizer used in wp_kses_post allows aria attributes.",
3814
+ "drupal": "Drupal themes render SVG icons via Twig include. Use {{ attributes.setAttribute('aria-hidden', 'true') }} for decorative icons, or {{ attributes.setAttribute('role', 'img').setAttribute('aria-label', label|t) }} for informative icons. Icon modules like Iconify for Drupal apply aria-hidden automatically when used as decorative."
3815
+ },
3816
+ "related_rules": [
3817
+ {
3818
+ "id": "image-alt",
3819
+ "reason": "The same alt text requirement applies to <img> — fix all alt text violations together."
3820
+ },
3821
+ {
3822
+ "id": "image-redundant-alt",
3823
+ "reason": "When adding aria-label to SVGs, avoid duplicating visible adjacent text."
3824
+ },
3825
+ {
3826
+ "id": "input-image-alt",
3827
+ "reason": "The same alt text requirement applies to <input type=\"image\"> — fix all alt text violations together."
3828
+ },
3829
+ {
3830
+ "id": "role-img-alt",
3831
+ "reason": "svg-img-alt specifically targets SVG elements with role='img' — they overlap with this rule."
3832
+ }
3833
+ ],
3834
+ "guardrails_overrides": {
3835
+ "must": [
3836
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3837
+ ],
3838
+ "must_not": [
3839
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3840
+ ],
3841
+ "verify": [
3842
+ "Confirm computed accessible name matches expected spoken phrase."
3843
+ ]
3844
+ }
3845
+ },
3846
+ "tabindex": {
3847
+ "category": "keyboard",
3848
+ "fix": {
3849
+ "description": "Remove positive tabindex values. Use tabindex=\"0\" to include in natural tab order, tabindex=\"-1\" to exclude.",
3850
+ "code": "<!-- Include in natural tab order: -->\n<div role=\"button\" tabindex=\"0\">Focusable element</div>\n<!-- Programmatically focusable only: -->\n<div tabindex=\"-1\">Focus via script only</div>"
3851
+ },
3852
+ "false_positive_risk": "low",
3853
+ "framework_notes": {
3854
+ "react": "In JSX, the attribute is camelCase: tabIndex={0} or tabIndex={-1}. Never use positive integers. For programmatically managed focus (e.g., opening a modal), call element.focus() instead of relying on tabIndex values greater than 0.",
3855
+ "vue": "Use :tabindex='0' or tabindex='-1' in Vue templates. Do not expose a tabindex prop that accepts positive integers — validate the value at the component level.",
3856
+ "angular": "Use [tabIndex]='value' for dynamic binding, ensuring only 0 or -1 are bound. The Angular CDK FocusTrap handles sequential focus management within dialogs without requiring positive tabindex values.",
3857
+ "svelte": "In Svelte, avoid tabindex values greater than 0 — they disrupt the natural tab order. Use tabindex='0' for custom interactive elements and tabindex='-1' for programmatic focus targets only.",
3858
+ "astro": "In .astro files, tabindex values render to static HTML. Never use tabindex > 0. For custom interactive elements, use tabindex='0' and add keyboard event handlers via a framework island."
3859
+ },
3860
+ "fix_difficulty_notes": "Positive tabindex values (tabindex='1', tabindex='2') create a separate focus sequence that runs before the natural DOM order. All positive-tabindex elements are visited first, then everything else. This almost always breaks focus flow. Remove all positive tabindex values and reorder the DOM instead if a different focus sequence is needed.",
3861
+ "related_rules": [
3862
+ {
3863
+ "id": "focus-order-semantics",
3864
+ "reason": "Both rules address focus order issues — fix tabindex values and semantic roles together."
3865
+ }
3866
+ ],
3867
+ "guardrails_overrides": {
3868
+ "must_not": [
3869
+ "Do not assign tabindex > 0 — this creates a separate focus sequence that runs before natural DOM order and breaks keyboard navigation.",
3870
+ "Do not use positive tabindex as a workaround for DOM order issues — reorder the DOM instead."
3871
+ ],
3872
+ "verify": [
3873
+ "Confirm no tabindex values greater than 0 remain in the affected file.",
3874
+ "Tab through the modified element to confirm it appears in logical, predictable focus order."
3875
+ ]
3876
+ }
3877
+ },
3878
+ "table-duplicate-name": {
3879
+ "category": "tables",
3880
+ "fix": {
3881
+ "description": "Remove the summary attribute or change the caption text so the table caption and summary do not contain the same text.",
3882
+ "code": "<!-- Remove summary entirely (deprecated in HTML5) -->\n<table>\n <caption>Sales data by region and quarter — columns are quarters Q1–Q4, rows are regions</caption>\n ...\n</table>\n\n<!-- Or if summary must remain, use different text: -->\n<table summary=\"Use arrow keys to navigate rows and columns.\">\n <caption>Sales data by region and quarter</caption>\n ...\n</table>"
3883
+ },
3884
+ "false_positive_risk": "low",
3885
+ "framework_notes": {
3886
+ "react": "In React table components, avoid passing both caption and summary props with the same content. Remove the summary attribute entirely — it is deprecated.",
3887
+ "vue": "In Vue, do not bind :summary on <table>. Use <caption> for all table descriptions.",
3888
+ "angular": "In Angular, remove [attr.summary] bindings from table components. Migrate to <caption> with descriptive text or aria-describedby pointing to an adjacent description.",
3889
+ "svelte": "In Svelte, remove the deprecated summary attribute from <table> elements. Use <caption> as the sole table description. If you must keep both a <caption> and a summary, ensure their text is different.",
3890
+ "astro": "In .astro files, remove the deprecated summary attribute from <table> elements. Use <caption> as the sole table description. The summary attribute is a legacy HTML4 pattern that should not appear in modern Astro templates."
3891
+ },
3892
+ "fix_difficulty_notes": "The summary attribute is deprecated in HTML5 and should not be used in new code. The preferred pattern is to put all necessary description in the <caption> element. If legacy code uses both, the simplest fix is to remove the summary attribute entirely. For complex tables requiring navigation instructions, use aria-describedby pointing to a visible description paragraph adjacent to the table.",
3893
+ "related_rules": [
3894
+ {
3895
+ "id": "table-fake-caption",
3896
+ "reason": "If both a <caption> and aria-label exist, they must not duplicate each other."
3897
+ }
3898
+ ],
3899
+ "guardrails_overrides": {
3900
+ "must": [
3901
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3902
+ ],
3903
+ "must_not": [
3904
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3905
+ ],
3906
+ "verify": [
3907
+ "Confirm computed accessible name matches expected spoken phrase."
3908
+ ]
3909
+ }
3910
+ },
3911
+ "table-fake-caption": {
3912
+ "category": "tables",
3913
+ "fix": {
3914
+ "description": "Replace any first-row fake caption (a <td> spanning all columns) with a proper <caption> element as the first child of <table>. Screen readers do not associate a spanning <td> with the table as a label.",
3915
+ "code": "<!-- Before: fake caption using first row -->\n<table>\n <tr>\n <td colspan=\"3\"><strong>Quarterly Sales</strong></td>\n </tr>\n <tr>\n <th>Region</th><th>Q1</th><th>Q2</th>\n </tr>\n <tr>\n <td>North</td><td>$100k</td><td>$120k</td>\n </tr>\n</table>\n\n<!-- After: proper caption element -->\n<table>\n <caption>Quarterly Sales</caption>\n <thead>\n <tr>\n <th>Region</th><th>Q1</th><th>Q2</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>North</td><td>$100k</td><td>$120k</td>\n </tr>\n </tbody>\n</table>"
3916
+ },
3917
+ "false_positive_risk": "medium",
3918
+ "framework_notes": {
3919
+ "react": "In React table components, add a <caption> element as the first child of <table>. UI libraries (Tanstack Table, AG Grid React) may not render a <caption> by default — add it manually in the table wrapper or use the library's caption/title slot.",
3920
+ "vue": "In Vue, add <caption> as the first child of the <table> element in your template. Vuetify's v-data-table uses a title slot — configure it to render as a proper <caption> for data tables.",
3921
+ "angular": "In Angular Material, mat-table does not include a <caption> element by default. Add a <caption> element inside the <table mat-table> element. For CDK Table, include the <caption> in the table template.",
3922
+ "svelte": "In Svelte, do not use <td colspan> as a table caption. Use the proper <caption> element inside <table> for the table's accessible name.",
3923
+ "astro": "In .astro files, tables should use <caption> for their title, not a spanning <td> in the first row."
3924
+ },
3925
+ "fix_difficulty_notes": "The <caption> element must be the first child of <table>. It is announced by screen readers as the table's name, enabling users to understand the table's purpose before navigating its cells. CSS can be used to style or visually reposition the caption (caption-side: bottom). Never use visibility:hidden or display:none — use the sr-only/visually-hidden pattern if the caption must be visible only to AT.",
3926
+ "related_rules": [
3927
+ {
3928
+ "id": "table-duplicate-name",
3929
+ "reason": "If both a <caption> and aria-label exist, they must not duplicate each other."
3930
+ }
3931
+ ],
3932
+ "guardrails_overrides": {
3933
+ "must": [
3934
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
3935
+ ],
3936
+ "must_not": [
3937
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
3938
+ ],
3939
+ "verify": [
3940
+ "Confirm computed accessible name matches expected spoken phrase."
3941
+ ]
3942
+ }
3943
+ },
3944
+ "target-size": {
3945
+ "category": "keyboard",
3946
+ "fix": {
3947
+ "description": "Increase small interactive elements to a minimum target size of 24×24 CSS pixels (WCAG 2.5.8). Use 44×44 px or larger for touch interfaces.",
3948
+ "code": "/* Minimum WCAG 2.2 AA target size */\n.btn, a, [role=\"button\"] {\n min-width: 24px;\n min-height: 24px;\n}\n\n/* Recommended for touch: */\n.btn {\n min-width: 44px;\n min-height: 44px;\n padding: 0.5rem 1rem;\n}"
3949
+ },
3950
+ "false_positive_risk": "medium",
3951
+ "framework_notes": {
3952
+ "react": "In React, icon-only buttons from component libraries (shadcn/ui, Radix, Chakra) often render below 24px. Add min-w-6 min-h-6 (Tailwind) or min-width/min-height in CSS. For touch targets, use min-w-11 min-h-11 (44px). Radix's IconButton defaults to small sizes — override via className.",
3953
+ "vue": "In Vue, Vuetify's icon buttons default to 36px (sufficient), but custom icon buttons may not. Ensure min-width and min-height are set. PrimeVue's Button with icon-only mode may render small — verify with DevTools.",
3954
+ "angular": "Angular Material's mat-icon-button defaults to 40px (sufficient). Custom icon buttons or mat-mini-fab (40px) meet the minimum. Verify third-party component libraries meet the 24px minimum — inspect computed size in DevTools.",
3955
+ "svelte": "In Svelte, there are no built-in size constraints on interactive elements. Ensure all buttons, links, and inputs have min-width: 24px and min-height: 24px. For touch interfaces, use 44px. Apply sizes in a global stylesheet to catch all interactive elements.",
3956
+ "astro": "In .astro files, target sizes depend entirely on CSS. Ensure icon-only buttons and small links in the base layout meet the 24px minimum. Framework islands inherit page CSS — verify the computed size of interactive elements inside hydrated islands."
3957
+ },
3958
+ "fix_difficulty_notes": "WCAG 2.5.8 provides an exception for inline text links within a sentence (e.g., 'see our privacy policy') where sufficient spacing around the link satisfies the criterion. axe may flag these — verify whether the exception applies before adding padding that could break the text flow.",
3959
+ "guardrails_overrides": {
3960
+ "must_not": [
3961
+ "Do not add padding to inline text links within a paragraph to meet the target size — the WCAG 2.5.8 inline exception allows these to be smaller if surrounding spacing is sufficient.",
3962
+ "Do not increase font-size to pad target size without confirming the design allows it — use min-width/min-height with padding instead."
3963
+ ],
3964
+ "verify": [
3965
+ "Confirm the element is a standalone interactive control (not an inline link within text) before applying the target size fix.",
3966
+ "Measure the computed size of the interactive element in DevTools — axe uses the bounding box, not just the text content area."
3967
+ ]
3968
+ },
3969
+ "related_rules": []
3970
+ },
3971
+ "td-has-header": {
3972
+ "category": "tables",
3973
+ "fix": {
3974
+ "description": "Associate every non-empty <td> in a multi-row/multi-column data table with a <th> header cell. Use <th> elements for column and row headers, and add scope or headers attributes for complex table structures.",
3975
+ "code": "<!-- Simple table: use <th> with scope -->\n<table>\n <caption>Employee Directory</caption>\n <thead>\n <tr>\n <th scope=\"col\">Name</th>\n <th scope=\"col\">Department</th>\n <th scope=\"col\">Extension</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Jane Doe</td>\n <td>Engineering</td>\n <td>1234</td>\n </tr>\n </tbody>\n</table>\n\n<!-- Complex table with row and column headers -->\n<table>\n <caption>Quarterly Revenue</caption>\n <thead>\n <tr><th></th><th scope=\"col\">Q1</th><th scope=\"col\">Q2</th></tr>\n </thead>\n <tbody>\n <tr>\n <th scope=\"row\">North</th><td>$100k</td><td>$120k</td>\n </tr>\n </tbody>\n</table>"
3976
+ },
3977
+ "false_positive_risk": "medium",
3978
+ "framework_notes": {
3979
+ "react": "In React, dynamic table components must render <th> elements for headers, not <td> with styling. Use scope='col' on column headers and scope='row' on row headers. Tanstack Table renders headers as <th> by default — verify the rendered output.",
3980
+ "vue": "In Vue, ensure the table header row uses <th> not <td>. Vuetify's v-data-table renders column headers as <th> automatically. For custom tables, add scope='col' to each <th> in the <thead>.",
3981
+ "angular": "Angular Material's mat-table uses mat-header-cell, which renders as <th> with role='columnheader'. Verify the rendered HTML includes proper scope attributes. For native HTML tables in Angular, add scope='col' or scope='row' to <th> elements.",
3982
+ "svelte": "In Svelte data table components, ensure every <td> is associated with a <th> via the table structure or the headers attribute. Complex tables with merged cells need explicit headers attributes.",
3983
+ "astro": "In .astro files, table cells must be associated with header cells. For simple tables, proper <th>/<td> structure is sufficient. For complex tables, use the headers attribute."
3984
+ },
3985
+ "fix_difficulty_notes": "This rule triggers on 'large' tables (more than 3 rows and 3 columns) where data cells are not associated with headers. The most common fix: convert the first row from <td> to <th scope='col'> and the first column from <td> to <th scope='row'>. For complex tables with multi-level headers, use the headers attribute on <td> to explicitly reference <th id> values. Avoid merged cells (colspan/rowspan) when possible — they complicate header association significantly.",
3986
+ "guardrails_overrides": {
3987
+ "must_not": [
3988
+ "Do not add role='presentation' or role='none' to the table to suppress this violation — this removes the table semantics and harms AT users who rely on table navigation."
3989
+ ],
3990
+ "verify": [
3991
+ "Confirm the table is a data table (not a layout table) before applying header fixes — layout tables should use role='presentation' instead.",
3992
+ "Confirm the table is not in an empty/loading state that is causing axe to find no data cells — guard tables to only display headers when data is present."
3993
+ ]
3994
+ },
3995
+ "related_rules": [
3996
+ {
3997
+ "id": "th-has-data-cells",
3998
+ "reason": "th-has-data-cells verifies headers have associated data; td-has-header verifies data cells have associated headers — fix both together."
3999
+ },
4000
+ {
4001
+ "id": "td-headers-attr",
4002
+ "reason": "For complex tables, the headers attribute on <td> must reference valid <th> id values."
4003
+ }
4004
+ ]
4005
+ },
4006
+ "td-headers-attr": {
4007
+ "category": "tables",
4008
+ "fix": {
4009
+ "description": "Ensure every ID referenced in a td's headers attribute matches an existing th element in the same table.",
4010
+ "code": "<table>\n <thead>\n <tr>\n <th id=\"col-name\">Name</th>\n <th id=\"col-dept\">Department</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td headers=\"col-name\">Alice</td>\n <td headers=\"col-dept\">Engineering</td>\n </tr>\n </tbody>\n</table>"
4011
+ },
4012
+ "false_positive_risk": "low",
4013
+ "framework_notes": {
4014
+ "react": "In React, generate table IDs programmatically: const colId = `col-${column.key}`; <th id={colId}> and <td headers={colId}>. Ensure IDs are unique if multiple table instances render on the same page.",
4015
+ "vue": "In Vue, bind unique IDs: :id='`col-${col.key}`' on <th> and :headers='`col-${col.key}`' on <td>. Use a table-scoped unique prefix if multiple tables exist.",
4016
+ "angular": "In Angular, generate IDs in the component: colId = (col: Column) => `col-${col.key}`; and bind [attr.id]='colId(col)' on th and [attr.headers]='colId(col)' on td.",
4017
+ "svelte": "In Svelte, the headers attribute on <td> must reference valid <th> IDs in the same table. When using dynamic table rendering, ensure header IDs are consistent with cell references.",
4018
+ "astro": "In .astro files, the headers attribute on <td> must reference <th> elements by id within the same table. Verify references in the rendered static HTML."
4019
+ },
4020
+ "fix_difficulty_notes": "The headers attribute is only needed for complex tables where a single cell spans multiple headers or the column/row relationship is ambiguous. For simple tables (one header row, one header column), use <th scope='col'> and <th scope='row'> instead — it is simpler and better supported across AT. Only use the headers attribute when scope is insufficient.",
4021
+ "guardrails_overrides": {
4022
+ "must_not": [
4023
+ "Do not add the headers attribute to simple tables — use <th scope='col'> and <th scope='row'> instead, which have broader AT support.",
4024
+ "Do not reference ids that belong to <th> elements in a different table — the headers attribute must reference <th> within the same table."
4025
+ ],
4026
+ "verify": [
4027
+ "Confirm every id referenced in a headers attribute exists in the DOM and belongs to a <th> element in the same table."
4028
+ ]
4029
+ },
4030
+ "related_rules": [
4031
+ {
4032
+ "id": "th-has-data-cells",
4033
+ "reason": "Fix td-headers-attr and th-has-data-cells together — they are complementary table structure rules."
4034
+ },
4035
+ {
4036
+ "id": "empty-table-header",
4037
+ "reason": "The headers attribute references <th> elements by id — empty headers make these references meaningless."
4038
+ },
4039
+ {
4040
+ "id": "scope-attr-valid",
4041
+ "reason": "For complex tables where scope is insufficient, the headers attribute on <td> provides explicit header associations."
4042
+ },
4043
+ {
4044
+ "id": "td-has-header",
4045
+ "reason": "For complex tables, the headers attribute on <td> must reference valid <th> id values."
4046
+ }
4047
+ ]
4048
+ },
4049
+ "th-has-data-cells": {
4050
+ "category": "tables",
4051
+ "fix": {
4052
+ "description": "Ensure every <th> element has at least one associated data cell (<td>). Remove header cells for empty columns or convert them to <td> if they contain data rather than labels.",
4053
+ "code": "<table>\n <thead>\n <tr>\n <th scope=\"col\">Name</th>\n <th scope=\"col\">Role</th>\n </tr>\n </thead>\n <tbody>\n <tr>\n <td>Alice</td>\n <td>Engineer</td>\n </tr>\n </tbody>\n</table>"
4054
+ },
4055
+ "false_positive_risk": "medium",
4056
+ "framework_notes": {
4057
+ "react": "In React, render a fallback when the data array is empty: if (!rows.length) return <p>No results found.</p>. Do not render the <table> with headers and an empty <tbody>.",
4058
+ "vue": "In Vue, use v-if='rows.length' on the <table> element. Provide an empty-state <p> or <div> with v-else outside the table.",
4059
+ "angular": "In Angular, use *ngIf='rows.length' on the <table> element and provide an alternative empty-state template with *ngIf='!rows.length'.",
4060
+ "svelte": "In Svelte, every <th> must have associated data cells (<td>) in the table. Empty rows or columns with only headers trigger this violation — remove unused headers.",
4061
+ "astro": "In .astro files, <th> elements must be associated with <td> cells in the rendered table. Headers without data cells are meaningless and should be removed."
4062
+ },
4063
+ "fix_difficulty_notes": "This rule commonly fires on dynamically rendered tables that show an empty state (loading spinner or 'No data' row). axe flags the <th> elements as having no data cells when the table body is empty or renders a colspan'd message. Guard the table to only display headers when data is present, or use a non-table element for the empty state.",
4064
+ "guardrails_overrides": {
4065
+ "must_not": [
4066
+ "Do not remove <th> elements that are legitimately waiting for data — guard the table to hide headers during empty state instead."
4067
+ ],
4068
+ "verify": [
4069
+ "Confirm the table is not in a loading or empty state during the scan — re-test with actual data rows present.",
4070
+ "Confirm the <td> cells that correspond to the flagged <th> are in the same table scope, not in a sibling or child table."
4071
+ ]
4072
+ },
4073
+ "related_rules": [
4074
+ {
4075
+ "id": "td-headers-attr",
4076
+ "reason": "Fix table structure rules together — td-headers-attr and th-has-data-cells are complementary."
4077
+ },
4078
+ {
4079
+ "id": "empty-table-header",
4080
+ "reason": "Headers without text and headers without associated data cells are related structural issues — fix together."
4081
+ },
4082
+ {
4083
+ "id": "scope-attr-valid",
4084
+ "reason": "Headers must have data cells associated with them — fix scope and header-data relationships together."
4085
+ },
4086
+ {
4087
+ "id": "td-has-header",
4088
+ "reason": "th-has-data-cells verifies headers have associated data; td-has-header verifies data cells have associated headers — fix both together."
4089
+ }
4090
+ ]
4091
+ },
4092
+ "valid-lang": {
4093
+ "category": "language",
4094
+ "fix": {
4095
+ "description": "Use a valid BCP 47 language code on any element with a lang attribute.",
4096
+ "code": "<p lang=\"es\">Hola mundo</p>\n<blockquote lang=\"fr\">Citation en français</blockquote>"
4097
+ },
4098
+ "false_positive_risk": "low",
4099
+ "framework_notes": {
4100
+ "react": "Add the lang attribute to HTML elements in JSX: <p lang='es'>Hola</p>. Works identically to plain HTML — no special React syntax needed.",
4101
+ "vue": "Use lang='es' as a standard attribute in Vue templates. For dynamic language content, bind it: <p :lang='contentLang'>{{ text }}</p>.",
4102
+ "angular": "Use [attr.lang]='locale' for dynamic language switching on inline content, or lang='es' as a static attribute for fixed foreign-language passages.",
4103
+ "svelte": "In Svelte, any element with a lang attribute must use a valid BCP 47 tag. This applies to inline language overrides like <span lang='fr'>Bonjour</span>.",
4104
+ "astro": "In .astro files, all lang attributes (on any element) must use valid BCP 47 tags. This includes the root <html lang> and any inline language overrides."
4105
+ },
4106
+ "fix_difficulty_notes": "This rule checks inline lang attributes on child elements (e.g., <p lang='es'>), not the root <html>. The most common mistake is copying locale codes from i18n libraries (e.g., 'en_US' with underscore) — only hyphenated BCP 47 tags are valid (e.g., 'en-US', 'es', 'fr-CA'). Verify each value against https://www.iana.org/assignments/language-subtag-registry/.",
4107
+ "cms_notes": {
4108
+ "shopify": "Shopify's Translate & Adapt app and third-party translation apps (Langify, Weglot) inject translated content as page overlays or replace DOM text. Weglot wraps translated content in <span> elements with a lang attribute — ensure it uses valid BCP 47 codes (e.g., 'fr' not 'fr_FR'). Verify via the Weglot dashboard's language code settings.",
4109
+ "wordpress": "WPML and Polylang inject lang attributes on translated content blocks. WPML uses ISO 639-1 codes with underscore separators (e.g., 'fr_FR') in PHP — the Twig/HTML output must convert these to hyphenated BCP 47 format. Use WPML's wpml_active_languages API to retrieve properly formatted language tags.",
4110
+ "drupal": "Drupal's Language module tags content blocks with lang attributes from the content's language assignment. Language IDs configured in /admin/config/regional/language must use valid BCP 47 tags. Custom Twig templates that output {{ node.langcode.value }} directly may produce underscore-format codes — convert using str_replace('_', '-', langcode)."
4111
+ },
4112
+ "related_rules": [
4113
+ {
4114
+ "id": "html-has-lang",
4115
+ "reason": "The root <html> lang attribute must be set before child lang attributes are meaningful."
4116
+ },
4117
+ {
4118
+ "id": "html-lang-valid",
4119
+ "reason": "The same BCP 47 validity requirement applies to the root lang attribute."
4120
+ }
4121
+ ]
4122
+ },
4123
+ "video-caption": {
4124
+ "category": "text-alternatives",
4125
+ "fix": {
4126
+ "description": "Add a <track kind='captions'> inside every <video> element. Captions must cover all speech and meaningful audio.",
4127
+ "code": "<video controls>\n <source src=\"presentation.mp4\" type=\"video/mp4\">\n <track kind=\"captions\" src=\"presentation.vtt\" srclang=\"en\" label=\"English\" default>\n <track kind=\"captions\" src=\"presentation-es.vtt\" srclang=\"es\" label=\"Español\">\n</video>"
4128
+ },
4129
+ "false_positive_risk": "low",
4130
+ "framework_notes": {
4131
+ "react": "In React, include <track> as a child of <video>. React warns if <track> lacks a key prop when rendered in a list. Use the crossOrigin prop (camelCase) if serving captions from a different domain.",
4132
+ "vue": "In Vue, nest <track> inside <video> in the template. For dynamically loaded captions, use :src='captionUrl'. Note that Vue does not reload <track> on src change alone — recreate the video element when switching sources.",
4133
+ "angular": "In Angular, include static or dynamic <track> elements inside the <video> template. For dynamically switching caption languages, use [attr.src] binding and reload the video source programmatically.",
4134
+ "svelte": "In Svelte, <video> elements must have <track kind='captions'> for synchronized captions. Svelte does not warn about missing video captions — add them manually.",
4135
+ "astro": "In .astro files, <video> elements need <track kind='captions'> in the static HTML. For video players inside framework islands, ensure the island component includes caption tracks."
4136
+ },
4137
+ "fix_difficulty_notes": "axe cannot verify whether existing captions are accurate or synchronized — it only detects the presence of a <track kind='captions'> element. A <track> pointing to an empty or inaccurate VTT file technically passes axe but still violates WCAG 1.2.2. Always review caption content manually. For YouTube/Vimeo embeds inside <iframe>, this rule does not apply — the embedded player's caption controls are the platform's responsibility.",
4138
+ "cms_notes": {
4139
+ "shopify": "Shopify's video section renders <video> elements using the native HTML5 player. Dawn does not add <track> elements by default. Add a section schema setting for a caption file URL and include <track kind='captions' src='{{ section.settings.caption_src }}' srclang='en' label='English' default> in the section template when the setting is non-empty.",
4140
+ "wordpress": "WordPress's Video block renders a native <video> element. The block editor does not natively support <track> — use a plugin like Able Player or Video.js WordPress Plugin for accessible captioning. For self-hosted videos, add a 'Captions' field to the media attachment and render it in the block template.",
4141
+ "drupal": "Drupal's Media module for local video does not automatically add <track> elements. Use the Video.js player integration (videojs_media module) which supports WebVTT caption tracks via the Media source configuration. Alternatively, add <track> manually in the video--*.html.twig template using a file field on the media entity."
4142
+ },
4143
+ "related_rules": [
4144
+ {
4145
+ "id": "audio-caption",
4146
+ "reason": "Both require media captions — fix all media accessibility violations together."
4147
+ },
4148
+ {
4149
+ "id": "no-autoplay-audio",
4150
+ "reason": "Auto-playing video with audio needs captions — address media accessibility holistically."
4151
+ }
4152
+ ],
4153
+ "guardrails_overrides": {
4154
+ "must": [
4155
+ "If visible text exists, preserve label-in-name: accessible name must include the visible label."
4156
+ ],
4157
+ "must_not": [
4158
+ "Do not add or replace aria-label when a valid accessible name already exists (visible text, aria-labelledby, existing aria-label, associated label, or sr-only text)."
4159
+ ],
4160
+ "verify": [
4161
+ "Confirm computed accessible name matches expected spoken phrase."
4162
+ ]
4163
+ }
4164
+ }
4165
+ }
4166
+ }