diff --git a/.ralph/agent/handoff.md b/.ralph/agent/handoff.md index 1fefccf..8c367b2 100644 --- a/.ralph/agent/handoff.md +++ b/.ralph/agent/handoff.md @@ -1,11 +1,11 @@ # Session Handoff -_Generated: 2026-02-16 14:36:25 UTC_ +_Generated: 2026-02-16 15:06:20 UTC_ ## Git Context - **Branch:** `master` -- **HEAD:** aca5771: chore: auto-commit before merge (loop primary) +- **HEAD:** e9a7581: chore: auto-commit before merge (loop primary) ## Tasks @@ -70,18 +70,16 @@ Session completed successfully. No pending work. **Original objective:** ``` -# Task: CareerConstellation Overhaul +# Task: Career Constellation Chart & Layout Polish -Refactor, visually improve, and add chronological animation to the CareerConstellation D3 force chart — the centrepiece of the portfolio's Patient Pathway section. +Visual polish and layout adjustments to the career constellation chart, sidebar, and repeat medications section. 12 discrete changes across 10 files. ## Requirements -### Phase 1 — Refactor the Monolith - -Decompose `src/components/CareerConstellation.tsx` (1102 lines) into focused modules: +### 1. Reduce link opacity (`src/components/constellation/constants.ts`) +- Lower `LINK_BASE_OPACITY` from `0.08` → `0.04` +- Lower `LINK_STRENGTH_OPACITY_FACTOR` from `0.12` → `0.06` +- Makes skill connection lines subtler so job pills are visually clearer -``` -src/components/constellation/ - CareerConstellation.tsx -- Orchestrator (< 300 lines) - MobileAccordion.tsx -- Mobile tap-to-e... +### 2. White backgro... ``` diff --git a/d3chart.jpg b/d3chart.jpg new file mode 100644 index 0000000..847cfab Binary files /dev/null and b/d3chart.jpg differ diff --git a/src/App.tsx b/src/App.tsx index 35493c4..4a85115 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,7 +45,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) { } function App() { - const [phase, setPhase] = useState('login') + const [phase, setPhase] = useState('boot') const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) useEffect(() => { diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 8112482..4729f97 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -69,7 +69,7 @@ const COLORS = { } const BOOT_CONFIG: BootConfig = { - header: 'CLINICAL TERMINAL v3.2.1', + header: 'CV Management Information System v1.0.0', lines: [ { type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' }, { type: 'separator', text: '---', style: 'dim' }, @@ -88,7 +88,7 @@ const BOOT_CONFIG: BootConfig = { timing: { lineDelay: 220, cursorBlinkInterval: 300, - holdAfterComplete: 900, + holdAfterComplete: 1000, fadeOutDuration: 600, cursorShrinkDuration: 600, ecgStartDelay: 0, diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index ac6f562..f3de6b4 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -128,7 +128,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse display: 'flex', flexWrap: 'wrap', gap: '20px', - marginBottom: '14px', + marginBottom: '1=px', paddingBottom: '14px', borderBottom: '1px solid var(--border-light)', cursor: 'pointer', @@ -182,7 +182,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse display: 'flex', flexDirection: 'column', gap: '7px', - marginBottom: '16px', + marginBottom: '0px', }} > {consultation.examination.map((bullet, index) => ( @@ -250,7 +250,7 @@ export function DashboardLayout() { const [highlightedNodeId, setHighlightedNodeId] = useState(null) const [highlightedRoleId, setHighlightedRoleId] = useState(null) const [chronologyHeight, setChronologyHeight] = useState(null) - const [sidebarForceCollapsed, setSidebarForceCollapsed] = useState(false) + const [constellationReady, setConstellationReady] = useState(false) const chronologyRef = useRef(null) const patientSummaryRef = useRef(null) const activeSection = useActiveSection() @@ -260,28 +260,18 @@ export function DashboardLayout() { [], ) - // Sidebar collapse when patient summary scrolls out of view (desktop only) + // Signal constellation animation readiness when patient summary scrolls out of view useEffect(() => { const el = patientSummaryRef.current if (!el) return - const mq = window.matchMedia('(min-width: 1024px)') const observer = new IntersectionObserver( ([entry]) => { - if (mq.matches) { - setSidebarForceCollapsed(!entry.isIntersecting) - } + if (!entry.isIntersecting) setConstellationReady(true) }, { threshold: 0 }, ) observer.observe(el) - const handleResize = () => { - if (!mq.matches) setSidebarForceCollapsed(false) - } - mq.addEventListener('change', handleResize) - return () => { - observer.disconnect() - mq.removeEventListener('change', handleResize) - } + return () => observer.disconnect() }, []) // Measure the chronology stream height so the constellation graph can match it @@ -436,7 +426,6 @@ export function DashboardLayout() { activeSection={activeSection} onNavigate={scrollToSection} onSearchClick={handleSearchClick} - forceCollapsed={sidebarForceCollapsed} /> @@ -465,31 +454,7 @@ export function DashboardLayout() {
-
-
- Clinical Record Stream -
-
- Chronological role and education entries. Select items to inspect full records. -
-
+
@@ -506,6 +471,7 @@ export function DashboardLayout() { onNodeHover={handleNodeHover} highlightedNodeId={highlightedNodeId} containerHeight={chronologyHeight} + animationReady={constellationReady} />
diff --git a/src/components/ECGAnimation.tsx b/src/components/ECGAnimation.tsx index 81964be..909c658 100644 --- a/src/components/ECGAnimation.tsx +++ b/src/components/ECGAnimation.tsx @@ -40,6 +40,7 @@ const HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out const BG_TRANSITION_SECONDS = 0.2 // Background color transition +const SKIP_TEXT = true // Skip text phase — transition directly after heartbeats // ============================================================================= // Letter Definitions (ECG waveform shapes for each letter) @@ -344,7 +345,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) { const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W) - const textEndWX = textStartWX + totalTextW + const textEndWX = SKIP_TEXT ? textStartWX : textStartWX + totalTextW const textLayout = layoutText( textStartWX, LETTER_W, LETTER_G, SPACE_W, baselineY, 0, Infinity @@ -354,7 +355,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) { const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED const holdEndTime = textEndTime const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS - const fadeStartTime = flatlineEndTime + HOLD_SECONDS + const fadeStartTime = flatlineEndTime + (SKIP_TEXT ? 0.3 : HOLD_SECONDS) const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS const exitEndTime = bgTransitionEndTime @@ -500,7 +501,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) { const isTextPhase = headWX > textStartWX const isTextDone = elapsed >= textEndTime - if (isTextPhase) { + if (isTextPhase && !SKIP_TEXT) { ctx.save() // Clip for progressive reveal diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 17b6304..1f29c10 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -23,7 +23,6 @@ interface SidebarProps { activeSection: string onNavigate: (tileId: string) => void onSearchClick: () => void - forceCollapsed?: boolean } interface NavSection { @@ -163,7 +162,7 @@ function AlertFlag({ alert }: AlertFlagProps) { ) } -export default function Sidebar({ activeSection, onNavigate, onSearchClick, forceCollapsed }: SidebarProps) { +export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) { const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches) const [isMobileExpanded, setIsMobileExpanded] = useState(false) @@ -185,7 +184,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc return () => mediaQuery.removeEventListener('change', listener) }, []) - const isExpanded = (isDesktop && !forceCollapsed) || isMobileExpanded + const isExpanded = isDesktop || isMobileExpanded const handleNavActivate = (tileId: string) => { onNavigate(tileId) @@ -196,7 +195,7 @@ export default function Sidebar({ activeSection, onNavigate, onSearchClick, forc return ( <> - {(!isDesktop || forceCollapsed) && isMobileExpanded && ( + {!isDesktop && isMobileExpanded && (
- -
{entity.title}
-
+
{entity.organization} -
-
{entity.dateRange.display} +
+ + + +
diff --git a/src/components/constellation/CareerConstellation.tsx b/src/components/constellation/CareerConstellation.tsx index f5733f5..a50eae1 100644 --- a/src/components/constellation/CareerConstellation.tsx +++ b/src/components/constellation/CareerConstellation.tsx @@ -24,6 +24,7 @@ interface CareerConstellationProps { onNodeHover?: (id: string | null) => void highlightedNodeId?: string | null containerHeight?: number | null + animationReady?: boolean } const nodeById = new Map(constellationNodes.map(node => [node.id, node])) @@ -35,6 +36,7 @@ const CareerConstellation: React.FC = ({ onNodeHover, highlightedNodeId, containerHeight, + animationReady = false, }) => { const svgRef = useRef(null) const containerRef = useRef(null) @@ -66,6 +68,9 @@ const CareerConstellation: React.FC = ({ const container = containerRef.current if (!container) return + let debounceTimer: ReturnType | null = null + const CHANGE_THRESHOLD = 0.3 + const updateDimensions = () => { const width = container.clientWidth const viewportWidth = window.innerWidth @@ -73,13 +78,28 @@ const CareerConstellation: React.FC = ({ const scaleFactor = viewportWidth >= 1024 ? Math.max(1, Math.min(1.6, viewportWidth / 1440)) : 1 - setDimensions({ width, height, scaleFactor }) + 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) { + return prev + } + return { width, height, scaleFactor } + }) } + // Initial measurement (no debounce) updateDimensions() - const observer = new ResizeObserver(updateDimensions) + + const observer = new ResizeObserver(() => { + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(updateDimensions, 2000) + }) observer.observe(container) - return () => observer.disconnect() + return () => { + observer.disconnect() + if (debounceTimer) clearTimeout(debounceTimer) + } }, [containerHeight]) const isMobile = typeof window !== 'undefined' && window.innerWidth < 640 @@ -157,6 +177,7 @@ const CareerConstellation: React.FC = ({ skillRestRadiiRef, srDefault, dimensionsTrigger: dimensions.width + dimensions.height, + ready: animationReady, }) // Sync visibleNodeIdsRef from animation hook @@ -231,12 +252,15 @@ const CareerConstellation: React.FC = ({ > @@ -249,6 +273,7 @@ const CareerConstellation: React.FC = ({ onToggle={animation.togglePlayPause} isMobile={isMobile} visible={chartInView} + containerRef={containerRef} /> )} diff --git a/src/components/constellation/ConstellationLegend.tsx b/src/components/constellation/ConstellationLegend.tsx index 658060d..11552bf 100644 --- a/src/components/constellation/ConstellationLegend.tsx +++ b/src/components/constellation/ConstellationLegend.tsx @@ -22,6 +22,7 @@ export const ConstellationLegend: React.FC = ({ isTouc right: 0, display: 'flex', flexDirection: 'column', + alignItems: 'center', gap: '2px', padding: '8px 12px', pointerEvents: 'none', diff --git a/src/components/constellation/PlayPauseButton.tsx b/src/components/constellation/PlayPauseButton.tsx index 8751391..553997c 100644 --- a/src/components/constellation/PlayPauseButton.tsx +++ b/src/components/constellation/PlayPauseButton.tsx @@ -1,25 +1,72 @@ -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' interface PlayPauseButtonProps { isPlaying: boolean onToggle: () => void isMobile: boolean visible?: boolean + containerRef: React.RefObject } -export const PlayPauseButton: React.FC = ({ isPlaying, onToggle, isMobile, visible = true }) => { - const size = isMobile ? 44 : 36 - const offset = isMobile ? 8 : 12 +export const PlayPauseButton: React.FC = ({ + isPlaying, onToggle, isMobile, visible = true, containerRef, +}) => { + 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 btnRef = useRef(null) + const [topPos, setTopPos] = useState(56) + const [scrolling, setScrolling] = useState(false) + const debounceRef = useRef(0) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const scrollParent = container.closest('.dashboard-main') as HTMLElement | null + if (!scrollParent) return + + const margin = isMobile ? 12 : 56 + + const update = () => { + const cRect = container.getBoundingClientRect() + const sRect = scrollParent.getBoundingClientRect() + const visibleTop = Math.max(sRect.top, cRect.top) + margin + 50 + const visibleBottom = Math.min(sRect.bottom, cRect.bottom) - size - 12 + const targetY = Math.min(visibleTop, visibleBottom) + const relativeTop = targetY - cRect.top + setTopPos(Math.max(margin, relativeTop)) + + setScrolling(true) + clearTimeout(debounceRef.current) + debounceRef.current = window.setTimeout(() => setScrolling(false), 1000) + } + + scrollParent.addEventListener('scroll', update, { passive: true }) + window.addEventListener('resize', update, { passive: true }) + update() + // Don't start hidden — clear the initial scroll trigger + setScrolling(false) + + return () => { + scrollParent.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + clearTimeout(debounceRef.current) + } + }, [containerRef, isMobile, size]) + + const showButton = visible && !scrolling return (