From 47b52b5a93d3bfce3aa2dcb870f2b339433f6c39 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Tue, 17 Feb 2026 14:17:21 +0000 Subject: [PATCH] feat: add global focus mode with cross-component dimming on hover When hovering a constellation node, skill pill, or timeline item, non-related UI elements across all components dim to 0.25 opacity, creating a focused visual relationship view. The constellation axis and year labels also dim via CSS class. Respects reduced-motion. --- src/components/DashboardLayout.tsx | 58 +++++++++++++++++-- src/components/ExpandableCardShell.tsx | 6 ++ src/components/LastConsultationCard.tsx | 7 ++- .../RepeatMedicationsSubsection.tsx | 13 ++++- .../TimelineInterventionsSubsection.tsx | 7 ++- .../constellation/CareerConstellation.tsx | 3 + src/hooks/useConstellationInteraction.ts | 4 +- src/index.css | 31 ++++++++++ 8 files changed, 114 insertions(+), 15 deletions(-) diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index 9364a51..7bc1fe0 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -12,8 +12,9 @@ import { LastConsultationCard } from './LastConsultationCard' import { ChatWidget } from './ChatWidget' import { useActiveSection } from '@/hooks/useActiveSection' import { useDetailPanel } from '@/contexts/DetailPanelContext' -import { timelineConsultations } from '@/data/timeline' +import { timelineConsultations, timelineEntities } from '@/data/timeline' import { skills } from '@/data/skills' +import { constellationNodes } from '@/data/constellation' import type { PaletteAction } from '@/lib/search' import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils' @@ -49,6 +50,47 @@ export function DashboardLayout() { [], ) + // Global focus mode: tracks which entity (skill or role) is being hovered across all components + const [globalFocusId, setGlobalFocusId] = useState(null) + + // Build lookup maps for resolving relationships between skills and roles + const nodeTypeById = useMemo( + () => new Map(constellationNodes.map(n => [n.id, n.type])), + [], + ) + const skillToRoles = useMemo(() => { + const map = new Map>() + for (const entity of timelineEntities) { + for (const skillId of entity.skills) { + if (!map.has(skillId)) map.set(skillId, new Set()) + map.get(skillId)!.add(entity.id) + } + } + return map + }, []) + const roleToSkills = useMemo( + () => new Map(timelineEntities.map(e => [e.id, new Set(e.skills)])), + [], + ) + + // Derive the set of all IDs related to the focused entity + const focusRelatedIds = useMemo(() => { + if (!globalFocusId) return null + const related = new Set() + related.add(globalFocusId) + const nodeType = nodeTypeById.get(globalFocusId) + if (nodeType === 'skill') { + // Skill focused: related roles are those containing this skill + const roles = skillToRoles.get(globalFocusId) + if (roles) roles.forEach(r => related.add(r)) + } else { + // Role/education focused: related skills are that entity's skills + const entitySkills = roleToSkills.get(globalFocusId) + if (entitySkills) entitySkills.forEach(s => related.add(s)) + } + return related + }, [globalFocusId, nodeTypeById, skillToRoles, roleToSkills]) + // Signal constellation animation readiness when patient summary scrolls out of view useEffect(() => { const el = patientSummaryRef.current @@ -115,11 +157,14 @@ export function DashboardLayout() { const handleNodeHighlight = useCallback((id: string | null) => { setHighlightedNodeId(id) + setGlobalFocusId(id) }, []) const handleNodeHover = useCallback((id: string | null) => { - setHighlightedRoleId(id) - }, []) + const nodeType = id ? nodeTypeById.get(id) : null + setHighlightedRoleId(nodeType !== 'skill' ? id : null) + setGlobalFocusId(id) + }, [nodeTypeById]) // Global Ctrl+K listener to open command palette useEffect(() => { @@ -243,11 +288,11 @@ export function DashboardLayout() {
- +
- +
@@ -258,6 +303,7 @@ export function DashboardLayout() { highlightedNodeId={highlightedNodeId} containerHeight={chronologyHeight} animationReady={constellationReady} + globalFocusActive={globalFocusId !== null} />
@@ -265,7 +311,7 @@ export function DashboardLayout() {
- +
diff --git a/src/components/ExpandableCardShell.tsx b/src/components/ExpandableCardShell.tsx index b58cbc8..e29b76a 100644 --- a/src/components/ExpandableCardShell.tsx +++ b/src/components/ExpandableCardShell.tsx @@ -6,6 +6,7 @@ import { hexToRgba, motionSafeTransition } from '@/lib/utils' interface ExpandableCardShellProps { isExpanded: boolean isHighlighted: boolean + isDimmedByFocus?: boolean accentColor: string onToggle: () => void ariaLabel: string @@ -21,6 +22,7 @@ interface ExpandableCardShellProps { export function ExpandableCardShell({ isExpanded, isHighlighted, + isDimmedByFocus = false, accentColor, onToggle, ariaLabel, @@ -52,6 +54,10 @@ export function ExpandableCardShell({ className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + style={{ + opacity: isDimmedByFocus ? 0.25 : 1, + transition: 'opacity 150ms ease-out', + }} >
| null } -export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) { +export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: LastConsultationCardProps) { const { openPanel } = useDetailPanel() const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0] if (!consultation) { return null } const isHighlighted = highlightedRoleId === consultation.id + const isDimmed = focusRelatedIds != null && !focusRelatedIds.has(consultation.id) const handleOpenPanel = () => { openPanel({ type: 'consultation', consultation }) @@ -67,9 +69,10 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard border: '1px solid', borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent', background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'transparent', - transition: 'border-color 150ms ease-out, background-color 150ms ease-out', + transition: 'border-color 150ms ease-out, background-color 150ms ease-out, opacity 150ms ease-out', padding: '8px', margin: '-8px', + opacity: isDimmed ? 0.25 : 1, }} > diff --git a/src/components/RepeatMedicationsSubsection.tsx b/src/components/RepeatMedicationsSubsection.tsx index 9e3410b..93aa4eb 100644 --- a/src/components/RepeatMedicationsSubsection.tsx +++ b/src/components/RepeatMedicationsSubsection.tsx @@ -26,9 +26,10 @@ interface SkillRowProps { yearsSuffix: string onClick: () => void onHighlight?: (id: string | null) => void + isDimmedByFocus?: boolean } -function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) { +function SkillRow({ skill, yearsSuffix, onClick, onHighlight, isDimmedByFocus = false }: SkillRowProps) { const IconComponent = iconMap[skill.icon] const handleKeyDown = (e: React.KeyboardEvent) => { @@ -55,7 +56,8 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) { borderRadius: 'var(--radius-sm)', border: '1px solid var(--border-light)', cursor: 'pointer', - transition: 'border-color 0.15s, box-shadow 0.15s', + transition: 'border-color 0.15s, box-shadow 0.15s, opacity 150ms ease-out', + opacity: isDimmedByFocus ? 0.25 : 1, }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent-border)' @@ -134,6 +136,7 @@ interface CategorySectionProps { onSkillClick: (skill: SkillMedication) => void isFirst: boolean onNodeHighlight?: (id: string | null) => void + focusRelatedIds?: Set | null } function CategorySection({ @@ -144,6 +147,7 @@ function CategorySection({ onSkillClick, isFirst, onNodeHighlight, + focusRelatedIds, }: CategorySectionProps) { return (
@@ -193,6 +197,7 @@ function CategorySection({ yearsSuffix={yearsSuffix} onClick={() => onSkillClick(skill)} onHighlight={onNodeHighlight} + isDimmedByFocus={focusRelatedIds != null && !focusRelatedIds.has(skill.id)} /> ))}
@@ -202,9 +207,10 @@ function CategorySection({ interface RepeatMedicationsSubsectionProps { onNodeHighlight?: (id: string | null) => void + focusRelatedIds?: Set | null } -export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicationsSubsectionProps) { +export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) { const { openPanel } = useDetailPanel() const skillsCopy = getSkillsUICopy() @@ -238,6 +244,7 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio onSkillClick={handleSkillClick} isFirst onNodeHighlight={onNodeHighlight} + focusRelatedIds={focusRelatedIds} /> ))}
diff --git a/src/components/TimelineInterventionsSubsection.tsx b/src/components/TimelineInterventionsSubsection.tsx index afa4f13..90e07dc 100644 --- a/src/components/TimelineInterventionsSubsection.tsx +++ b/src/components/TimelineInterventionsSubsection.tsx @@ -11,6 +11,7 @@ interface TimelineInterventionItemProps { entity: TimelineEntity isExpanded: boolean isHighlightedFromGraph: boolean + isDimmedByFocus: boolean isEducationAnchor: boolean onToggle: () => void onViewFull: () => void @@ -21,6 +22,7 @@ function TimelineInterventionItem({ entity, isExpanded, isHighlightedFromGraph, + isDimmedByFocus, isEducationAnchor, onToggle, onViewFull, @@ -34,6 +36,7 @@ function TimelineInterventionItem({ void highlightedRoleId?: string | null + focusRelatedIds?: Set | null } -export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId }: TimelineInterventionsSubsectionProps) { +export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) { const [expandedId, setExpandedId] = useState(null) const { openPanel } = useDetailPanel() @@ -288,6 +292,7 @@ export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRo entity={entity} isExpanded={expandedId === entity.id} isHighlightedFromGraph={highlightedRoleId === entity.id} + isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)} isEducationAnchor={entity.id === firstEducationId} onToggle={() => handleToggle(entity.id)} onViewFull={() => handleViewFull(entity)} diff --git a/src/components/constellation/CareerConstellation.tsx b/src/components/constellation/CareerConstellation.tsx index fd3c308..fa33e73 100644 --- a/src/components/constellation/CareerConstellation.tsx +++ b/src/components/constellation/CareerConstellation.tsx @@ -27,6 +27,7 @@ interface CareerConstellationProps { highlightedNodeId?: string | null containerHeight?: number | null animationReady?: boolean + globalFocusActive?: boolean } const nodeById = new Map(constellationNodes.map(node => [node.id, node])) @@ -39,6 +40,7 @@ const CareerConstellation: React.FC = ({ highlightedNodeId, containerHeight, animationReady = false, + globalFocusActive = false, }) => { const svgRef = useRef(null) const containerRef = useRef(null) @@ -301,6 +303,7 @@ const CareerConstellation: React.FC = ({ viewBox={`0 0 ${dimensions.width} ${dimensions.height}`} role="img" aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline" + className={globalFocusActive || highlightedNodeId || pinnedNodeId ? 'constellation-focus-active' : ''} style={{ display: 'block', width: '100%', diff --git a/src/hooks/useConstellationInteraction.ts b/src/hooks/useConstellationInteraction.ts index 30cb493..f58aacd 100644 --- a/src/hooks/useConstellationInteraction.ts +++ b/src/hooks/useConstellationInteraction.ts @@ -42,9 +42,7 @@ export function useConstellationInteraction(deps: { if (supportsCoarsePointer) return deps.pauseForInteraction?.() deps.highlightGraphRef.current?.(d.id) - if (d.type !== 'skill') { - deps.callbacksRef.current.onNodeHover?.(d.id) - } + deps.callbacksRef.current.onNodeHover?.(d.id) }) nodeSelection.on('mouseleave.interaction', function() { diff --git a/src/index.css b/src/index.css index 1c17549..dffe581 100644 --- a/src/index.css +++ b/src/index.css @@ -494,6 +494,27 @@ html { to { transform: scale(1); opacity: 1; } } +/* ===== CONSTELLATION FOCUS MODE — axis/background dimming ===== */ +svg.constellation-focus-active .axis-line, +svg.constellation-focus-active .year-tick { + stroke-opacity: 0.25; + transition: stroke-opacity 150ms ease-out; +} + +svg.constellation-focus-active .year-label { + opacity: 0.25; + transition: opacity 150ms ease-out; +} + +svg:not(.constellation-focus-active) .axis-line, +svg:not(.constellation-focus-active) .year-tick { + transition: stroke-opacity 150ms ease-out; +} + +svg:not(.constellation-focus-active) .year-label { + transition: opacity 150ms ease-out; +} + /* ===== FOCUS VISIBLE STYLES (WCAG Compliance) ===== */ /* Default focus ring for all focusable elements */ *:focus-visible { @@ -593,6 +614,16 @@ textarea:focus-visible { to { opacity: 1; } } + /* No transition for constellation focus mode axis dimming */ + svg.constellation-focus-active .axis-line, + svg.constellation-focus-active .year-tick, + svg.constellation-focus-active .year-label, + svg:not(.constellation-focus-active) .axis-line, + svg:not(.constellation-focus-active) .year-tick, + svg:not(.constellation-focus-active) .year-label { + transition: none; + } + /* Instant constellation fullscreen */ @keyframes constellation-fullscreen-in { from { transform: none; opacity: 1; }