Substantial refinement/polish on content of webpage (not just structural/coding elements)

This commit is contained in:
2026-02-17 14:05:32 +00:00
parent 38e40d36c0
commit 82db5fda54
98 changed files with 19572 additions and 22192 deletions
+1 -5
View File
@@ -4,7 +4,6 @@ import Sidebar from './Sidebar'
import { CommandPalette } from './CommandPalette'
import { DetailPanel } from './DetailPanel'
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
import { ProjectsTile } from './tiles/ProjectsTile'
import { ParentSection } from './ParentSection'
import CareerConstellation from './constellation/CareerConstellation'
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
@@ -210,7 +209,7 @@ export function DashboardLayout() {
initial="hidden"
animate="visible"
variants={sidebarVariants}
style={{ flexShrink: 0 }}
style={{ flexShrink: 0, height: '100%' }}
>
<Sidebar
activeSection={activeSection}
@@ -237,9 +236,6 @@ export function DashboardLayout() {
<PatientSummaryTile />
</div>
{/* ProjectsTile — full width */}
<ProjectsTile />
{/* Patient Pathway — parent section with constellation graph + subsections */}
<ParentSection title="Patient Pathway" tileId="patient-pathway">
<div className="pathway-columns">
+3 -6
View File
@@ -12,7 +12,7 @@ interface LastConsultationCardProps {
export function LastConsultationCard({ highlightedRoleId }: LastConsultationCardProps) {
const { openPanel } = useDetailPanel()
const consultation = timelineConsultations[0]
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
if (!consultation) {
return null
}
@@ -42,10 +42,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
}
const getBand = (): string => {
if (consultation.role.includes('Head')) {
return '8a'
}
return '—'
return consultation.band ?? '—'
}
const fieldLabelStyle: React.CSSProperties = {
@@ -75,7 +72,7 @@ export function LastConsultationCard({ highlightedRoleId }: LastConsultationCard
margin: '-8px',
}}
>
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Current role" />
<div
role="button"
+3 -51
View File
@@ -11,7 +11,7 @@ import { CardHeader } from './Card'
import { skills } from '@/data/skills'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getSkillsUICopy } from '@/lib/profile-content'
import type { SkillMedication, SkillCategory } from '@/types/pmr'
import type { SkillMedication } from '@/types/pmr'
const iconMap: Record<string, LucideIcon> = {
BarChart3, Code2, Database, PieChart, FileCode2,
@@ -20,7 +20,6 @@ const iconMap: Record<string, LucideIcon> = {
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
}
const SKILLS_PER_CATEGORY = 4
interface SkillRowProps {
skill: SkillMedication
@@ -129,32 +128,23 @@ function SkillRow({ skill, yearsSuffix, onClick, onHighlight }: SkillRowProps) {
interface CategorySectionProps {
label: string
categoryId: SkillCategory
skills: SkillMedication[]
itemCountSuffix: string
yearsSuffix: string
viewAllLabel: string
onSkillClick: (skill: SkillMedication) => void
onViewAll: (category: SkillCategory) => void
isFirst: boolean
onNodeHighlight?: (id: string | null) => void
}
function CategorySection({
label,
categoryId,
skills: categorySkills,
itemCountSuffix,
yearsSuffix,
viewAllLabel,
onSkillClick,
onViewAll,
isFirst,
onNodeHighlight,
}: CategorySectionProps) {
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
return (
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
<div
@@ -196,7 +186,7 @@ function CategorySection({
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{visibleSkills.map((skill) => (
{categorySkills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
@@ -206,37 +196,6 @@ function CategorySection({
/>
))}
</div>
{remainingCount > 0 && (
<button
onClick={() => onViewAll(categoryId)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
marginTop: '8px',
padding: '4px 0',
minHeight: '44px',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500,
color: 'var(--accent)',
fontFamily: 'inherit',
transition: 'color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--accent)'
}}
aria-label={`${viewAllLabel} ${categorySkills.length} ${label} skills`}
>
{viewAllLabel} ({categorySkills.length})
<ChevronRight size={12} />
</button>
)}
</div>
)
}
@@ -254,17 +213,13 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
label,
skills: skills
.filter((s) => s.category === id)
.sort((a, b) => b.proficiency - a.proficiency),
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
}))
const handleSkillClick = (skill: SkillMedication) => {
openPanel({ type: 'skill', skill })
}
const handleViewAll = (category: SkillCategory) => {
openPanel({ type: 'skills-all', category })
}
return (
<div>
<CardHeader
@@ -277,13 +232,10 @@ export function RepeatMedicationsSubsection({ onNodeHighlight }: RepeatMedicatio
<CategorySection
key={group.id}
label={group.label}
categoryId={group.id}
skills={group.skills}
itemCountSuffix={skillsCopy.itemCountSuffix}
yearsSuffix={skillsCopy.yearsSuffix}
viewAllLabel={skillsCopy.viewAllLabel}
onSkillClick={handleSkillClick}
onViewAll={handleViewAll}
isFirst
onNodeHighlight={onNodeHighlight}
/>
+2 -5
View File
@@ -3,10 +3,8 @@ import type { CSSProperties, ReactNode } from 'react'
import {
AlertCircle,
AlertTriangle,
GraduationCap,
type LucideIcon,
Menu,
Pill,
Search,
UserRound,
Workflow,
@@ -34,10 +32,8 @@ interface NavSection {
}
const navSections: NavSection[] = [
{ id: 'overview', label: 'Overview', tileId: 'patient-summary', Icon: UserRound },
{ id: 'projects', label: 'Projects', tileId: 'projects', Icon: Pill },
{ id: 'overview', label: 'Overview / Highlights', tileId: 'patient-summary', Icon: UserRound },
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
{ id: 'education', label: 'Education', tileId: 'section-education', Icon: GraduationCap },
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
]
@@ -221,6 +217,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick }: Si
top: 0,
left: 0,
bottom: 0,
height: isDesktop ? '100%' : undefined,
width: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
minWidth: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
background: 'var(--sidebar-bg)',
@@ -43,53 +43,113 @@ function TimelineInterventionItem({
onMouseEnter={() => onHighlight?.(entity.id)}
onMouseLeave={() => onHighlight?.(null)}
renderHeader={() => (
<>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '6px',
}}
>
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
{interventionLabel}
</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '6px',
}}
>
{entity.title}
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
{interventionLabel}
</span>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{entity.title}
</div>
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{entity.organization}
<span
style={{
fontSize: '11px',
paddingLeft: '6px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
>
{entity.dateRange.display}
</span>
</div>
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{entity.organization}
<span
{(entity.band || entity.employmentBasis) && (
<div
style={{
fontSize: '11px',
paddingLeft: '6px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
display: 'flex',
flexShrink: 0,
alignItems: 'center',
gap: '5px',
}}
>
{entity.dateRange.display}
</span>
</div>
</>
{entity.band && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '2px 6px',
borderRadius: '3px',
background: hexToRgba(entity.orgColor, 0.1),
color: entity.orgColor,
border: `1px solid ${hexToRgba(entity.orgColor, 0.25)}`,
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
Band {entity.band.toUpperCase()}
</span>
)}
{entity.employmentBasis && (
<span
title={entity.contextNote}
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '3px',
background: 'rgba(245, 158, 11, 0.1)',
color: '#b45309',
border: '1px solid rgba(245, 158, 11, 0.25)',
cursor: 'default',
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
{entity.employmentBasis}
</span>
)}
</div>
)}
</div>
)}
renderBody={() => (
<>
{entity.contextNote && (
<div
style={{
fontSize: '12px',
fontStyle: 'italic',
color: 'var(--text-tertiary)',
marginBottom: '10px',
}}
>
{entity.contextNote}
</div>
)}
<ul
style={{
listStyle: 'none',
-207
View File
@@ -1,207 +0,0 @@
import { useState, useCallback } from 'react'
import { ChevronRight } from 'lucide-react'
import { CardHeader } from './Card'
import { ExpandableCardShell } from './ExpandableCardShell'
import { timelineConsultations } from '@/data/timeline'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { hexToRgba } from '@/lib/utils'
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
interface RoleItemProps {
consultation: typeof timelineConsultations[0]
isExpanded: boolean
isHighlightedFromGraph: boolean
onToggle: () => void
onViewFull: () => void
onHighlight?: (id: string | null) => void
}
function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle, onViewFull, onHighlight }: RoleItemProps) {
const orgColor = consultation.orgColor ?? DEFAULT_ORG_COLOR
return (
<ExpandableCardShell
isExpanded={isExpanded}
isHighlighted={isHighlightedFromGraph}
accentColor={orgColor}
onToggle={onToggle}
ariaLabel={`${consultation.role} at ${consultation.organization}, ${consultation.duration}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
headerPadding="12px 14px"
onMouseEnter={() => onHighlight?.(consultation.id)}
onMouseLeave={() => onHighlight?.(null)}
renderHeader={() => (
<>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{consultation.role}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{consultation.organization}
</div>
<div
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
>
{consultation.duration}
</div>
</>
)}
renderBody={() => (
<>
{/* Examination bullets */}
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '0 0 10px 0',
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
{consultation.examination.map((bullet, i) => (
<li
key={i}
style={{
fontSize: '13px',
color: 'var(--text-primary)',
lineHeight: 1.5,
paddingLeft: '12px',
position: 'relative',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: '6px',
width: '4px',
height: '4px',
borderRadius: '50%',
background: consultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
}}
/>
{bullet}
</li>
))}
</ul>
{/* Coded entries */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '10px',
}}
>
{consultation.codedEntries.map((entry) => (
<span
key={entry.code}
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '4px',
background: hexToRgba(orgColor, 0.08),
color: consultation.orgColor ?? 'var(--accent)',
border: `1px solid ${hexToRgba(orgColor, 0.2)}`,
}}
>
{entry.code}: {entry.description}
</span>
))}
</div>
{/* View full record link */}
<button
onClick={(e) => {
e.stopPropagation()
onViewFull()
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: 500,
color: consultation.orgColor ?? 'var(--accent)',
background: 'transparent',
border: 'none',
padding: '4px 0',
cursor: 'pointer',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
>
View full record
<ChevronRight size={12} />
</button>
</>
)}
/>
)
}
interface WorkExperienceSubsectionProps {
onNodeHighlight?: (id: string | null) => void
highlightedRoleId?: string | null
}
export function WorkExperienceSubsection({ onNodeHighlight, highlightedRoleId }: WorkExperienceSubsectionProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const { openPanel } = useDetailPanel()
const handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}, [])
const handleViewFull = useCallback(
(consultation: typeof timelineConsultations[0]) => {
openPanel({ type: 'career-role', consultation })
},
[openPanel],
)
return (
<div>
<CardHeader dotColor="teal" title="WORK EXPERIENCE" rightText={`${timelineConsultations.length} roles`} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{timelineConsultations.map((c) => (
<RoleItem
key={c.id}
consultation={c}
isExpanded={expandedId === c.id}
isHighlightedFromGraph={highlightedRoleId === c.id}
onToggle={() => handleToggle(c.id)}
onViewFull={() => handleViewFull(c)}
onHighlight={onNodeHighlight}
/>
))}
</div>
</div>
)
}
@@ -6,10 +6,12 @@ import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { MobileAccordion } from './MobileAccordion'
import { ConstellationLegend } from './ConstellationLegend'
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
import { PlayPauseButton } from './PlayPauseButton'
import { FullscreenButton } from './FullscreenButton'
import { srDescription } from './screen-reader-description'
import {
MIN_HEIGHT,
@@ -45,6 +47,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [chartInView, setChartInView] = useState(true)
const [isFullscreen, setIsFullscreen] = useState(false)
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
@@ -69,27 +72,27 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (!container) return
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const CHANGE_THRESHOLD = 0.3
const X_CHANGE_THRESHOLD = 0.3
const updateDimensions = () => {
const width = container.clientWidth
const viewportWidth = window.innerWidth
const height = getHeight(viewportWidth, containerHeight)
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
const scaleFactor = viewportWidth >= 1024
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
: 1
setDimensions(prev => {
const widthDelta = Math.abs(prev.width - width) / prev.width
const heightDelta = Math.abs(prev.height - height) / prev.height
if (widthDelta < CHANGE_THRESHOLD && heightDelta < CHANGE_THRESHOLD) {
const heightRatio = Math.max(height / prev.height, prev.height / height)
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
return prev
}
return { width, height, scaleFactor }
})
}
// Initial measurement (no debounce)
updateDimensions()
// Use rAF for fullscreen toggle so CSS layout settles before measuring
requestAnimationFrame(updateDimensions)
const observer = new ResizeObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer)
@@ -100,7 +103,29 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
observer.disconnect()
if (debounceTimer) clearTimeout(debounceTimer)
}
}, [containerHeight])
}, [containerHeight, isFullscreen])
const toggleFullscreen = useCallback(() => setIsFullscreen(prev => !prev), [])
// ESC key to exit fullscreen
useEffect(() => {
if (!isFullscreen) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setIsFullscreen(false) }
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [isFullscreen])
// Body scroll lock while fullscreen
useEffect(() => {
if (!isFullscreen) return
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [isFullscreen])
// Focus trap when fullscreen
useFocusTrap(containerRef, isFullscreen)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
const sf = isMobile ? 1 : dimensions.scaleFactor
@@ -240,84 +265,112 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
return (
<div
ref={containerRef}
style={{
width: '100%',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
overflow: 'hidden',
position: 'relative',
}}
>
<svg
ref={svgRef}
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"
style={{
display: 'block',
width: '100%',
height: dimensions.height,
opacity: 1,
}}
/>
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
{!prefersReducedMotion && (
<PlayPauseButton
isPlaying={animation.isPlaying}
onToggle={animation.togglePlayPause}
isMobile={isMobile}
visible={chartInView}
containerRef={containerRef}
<>
{isFullscreen && (
<div
onClick={toggleFullscreen}
style={{
position: 'fixed',
inset: 0,
zIndex: 899,
background: 'var(--backdrop-bg)',
backdropFilter: 'blur(var(--backdrop-blur))',
animation: 'backdrop-fade-in 200ms ease-out',
}}
/>
)}
<p
<div
ref={containerRef}
{...(isFullscreen ? {
role: 'dialog',
'aria-modal': true,
'aria-label': 'Career constellation fullscreen view',
} : {})}
style={{
position: 'absolute',
width: 1, height: 1, padding: 0, margin: -1,
overflow: 'hidden', clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap', border: 0,
width: '100%',
borderRadius: isFullscreen ? 0 : 'var(--radius-sm)',
border: isFullscreen ? 'none' : '1px solid var(--border-light)',
overflow: 'hidden',
position: isFullscreen ? 'fixed' : 'relative',
...(isFullscreen ? { inset: 0, zIndex: 900, background: 'var(--surface)' } : {}),
animation: isFullscreen ? 'constellation-fullscreen-in 200ms ease-out' : undefined,
}}
>
{srDescription}
</p>
<svg
ref={svgRef}
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"
style={{
display: 'block',
width: '100%',
height: dimensions.height,
opacity: 1,
}}
/>
<AccessibleNodeOverlay
nodes={constellationNodes}
nodeButtonPositions={sim.nodeButtonPositions}
dimensions={dimensions}
onFocus={(nodeId) => {
setFocusedNodeId(nodeId)
highlightGraphRef.current?.(nodeId)
const node = nodeById.get(nodeId)
if (node?.type !== 'skill') onNodeHover?.(nodeId)
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(resolveGraphFallback())
onNodeHover?.(resolveRoleFallback())
}}
onClick={(nodeId, nodeType) => {
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType !== 'skill') {
onNodeHover?.(nodeId)
onRoleClick(nodeId)
} else {
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
{!prefersReducedMotion && (
<PlayPauseButton
isPlaying={animation.isPlaying}
onToggle={animation.togglePlayPause}
isMobile={isMobile}
visible={chartInView}
containerRef={containerRef}
/>
)}
<FullscreenButton
isFullscreen={isFullscreen}
onToggle={toggleFullscreen}
isMobile={isMobile}
/>
<p
style={{
position: 'absolute',
width: 1, height: 1, padding: 0, margin: -1,
overflow: 'hidden', clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap', border: 0,
}}
>
{srDescription}
</p>
<AccessibleNodeOverlay
nodes={constellationNodes}
nodeButtonPositions={sim.nodeButtonPositions}
dimensions={dimensions}
onFocus={(nodeId) => {
setFocusedNodeId(nodeId)
highlightGraphRef.current?.(nodeId)
const node = nodeById.get(nodeId)
if (node?.type !== 'skill') onNodeHover?.(nodeId)
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(resolveGraphFallback())
onNodeHover?.(resolveRoleFallback())
onSkillClick(nodeId)
}
}}
onKeyDown={handleNodeKeyDown}
/>
</div>
}}
onClick={(nodeId, nodeType) => {
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType !== 'skill') {
onNodeHover?.(nodeId)
onRoleClick(nodeId)
} else {
onNodeHover?.(resolveRoleFallback())
onSkillClick(nodeId)
}
}}
onKeyDown={handleNodeKeyDown}
/>
</div>
</>
)
}
@@ -0,0 +1,48 @@
import React from 'react'
import { Maximize2, Minimize2 } from 'lucide-react'
interface FullscreenButtonProps {
isFullscreen: boolean
onToggle: () => void
isMobile: boolean
}
export const FullscreenButton: React.FC<FullscreenButtonProps> = ({
isFullscreen, onToggle, isMobile,
}) => {
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
const size = isMobile ? 44 : Math.round(36 * scale)
const offset = isMobile ? 8 : Math.round(12 * scale)
const iconSize = isMobile ? 16 : Math.round(14 * scale)
const Icon = isFullscreen ? Minimize2 : Maximize2
return (
<button
onClick={onToggle}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
style={{
position: 'absolute',
right: offset,
top: offset,
width: size,
height: size,
borderRadius: '50%',
border: '1.5px solid var(--border)',
background: 'var(--surface)',
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: 0.85,
transition: 'opacity 200ms ease',
zIndex: 5,
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={e => { e.currentTarget.style.opacity = '0.85' }}
>
<Icon size={iconSize} color="var(--text-secondary)" strokeWidth={2} />
</button>
)
}
+102 -28
View File
@@ -94,6 +94,32 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
</div>
</div>
{/* Domain skills */}
{investigation.skills && investigation.skills.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Domain Skills</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{investigation.skills.map((skill) => (
<span
key={skill}
style={{
padding: '3px 10px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'var(--font-geist-mono)',
color: '#0D9488',
backgroundColor: 'rgba(13,148,136,0.08)',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(13,148,136,0.2)',
}}
>
{skill}
</span>
))}
</div>
</div>
)}
{/* Results */}
<div>
<h3 style={sectionHeadingStyle}>Results</h3>
@@ -106,37 +132,85 @@ export function ProjectDetail({ investigation }: ProjectDetailProps) {
</ul>
</div>
{/* External link */}
{investigation.externalUrl && (
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
{/* Action buttons */}
{(investigation.externalUrl || investigation.demoUrl) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignSelf: 'flex-start' }}>
{investigation.externalUrl && (
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-ui)',
color: 'var(--surface)',
backgroundColor: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
transition: 'background-color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent)'
}}
>
<ExternalLink size={14} />
View Live Project
</a>
)}
{investigation.demoUrl && (
<a
href={investigation.demoUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-ui)',
color: '#0D9488',
backgroundColor: 'transparent',
border: '1px solid #0D9488',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
transition: 'background-color 150ms, color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(13,148,136,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<ExternalLink size={14} />
Interactive Demo
</a>
)}
</div>
)}
{/* Thumbnail */}
{investigation.thumbnail && (
<img
src={investigation.thumbnail}
alt={`${investigation.name} screenshot`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-ui)',
color: 'var(--surface)',
backgroundColor: 'var(--accent)',
width: '100%',
height: 'auto',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
alignSelf: 'flex-start',
transition: 'background-color 150ms',
border: '1px solid var(--border-light)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent)'
}}
>
<ExternalLink size={14} />
View Live Project
</a>
/>
)}
</div>
)
-45
View File
@@ -13,13 +13,6 @@ const categoryLabels: Record<SkillMedication['category'], string> = {
Leadership: 'Strategic & Leadership',
}
// Proficiency bar color based on value
function getProficiencyColor(proficiency: number): string {
if (proficiency >= 90) return 'var(--success)'
if (proficiency >= 75) return 'var(--accent)'
return 'var(--amber)'
}
export function SkillDetail({ skill }: SkillDetailProps) {
// Find roles that use this skill from constellation data
const usedInRoles = roleSkillMappings
@@ -96,44 +89,6 @@ export function SkillDetail({ skill }: SkillDetailProps) {
</span>
</div>
{/* Proficiency bar */}
<div>
<h3 style={sectionHeadingStyle}>Proficiency</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
flex: 1,
height: '6px',
backgroundColor: 'var(--border-light)',
borderRadius: '3px',
overflow: 'hidden',
}}
>
<div
style={{
width: `${skill.proficiency}%`,
height: '100%',
backgroundColor: getProficiencyColor(skill.proficiency),
borderRadius: '3px',
transition: 'width 400ms ease-out',
}}
/>
</div>
<span
style={{
fontSize: '13px',
fontWeight: 700,
fontFamily: 'var(--font-geist)',
color: getProficiencyColor(skill.proficiency),
minWidth: '36px',
textAlign: 'right',
}}
>
{skill.proficiency}%
</span>
</div>
</div>
{/* Years of experience */}
<div>
<h3 style={sectionHeadingStyle}>Experience</h3>
+1 -41
View File
@@ -40,7 +40,7 @@ export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
label,
skills: skills
.filter((s) => s.category === id)
.sort((a, b) => b.proficiency - a.proficiency),
.sort((a, b) => b.yearsOfExperience - a.yearsOfExperience),
}))
const handleSkillClick = (skill: SkillMedication) => {
@@ -200,46 +200,6 @@ function SkillRow({ skill, yearsSuffix, onClick }: SkillRowProps) {
</div>
</div>
{/* Proficiency */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
flexShrink: 0,
}}
>
<div
style={{
width: '40px',
height: '4px',
backgroundColor: 'var(--border-light)',
borderRadius: '2px',
overflow: 'hidden',
}}
>
<div
style={{
width: `${skill.proficiency}%`,
height: '100%',
backgroundColor: skill.proficiency >= 90 ? 'var(--success)' : skill.proficiency >= 75 ? 'var(--accent)' : 'var(--amber)',
borderRadius: '2px',
}}
/>
</div>
<span
style={{
fontSize: '10px',
fontFamily: '"Geist Mono", monospace',
color: 'var(--text-tertiary)',
minWidth: '28px',
textAlign: 'right',
}}
>
{skill.proficiency}%
</span>
</div>
{/* Chevron */}
<ChevronRight
size={14}
+5 -1
View File
@@ -7,6 +7,7 @@ import type { KPI } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
import { KPI_COLORS } from '@/lib/theme-colors'
import { ProjectsCarousel } from './ProjectsTile'
interface MetricCardProps {
kpi: KPI
@@ -120,7 +121,7 @@ export function PatientSummaryTile() {
const kpiGridStyles: React.CSSProperties = {
display: 'grid',
gap: '10px',
gridTemplateColumns: '1fr',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
}
return (
@@ -149,6 +150,9 @@ export function PatientSummaryTile() {
))}
</div>
</div>
{/* Projects carousel */}
<ProjectsCarousel />
</ParentSection>
)
}
+107 -52
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { investigations } from '@/data/investigations'
import { Card, CardHeader } from '../Card'
import { CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { Investigation } from '@/types/pmr'
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
@@ -9,7 +9,6 @@ interface ProjectItemProps {
project: Investigation
slideWidth: string
cardMinHeight: number
thumbnailHeight: number
onClick: () => void
}
@@ -17,7 +16,6 @@ function ProjectItem({
project,
slideWidth,
cardMinHeight,
thumbnailHeight,
onClick,
}: ProjectItemProps) {
const dotColor = PROJECT_STATUS_COLORS[project.status]
@@ -78,15 +76,16 @@ function ProjectItem({
>
<div
style={{
minHeight: `${thumbnailHeight}px`,
flex: 1,
aspectRatio: '16 / 9',
borderRadius: '6px',
border: '1px solid var(--border-light)',
background:
'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
background: project.thumbnail
? undefined
: 'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
fontFamily: 'var(--font-geist-mono)',
fontSize: '10px',
letterSpacing: '0.08em',
@@ -94,7 +93,20 @@ function ProjectItem({
textTransform: 'uppercase',
}}
>
Thumbnail Pending
{project.thumbnail ? (
<img
src={project.thumbnail}
alt={`${project.name} thumbnail`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'top',
}}
/>
) : (
'Thumbnail Pending'
)}
</div>
<div
@@ -129,38 +141,95 @@ function ProjectItem({
</span>
</div>
{project.techStack && project.techStack.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
}}
>
{project.techStack.map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
border: '1px solid var(--amber-border)',
}}
>
{tech}
</span>
))}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '8px',
alignItems: 'flex-start',
}}
>
{project.techStack && project.techStack.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', minWidth: 0 }}>
{project.techStack.slice(0, 3).map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
border: '1px solid var(--amber-border)',
}}
>
{tech}
</span>
))}
{project.techStack.length > 3 && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 6px',
color: 'var(--text-tertiary)',
}}
>
+{project.techStack.length - 3}
</span>
)}
</div>
)}
{project.skills && project.skills.length > 0 && (
<div
className="skills-tags"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
justifyContent: 'flex-end',
minWidth: 0,
}}
>
{project.skills.slice(0, 2).map((skill) => (
<span
key={skill}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'rgba(13,148,136,0.08)',
color: '#0D9488',
border: '1px solid rgba(13,148,136,0.2)',
whiteSpace: 'nowrap',
}}
>
{skill}
</span>
))}
{project.skills.length > 2 && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 6px',
color: 'var(--text-tertiary)',
}}
>
+{project.skills.length - 2}
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}
export function ProjectsTile() {
export function ProjectsCarousel() {
const { openPanel } = useDetailPanel()
const viewportRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
@@ -250,7 +319,7 @@ export function ProjectsTile() {
const cardsPerView = useMemo(() => {
if (viewportWidth < 768) {
return 1
return 2
}
return 4
}, [viewportWidth])
@@ -275,25 +344,12 @@ export function ProjectsTile() {
return 214
}, [viewportWidth])
const thumbnailHeight = useMemo(() => {
if (viewportWidth < 640) {
return 62
}
if (viewportWidth < 1024) {
return 68
}
if (viewportWidth < 1440) {
return 76
}
return 84
}, [viewportWidth])
const setPaused = (value: boolean) => {
isPausedRef.current = value
}
return (
<Card full tileId="projects">
<div style={{ marginTop: '28px' }}>
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
<div
@@ -329,7 +385,6 @@ export function ProjectsTile() {
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
thumbnailHeight={thumbnailHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
@@ -337,6 +392,6 @@ export function ProjectsTile() {
))}
</div>
</div>
</Card>
</div>
)
}