chill-agent 1.1.0 → 1.3.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.
Files changed (40) hide show
  1. package/GUIDE.md +47 -0
  2. package/INSTALL.md +78 -0
  3. package/after-install.md +23 -0
  4. package/antislop.md +764 -0
  5. package/chillslop.md +764 -0
  6. package/commands/chill-audit.toml +2 -0
  7. package/commands/chill-debt.toml +2 -0
  8. package/commands/chill-gain.toml +2 -0
  9. package/commands/chill-help.toml +2 -0
  10. package/commands/chill-review.toml +2 -0
  11. package/commands/chill.toml +2 -0
  12. package/docs/agent-portability.md +62 -0
  13. package/docs/cursor-hooks.md +200 -0
  14. package/gemini-extension.json +6 -0
  15. package/opencode.json +18 -0
  16. package/package.json +17 -2
  17. package/plugin.json +3 -0
  18. package/plugin.yaml +12 -0
  19. package/rules/antislop.md +764 -0
  20. package/rules/chillslop.md +764 -0
  21. package/rules/chillslop.mdc +764 -0
  22. package/skills/chill-audit/SKILL.md +103 -0
  23. package/skills/chill-debt/SKILL.md +44 -0
  24. package/skills/chill-gain/SKILL.md +52 -0
  25. package/skills/chill-help/SKILL.md +76 -0
  26. package/skills/chill-review/SKILL.md +101 -0
  27. package/skills/chill-slop/SKILL.md +218 -40
  28. package/skills/chill-slop/VERSION +1 -0
  29. package/skills/chill-slop-code/SKILL.md +85 -37
  30. package/skills/chill-slop-copywriting/SKILL.md +72 -24
  31. package/skills/chill-slop-human/SKILL.md +64 -15
  32. package/skills/chill-slop-human/contrast-check.py +168 -0
  33. package/skills/chill-slop-human/contrast-mcp.py +121 -0
  34. package/skills/chill-slop-human/references/A11Y-PATTERNS.md +249 -0
  35. package/skills/chill-slop-human/references/WCAG.md +127 -0
  36. package/skills/chill-slop-mobile/SKILL.md +85 -18
  37. package/skills/chill-slop-ui/SKILL.md +106 -33
  38. package/skills/chill-slop-ui/references/CORE-WEB-VITALS.md +181 -0
  39. package/skills/chill-slop-ui/references/WEB-BASELINE.md +28 -0
  40. package/src/auditor.js +16 -0
@@ -1,24 +1,73 @@
1
1
  ---
2
2
  name: chill-slop-human
3
- description: "Ergonomics, Human Factors & Accessibility Standard. Guarantees WCAG AA 4.5:1 contrast, visible focus rings, 44px touch targets, and zero-barrier keyboard navigation."
3
+ description: "Comprehensive Ergonomics, Human Factors & Accessibility Standard. Enforces WCAG AA 4.5:1 contrast, visible focus rings, 44px touch targets, Escape-key dialogs, and accessible screen-reader semantics."
4
4
  license: MIT
5
5
  ---
6
6
 
7
- # ChillSlop Human: Ergonomics & Accessibility
7
+ # ChillSlop Human: Comprehensive Ergonomics & Accessibility Standard
8
8
 
9
- > *"Accessibility is not a feature; it is the baseline of professional craftsmanship."*
9
+ > *"Accessibility is not an optional polish phase. An interface that cannot be used by everyone is simply an unfinished interface."*
10
10
 
