From 52238c5662ebe9049f240e0fc1f477159bf865f9 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 02:42:41 +0000 Subject: [PATCH] feat: US-005 - Skill node redesign with muted default and reveal on interaction --- Ralph/prd.json | 2 +- Ralph/progress.txt | 20 ++++ src/components/CareerConstellation.tsx | 133 +++++++++++++------------ 3 files changed, 91 insertions(+), 64 deletions(-) diff --git a/Ralph/prd.json b/Ralph/prd.json index ff1349e..16a6066 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -98,7 +98,7 @@ "Verify in browser — graph looks clean and quiet at rest, informative on hover" ], "priority": 5, - "passes": false, + "passes": true, "notes": "This modifies the applyGraphHighlight() function (line ~439) and the initial skill node rendering (lines ~382-403). The resting state setup happens when nodes are first created and in the 'no activeNodeId' branch of applyGraphHighlight. The highlighted state logic is in the activeNodeId branch. Key change: skill labels default to opacity 0 (not the current collision-based visibility), and only become visible via applyGraphHighlight when connected. The updateSkillLabelVisibility() function can be simplified or merged into applyGraphHighlight. The SKILL_RADIUS constant should be split into SKILL_RADIUS_DEFAULT (7) and SKILL_RADIUS_ACTIVE (11). Link line styling in the resting branch should use much lower opacity than current 0.45. Use the d3-viz skill for implementation." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 6b679a7..3bd1194 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -69,4 +69,24 @@ - 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 +- Skill nodes use SKILL_RADIUS_DEFAULT (7) for resting state and SKILL_RADIUS_ACTIVE (11) for highlighted state — controlled via applyGraphHighlight, not CSS transitions (SVG `r` doesn't transition via CSS) +- Skill labels default to opacity 0 and are shown/hidden via D3 transitions in applyGraphHighlight — the old updateSkillLabelVisibility collision-based approach was removed +- Link lines use var(--border-light) at opacity 0.08 for resting state — highlighted links use the skill's domain colour from domainColorMap with strength-proportional opacity +--- + +## 2026-02-16 - US-005 +- Replaced SKILL_RADIUS (14) with SKILL_RADIUS_DEFAULT (7) and SKILL_RADIUS_ACTIVE (11) +- Skill nodes now default to small (r=7), low opacity (0.2), no stroke, hidden labels (opacity 0) +- On hover/pin: connected skills grow to r=11, fill-opacity 0.85, labels fade in; unconnected nodes dim to opacity 0.06 +- Link lines default to var(--border-light) at opacity 0.08; highlighted links use domain colour with strength-proportional opacity (0.35-0.65) +- Removed updateSkillLabelVisibility function — label visibility now fully controlled by applyGraphHighlight +- D3 transitions (180ms) used for skill radius and opacity changes, respecting prefers-reduced-motion +- Updated collision force and boundary clamping to use SKILL_RADIUS_ACTIVE +- Skill labels styled: font-geist-mono, 10px, var(--text-secondary) +- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json +- **Learnings for future iterations:** + - SVG `r` attribute cannot be animated via CSS transitions — must use D3 `.transition().duration()` for radius changes + - The applyGraphHighlight function is the single source of truth for all visual states (resting, highlighted, dimmed) — keep all styling logic there, not split between initial rendering and highlight + - D3 transition on a selection that already has a pending transition interrupts it — this is fine for hover interactions where the latest state wins + - domainColorMap hex values are needed for D3 attrs (can't use CSS custom properties for computed color values in stroke/fill of highlighted links) --- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 2849cb9..4c4f3e1 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -16,7 +16,8 @@ const MOBILE_FALLBACK_HEIGHT = 360 const ROLE_WIDTH = 104 const ROLE_HEIGHT = 32 const ROLE_RX = 16 -const SKILL_RADIUS = 14 +const SKILL_RADIUS_DEFAULT = 7 +const SKILL_RADIUS_ACTIVE = 11 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches @@ -313,9 +314,9 @@ const CareerConstellation: React.FC = ({ const linkSelection = linkGroup.selectAll('line') .data(links) .join('line') - .attr('stroke', '#B0C4C0') - .attr('stroke-width', 1.5) - .attr('stroke-opacity', 0.45) + .attr('stroke', 'var(--border-light)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.08) const nodeSelection = nodeGroup.selectAll('g') .data(nodes) @@ -365,21 +366,21 @@ const CareerConstellation: React.FC = ({ nodeSelection.filter(d => d.type === 'skill') .append('circle') .attr('class', 'node-circle') - .attr('r', SKILL_RADIUS) + .attr('r', SKILL_RADIUS_DEFAULT) .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E') - .attr('stroke', '#FFFFFF') - .attr('stroke-width', 1.5) - .attr('fill-opacity', 0.86) + .attr('stroke', 'none') + .attr('fill-opacity', 0.2) nodeSelection.filter(d => d.type === 'skill') .append('text') .attr('class', 'node-label') .attr('text-anchor', 'middle') - .attr('dy', SKILL_RADIUS + 14) - .attr('fill', '#436964') - .attr('font-size', '11') + .attr('dy', SKILL_RADIUS_ACTIVE + 14) + .attr('fill', 'var(--text-secondary)') + .attr('font-size', '10') .attr('font-family', 'var(--font-geist-mono)') .attr('pointer-events', 'none') + .attr('opacity', 0) .text(d => { const label = d.shortLabel ?? d.label return label.length > 16 ? `${label.slice(0, 15)}…` : label @@ -400,58 +401,47 @@ const CareerConstellation: React.FC = ({ connectedMap.get(l.source)!.add(l.target) connectedMap.get(l.target)!.add(l.source) }) - const updateSkillLabelVisibility = (activeNodeId: string | null) => { - const shownPositions: Array<{ x: number; y: number }> = [] - nodeSelection - .filter(n => n.type === 'skill') - .each(function(n) { - const textSel = d3.select(this).select('text.node-label') - const connected = activeNodeId ? connectedMap.get(activeNodeId) : null - const shouldForceShow = Boolean(activeNodeId && (n.id === activeNodeId || connected?.has(n.id))) - - if (shouldForceShow) { - textSel.attr('opacity', 1) - shownPositions.push({ x: n.x, y: n.y + SKILL_RADIUS + 14 }) - return - } - - const x = n.x - const y = n.y + SKILL_RADIUS + 14 - const collides = shownPositions.some(p => Math.abs(p.x - x) < 28 && Math.abs(p.y - y) < 14) - - textSel.attr('opacity', collides ? 0 : 1) - - if (!collides) { - shownPositions.push({ x, y }) - } - }) - } - const applyGraphHighlight = (activeNodeId: string | null) => { + const dur = prefersReducedMotion ? 0 : 180 + 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) + + const skillNodes = nodeSelection.filter(d => d.type === 'skill') + if (dur > 0) { + skillNodes.select('.node-circle') + .transition().duration(dur) + .attr('r', SKILL_RADIUS_DEFAULT) + .attr('fill-opacity', 0.2) + skillNodes.select('.node-label') + .transition().duration(dur) + .attr('opacity', 0) + } else { + skillNodes.select('.node-circle') + .attr('r', SKILL_RADIUS_DEFAULT) + .attr('fill-opacity', 0.2) + skillNodes.select('.node-label') + .attr('opacity', 0) + } + linkSelection - .attr('stroke', '#B0C4C0') - .attr('stroke-width', 1.5) - .attr('stroke-opacity', 0.45) - updateSkillLabelVisibility(null) + .attr('stroke', 'var(--border-light)') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.08) + return } const connected = connectedMap.get(activeNodeId) ?? new Set() + const isInGroup = (id: string) => id === activeNodeId || connected.has(id) - nodeSelection.style('opacity', d => { - if (d.id === activeNodeId || connected.has(d.id)) return '1' - return '0.16' - }) + nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.06') nodeSelection.filter(d => d.type === 'role') .attr('filter', d => { @@ -467,31 +457,48 @@ const CareerConstellation: React.FC = ({ }) .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) + const skillNodes = nodeSelection.filter(d => d.type === 'skill') + if (dur > 0) { + skillNodes.select('.node-circle') + .transition().duration(dur) + .attr('r', d => isInGroup(d.id) ? SKILL_RADIUS_ACTIVE : SKILL_RADIUS_DEFAULT) + .attr('fill-opacity', d => isInGroup(d.id) ? 0.85 : 0.2) + skillNodes.select('.node-label') + .transition().duration(dur) + .attr('opacity', d => isInGroup(d.id) ? 1 : 0) + } else { + skillNodes.select('.node-circle') + .attr('r', d => isInGroup(d.id) ? SKILL_RADIUS_ACTIVE : SKILL_RADIUS_DEFAULT) + .attr('fill-opacity', d => isInGroup(d.id) ? 0.85 : 0.2) + skillNodes.select('.node-label') + .attr('opacity', d => isInGroup(d.id) ? 1 : 0) + } linkSelection .attr('stroke', l => { const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id - if (src === activeNodeId || tgt === activeNodeId) return '#0D6E6E' - return '#B0C4C0' + if (src === activeNodeId || tgt === activeNodeId) { + const skillId = src === activeNodeId ? tgt : src + const skillNode = nodes.find(n => n.id === skillId) + return domainColorMap[skillNode?.domain ?? 'technical'] ?? '#0D6E6E' + } + return 'var(--border-light)' }) .attr('stroke-opacity', l => { const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id - if (src === activeNodeId || tgt === activeNodeId) return 0.76 - return 0.1 + if (src === activeNodeId || tgt === activeNodeId) { + return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2)) + } + return 0.08 }) .attr('stroke-width', l => { const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id - if (src === activeNodeId || tgt === activeNodeId) return 2.5 - return 1.5 + if (src === activeNodeId || tgt === activeNodeId) return 1.5 + return 1 }) - - updateSkillLabelVisibility(activeNodeId) } highlightGraphRef.current = applyGraphHighlight @@ -538,7 +545,7 @@ const CareerConstellation: React.FC = ({ return d.homeY }).strength(d => d.type === 'role' ? 1 : 0.2)) .force('collide', d3.forceCollide(d => - d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS + 8 + d.type === 'role' ? Math.max(ROLE_WIDTH, ROLE_HEIGHT) / 2 + 8 : SKILL_RADIUS_ACTIVE + 10 )) simulationRef.current = simulation @@ -549,8 +556,8 @@ const CareerConstellation: React.FC = ({ 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)) + d.x = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(width - SKILL_RADIUS_ACTIVE - 6, d.x)) + d.y = Math.max(SKILL_RADIUS_ACTIVE + 6, Math.min(height - SKILL_RADIUS_ACTIVE - 6, d.y)) } })