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.
This commit is contained in:
2026-02-17 14:17:21 +00:00
parent 82db5fda54
commit 47b52b5a93
8 changed files with 114 additions and 15 deletions
+52 -6
View File
@@ -12,8 +12,9 @@ import { LastConsultationCard } from './LastConsultationCard'
import { ChatWidget } from './ChatWidget' import { ChatWidget } from './ChatWidget'
import { useActiveSection } from '@/hooks/useActiveSection' import { useActiveSection } from '@/hooks/useActiveSection'
import { useDetailPanel } from '@/contexts/DetailPanelContext' import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineConsultations } from '@/data/timeline' import { timelineConsultations, timelineEntities } from '@/data/timeline'
import { skills } from '@/data/skills' import { skills } from '@/data/skills'
import { constellationNodes } from '@/data/constellation'
import type { PaletteAction } from '@/lib/search' import type { PaletteAction } from '@/lib/search'
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils' 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<string | null>(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<string, Set<string>>()
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<string>()
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 // Signal constellation animation readiness when patient summary scrolls out of view
useEffect(() => { useEffect(() => {
const el = patientSummaryRef.current const el = patientSummaryRef.current
@@ -115,11 +157,14 @@ export function DashboardLayout() {
const handleNodeHighlight = useCallback((id: string | null) => { const handleNodeHighlight = useCallback((id: string | null) => {
setHighlightedNodeId(id) setHighlightedNodeId(id)
setGlobalFocusId(id)
}, []) }, [])
const handleNodeHover = useCallback((id: string | null) => { 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 // Global Ctrl+K listener to open command palette
useEffect(() => { useEffect(() => {
@@ -243,11 +288,11 @@ export function DashboardLayout() {
<div className="chronology-item"> <div className="chronology-item">
<LastConsultationCard highlightedRoleId={highlightedRoleId} /> <LastConsultationCard highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
</div> </div>
<div className="chronology-item"> <div className="chronology-item">
<TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} /> <TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
</div> </div>
</div> </div>
<div className="pathway-graph-sticky"> <div className="pathway-graph-sticky">
@@ -258,6 +303,7 @@ export function DashboardLayout() {
highlightedNodeId={highlightedNodeId} highlightedNodeId={highlightedNodeId}
containerHeight={chronologyHeight} containerHeight={chronologyHeight}
animationReady={constellationReady} animationReady={constellationReady}
globalFocusActive={globalFocusId !== null}
/> />
</div> </div>
@@ -265,7 +311,7 @@ export function DashboardLayout() {
</div> </div>
<div data-tile-id="section-skills" style={{ marginTop: '22px' }}> <div data-tile-id="section-skills" style={{ marginTop: '22px' }}>
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} /> <RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} focusRelatedIds={focusRelatedIds} />
</div> </div>
</ParentSection> </ParentSection>
</div> </div>
+6
View File
@@ -6,6 +6,7 @@ import { hexToRgba, motionSafeTransition } from '@/lib/utils'
interface ExpandableCardShellProps { interface ExpandableCardShellProps {
isExpanded: boolean isExpanded: boolean
isHighlighted: boolean isHighlighted: boolean
isDimmedByFocus?: boolean
accentColor: string accentColor: string
onToggle: () => void onToggle: () => void
ariaLabel: string ariaLabel: string
@@ -21,6 +22,7 @@ interface ExpandableCardShellProps {
export function ExpandableCardShell({ export function ExpandableCardShell({
isExpanded, isExpanded,
isHighlighted, isHighlighted,
isDimmedByFocus = false,
accentColor, accentColor,
onToggle, onToggle,
ariaLabel, ariaLabel,
@@ -52,6 +54,10 @@ export function ExpandableCardShell({
className={className} className={className}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
style={{
opacity: isDimmedByFocus ? 0.25 : 1,
transition: 'opacity 150ms ease-out',
}}
> >
<div <div
style={{ style={{
+5 -2
View File
@@ -8,15 +8,17 @@ import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
interface LastConsultationCardProps { interface LastConsultationCardProps {
highlightedRoleId?: string | null highlightedRoleId?: string | null
focusRelatedIds?: Set<string> | null
} }
export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) { export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: LastConsultationCardProps) {
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0] const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
if (!consultation) { if (!consultation) {
return null return null
} }
const isHighlighted = highlightedRoleId === consultation.id const isHighlighted = highlightedRoleId === consultation.id
const isDimmed = focusRelatedIds != null && !focusRelatedIds.has(consultation.id)
const handleOpenPanel = () => { const handleOpenPanel = () => {
openPanel({ type: 'consultation', consultation }) openPanel({ type: 'consultation', consultation })
@@ -67,9 +69,10 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
border: '1px solid', border: '1px solid',
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent', borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent',
background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : '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', padding: '8px',
margin: '-8px', margin: '-8px',
opacity: isDimmed ? 0.25 : 1,
}} }}
> >
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" /> <CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
+10 -3
View File
@@ -26,9 +26,10 @@ interface SkillRowProps {
yearsSuffix: string yearsSuffix: string
onClick: () => void onClick: () => void
onHighlight?: (id: string | null) => 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 IconComponent = iconMap[skill.icon]
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -55,7 +56,8 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)', border: '1px solid var(--border-light)',
cursor: 'pointer', 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) => { onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)' e.currentTarget.style.borderColor = 'var(--accent-border)'
@@ -134,6 +136,7 @@ interface CategorySectionProps {
onSkillClick: (skill: SkillMedication) => void onSkillClick: (skill: SkillMedication) => void
isFirst: boolean isFirst: boolean
onNodeHighlight?: (id: string | null) => void onNodeHighlight?: (id: string | null) => void
focusRelatedIds?: Set<string> | null
} }
function CategorySection({ function CategorySection({
@@ -144,6 +147,7 @@ function CategorySection({
onSkillClick, onSkillClick,
isFirst, isFirst,
onNodeHighlight, onNodeHighlight,
focusRelatedIds,
}: CategorySectionProps) { }: CategorySectionProps) {
return ( return (
<div style={{ marginTop: isFirst ? 0 : '16px' }}> <div style={{ marginTop: isFirst ? 0 : '16px' }}>
@@ -193,6 +197,7 @@ function CategorySection({
yearsSuffix={yearsSuffix} yearsSuffix={yearsSuffix}
onClick={() => onSkillClick(skill)} onClick={() => onSkillClick(skill)}
onHighlight={onNodeHighlight} onHighlight={onNodeHighlight}
isDimmedByFocus={focusRelatedIds != null && !focusRelatedIds.has(skill.id)}
/> />
))} ))}
</div> </div>
@@ -202,9 +207,10 @@ function CategorySection({
interface RepeatMedicationsSubsectionProps { interface RepeatMedicationsSubsectionProps {
onNodeHighlight?: (id: string | null) => void onNodeHighlight?: (id: string | null) => void
focusRelatedIds?: Set<string> | null
} }
export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicationsSubsectionProps) { export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) {
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
const skillsCopy = getSkillsUICopy() const skillsCopy = getSkillsUICopy()
@@ -238,6 +244,7 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
onSkillClick={handleSkillClick} onSkillClick={handleSkillClick}
isFirst isFirst
onNodeHighlight={onNodeHighlight} onNodeHighlight={onNodeHighlight}
focusRelatedIds={focusRelatedIds}
/> />
))} ))}
</div> </div>
@@ -11,6 +11,7 @@ interface TimelineInterventionItemProps {
entity: TimelineEntity entity: TimelineEntity
isExpanded: boolean isExpanded: boolean
isHighlightedFromGraph: boolean isHighlightedFromGraph: boolean
isDimmedByFocus: boolean
isEducationAnchor: boolean isEducationAnchor: boolean
onToggle: () => void onToggle: () => void
onViewFull: () => void onViewFull: () => void
@@ -21,6 +22,7 @@ function TimelineInterventionItem({
entity, entity,
isExpanded, isExpanded,
isHighlightedFromGraph, isHighlightedFromGraph,
isDimmedByFocus,
isEducationAnchor, isEducationAnchor,
onToggle, onToggle,
onViewFull, onViewFull,
@@ -34,6 +36,7 @@ function TimelineInterventionItem({
<ExpandableCardShell <ExpandableCardShell
isExpanded={isExpanded} isExpanded={isExpanded}
isHighlighted={isHighlightedFromGraph} isHighlighted={isHighlightedFromGraph}
isDimmedByFocus={isDimmedByFocus}
accentColor={entity.orgColor} accentColor={entity.orgColor}
onToggle={onToggle} onToggle={onToggle}
ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`} ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
@@ -254,9 +257,10 @@ function TimelineInterventionItem({
interface TimelineInterventionsSubsectionProps { interface TimelineInterventionsSubsectionProps {
onNodeHighlight?: (id: string | null) => void onNodeHighlight?: (id: string | null) => void
highlightedRoleId?: string | null highlightedRoleId?: string | null
focusRelatedIds?: Set<string> | null
} }
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId }: TimelineInterventionsSubsectionProps) { export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
@@ -288,6 +292,7 @@ export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRo
entity={entity} entity={entity}
isExpanded={expandedId === entity.id} isExpanded={expandedId === entity.id}
isHighlightedFromGraph={highlightedRoleId === entity.id} isHighlightedFromGraph={highlightedRoleId === entity.id}
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
isEducationAnchor={entity.id === firstEducationId} isEducationAnchor={entity.id === firstEducationId}
onToggle={() => handleToggle(entity.id)} onToggle={() => handleToggle(entity.id)}
onViewFull={() => handleViewFull(entity)} onViewFull={() => handleViewFull(entity)}
@@ -27,6 +27,7 @@ interface CareerConstellationProps {
highlightedNodeId?: string | null highlightedNodeId?: string | null
containerHeight?: number | null containerHeight?: number | null
animationReady?: boolean animationReady?: boolean
globalFocusActive?: boolean
} }
const nodeById = new Map(constellationNodes.map(node => [node.id, node])) const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
@@ -39,6 +40,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
highlightedNodeId, highlightedNodeId,
containerHeight, containerHeight,
animationReady = false, animationReady = false,
globalFocusActive = false,
}) => { }) => {
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -301,6 +303,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`} viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
role="img" role="img"
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline" 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={{ style={{
display: 'block', display: 'block',
width: '100%', width: '100%',
-2
View File
@@ -42,9 +42,7 @@ export function useConstellationInteraction(deps: {
if (supportsCoarsePointer) return if (supportsCoarsePointer) return
deps.pauseForInteraction?.() deps.pauseForInteraction?.()
deps.highlightGraphRef.current?.(d.id) 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() { nodeSelection.on('mouseleave.interaction', function() {
+31
View File
@@ -494,6 +494,27 @@ html {
to { transform: scale(1); opacity: 1; } 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) ===== */ /* ===== FOCUS VISIBLE STYLES (WCAG Compliance) ===== */
/* Default focus ring for all focusable elements */ /* Default focus ring for all focusable elements */
*:focus-visible { *:focus-visible {
@@ -593,6 +614,16 @@ textarea:focus-visible {
to { opacity: 1; } 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 */ /* Instant constellation fullscreen */
@keyframes constellation-fullscreen-in { @keyframes constellation-fullscreen-in {
from { transform: none; opacity: 1; } from { transform: none; opacity: 1; }