11
- ## 1. Contrast & Legibility
12
- - **WCAG AA Minimum Contrast:** Text and interactive elements must maintain at least `4.5:1` contrast ratio against their immediate background (`3:1` for bold text >= 18pt).
13
- - **No Low-Contrast Gray-on-Gray Slop:** Never use `#71717A` text on `#18181B` backgrounds for critical body copy. Reserve muted gray exclusively for secondary metadata labels.
11
+ ---
12
+
13
+ ## 1. Contrast Ratios & Visual Legibility
14
+
15
+ ### WCAG AA Minimum Thresholds
16
+ - **Normal Body Text (< 18pt regular):** Minimum contrast ratio of **4.5:1** against its immediate background.
17
+ - **Large Text (>= 18pt regular or >= 14pt bold):** Minimum contrast ratio of **3:1**.
18
+ - **Interactive UI Components & Hairlines:** Minimum contrast ratio of **3:1** against adjacent surfaces.
19
+
20
+ ### Banned Contrast Sins
21
+ - **No Low-Contrast Gray-on-Gray Slop:** Never place `#71717A` body text on a `#18181B` card surface. Secondary metadata may use muted text only if text size is at least 11px and contrast is verified >= 4.5:1.
22
+ - **No White Text Over Light Gradients:** If using a gradient, text passing over it must maintain 4.5:1 contrast across the *entire* gradient spectrum, not just the dark endpoint.
23
+
24
+ ---
25
+
26
+ ## 2. Keyboard Navigation & Focus Management
27
+
28
+ ### Visible Focus Indicators
29
+ - Every interactive element (`<button>`, `<a>`, `<input>`, `<select>`, `<textarea>`) must display a visible, unclipped focus indicator upon keyboard Tab navigation:
30
+ ```css
31
+ :focus-visible {
32
+ outline: 2px solid #818CF8;
33
+ outline-offset: 2px;
34
+ }
35
+ ```
36
+ - **FORBIDDEN:** Setting `outline: none` or `outline: 0` without immediately supplying a custom focus indicator.
37
+
38
+ ### Modals, Drawers & Focus Trapping
39
+ - **Escape Key Interception:** Any open modal, drawer, dropdown, or overlay dialog MUST close immediately upon pressing the `Escape` key.
40
+ - **Focus Restoration:** Upon closing an overlay, focus must return directly to the trigger button that launched it.
41
+ - **Focus Trap:** When a modal is open, keyboard `Tab` cycles only through interactive elements inside the modal; background page content must be inert (`aria-hidden="true"` or `inert`).
42
+
43
+ ---
44
+
45
+ ## 3. Touch Targets & Tactile Ergonomics
46
+
47
+ - **Minimum Dimensions:** All buttons, toggle switches, links, and icon triggers on touch screens must measure at least **44x44px** (or provide 44px of hit area via padding).
48
+ - **Spacing Between Targets:** Adjacent touch controls must have at least `8px` of separation to prevent accidental mis-taps.
49
+
50
+ ---
51
+
52
+ ## 4. Semantics & Screen Reader Affordances
53
+
54
+ ### Native HTML First
55
+ - Use `<button>` for actions. Use `<a href="...">` for navigation to real URLs.
56
+ - **FORBIDDEN:** `<div onClick={...}>` or `<span onClick={...}>` without `role="button"`, `tabIndex={0}`, and `onKeyDown` handlers.
14
57
 
15
- ## 2. Keyboard & Focus Management
16
- - **Visible Focus Rings:** Every button, input, anchor, and clickable div must render a clear, unclipped focus indicator:
17
- `outline: 2px solid var(--focus-ring); outline-offset: 2px;`
18
- Never set `outline: none` without a high-visibility replacement.
19
- - **Escape Key Interception:** All modals, drawers, and overlay dialogs must close immediately on `Escape` keypress and restore focus to the triggering element.
20
- - **Logical Tab Order:** Ensure the DOM tab order matches visual layout. Never jump focus unexpectedly across the viewport.
58
+ ### Icon Buttons & Accessible Labels
59
+ - Every button containing only an SVG or icon glyph **MUST** have an accessible text label:
60
+ ```html
61
+ <button aria-label="Close dialog">
62
+ <svg aria-hidden="true">...</svg>
63
+ </button>
64
+ ```
65
+ - All decorative images must declare empty `alt=""`. All content images must have concise, descriptive `alt` text.
21
66
 
