From 67fe5567a9473539aab728d97dbc8e924a4c5f9e Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Mon, 16 Feb 2026 09:58:27 +0000 Subject: [PATCH] feat: US-005 - Hover-to-highlight interaction on desktop --- Ralph/prd.json | 2 +- Ralph/progress.txt | 15 +++++++++++++++ src/components/CareerConstellation.tsx | 25 +++++++++++++------------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/Ralph/prd.json b/Ralph/prd.json index e5f0cc9..ceb86b7 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -96,7 +96,7 @@ "Verify in browser: hover on/off roles cycles highlight cleanly with no stuck states" ], "priority": 5, - "passes": false, + "passes": true, "notes": "The interaction handlers are in the D3 useEffect where mouseenter/mouseleave/click are attached to node groups. supportsCoarsePointer is a module-level window.matchMedia('(pointer: coarse)').matches check. For fine pointer (desktop): mouseenter calls applyGraphHighlight(nodeId) + fires onNodeHover(nodeId), mouseleave calls applyGraphHighlight(null) + fires onNodeHover(null). Remove the click handler's pin/unpin toggle for fine pointer. For coarse pointer (touch): keep existing tap-to-pin unchanged. The pinnedNodeId useState remains but only gets set on coarse pointer or keyboard interactions. The callbacksRef pattern prevents stale closures — use it for onNodeHover. The onNodeHover callback propagates to DashboardLayout for bidirectional highlighting (graph→timeline). Use the d3-viz skill." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index f2ff4d3..766b31d 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -95,3 +95,18 @@ - Math.round() wraps all scaled values to avoid sub-pixel rendering artifacts - Accessibility overlay buttons in the React JSX also need scaling — they use base constants directly, not the D3-scoped variables --- + +## 2026-02-16 - US-005 +- Changed mouseenter handler: on desktop (supportsCoarsePointer === false), calls applyGraphHighlight(d.id) + onNodeHover(d.id) for hover-to-highlight +- Changed mouseleave handler: resets to highlightedNodeId ?? null (external timeline state or resting), NOT pinnedNodeId +- Changed click handler: desktop clicks only fire detail callbacks (onRoleClick/onSkillClick), no pin toggle +- Touch (coarse pointer) retains tap-to-pin toggle unchanged inside click handler +- pinnedNodeId state only set/cleared for touch interactions +- Files changed: src/components/CareerConstellation.tsx +- Browser verified: hover on "Interim Head" → 12 connected skills at fill-opacity 0.9, 9 dimmed at opacity 0.15; hover off → all reset to resting (fill-opacity 0.35, label opacity 0.5); desktop click → no pin state +- **Learnings for future iterations:** + - D3 mouseenter/mouseleave events require dispatchEvent() in Playwright headless — native page.hover() on SVG elements doesn't reliably trigger D3 handlers + - Role rect fill-opacity 0.12 IS the resting state (initialized at line 384), not a dimmed state — don't confuse with skill resting at 0.35 + - mouseleave should reset to highlightedNodeId (external prop) not pinnedNodeId — on desktop there is no pin, so fallback is null (resting) + - The supportsCoarsePointer guard at top of each handler cleanly separates desktop/touch paths without duplicating the handler +--- diff --git a/src/components/CareerConstellation.tsx b/src/components/CareerConstellation.tsx index 0e818c2..0c4bf0e 100644 --- a/src/components/CareerConstellation.tsx +++ b/src/components/CareerConstellation.tsx @@ -561,24 +561,25 @@ const CareerConstellation: React.FC = ({ nodeSelection.on('mouseleave', function() { if (supportsCoarsePointer) return - applyGraphHighlight(highlightedNodeId ?? pinnedNodeId) - callbacksRef.current.onNodeHover?.(pinnedNodeId) + applyGraphHighlight(highlightedNodeId ?? null) + callbacksRef.current.onNodeHover?.(null) }) nodeSelection.on('click', function(_event, d) { - if (supportsCoarsePointer && pinnedNodeId !== d.id) { - setPinnedNodeId(d.id) - applyGraphHighlight(d.id) - if (d.type === 'role') { - callbacksRef.current.onNodeHover?.(d.id) + if (supportsCoarsePointer) { + // Touch: tap-to-pin toggle + if (pinnedNodeId === d.id) { + setPinnedNodeId(null) + applyGraphHighlight(null) + callbacksRef.current.onNodeHover?.(null) + } else { + setPinnedNodeId(d.id) + applyGraphHighlight(d.id) + callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : null) } - return } - const newPinned = pinnedNodeId === d.id ? null : d.id - setPinnedNodeId(newPinned) - callbacksRef.current.onNodeHover?.(d.type === 'role' ? newPinned : null) - + // Fire detail callbacks for both desktop and touch if (d.type === 'role') { callbacksRef.current.onRoleClick(d.id) } else {