Skip to content

Commit 6bb210f

Browse files
hyperpolymathclaude
andcommitted
feat: accessibility audit + KeyboardNav composable utility
Audit findings: 91/123 components have ariaLabel (74%), 90 have role attrs, but only 1/123 has keyboard handlers. Critical gap documented. KeyboardNav.res provides composable keyboard patterns: - onActivate (Enter/Space), onEscape, onVerticalNav, onHorizontalNav - onListNav (full dropdown/combobox pattern) - srOnly helper, livePolite/liveAssertive region wrappers - focusable/focusableHidden tabIndex helpers 766 modules compile clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 36667f0 commit 6bb210f

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
2+
<!-- Accessibility Audit — 2026-03-29 -->
3+
4+
# PanLL Accessibility Audit
5+
6+
**Date:** 2026-03-29
7+
**Auditor:** Claude (automated scan) — manual testing with NVDA/Orca still needed
8+
**Scope:** All 123 component files in src/components/, supporting engines, tests
9+
10+
## Summary
11+
12+
| Metric | Count | Rating |
13+
|--------|-------|--------|
14+
| Components with ariaLabel | 91/123 | Good (74%) |
15+
| Components with role attributes | 90/123 | Good (73%) |
16+
| Components with keyboard handlers | 1/123 | Critical gap |
17+
| Components with tabIndex | 2/123 | Critical gap |
18+
| aria-live regions | 7 total | Poor |
19+
| sr-only screen reader text | 1 (skip-links only) | Poor |
20+
| ariaDescribedBy / ariaLabelledBy | 8 total | Poor |
21+
| Colour palettes defined | 4 | Complete |
22+
| Font size presets | 4 (14-20px) | Complete |
23+
| Focus style options | 4 | Complete |
24+
| Accessibility engine tests | 30+ | Complete |
25+
26+
## Architecture (Excellent)
27+
28+
PanLL has dedicated accessibility infrastructure:
29+
- `AccessibilityEngine.res` — theme, palette, animation, font, focus
30+
- `AccessibilityModel.res` — type-safe state
31+
- `AccessibilityToolbar.res` — floating FAB widget
32+
- `FocusDimmingEngine.res` — focus indicator styling
33+
- `KeyboardUtil.res` — keyboard utilities
34+
- `accessibility-baseline.k9.ncl` — K9 validator contract
35+
36+
## Critical Gaps
37+
38+
### 1. Keyboard Navigation (Priority 1)
39+
40+
Only 1 of 123 components (MyLang.res) has keyboard event handlers.
41+
All interactive panels need:
42+
- Enter/Space to activate buttons
43+
- Arrow keys for lists/tabs
44+
- Escape to close overlays
45+
46+
**Remediation:** Add a `KeyboardNav.res` utility module with standard handlers
47+
that components can compose. Target: all 108 panels keyboard-navigable.
48+
49+
### 2. Tab Order (Priority 1)
50+
51+
Only 2 components use tabIndex. Custom interactive elements are invisible
52+
to keyboard users.
53+
54+
**Remediation:** Add `Attrs.tabIndex(0)` to all custom interactive elements.
55+
Add `Attrs.tabIndex(-1)` to programmatically focusable containers.
56+
57+
### 3. Live Regions (Priority 2)
58+
59+
Only 7 aria-live regions across 108+ panels. Status changes, form validation,
60+
and dynamic content updates are silent to screen readers.
61+
62+
**Remediation:** Add `aria-live="polite"` to status bars, notification areas,
63+
and VQL result displays. Add `aria-live="assertive"` to error messages.
64+
65+
### 4. Screen Reader Text (Priority 2)
66+
67+
Only 1 sr-only instance (skip-links). Icon-only buttons, data visualizations,
68+
and status indicators lack text alternatives.
69+
70+
**Remediation:** Add sr-only labels to icon buttons and decorative elements
71+
that carry meaning.
72+
73+
### 5. Complex Relationships (Priority 3)
74+
75+
Only 8 ariaDescribedBy/ariaLabelledBy instances. Form fields, error messages,
76+
and complex controls need semantic linking.
77+
78+
## What's Already Working
79+
80+
- 4 colour palettes (Standard, Deuteranopia, Protanopia, High Contrast)
81+
- Scalable font sizes (14-20px presets, rem-based)
82+
- Reduced motion detection and respect
83+
- Focus indicator options (Default, High Contrast, Thick, Dotted)
84+
- OS theme preference detection (System mode)
85+
- Floating accessibility toolbar (non-intrusive FAB)
86+
- 30+ engine-level tests for preference persistence
87+
88+
## DD-008 Compliance
89+
90+
| Level | Criteria | Status |
91+
|-------|----------|--------|
92+
| Baseline | ariaLabel + role per component | 90/123 (73%) |
93+
| Full | Baseline + keyboard + tabIndex | ~3/123 (~2%) |
94+
| Target | Full + aria-live + sr-only | 0% |
95+
96+
## Next Steps
97+
98+
1. Create `KeyboardNav.res` utility with composable keyboard handlers
99+
2. Add tabIndex to all custom interactive elements
100+
3. Add aria-live regions to status-changing areas
101+
4. Add sr-only labels to icon-only buttons
102+
5. Manual testing with NVDA/Orca (requires Jonathan)
103+
6. Update K9 validator to enforce full compliance