22
- ## 3. Touch Targets & Affordances
23
- - Interactive touch targets on mobile and touch-enabled devices must measure at least `44x44px` (or `48x48px` for primary controls).
24
- - Every icon button lacking visible text must include an unambiguous `aria-label` attribute (e.g. `<button aria-label="Close dialog">`).
67
+ ### Form Fields
68
+ - Every `<input>`, `<textarea>`, and `<select>` must have a visible `<label>` bound via `id` and `for`:
69
+ ```html
70
+ <label for="session-id">Session ID</label>
71
+ <input id="session-id" type="text" />
72
+ ```
73
+ - Never rely solely on `placeholder="..."` as the field label. Placeholders disappear when typed and fail contrast checks.
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """WCAG 2.x contrast checker, home of the antislop-human contrast checker.
3
+
4
+ Usage:
5
+ python3 contrast-check.py "#FFFFFF" "#777777"
6
+ python3 contrast-check.py FFFFFF 777777
7
+ python3 contrast-check.py --selftest
8
+
9
+ Prints the contrast ratio and a PASS/FAIL verdict for normal text (4.5:1)
10
+ and large text (3:1, 18px+ per antislop R-25). Exit code 0 only when both
11
+ verdicts pass, so scripts can chain on it.
12
+
13
+ --selftest parses the reference table out of the SKILL.md next to this script
14
+ (the skill doc is the source of truth, not a list copied into this script) and
15
+ recomputes every row with the formula, checking the ratio and both verdicts.
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ REFERENCE_HEADER = "| Pairing (text on background) | Ratio | Normal text (4.5) | Large text (3.0) |"
23
+ NAMED_COLORS = {"black": (0, 0, 0), "white": (255, 255, 255)}
24
+
25
+
26
+ def parse_hex(value):
27
+ value = value.strip().lstrip("#")
28
+ if len(value) == 3:
29
+ value = "".join(ch * 2 for ch in value)
30
+ if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
31
+ raise ValueError(f"expected a hex color like #FFFFFF, got {value!r}")
32
+ return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4))
33
+
34
+
35
+ def parse_pairing(pairing):
36
+ """Turn 'White on #333333' or '#555555 on black' into two RGB tuples."""
37
+ colors = []
38
+ for token in re.split(r"\s+on\s+", pairing):
39
+ token = token.strip()
40
+ match = re.search(r"#[0-9A-Fa-f]{3,6}", token)
41
+ if match:
42
+ colors.append(parse_hex(match.group(0)))
43
+ elif token.lower() in NAMED_COLORS:
44
+ colors.append(NAMED_COLORS[token.lower()])
45
+ else:
46
+ raise ValueError(f"cannot parse {token!r} from pairing {pairing!r}")
47
+ if len(colors) != 2:
48
+ raise ValueError(f"expected two colors in pairing {pairing!r}")
49
+ return tuple(colors)
50
+
51
+
52
+ def linearize(channel):
53
+ c = channel / 255.0
54
+ if c <= 0.03928:
55
+ return c / 12.92
56
+ return ((c + 0.055) / 1.055) ** 2.4
57
+
58
+
59
+ def luminance(rgb):
60
+ r, g, b = (linearize(ch) for ch in rgb)
61
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
62
+
63
+
64
+ def contrast_ratio(color_a, color_b):
65
+ lum_a, lum_b = luminance(color_a), luminance(color_b)
66
+ lighter, darker = sorted((lum_a, lum_b), reverse=True)
67
+ return (lighter + 0.05) / (darker + 0.05)
68
+
69
+
70
+ def reference_doc_path():
71
+ here = os.path.dirname(os.path.abspath(__file__))
72
+ candidate = os.path.join(here, "SKILL.md")
73
+ return candidate if os.path.isfile(candidate) else None
74
+
75
+
76
+ def parse_reference_rows(path):
77
+ """Reference table rows from SKILL.md, with the ratio already a float.
78
+
79
+ A row that does not split into four cells is dropped silently, not raised.
80
+ """
81
+ rows = []
82
+ with open(path, encoding="utf-8") as handle:
83
+ in_table = False
84
+ for raw in handle:
85
+ line = raw.rstrip("\n")
86
+ if line.startswith(REFERENCE_HEADER):
87
+ in_table = True
88
+ continue
89
+ if not in_table:
90
+ continue
91
+ if not line.startswith("|"):
92
+ break
93
+ if line.startswith("|---"):
94
+ continue
95
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
96
+ if len(cells) != 4:
97
+ continue
98
+ pairing, ratio, normal, large = cells
99
+ rows.append({
100
+ "pairing": pairing,
101
+ "ratio": float(ratio),
102
+ "normal": normal,
103
+ "large": large,
104
+ })
105
+ return rows
106
+
107
+
108
+ def selftest():
109
+ doc = reference_doc_path()
110
+ if doc is None:
111
+ print("selftest: SKILL.md not found next to the script; cannot check the table")
112
+ return 2
113
+ try:
114
+ rows = parse_reference_rows(doc)
115
+ except (OSError, ValueError) as exc:
116
+ print(f"selftest: could not read the table from SKILL.md: {exc}")
117
+ return 1
118
+ if not rows:
119
+ print("selftest: no reference rows found in SKILL.md")
120
+ return 1
121
+
122
+ failures = 0
123
+ for row in rows:
124
+ color_a, color_b = parse_pairing(row["pairing"])
125
+ # The verdicts read the unrounded ratio, exactly as main() does. Rounding first
126
+ # turns a 4.498:1 pairing into a displayed 4.50 that would pass 4.5:1 on paper.
127
+ raw = contrast_ratio(color_a, color_b)
128
+ computed = round(raw, 2)
129
+ expected_normal = "Pass" if raw >= 4.5 else "Fail"
130
+ expected_large = "Pass" if raw >= 3.0 else "Fail"
131
+ if f"{computed:.2f}" != f"{row['ratio']:.2f}":
132
+ failures += 1
133
+ print(f"selftest: {row['pairing']}: table says {row['ratio']}, the formula says {computed:.2f}")
134
+ if row["normal"] != expected_normal:
135
+ failures += 1
136
+ print(f"selftest: {row['pairing']}: normal text marked {row['normal']}, should be {expected_normal}")
137
+ if row["large"] != expected_large:
138
+ failures += 1
139
+ print(f"selftest: {row['pairing']}: large text marked {row['large']}, should be {expected_large}")
140
+
141
+ if failures:
142
+ return 1
143
+ print(f"selftest: {len(rows)} reference pairs OK")
144
+ return 0
145
+
146
+
147
+ def main(argv):
148
+ if len(argv) == 1 and argv[0] == "--selftest":
149
+ return selftest()
150
+ if len(argv) != 2:
151
+ print("usage: python3 contrast-check.py <hex1> <hex2> | --selftest")
152
+ return 2
153
+ try:
154
+ ratio = contrast_ratio(parse_hex(argv[0]), parse_hex(argv[1]))
155
+ except ValueError as exc:
156
+ print(f"error: {exc}")
157
+ return 2
158
+
159
+ normal = ratio >= 4.5
160
+ large = ratio >= 3.0
161
+ print(f"ratio: {ratio:.2f}:1")
162
+ print(f"normal text (4.5:1): {'PASS' if normal else 'FAIL'}")
163
+ print(f"large text (3.0:1): {'PASS' if large else 'FAIL'}")
164
+ return 0 if normal and large else 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal MCP stdio server exposing the antislop contrast checker as a tool.
3
+
4
+ All logging goes to stderr; stdout is reserved for JSON-RPC responses.
5
+ """
6
+ import json
7
+ import sys
8
+
9
+ PROTOCOL_VERSION = "2024-11-05"
10
+ SERVER_NAME = "antislop-contrast"
11
+ SERVER_VERSION = "3.2.26"
12
+
13
+ TOOLS = [
14
+ {
15
+ "name": "check_contrast",
16
+ "description": "Compute the WCAG contrast ratio between two hex colors and the pass or fail verdict for normal and large text. Use this instead of guessing whether a color pairing meets AA.",
17
+ "inputSchema": {
18
+ "type": "object",
19
+ "properties": {
20
+ "foreground": {"type": "string", "description": "Hex color of the text, for example #777777"},
21
+ "background": {"type": "string", "description": "Hex color of the background, for example #FFFFFF"},
22
+ },
23
+ "required": ["foreground", "background"],
24
+ },
25
+ }
26
+ ]
27
+
28
+
29
+ def _channel(c):
30
+ c = c / 255.0
31
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
32
+
33
+
34
+ def relative_luminance(hex_color):
35
+ h = hex_color.strip().lstrip("#")
36
+ if len(h) == 3:
37
+ h = "".join(ch * 2 for ch in h)
38
+ r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4))
39
+ return 0.2126 * _channel(r) + 0.7152 * _channel(g) + 0.0722 * _channel(b)
40
+
41
+
42
+ def contrast_ratio(fg, bg):
43
+ """The unrounded WCAG ratio. Callers decide what to print, never what to compare."""
44
+ l1, l2 = relative_luminance(fg), relative_luminance(bg)
45
+ lighter, darker = max(l1, l2), min(l1, l2)
46
+ return (lighter + 0.05) / (darker + 0.05)
47
+
48
+
49
+ def check_contrast(fg, bg):
50
+ # The threshold is read from the raw ratio: 4.498:1 fails 4.5:1 even though it
51
+ # displays as 4.50 once rounded, and a rounded verdict would report the opposite.
52
+ ratio = contrast_ratio(fg, bg)
53
+ return {
54
+ "foreground": fg,
55
+ "background": bg,
56
+ "ratio": round(ratio, 2),
57
+ "normal_text": {"threshold": 4.5, "pass": ratio >= 4.5},
58
+ "large_text": {"threshold": 3.0, "pass": ratio >= 3.0},
59
+ }
60
+
61
+
62
+ def _send(msg):
63
+ sys.stdout.write(json.dumps(msg) + "\n")
64
+ sys.stdout.flush()
65
+
66
+
67
+ def _text(content):
68
+ return {"content": [{"type": "text", "text": content}], "isError": False}
69
+
70
+
71
+ def _error(message):
72
+ return {"content": [{"type": "text", "text": message}], "isError": True}
73
+
74
+
75
+ def _reply(msg_id, result):
76
+ _send({"jsonrpc": "2.0", "id": msg_id, "result": result})
77
+
78
+
79
+ def main():
80
+ for line in sys.stdin:
81
+ line = line.strip()
82
+ if not line:
83
+ continue
84
+ try:
85
+ msg = json.loads(line)
86
+ except json.JSONDecodeError:
87
+ continue
88
+
89
+ msg_id = msg.get("id")
90
+ method = msg.get("method")
91
+
92
+ if method == "initialize":
93
+ _reply(msg_id, {
94
+ "protocolVersion": PROTOCOL_VERSION,
95
+ "capabilities": {"tools": {}},
96
+ "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
97
+ })
98
+ elif method == "notifications/initialized":
99
+ pass
100
+ elif method == "ping":
101
+ _reply(msg_id, {})
102
+ elif method == "tools/list":
103
+ _reply(msg_id, {"tools": TOOLS})
104
+ elif method == "tools/call":
105
+ params = msg.get("params") or {}
106
+ name = params.get("name")
107
+ args = params.get("arguments") or {}
108
+ if name == "check_contrast":
109
+ try:
110
+ result = check_contrast(args["foreground"], args["background"])
111
+ _reply(msg_id, _text(json.dumps(result)))
112
+ except Exception as exc:
113
+ _reply(msg_id, _error("check_contrast failed: " + str(exc)))
114
+ else:
115
+ _reply(msg_id, _error("Unknown tool: " + str(name)))
116
+ elif msg_id is not None:
117
+ _reply(msg_id, {})
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
@@ -0,0 +1,249 @@
1
+ # Accessibility Code Patterns
2
+
3
+ > Reference for antislop. Adapted from the MIT-licensed web-quality-skills by Addy Osmani (https://github.com/addyosmani/web-quality-skills); see LICENSE-web-quality-skills in the skill folder.
4
+
5
+ Practical, copy-paste-ready patterns for common accessibility requirements. Each pattern is self-contained and linked from the main [SKILL.md](../SKILL.md).
6
+
7
+ ---
8
+
9
+ ## Pointer cursor base layer
10
+
11
+ Set once, in the base CSS layer, so no component has to remember it (R-39). A CSS reset such as Tailwind preflight sets `button` to `cursor: default`, so this layer comes after the reset.
12
+
13
+ ```css
14
+ a[href], button, summary, select, label[for],
15
+ label:has(> input[type="checkbox"], > input[type="radio"]),
16
+ [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], [role="switch"],
17
+ input[type="checkbox"], input[type="radio"], input[type="file"],
18
+ input[type="submit"], input[type="button"], input[type="reset"], input[type="range"], input[type="color"] {
19
+ cursor: pointer;
20
+ }
21
+ :disabled, [aria-disabled="true"] { cursor: not-allowed; }
22
+ ```
23
+
24
+ Text inputs keep `cursor: text`, and drag handles use `grab` / `grabbing`. An implicit label that wraps a checkbox or radio is included, since clicking it toggles the control.
25
+
26
+ ## Modal focus trap
27
+
28
+ Trap keyboard focus inside a modal dialog so Tab/Shift+Tab cycle through its focusable elements and Escape closes it.
29
+
30
+ ```javascript
31
+ function trapFocus(modal, onClose) {
32
+ const focusable = modal.querySelectorAll(
33
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
34
+ );
35
+ if (focusable.length === 0) return () => {};
36
+
37
+ const first = focusable[0];
38
+ const last = focusable[focusable.length - 1];
39
+
40
+ function onKeydown(e) {
41
+ if (e.key === 'Escape') return onClose();
42
+ if (e.key !== 'Tab') return;
43
+ if (e.shiftKey && document.activeElement === first) {
44
+ e.preventDefault();
45
+ last.focus();
46
+ } else if (!e.shiftKey && document.activeElement === last) {
47
+ e.preventDefault();
48
+ first.focus();
49
+ }
50
+ }
51
+
52
+ modal.addEventListener('keydown', onKeydown);
53
+ first.focus();
54
+ return () => modal.removeEventListener('keydown', onKeydown);
55
+ }
56
+ ```
57
+
58
+ Call the returned function when the dialog closes, or a second open stacks a second listener:
59
+
60
+ ```javascript
61
+ const release = trapFocus(dialog, () => dialog.close());
62
+ dialog.addEventListener('close', release, { once: true });
63
+ ```
64
+
65
+ The native `<dialog>` element handles focus trapping automatically, prefer it when browser support allows.
66
+
67
+ ---
68
+
69
+ ## Skip link
70
+
71
+ Allows keyboard users to bypass repetitive navigation and jump straight to main content.
72
+
73
+ ```html
74
+ <body>
75
+ <a href="#main-content" class="skip-link">Skip to main content</a>
76
+ <header><!-- navigation --></header>
77
+ <main id="main-content" tabindex="-1">
78
+ <!-- main content -->
79
+ </main>
80
+ </body>
81
+ ```
82
+
83
+ ```css
84
+ .skip-link {
85
+ position: absolute;
86
+ top: -40px;
87
+ left: 0;
88
+ background: #000;
89
+ color: #fff;
90
+ padding: 8px 16px;
91
+ z-index: 100;
92
+ }
93
+
94
+ .skip-link:focus {
95
+ top: 0;
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Error handling
102
+
103
+ Announce errors to screen readers and focus the first invalid field on submit.
104
+
105
+ ```html
106
+ <form novalidate>
107
+ <div id="error-summary" tabindex="-1" hidden></div>
108
+ <div class="field" aria-live="polite">
109
+ <label for="email">Email</label>
110
+ <input type="email" id="email"
111
+ aria-invalid="true"
112
+ aria-describedby="email-error">
113
+ <p id="email-error" class="error" role="alert">
114
+ Please enter a valid email address (e.g., name@example.com)
115
+ </p>
116
+ </div>
117
+ </form>
118
+ ```
119
+
120
+ ```javascript
121
+ const form = document.querySelector('form');
122
+ const summary = document.getElementById('error-summary');
123
+
124
+ form.addEventListener('submit', (e) => {
125
+ const invalid = [...form.querySelectorAll('[aria-invalid="true"]')];
126
+ if (invalid.length === 0) return;
127
+
128
+ e.preventDefault();
129
+ summary.textContent =
130
+ `${invalid.length} errors found. Please fix them and try again.`;
131
+ summary.hidden = false;
132
+ invalid[0].focus();
133
+ });
134
+ ```
135
+
136
+ This handler reports what the validation already marked: set `aria-invalid="true"` and the field's error text before submit runs.
137
+
138
+ ---
139
+
140
+ ## Form labels
141
+
142
+ Every input needs an associated label, either explicit (`for`/`id`) or implicit (wrapping `<label>`).
143
+
144
+ ```html
145
+ <!-- ❌ No label association -->
146
+ <input type="email" placeholder="Email">
147
+
148
+ <!-- ✅ Explicit label -->
149
+ <label for="email">Email address</label>
150
+ <input type="email" id="email" name="email"
151
+ autocomplete="email" required>
152
+
153
+ <!-- ✅ Implicit label -->
154
+ <label>
155
+ Email address
156
+ <input type="email" name="email" autocomplete="email" required>
157
+ </label>
158
+
159
+ <!-- ✅ With instructions -->
160
+ <label for="password">Password</label>
161
+ <input type="password" id="password"
162
+ aria-describedby="password-requirements">
163
+ <p id="password-requirements">
164
+ Must be at least 8 characters with one number.
165
+ </p>
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Dragging movements
171
+
172
+ Any action triggered by dragging must offer a single-pointer alternative (WCAG 2.5.7).
173
+
174
+ ```html
175
+ <!-- ❌ Drag-only reorder -->
176
+ <ul class="sortable-list" draggable="true">
177
+ <li>Item 1</li>
178
+ <li>Item 2</li>
179
+ </ul>
180
+
181
+ <!-- ✅ Drag + button alternatives -->
182
+ <ul class="sortable-list">
183
+ <li>
184
+ <span>Item 1</span>
185
+ <button aria-label="Move Item 1 up">↑</button>
186
+ <button aria-label="Move Item 1 down">↓</button>
187
+ </li>
188
+ <li>
189
+ <span>Item 2</span>
190
+ <button aria-label="Move Item 2 up">↑</button>
191
+ <button aria-label="Move Item 2 down">↓</button>
192
+ </li>
193
+ </ul>
194
+ ```
195
+
196
+ Also applies to sliders, map panning, colour pickers, and similar drag-based widgets, always provide an equivalent click/tap or keyboard path.
197
+
198
+ ---
199
+
200
+ ## ARIA tabs
201
+
202
+ Tabs require `role="tablist"`, `role="tab"`, and `role="tabpanel"` with proper `aria-selected`, `aria-controls`, and keyboard support.
203
+
204
+ ```html
205
+ <div role="tablist" aria-label="Product information">
206
+ <button role="tab" id="tab-1" aria-selected="true"
207
+ aria-controls="panel-1">Description</button>
208
+ <button role="tab" id="tab-2" aria-selected="false"
209
+ aria-controls="panel-2" tabindex="-1">Reviews</button>
210
+ </div>
211
+ <div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
212
+ <!-- Panel content -->
213
+ </div>
214
+ <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
215
+ <!-- Panel content -->
216
+ </div>
217
+ ```
218
+
219
+ Arrow keys should move focus between tabs; the active tab receives `tabindex="0"` while inactive tabs use `tabindex="-1"`.
220
+
221
+ ---
222
+
223
+ ## Live regions and notifications
224
+
225
+ Use `aria-live` to announce dynamic content changes to screen readers without moving focus.
226
+
227
+ ```html
228
+ <!-- Status updates (polite, waits for pause in speech) -->
229
+ <div id="polite-announcer" aria-live="polite" aria-atomic="true" class="status">
230
+ <!-- Content updates announced to screen readers -->
231
+ </div>
232
+
233
+ <!-- Urgent alerts (assertive, interrupts) -->
234
+ <div id="assertive-announcer" role="alert" aria-live="assertive">
235
+ <!-- Interrupts current announcement -->
236
+ </div>
237
+ ```
238
+
239
+ ```javascript
240
+ function showNotification(message, type = 'polite') {
241
+ const container = document.getElementById(`${type}-announcer`);
242
+ container.textContent = '';
243
+ requestAnimationFrame(() => {
244
+ container.textContent = message;
245
+ });
246
+ }
247
+ ```
248
+
249
+ Clear the container before writing to ensure the same message triggers a new announcement.