From 46cc22500bf5eb9e7753538416cd6c9fa6dfdc2f Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 02:37:16 +0000 Subject: [PATCH] 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. --- Ralph/prd.json | 2 +- Ralph/progress.txt | 15 ++++ src/components/CareerConstellation.tsx | 111 ++++++++++++++++++++----- 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/Ralph/prd.json b/Ralph/prd.json index d8abddf..ff1349e 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -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 to 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." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index a5cdbb1..6b679a7 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -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 with in , apply to 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 `` 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 `` with `` — apply to the parent `` 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 +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 5f4f12d..2849cb9 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -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 = ({ .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 = ({ }) 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 = ({ .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 = ({ 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>() constellationLinks.forEach(l => { if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set()) @@ -389,6 +430,11 @@ const CareerConstellation: React.FC = ({ 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 = ({ 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 = ({ return d.homeY }).strength(d => d.type === 'role' ? 1 : 0.2)) .force('collide', d3.forceCollide(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 = ({ 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 = {} nodes.forEach(node => { nextNodePositions[node.id] = { @@ -615,7 +685,8 @@ const CareerConstellation: React.FC = ({ : `${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 (