feat: US-004 - Role node redesign with clinical record pill badges

Role nodes now render as rounded rectangle pills (104x32px) with orgColor
badge styling, connector lines to timeline, and SVG drop shadow effects
on hover/pinned states.
This commit is contained in:
2026-02-16 02:37:16 +00:00
parent 832c904376
commit 46cc22500b
3 changed files with 107 additions and 21 deletions
+1 -1
View File
@@ -77,7 +77,7 @@
"Verify in browser — role nodes appear as labelled pill badges along the timeline"
],
"priority": 4,
"passes": false,
"passes": true,
"notes": "This changes role nodes from <circle> to <rect> with rounded corners. The nodeSelection code that filters d.type === 'role' (lines ~354-380) needs to append 'rect' instead of 'circle'. Position with x = -ROLE_WIDTH/2 and y = -ROLE_HEIGHT/2 so they centre on the force simulation position. The focus-ring can also become a rect. The text element stays largely the same but needs its positioning adjusted (no more dy offset needed if dominant-baseline is middle). The collision force for roles should use a radius roughly equal to Math.max(ROLE_WIDTH, ROLE_HEIGHT)/2 + padding. The connector line should go from the timeline X position to the left edge of the pill node. Use the d3-viz skill for implementation."
},
{
+15
View File
@@ -16,6 +16,8 @@
- Use the d3-viz skill for all D3 rendering stories
- yScale domain reversal automatically flows through all timeline elements (guides, dots, labels, role positions, simulation forces) — no per-element changes needed
- Always use CSS custom properties (var(--border), var(--surface), var(--text-tertiary), etc.) for colours in D3 — never hardcode hex values
- SVG shadows: use <filter> with <feDropShadow> in <defs>, apply to <g> groups via .attr('filter', 'url(#filter-id)'), clear with .attr('filter', null)
- Role nodes are already pill-shaped rects (ROLE_WIDTH=104, ROLE_HEIGHT=32, ROLE_RX=16) with orgColor badge styling — check before re-implementing
## 2026-02-16 - US-001
- Reversed yScale domain from [minYear, maxYear] to [maxYear, minYear] so 2025 appears at top
@@ -55,3 +57,16 @@
- Year ticks as horizontal lines are positioned with x1=timelineX, x2=timelineX+width — they extend right from the timeline axis, not centred on it
- The container div border + borderRadius + overflow:hidden creates a clean framed look for the SVG without needing an SVG-level border
---
## 2026-02-16 - US-004
- Added SVG filter defs for drop shadows: shadow-sm-filter (subtle, for hover/connected) and shadow-md-filter (stronger, for active/pinned)
- Updated applyGraphHighlight to apply shadow filters on role node `<g>` elements during highlight states
- Resting state: no filter; connected role: shadow-sm; active/pinned role: shadow-md with stroke-opacity 1 and stroke-width 1.5
- Note: most of US-004 (pill shape, orgColor styling, connector lines, focus rings, collision detection) was already implemented in prior iterations
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json
- **Learnings for future iterations:**
- SVG drop shadows use `<filter>` with `<feDropShadow>` — apply to the parent `<g>` group, not the individual shape, for proper rendering
- Filter bounds need generous overflow (x/y -20%, width/height 140%+) to avoid clipping the shadow
- When clearing a filter, use `.attr('filter', null)` — not empty string
- The role node pill rendering (rect with rx/ry, orgColor fill at 0.12, border at 0.4) was built incrementally across US-003 and US-004 — check existing code before implementing to avoid duplication
---
+91 -20
View File
@@ -13,9 +13,10 @@ interface CareerConstellationProps {
const MIN_HEIGHT = 400
const MOBILE_FALLBACK_HEIGHT = 360
const ROLE_RADIUS = 30
const ROLE_WIDTH = 104
const ROLE_HEIGHT = 32
const ROLE_RX = 16
const SKILL_RADIUS = 14
const COLLIDE_RADIUS = 36
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
@@ -165,6 +166,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('fill', 'var(--surface)')
.attr('rx', 6)
// SVG filter defs for role node shadows
const defs = svg.append('defs')
const shadowSm = defs.append('filter')
.attr('id', 'shadow-sm-filter')
.attr('x', '-20%').attr('y', '-20%')
.attr('width', '140%').attr('height', '140%')
shadowSm.append('feDropShadow')
.attr('dx', 0).attr('dy', 1)
.attr('stdDeviation', 1.5)
.attr('flood-color', 'rgba(26,43,42,0.08)')
const shadowMd = defs.append('filter')
.attr('id', 'shadow-md-filter')
.attr('x', '-30%').attr('y', '-30%')
.attr('width', '160%').attr('height', '160%')
shadowMd.append('feDropShadow')
.attr('dx', 0).attr('dy', 2)
.attr('stdDeviation', 3)
.attr('flood-color', 'rgba(26,43,42,0.12)')
// Timeline guides and subtle era lanes
const timelineGroup = svg.append('g').attr('class', 'timeline-guides')
@@ -285,6 +307,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
})
const linkGroup = svg.append('g').attr('class', 'links')
const connectorGroup = svg.append('g').attr('class', 'connectors')
const nodeGroup = svg.append('g').attr('class', 'nodes')
const linkSelection = linkGroup.selectAll('line')
@@ -302,32 +325,42 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
.attr('data-node-id', d => d.id)
nodeSelection.filter(d => d.type === 'role')
.append('circle')
.append('rect')
.attr('class', 'focus-ring')
.attr('r', ROLE_RADIUS + 4)
.attr('x', -ROLE_WIDTH / 2 - 3)
.attr('y', -ROLE_HEIGHT / 2 - 3)
.attr('width', ROLE_WIDTH + 6)
.attr('height', ROLE_HEIGHT + 6)
.attr('rx', ROLE_RX + 2)
.attr('fill', 'none')
.attr('stroke', 'transparent')
.attr('stroke-width', 2)
nodeSelection.filter(d => d.type === 'role')
.append('circle')
.append('rect')
.attr('class', 'node-circle')
.attr('r', ROLE_RADIUS)
.attr('fill', d => d.orgColor ?? '#0D6E6E')
.attr('stroke', '#FFFFFF')
.attr('stroke-width', 2)
.attr('x', -ROLE_WIDTH / 2)
.attr('y', -ROLE_HEIGHT / 2)
.attr('width', ROLE_WIDTH)
.attr('height', ROLE_HEIGHT)
.attr('rx', ROLE_RX)
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('fill-opacity', 0.12)
.attr('stroke', d => d.orgColor ?? 'var(--accent)')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
nodeSelection.filter(d => d.type === 'role')
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('fill', '#FFFFFF')
.attr('font-size', '10')
.attr('dominant-baseline', 'central')
.attr('fill', d => d.orgColor ?? 'var(--accent)')
.attr('font-size', '11')
.attr('font-weight', '600')
.attr('font-family', 'var(--font-ui)')
.attr('pointer-events', 'none')
.text(d => d.shortLabel ?? d.label.slice(0, 9))
.text(d => d.shortLabel ?? d.label.slice(0, 12))
nodeSelection.filter(d => d.type === 'skill')
.append('circle')
@@ -352,6 +385,14 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return label.length > 16 ? `${label.slice(0, 15)}` : label
})
const roleConnectors = connectorGroup.selectAll('line.role-connector')
.data(nodes.filter(n => n.type === 'role'))
.join('line')
.attr('class', 'role-connector')
.attr('stroke', 'var(--border)')
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.3)
const connectedMap = new Map<string, Set<string>>()
constellationLinks.forEach(l => {
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
@@ -389,6 +430,11 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const applyGraphHighlight = (activeNodeId: string | null) => {
if (!activeNodeId) {
nodeSelection.style('opacity', '1')
nodeSelection.filter(d => d.type === 'role')
.attr('filter', null)
.select('.node-circle')
.attr('stroke-opacity', 0.4)
.attr('stroke-width', 1)
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', SKILL_RADIUS)
@@ -407,6 +453,20 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return '0.16'
})
nodeSelection.filter(d => d.type === 'role')
.attr('filter', d => {
if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
return null
})
.select('.node-circle')
.attr('stroke-opacity', d => {
if (d.id === activeNodeId) return 1
if (connected.has(d.id)) return 0.7
return 0.4
})
.attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)
nodeSelection.filter(d => d.type === 'skill')
.select('.node-circle')
.attr('r', d => (d.id === activeNodeId || connected.has(d.id)) ? SKILL_RADIUS + 3 : SKILL_RADIUS)
@@ -478,16 +538,20 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return d.homeY
}).strength(d => d.type === 'role' ? 1 : 0.2))
.force('collide', d3.forceCollide<SimNode>(d =>
d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 8
d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS + 8
))
simulationRef.current = simulation
const renderTick = () => {
nodes.forEach(d => {
const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS
d.x = Math.max(r + 6, Math.min(width - r - 6, d.x))
d.y = Math.max(r + 6, Math.min(height - r - 6, d.y))
if (d.type === 'role') {
d.x = Math.max(ROLE_WIDTH / 2 + 6, Math.min(width - ROLE_WIDTH / 2 - 6, d.x))
d.y = Math.max(ROLE_HEIGHT / 2 + 6, Math.min(height - ROLE_HEIGHT / 2 - 6, d.y))
} else {
d.x = Math.max(SKILL_RADIUS + 6, Math.min(width - SKILL_RADIUS - 6, d.x))
d.y = Math.max(SKILL_RADIUS + 6, Math.min(height - SKILL_RADIUS - 6, d.y))
}
})
linkSelection
@@ -498,6 +562,12 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
roleConnectors
.attr('x1', timelineX)
.attr('y1', d => d.y)
.attr('x2', d => d.x - ROLE_WIDTH / 2)
.attr('y2', d => d.y)
const nextNodePositions: Record<string, { x: number; y: number }> = {}
nodes.forEach(node => {
nextNodePositions[node.id] = {
@@ -615,7 +685,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
: `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const buttonSize = node.type === 'role' ? 54 : 34
const buttonWidth = node.type === 'role' ? ROLE_WIDTH : 34
const buttonHeight = node.type === 'role' ? ROLE_HEIGHT : 34
return (
<button
@@ -628,8 +699,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
}
style={{
position: 'absolute',
width: buttonSize,
height: buttonSize,
width: buttonWidth,
height: buttonHeight,
top: `${position.y}px`,
left: `${position.x}px`,
transform: 'translate(-50%, -50%)',