feat: US-011 - Accessibility hardening for career constellation

Fix focusable buttons (pointerEvents 'auto'), sort tab order
(roles reverse-chronological, skills by domain), add skill focus
rings, update aria-label to mention clinical pathway, and trigger
graph highlights on keyboard focus.
This commit is contained in:
2026-02-16 03:12:33 +00:00
parent 622baeb449
commit 408cd9573c
3 changed files with 60 additions and 7 deletions
+1 -1
View File
@@ -212,7 +212,7 @@
"Typecheck passes (npm run typecheck)" "Typecheck passes (npm run typecheck)"
], ],
"priority": 11, "priority": 11,
"passes": false, "passes": true,
"notes": "The accessibility buttons are at lines ~661-705 in the JSX. The critical bug is pointerEvents: 'none' on line 688 — change to 'auto'. Also check the containing div at line 658 which also has pointerEvents: 'none' — the buttons inside should override with 'auto'. The constellationNodes.map ordering determines tab order — consider sorting the nodes array for this specific rendering (roles first sorted by startYear desc, then skills grouped by domain). The focus/blur handlers at lines 692-693 already exist and work with the D3 focus ring. The SVG aria-label at line 629 should be updated." "notes": "The accessibility buttons are at lines ~661-705 in the JSX. The critical bug is pointerEvents: 'none' on line 688 — change to 'auto'. Also check the containing div at line 658 which also has pointerEvents: 'none' — the buttons inside should override with 'auto'. The constellationNodes.map ordering determines tab order — consider sorting the nodes array for this specific rendering (roles first sorted by startYear desc, then skills grouped by domain). The focus/blur handlers at lines 692-693 already exist and work with the D3 focus ring. The SVG aria-label at line 629 should be updated."
}, },
{ {
+20
View File
@@ -76,6 +76,8 @@
- callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there - callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there
- LastConsultationSubsection is defined inline in DashboardLayout.tsx, not a separate file - LastConsultationSubsection is defined inline in DashboardLayout.tsx, not a separate file
- Link lines are `<path>` elements (not `<line>`) using quadratic bezier curves — tick handler sets `d` attr, not x1/y1/x2/y2. CSS transitions handle highlight animations on stroke properties - Link lines are `<path>` elements (not `<line>`) using quadratic bezier curves — tick handler sets `d` attr, not x1/y1/x2/y2. CSS transitions handle highlight animations on stroke properties
- Accessibility buttons are overlaid React `<button>` elements at opacity 0 — container div has pointerEvents 'none', buttons have 'auto'. Tab order is controlled by DOM order (sort the array before .map())
- Focus on an accessibility button should call `highlightGraphRef.current?.(node.id)` to trigger the D3 focus ring and graph highlights — otherwise keyboard users can't see which node they've tabbed to
- Force simulation parameters: role forceX/Y strength ~1.0, skill forceX/Y ~0.18, charge -120 (role) / -55 (skill), link distance 72, collide iterations 2 - Force simulation parameters: role forceX/Y strength ~1.0, skill forceX/Y ~0.18, charge -120 (role) / -55 (skill), link distance 72, collide iterations 2
- Role homeX uses consistent offset (`timelineX + 80 + ROLE_WIDTH/2`), no jitter — roles align vertically - Role homeX uses consistent offset (`timelineX + 80 + ROLE_WIDTH/2`), no jitter — roles align vertically
- Skill homeX pushed right of roles: `skillSpaceStart = roleX + ROLE_WIDTH/2 + 40` ensures skills cluster in the right-side space - Skill homeX pushed right of roles: `skillSpaceStart = roleX + ROLE_WIDTH/2 + 40` ensures skills cluster in the right-side space
@@ -180,3 +182,21 @@
- The `javascript-typescript` skill node exists but has no constellationLinks entries — it appears in the graph as a disconnected node, which is intentional since JS/TS isn't attributed to any specific role - The `javascript-typescript` skill node exists but has no constellationLinks entries — it appears in the graph as a disconnected node, which is intentional since JS/TS isn't attributed to any specific role
- codedEntries arrays in consultations.ts are portfolio-specific shorthand codes, not from the CV — they're part of the clinical metaphor design - codedEntries arrays in consultations.ts are portfolio-specific shorthand codes, not from the CV — they're part of the clinical metaphor design
--- ---
## 2026-02-16 - US-011
- Fixed accessibility button `pointerEvents` from `'none'` to `'auto'` so buttons are actually focusable and clickable
- Sorted accessibility buttons for tab order: roles in reverse-chronological order (Interim Head → Deputy Head → HCD → Pharm Mgr), then skills grouped by domain (technical → clinical → leadership), alphabetically within each domain
- Added focus ring for skill nodes (circle with radius SKILL_RADIUS_ACTIVE + 3) — previously only role nodes had focus rings
- Updated focus ring stroke to use `var(--accent)` instead of hardcoded `#0D6E6E`
- Updated SVG `aria-label` to mention "Clinical pathway constellation" and reverse-chronological order
- Added keyboard focus triggers: when a button receives focus, the corresponding node highlights in the graph and fires `onNodeHover` for bidirectional highlighting
- On blur, highlight reverts to pinned node state (or clears)
- Verified prefers-reduced-motion is already properly respected throughout (no changes needed)
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
- **Learnings for future iterations:**
- The accessibility buttons are React `<button>` elements overlaid on top of the SVG, positioned via `nodeButtonPositions` state — they are invisible (opacity: 0) but focusable
- The containing div has `pointerEvents: 'none'` correctly — only the buttons inside override with `pointerEvents: 'auto'`
- Tab order is determined by DOM order of the buttons, not by any `tabindex` — sorting the `constellationNodes` array before `.map()` controls the tab sequence
- Focus on a button should trigger `highlightGraphRef.current?.(node.id)` to show the D3 focus ring AND highlight connected nodes — without this, keyboard users can't see which node they've tabbed to
- The focus ring useEffect syncs `focusedNodeId` → D3 `.focus-ring` elements; it clears all first then applies to the focused one
---
+39 -6
View File
@@ -374,6 +374,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('pointer-events', 'none') .attr('pointer-events', 'none')
.text(d => d.shortLabel ?? d.label.slice(0, 12)) .text(d => d.shortLabel ?? d.label.slice(0, 12))
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
.attr('class', 'focus-ring')
.attr('r', SKILL_RADIUS_ACTIVE + 3)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
nodeSelection.filter(d => d.type === 'skill') nodeSelection.filter(d => d.type === 'skill')
.append('circle') .append('circle')
.attr('class', 'node-circle') .attr('class', 'node-circle')
@@ -657,7 +665,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
svg.selectAll<SVGGElement, SimNode>('g.node') svg.selectAll<SVGGElement, SimNode>('g.node')
.filter(d => d.id === focusedNodeId) .filter(d => d.id === focusedNodeId)
.select('.focus-ring') .select('.focus-ring')
.attr('stroke', '#0D6E6E') .attr('stroke', 'var(--accent)')
.attr('stroke-width', 2)
} }
}, [focusedNodeId]) }, [focusedNodeId])
@@ -683,7 +692,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
height={dimensions.height} height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`} viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
role="img" role="img"
aria-label="Career constellation showing roles and skills across career timeline" aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
style={{ display: 'block' }} style={{ display: 'block' }}
/> />
@@ -756,7 +765,21 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
{constellationNodes.map(node => { {(() => {
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
const sorted = [...constellationNodes].sort((a, b) => {
if (a.type === 'role' && b.type !== 'role') return -1
if (a.type !== 'role' && b.type === 'role') return 1
if (a.type === 'role' && b.type === 'role') {
return (b.startYear ?? 0) - (a.startYear ?? 0)
}
const da = domainOrder[a.domain ?? 'technical'] ?? 0
const db = domainOrder[b.domain ?? 'technical'] ?? 0
if (da !== db) return da - db
return (a.label ?? '').localeCompare(b.label ?? '')
})
return sorted
})().map(node => {
const yearRange = node.endYear const yearRange = node.endYear
? `${node.startYear}-${node.endYear}` ? `${node.startYear}-${node.endYear}`
: `${node.startYear}-present` : `${node.startYear}-present`
@@ -784,12 +807,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
pointerEvents: 'none', pointerEvents: 'auto',
padding: 0, padding: 0,
opacity: 0, opacity: 0,
}} }}
onFocus={() => setFocusedNodeId(node.id)} onFocus={() => {
onBlur={() => setFocusedNodeId(null)} setFocusedNodeId(node.id)
highlightGraphRef.current?.(node.id)
if (node.type === 'role') {
onNodeHover?.(node.id)
}
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(pinnedNodeId)
onNodeHover?.(pinnedNodeId)
}}
onClick={() => { onClick={() => {
setPinnedNodeId(node.id) setPinnedNodeId(node.id)
if (node.type === 'role') { if (node.type === 'role') {