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)"
|
"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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
---
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user