src/core/KeyboardNav.res

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
3+
/// PanLL KeyboardNav — composable keyboard navigation handlers for panels.
4+
///
5+
/// Provides standard keyboard interaction patterns that components can compose
6+
/// into their event attributes. Follows WAI-ARIA Authoring Practices:
7+
/// - Enter/Space activates buttons
8+
/// - Arrow keys navigate lists and tab bars
9+
/// - Escape closes overlays/modals
10+
/// - Home/End jump to first/last item
11+
///
12+
/// Usage in components:
13+
/// button(list{
14+
/// Events.onClick(MyAction),
15+
/// KeyboardNav.onActivate(MyAction), // Enter + Space
16+
/// Attrs.tabIndex(0),
17+
/// }, list{text("Click me")})
18+
///
19+
/// All functions are pure — they return event attributes, not side effects.
20+
21+
open Tea_Vdom
22+
open Tea_Html
23+
24+
/// Key string constants for readability.
25+
module Key = {
26+
let enter = "Enter"
27+
let space = " "
28+
let escape = "Escape"
29+
let arrowUp = "ArrowUp"
30+
let arrowDown = "ArrowDown"
31+
let arrowLeft = "ArrowLeft"
32+
let arrowRight = "ArrowRight"
33+
let home = "Home"
34+
let end_ = "End"
35+
let tab = "Tab"
36+
}
37+
38+
/// Dispatch a message when Enter or Space is pressed (button activation pattern).
39+
let onActivate = (msg: 'msg): attribute<'msg> =>
40+
Events.onKeyDown(key =>
41+
if key === Key.enter || key === Key.space {
42+
Some(msg)
43+
} else {
44+
None
45+
}
46+
)
47+
48+
/// Dispatch a message when Escape is pressed (close/dismiss pattern).
49+
let onEscape = (msg: 'msg): attribute<'msg> =>
50+
Events.onKeyDown(key =>
51+
if key === Key.escape {
52+
Some(msg)
53+
} else {
54+
None
55+
}
56+
)
57+
58+
/// Dispatch messages for vertical arrow navigation (list/menu pattern).
59+
let onVerticalNav = (~onUp: 'msg, ~onDown: 'msg): attribute<'msg> =>
60+
Events.onKeyDown(key =>
61+
if key === Key.arrowUp {
62+
Some(onUp)
63+
} else if key === Key.arrowDown {
64+
Some(onDown)
65+
} else {
66+
None
67+
}
68+
)
69+
70+
/// Dispatch messages for horizontal arrow navigation (tab bar pattern).
71+
let onHorizontalNav = (~onLeft: 'msg, ~onRight: 'msg): attribute<'msg> =>
72+
Events.onKeyDown(key =>
73+
if key === Key.arrowLeft {
74+
Some(onLeft)
75+
} else if key === Key.arrowRight {
76+
Some(onRight)
77+
} else {
78+
None
79+
}
80+
)
81+
82+
/// Dispatch messages for Home/End navigation (jump to first/last).
83+
let onHomeEnd = (~onHome: 'msg, ~onEnd: 'msg): attribute<'msg> =>
84+
Events.onKeyDown(key =>
85+
if key === Key.home {
86+
Some(onHome)
87+
} else if key === Key.end_ {
88+
Some(onEnd)
89+
} else {
90+
None
91+
}
92+
)
93+
94+
/// Combined list navigation: ArrowUp/Down + Home/End + Enter to select + Escape to dismiss.
95+
/// Useful for dropdown menus, comboboxes, listboxes.
96+
let onListNav = (
97+
~onUp: 'msg,
98+
~onDown: 'msg,
99+
~onHome: 'msg,
100+
~onEnd: 'msg,
101+
~onSelect: 'msg,
102+
~onDismiss: 'msg,
103+
): attribute<'msg> =>
104+
Events.onKeyDown(key =>
105+
if key === Key.arrowUp {
106+
Some(onUp)
107+
} else if key === Key.arrowDown {
108+
Some(onDown)
109+
} else if key === Key.home {
110+
Some(onHome)
111+
} else if key === Key.end_ {
112+
Some(onEnd)
113+
} else if key === Key.enter || key === Key.space {
114+
Some(onSelect)
115+
} else if key === Key.escape {
116+
Some(onDismiss)
117+
} else {
118+
None
119+
}
120+
)
121+
122+
/// Helper: make a focusable div container (tabIndex 0).
123+
let focusable: attribute<'msg> = Attrs.tabIndex(0)
124+
125+
/// Helper: programmatically focusable but not in tab order (tabIndex -1).
126+
let focusableHidden: attribute<'msg> = Attrs.tabIndex(-1)
127+
128+
/// Helper: screen reader only text (visually hidden but announced).
129+
let srOnly = (label: string): Tea_Vdom.t<'msg> =>
130+
span(
131+
list{Attrs.class_("sr-only")},
132+
list{text(label)},
133+
)
134+
135+
/// Helper: aria-live polite region wrapper (for status updates).
136+
let livePolite = (children: list<Tea_Vdom.t<'msg>>): Tea_Vdom.t<'msg> =>
137+
div(list{Attrs.ariaLive("polite")}, children)
138+
139+
/// Helper: aria-live assertive region wrapper (for errors/alerts).
140+
let liveAssertive = (children: list<Tea_Vdom.t<'msg>>): Tea_Vdom.t<'msg> =>
141+
div(list{Attrs.ariaLive("assertive")}, children)

0 commit comments

Comments
 (0)