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.
- package/GUIDE.md +47 -0
- package/INSTALL.md +78 -0
- package/after-install.md +23 -0
- package/antislop.md +764 -0
- package/chillslop.md +764 -0
- package/commands/chill-audit.toml +2 -0
- package/commands/chill-debt.toml +2 -0
- package/commands/chill-gain.toml +2 -0
- package/commands/chill-help.toml +2 -0
- package/commands/chill-review.toml +2 -0
- package/commands/chill.toml +2 -0
- package/docs/agent-portability.md +62 -0
- package/docs/cursor-hooks.md +200 -0
- package/gemini-extension.json +6 -0
- package/opencode.json +18 -0
- package/package.json +17 -2
- package/plugin.json +3 -0
- package/plugin.yaml +12 -0
- package/rules/antislop.md +764 -0
- package/rules/chillslop.md +764 -0
- package/rules/chillslop.mdc +764 -0
- package/skills/chill-audit/SKILL.md +103 -0
- package/skills/chill-debt/SKILL.md +44 -0
- package/skills/chill-gain/SKILL.md +52 -0
- package/skills/chill-help/SKILL.md +76 -0
- package/skills/chill-review/SKILL.md +101 -0
- package/skills/chill-slop/SKILL.md +218 -40
- package/skills/chill-slop/VERSION +1 -0
- package/skills/chill-slop-code/SKILL.md +85 -37
- package/skills/chill-slop-copywriting/SKILL.md +72 -24
- package/skills/chill-slop-human/SKILL.md +64 -15
- package/skills/chill-slop-human/contrast-check.py +168 -0
- package/skills/chill-slop-human/contrast-mcp.py +121 -0
- package/skills/chill-slop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/chill-slop-human/references/WCAG.md +127 -0
- package/skills/chill-slop-mobile/SKILL.md +85 -18
- package/skills/chill-slop-ui/SKILL.md +106 -33
- package/skills/chill-slop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/chill-slop-ui/references/WEB-BASELINE.md +28 -0
- 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.
|
|
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
|
|
9
|
+
> *"Accessibility is not an optional polish phase. An interface that cannot be used by everyone is simply an unfinished interface."*
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
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.
|