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:
+1
-1
@@ -212,7 +212,7 @@
|
||||
"Typecheck passes (npm run typecheck)"
|
||||
],
|
||||
"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."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
- callbacksRef pattern in CareerConstellation prevents stale closures — always add new callbacks there
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
@@ -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
|
||||
- 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
|
||||
---
|
||||
|
||||
@@ -374,6 +374,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
.attr('pointer-events', 'none')
|
||||
.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')
|
||||
.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
@@ -657,7 +665,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
svg.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.filter(d => d.id === focusedNodeId)
|
||||
.select('.focus-ring')
|
||||
.attr('stroke', '#0D6E6E')
|
||||
.attr('stroke', 'var(--accent)')
|
||||
.attr('stroke-width', 2)
|
||||
}
|
||||
}, [focusedNodeId])
|
||||
|
||||
@@ -683,7 +692,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
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' }}
|
||||
/>
|
||||
|
||||
@@ -756,7 +765,21 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
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
|
||||
? `${node.startYear}-${node.endYear}`
|
||||
: `${node.startYear}-present`
|
||||
@@ -784,12 +807,22 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'none',
|
||||
pointerEvents: 'auto',
|
||||
padding: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
onFocus={() => setFocusedNodeId(node.id)}
|
||||
onBlur={() => setFocusedNodeId(null)}
|
||||
onFocus={() => {
|
||||
setFocusedNodeId(node.id)
|
||||
highlightGraphRef.current?.(node.id)
|
||||
if (node.type === 'role') {
|
||||
onNodeHover?.(node.id)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedNodeId(null)
|
||||
highlightGraphRef.current?.(pinnedNodeId)
|
||||
onNodeHover?.(pinnedNodeId)
|
||||
}}
|
||||
onClick={() => {
|
||||
setPinnedNodeId(node.id)
|
||||
if (node.type === 'role') {
|
||||
|
||||
Reference in New Issue
Block a user