@ctxr/skill-frontend-excellence 0.1.1

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,396 @@
1
+ # Accessibility Mastery
2
+
3
+ WCAG 2.2 AA is the working bar. AAA where reasonable. Lighthouse + axe + manual keyboard + screen reader is the verification stack. Framework-agnostic.
4
+
5
+ ## The Four POUR Principles (WCAG)
6
+
7
+ 1. **Perceivable**: information must be presentable in ways users can perceive.
8
+ 2. **Operable**: interface components must be operable.
9
+ 3. **Understandable**: information and operation must be understandable.
10
+ 4. **Robust**: content must be robust enough to be interpreted by current and future user agents, including assistive technologies.
11
+
12
+ Every rule below maps to one of these.
13
+
14
+ ## Semantic HTML First
15
+
16
+ ARIA is the polyfill, semantics are the standard. The single biggest accessibility win is using the right element.
17
+
18
+ | Use this | Not this |
19
+ |----------|---------|
20
+ | `<button>` | `<div onclick>` |
21
+ | `<a href>` | `<span onclick>` (for navigation) |
22
+ | `<nav>` | `<div class="nav">` |
23
+ | `<main>` | `<div id="main">` |
24
+ | `<header>` / `<footer>` | `<div class="header">` |
25
+ | `<section>` with heading | bare `<div>` |
26
+ | `<article>` for self-contained content | `<div>` |
27
+ | `<aside>` for tangentially related | `<div class="sidebar">` |
28
+ | `<form>` with `<label>` | `<div>` with floating placeholder |
29
+ | `<input type="email">` | `<input type="text" pattern>` |
30
+ | `<details>`/`<summary>` | div + click handler |
31
+ | `<dialog>` (with HTMLDialogElement API) | div with role="dialog" + custom focus trap |
32
+ | `<table>` for tabular data | div grid |
33
+ | `<ul>`/`<ol>` for lists | div with line breaks |
34
+ | `<time datetime>` | `<span>` |
35
+ | `<address>` for contact info | `<div>` |
36
+
37
+ The native element gives you keyboard, focus, semantics, and screen reader support automatically.
38
+
39
+ ## ARIA Rules (when semantics aren't enough)
40
+
41
+ ### The Five ARIA Rules
42
+
43
+ 1. If you can use a native element, use the native element.
44
+ 2. Don't change native semantics unless necessary (`<button role="link">` is suspicious).
45
+ 3. All interactive ARIA elements must be keyboard-accessible.
46
+ 4. Don't use `role="presentation"` or `aria-hidden="true"` on a focusable element.
47
+ 5. All interactive elements must have an accessible name.
48
+
49
+ ### Common ARIA patterns and their accessible name source
50
+
51
+ | Pattern | Accessible name |
52
+ |---------|----------------|
53
+ | Button with text | The text content |
54
+ | Icon-only button | `aria-label="..."` |
55
+ | Link with text | The text content |
56
+ | Link with image only | `<img alt="...">` or `aria-label` on the link |
57
+ | Input | `<label for="id">` or `aria-labelledby="id"` or (last resort) `aria-label` |
58
+ | Disclosure | Use `aria-expanded="true"` or `aria-expanded="false"` on the trigger |
59
+ | Tab | `role="tab"`, `aria-selected`, `aria-controls` referencing the panel |
60
+ | Modal | `role="dialog"`, `aria-modal="true"`, `aria-labelledby` referencing the title |
61
+ | Tooltip | `aria-describedby` on the trigger pointing to the tooltip |
62
+ | Live region | `aria-live="polite"` (or `assertive` for urgent), `role="status"` for non-interrupting |
63
+ | Alert | `role="alert"` (implies `aria-live="assertive"`) |
64
+ | Listbox | `role="listbox"`, `aria-activedescendant` for current option |
65
+ | Combobox | `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete` |
66
+
67
+ ### `aria-label` vs `aria-labelledby` vs `aria-describedby`
68
+
69
+ - `aria-label`: short string. Use only when no visible text exists.
70
+ - `aria-labelledby`: references existing on-page text by id. Preferred over `aria-label` because it stays in sync with visible UI.
71
+ - `aria-describedby`: supplemental description (helper text, error message). Announced after the label.
72
+
73
+ ## Color and Contrast
74
+
75
+ ### Targets (WCAG AA)
76
+
77
+ | Element | Minimum ratio |
78
+ |---------|--------------|
79
+ | Body text (< 18px regular or < 14px bold) | 4.5:1 |
80
+ | Large text (>= 18px regular or >= 14px bold) | 3:1 |
81
+ | UI components and graphical objects (icons that convey meaning, focus rings, form borders) | 3:1 |
82
+
83
+ WCAG AAA (preferred where reasonable):
84
+
85
+ | Element | Minimum ratio |
86
+ |---------|--------------|
87
+ | Body text | 7:1 |
88
+ | Large text | 4.5:1 |
89
+
90
+ ### Verification
91
+
92
+ - Browser DevTools color picker shows the live ratio for the current foreground vs computed background.
93
+ - Test in light AND dark mode independently. Inverting a palette rarely preserves contrast.
94
+ - Test with `prefers-contrast: more` if your design system supports it.
95
+
96
+ ### Common contrast traps
97
+
98
+ - `text-slate-400` on white background fails 4.5:1.
99
+ - `text-slate-400` on `bg-slate-950` passes; on `bg-slate-900` it's marginal.
100
+ - Placeholder text (`color: gray`) often fails. Treat placeholders as decorative; never put critical info there.
101
+ - Disabled state contrast does not need to meet 4.5:1, but should still be perceivable.
102
+ - Brand colors over photographic backgrounds need a scrim or darkening overlay.
103
+
104
+ ### Color is never the only signal
105
+
106
+ Pair color with text, icon, or pattern. Examples:
107
+
108
+ - Required field: red asterisk + the word "required" in helper text.
109
+ - Error: red border + error icon + error message.
110
+ - Status: colored dot + status word.
111
+ - Chart series: color + pattern/dash + label.
112
+
113
+ ## Keyboard
114
+
115
+ ### Every interactive element must be:
116
+
117
+ 1. **Reachable** by Tab in a logical order.
118
+ 2. **Operable** by Enter, Space, or arrow keys per the WAI-ARIA Authoring Practices.
119
+ 3. **Visible** when focused (focus ring with 3:1 contrast against the surface).
120
+
121
+ ### Tab order
122
+
123
+ - Use natural document order. Avoid `tabindex` > 0 (it overrides natural order and creates surprising flows).
124
+ - `tabindex="0"` makes a non-focusable element focusable.
125
+ - `tabindex="-1"` makes an element programmatically focusable (e.g., for moving focus into a modal) but not in tab order.
126
+
127
+ ### Keyboard shortcuts per pattern
128
+
129
+ | Pattern | Keys |
130
+ |---------|------|
131
+ | Button | Enter or Space |
132
+ | Link | Enter |
133
+ | Checkbox | Space |
134
+ | Radio group | Arrow keys move within group, Tab moves out |
135
+ | Tabs | Arrow keys move between tabs (if focus follows), Tab moves to panel |
136
+ | Combobox | Arrow keys, Enter to select, Esc to close |
137
+ | Menu / menubar | Arrow keys, Enter to activate, Esc to close |
138
+ | Modal | Esc to close, Tab loops within, focus restored on close |
139
+ | Slider | Arrow keys for fine, Page Up/Down for coarse, Home/End for min/max |
140
+ | Tree | Arrow keys, Enter/Space to activate, Right to expand, Left to collapse |
141
+
142
+ Implement what users expect; don't invent new patterns.
143
+
144
+ ### Focus management
145
+
146
+ - On modal open: move focus to the modal (typically the close button or the first form field). Save the previously-focused element. On close, restore focus to it.
147
+ - On route change (SPA): move focus to the main heading or to a `<main tabindex="-1">` so screen readers announce the new page.
148
+ - After form submission with errors: move focus to the first invalid field (or to a summary at the top with anchor links to each error).
149
+ - After form submission success: move focus to the success message and announce it via `role="status"`.
150
+
151
+ ### Focus visibility
152
+
153
+ - Never `outline: none` without a replacement.
154
+ - Use `:focus-visible` (not `:focus`) so mouse users don't see a ring when clicking, but keyboard users always do.
155
+ - Focus ring requirements: 2-4px, contrast 3:1 against both the surface and the resting state of the element.
156
+
157
+ ## Screen Reader
158
+
159
+ ### Test on at least one combination
160
+
161
+ - VoiceOver on macOS / iOS (Cmd+F5 to toggle).
162
+ - NVDA on Windows (free).
163
+ - TalkBack on Android.
164
+ - JAWS on Windows (paid; only test if your audience needs it).
165
+
166
+ ### What to verify
167
+
168
+ 1. **Page structure announced correctly.** Headings, landmarks (`main`, `nav`, `aside`, `footer`), section labels.
169
+ 2. **Reading order matches visual order.** Read the page top-to-bottom with the screen reader; the order should match what you see.
170
+ 3. **Interactive controls have names and roles announced.** "Submit, button" not "Submit, clickable element".
171
+ 4. **State changes are announced.** Adding an item to a cart should announce the new state (live region or focus move).
172
+ 5. **Errors are announced** via `role="alert"` or `aria-live="assertive"` for blocking errors, `aria-live="polite"` for advisories.
173
+
174
+ ### `aria-live` regions
175
+
176
+ - `aria-live="polite"` for non-blocking updates (toast, sync status, autosave). Announced when the SR finishes its current utterance.
177
+ - `aria-live="assertive"` for blocking errors only. Interrupts the SR.
178
+ - `role="status"` is `aria-live="polite"` + `aria-atomic="true"`.
179
+ - `role="alert"` is `aria-live="assertive"` + `aria-atomic="true"`.
180
+ - The element must be present in the DOM at page load for SRs to monitor it. Inserting it later sometimes fails.
181
+
182
+ ## Heading Hierarchy
183
+
184
+ - One `<h1>` per page (the page's primary intent).
185
+ - Sequential descent: H1 -> H2 -> H3. Never skip a level.
186
+ - Headings describe sections; not used for typography.
187
+ - Section landmarks (`<section>`) should be labeled by their heading: `<section aria-labelledby="hero-title"><h2 id="hero-title">...</h2></section>`.
188
+
189
+ ## Forms
190
+
191
+ ### Every input has a programmatic label
192
+
193
+ ```html
194
+ <!-- Best -->
195
+ <label for="email">Email</label>
196
+ <input id="email" type="email" autocomplete="email" required />
197
+
198
+ <!-- Acceptable when wrapping is impractical -->
199
+ <label>Email <input type="email" /></label>
200
+
201
+ <!-- Last resort -->
202
+ <input type="email" aria-label="Email" />
203
+ ```
204
+
205
+ `placeholder` is not a label. Placeholders disappear when the user types and have low contrast by default.
206
+
207
+ ### Input attributes you almost always want
208
+
209
+ - `type` matching the data: `email`, `tel`, `url`, `number`, `search`. This affects mobile keyboards and validation.
210
+ - `autocomplete` per the WHATWG list (`given-name`, `email`, `street-address`, `cc-number`, `one-time-code`, etc.). This is critical for mobile UX and password managers.
211
+ - `inputmode` to override the keyboard without changing semantics: `numeric` for codes, `decimal` for prices.
212
+ - `required`, `min`, `max`, `pattern` for native validation.
213
+ - `aria-invalid="true"` when invalid; `aria-describedby` pointing to the error message.
214
+
215
+ ### Error handling
216
+
217
+ - Show errors inline, near the field.
218
+ - Use `role="alert"` or `aria-live="polite"` so SRs announce them.
219
+ - The error message must say cause AND fix. "Email is required" is OK; "Enter your work email" is better.
220
+ - For multi-error submission, show a summary at the top with anchor links to each invalid field.
221
+ - After submit error, move focus to the first invalid field.
222
+
223
+ ### See [forms.md](forms.md) for the deep dive.
224
+
225
+ ## Images and Media
226
+
227
+ ### Alt text
228
+
229
+ - **Decorative image**: `alt=""` (empty string is required; missing alt is worse).
230
+ - **Functional image** (icon button): describe the action, not the image. `<button><img alt="Close" src="x.svg" /></button>`.
231
+ - **Informational image**: describe what conveys information to the user. Be concise; prefer 8-12 words.
232
+ - **Complex image** (chart, diagram): short alt + long description nearby (`figure` + `figcaption` or `aria-describedby` to a hidden description).
233
+ - **Logo image**: `alt="Brand"` not `alt="Brand logo"` (the word "logo" is redundant).
234
+ - **CSS background images**: should be decorative only. Anything informational should be an `<img>`.
235
+
236
+ ### Video
237
+
238
+ - Provide captions (`<track kind="captions">`) for any spoken content.
239
+ - Provide a transcript for long-form video.
240
+ - Provide audio description for video where visual information is critical.
241
+ - Don't autoplay with sound.
242
+ - Don't autoplay-loop a hero video without a pause control.
243
+
244
+ ### Audio
245
+
246
+ - Provide a transcript.
247
+ - Don't autoplay.
248
+
249
+ ## Motion and Reduced Motion
250
+
251
+ ```css
252
+ @media (prefers-reduced-motion: reduce) {
253
+ *, *::before, *::after {
254
+ animation-duration: 0.01ms !important;
255
+ animation-iteration-count: 1 !important;
256
+ transition-duration: 0.01ms !important;
257
+ scroll-behavior: auto !important;
258
+ }
259
+ }
260
+ ```
261
+
262
+ This is the floor. Better practice: actually remove non-essential animation rather than just shortening it.
263
+
264
+ For essential motion (loading spinner, video playback), keep it but consider:
265
+
266
+ - Whether a static skeleton is sufficient.
267
+ - Whether the user can pause.
268
+
269
+ ## Dynamic Type / Zoom
270
+
271
+ - Use relative units (`rem`, `em`, `%`) for font size, line height, padding. Avoid `px` for type.
272
+ - Test at 200% browser zoom: layout should remain usable, no horizontal scroll, no clipped content.
273
+ - Test with `font-size: 24px` set on `<html>`: components should scale.
274
+ - Don't disable user zoom (`user-scalable=no` and `maximum-scale=1` are forbidden).
275
+
276
+ ## Touch and Hit Targets
277
+
278
+ - Minimum 44x44 CSS pixels (iOS HIG) or 48x48 dp (Material Design).
279
+ - Minimum 8 px spacing between adjacent targets.
280
+ - For small icons, expand the hit area with padding or `::before` extension while keeping the visual size small.
281
+
282
+ ## Language
283
+
284
+ - `<html lang="en">` (or your locale, e.g., `lang="en-US"`, `lang="ja"`).
285
+ - `lang="..."` on inline elements when language changes mid-content: `<span lang="fr">déjà vu</span>`.
286
+ - `dir="rtl"` on `<html>` for right-to-left languages, with logical properties (`margin-inline-start`) so layout adapts.
287
+
288
+ ## Lang, Main, and Skip Link
289
+
290
+ Three structural basics that every route must satisfy. They are cheap to verify and expensive to discover broken in production. The multi-page audit Phase 14 ([audit-workflow.md](audit-workflow.md)) checks all three.
291
+
292
+ - [ ] `<html lang>` is present and valid (e.g., `lang="en"`, `lang="en-US"`, `lang="ja"`).
293
+ - [ ] Exactly one primary `<main>` per route. Nested `<main>` elements break landmark navigation.
294
+ - [ ] A skip link is the first focusable element on the page, hidden until focused, jumps to `<main>`, and does not create viewport overflow while hidden.
295
+
296
+ The skip-link "no overflow while hidden" requirement is the most-skipped of the three. The `position: absolute; left: -9999px` pattern works only when paired with a focus state that brings the link back on-screen without growing the page. Use the standard `.sr-only-focusable` pattern below.
297
+
298
+ ## Skip Links
299
+
300
+ A skip link is the first focusable element on the page, hidden until focused, that jumps to `<main>`.
301
+
302
+ ```html
303
+ <a href="#main" class="sr-only-focusable">Skip to main content</a>
304
+ ...
305
+ <main id="main" tabindex="-1">...</main>
306
+ ```
307
+
308
+ ```css
309
+ .sr-only-focusable {
310
+ position: absolute;
311
+ left: -9999px;
312
+ }
313
+ .sr-only-focusable:focus {
314
+ position: fixed;
315
+ top: 1rem;
316
+ left: 1rem;
317
+ z-index: 1000;
318
+ padding: 0.5rem 1rem;
319
+ background: white;
320
+ color: black;
321
+ border: 2px solid currentColor;
322
+ }
323
+ ```
324
+
325
+ ## Modal and Dialog
326
+
327
+ ### Native `<dialog>` (preferred)
328
+
329
+ ```html
330
+ <dialog id="confirm">
331
+ <h2>Confirm action</h2>
332
+ <p>Are you sure?</p>
333
+ <form method="dialog">
334
+ <button value="cancel">Cancel</button>
335
+ <button value="confirm">Confirm</button>
336
+ </form>
337
+ </dialog>
338
+ <button onclick="document.getElementById('confirm').showModal()">Open</button>
339
+ ```
340
+
341
+ `<dialog>` with `showModal()` provides:
342
+
343
+ - Auto focus management (focus moves into the dialog).
344
+ - Esc to close.
345
+ - Backdrop element you can style (`::backdrop`).
346
+ - Inert background (modal pattern).
347
+
348
+ ### Custom modal
349
+
350
+ If you must build one (rich content, framework-managed), implement:
351
+
352
+ - `role="dialog"` (or `alertdialog` for blocking errors), `aria-modal="true"`.
353
+ - `aria-labelledby` pointing to the title.
354
+ - Focus trap: Tab and Shift+Tab loop within the modal.
355
+ - Esc closes.
356
+ - On open, focus moves to the modal (close button or first field).
357
+ - On close, focus returns to the trigger.
358
+ - Background is `inert` (or has `aria-hidden="true"` and `tabindex="-1"` plus `pointer-events: none`).
359
+
360
+ ## Common Accessibility Mistakes
361
+
362
+ - `<div onClick>` instead of `<button>`. Loses keyboard, focus, semantics.
363
+ - `<a href="#">` for buttons. Use `<button>`.
364
+ - `placeholder` as label. Disappears when typing.
365
+ - `aria-label` on a button that already has visible text. Overrides the visible text.
366
+ - `aria-hidden="true"` on a focusable element. Creates a "ghost" focus.
367
+ - `outline: none` without a replacement. Removes keyboard focus indication.
368
+ - Custom dropdown without arrow-key support.
369
+ - Modal without focus management.
370
+ - Toast that announces every minor event with `aria-live="assertive"`. Use `polite`.
371
+ - Loading spinner without an accessible name (`aria-label="Loading"` on the spinner element).
372
+ - Drag-and-drop without a keyboard alternative.
373
+
374
+ ## Self-Healing for Accessibility
375
+
376
+ After any change, verify:
377
+
378
+ - [ ] Lighthouse Accessibility = 100
379
+ - [ ] axe DevTools shows zero violations
380
+ - [ ] Tab through the full page; everything reachable, no traps
381
+ - [ ] Esc closes any open modal/menu
382
+ - [ ] Screen reader pass on the primary user flow (VO or NVDA)
383
+ - [ ] Light AND dark mode contrast checked separately
384
+ - [ ] 200% browser zoom: layout intact, no horizontal scroll
385
+ - [ ] `prefers-reduced-motion` respected
386
+ - [ ] Forms: every input labeled, errors announced, focus moves to first invalid field on submit
387
+ - [ ] Modals: focus trapped, Esc closes, focus restored on close
388
+ - [ ] Images: alt text present, decorative images have `alt=""`
389
+ - [ ] Headings: one H1, sequential, no skipped levels
390
+ - [ ] Color is never the only signal
391
+
392
+ ## See Also
393
+
394
+ - [forms.md](forms.md) for form-specific accessibility
395
+ - [motion.md](motion.md) for reduced-motion implementation
396
+ - [ui-ux.md](ui-ux.md) for state and interaction patterns