import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' // ============================================================================= // Types // ============================================================================= type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready' type BootLineStyle = 'bright' | 'dim' | 'cyan' interface BootLine { type: BootLineType text?: string label?: string value?: string style?: BootLineStyle } interface BootConfig { header: string lines: BootLine[] timing: { lineDelay: number cursorBlinkInterval: number holdAfterComplete: number fadeOutDuration: number cursorShrinkDuration: number ecgStartDelay: number } colors: { bright: string dim: string cyan: string } } interface BootSequenceProps { onComplete: () => void onCursorPositionReady?: (position: { x: number; y: number }) => void } interface TypedSegment { text: string color: string bold?: boolean isSeedDot?: boolean } interface TypedLine { segments: TypedSegment[] totalChars: number pauseAfter: number // ms to pause after this line completes speed: number // ms per character (0 = instant) } // ============================================================================= // Configuration // ============================================================================= // Global speed multiplier for typing animation. // 1.0 = default (~3.3s typing). Lower = faster, higher = slower. const TYPING_SPEED = 2 const COLORS = { bright: '#00ff41', dim: '#3a6b45', cyan: '#00e5ff', } const BOOT_CONFIG: BootConfig = { header: 'CLINICAL TERMINAL v3.2.1', lines: [ { type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' }, { type: 'separator', text: '---', style: 'dim' }, { type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' }, { type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' }, { type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis', style: 'bright' }, { type: 'field', label: 'LOCATION', value: 'Norwich, UK', style: 'bright' }, { type: 'separator', text: '---', style: 'dim' }, { type: 'status', text: 'Loading modules...', style: 'dim' }, { type: 'module', text: 'pharmacist_core.sys', style: 'dim' }, { type: 'module', text: 'population_health.mod', style: 'dim' }, { type: 'module', text: 'data_analytics.eng', style: 'dim' }, { type: 'separator', text: '---', style: 'dim' }, { type: 'ready', text: 'READY \u2014 Rendering CV..', style: 'bright' }, ], timing: { lineDelay: 220, cursorBlinkInterval: 300, holdAfterComplete: 900, fadeOutDuration: 600, cursorShrinkDuration: 600, ecgStartDelay: 0, }, colors: COLORS, } // Apply speed multiplier — instant lines (speed=0) stay instant function s(ms: number): number { return Math.round(ms * TYPING_SPEED) } // Build typed lines from BOOT_CONFIG function buildTypedLines(): TypedLine[] { const lines: TypedLine[] = [] // Header const headerText = BOOT_CONFIG.header lines.push({ segments: [{ text: headerText, color: COLORS.bright, bold: true }], totalChars: headerText.length, pauseAfter: s(40), speed: s(18), }) for (const line of BOOT_CONFIG.lines) { switch (line.type) { case 'status': { const text = line.text || '' lines.push({ segments: [{ text, color: COLORS.dim }], totalChars: text.length, pauseAfter: s(40), speed: s(14), }) break } case 'separator': { const text = line.text || '---' lines.push({ segments: [{ text, color: COLORS.dim }], totalChars: text.length, pauseAfter: s(50), speed: 0, // instant }) break } case 'field': { const label = (line.label || '').padEnd(9) const value = line.value || '' const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright lines.push({ segments: [ { text: label, color: COLORS.cyan }, { text: value, color: valueColor }, ], totalChars: label.length + value.length, pauseAfter: s(30), speed: s(10), }) break } case 'module': { const prefix = '[OK] ' const name = line.text || '' lines.push({ segments: [ { text: '[OK]', color: COLORS.bright, bold: true }, { text: ' ', color: COLORS.dim }, { text: name, color: COLORS.dim }, ], totalChars: prefix.length + name.length, pauseAfter: s(50), speed: 0, // instant — stdout output }) break } case 'ready': { const prefix = '> ' const body = line.text || '' const seedDot = '.' lines.push({ segments: [ { text: prefix + body, color: COLORS.bright, bold: true }, { text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true }, ], totalChars: prefix.length + body.length + seedDot.length, pauseAfter: 0, speed: s(16), }) break } } } return lines } const TYPED_LINES = buildTypedLines() const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0) // ============================================================================= // Main Component // ============================================================================= export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) { const [typedCount, setTypedCount] = useState(0) const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing') const [isVisible, setIsVisible] = useState(true) const cursorRef = useRef(null) const cursorAnchorRef = useRef(null) const containerRef = useRef(null) const cursorCapturedRef = useRef(false) const timeoutRef = useRef | null>(null) const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(null) const reducedMotion = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false // Capture cursor position for ECG handoff const captureCursorPosition = useCallback(() => { if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) { const rect = cursorRef.current.getBoundingClientRect() onCursorPositionReady({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }) cursorCapturedRef.current = true } }, [onCursorPositionReady]) // Typing engine — runs as a self-scheduling setTimeout chain useEffect(() => { if (reducedMotion || phase !== 'typing') return // All characters typed if (typedCount >= TOTAL_CHARS) { setPhase('holding') return } // Find which line the cursor is on and position within it let lineStart = 0 let lineIdx = 0 for (let i = 0; i < TYPED_LINES.length; i++) { if (lineStart + TYPED_LINES[i].totalChars > typedCount) { lineIdx = i break } lineStart += TYPED_LINES[i].totalChars } const line = TYPED_LINES[lineIdx] const posInLine = typedCount - lineStart if (posInLine === 0 && line.speed === 0) { // Instant line: show all chars at once after a brief pause timeoutRef.current = setTimeout(() => { setTypedCount(lineStart + line.totalChars) }, line.pauseAfter || 10) } else if (posInLine === 0 && lineIdx > 0) { // Start of a new typed line — apply previous line's pauseAfter timeoutRef.current = setTimeout(() => { setTypedCount(prev => prev + 1) }, TYPED_LINES[lineIdx - 1].pauseAfter) } else { // Type one character at the line's speed timeoutRef.current = setTimeout(() => { setTypedCount(prev => prev + 1) }, line.speed) } return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) } }, [typedCount, phase, reducedMotion]) // Hold phase: capture cursor, then start fading useEffect(() => { if (phase !== 'holding') return captureCursorPosition() const fadeTimer = setTimeout(() => { setPhase('fading') }, BOOT_CONFIG.timing.holdAfterComplete) return () => clearTimeout(fadeTimer) }, [phase, captureCursorPosition]) // Fade phase: wait for animations to finish, then complete useEffect(() => { if (phase !== 'fading') return const longestFade = Math.max( BOOT_CONFIG.timing.fadeOutDuration, BOOT_CONFIG.timing.cursorShrinkDuration ) const completeTimer = setTimeout(() => { setIsVisible(false) setPhase('done') onComplete() }, longestFade + BOOT_CONFIG.timing.ecgStartDelay) return () => clearTimeout(completeTimer) }, [phase, onComplete]) // Reduced motion: skip animation useEffect(() => { if (!reducedMotion) return const timer = setTimeout(onComplete, 500) return () => clearTimeout(timer) }, [reducedMotion, onComplete]) // Track cursor anchor position relative to the content container useLayoutEffect(() => { if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return const anchor = cursorAnchorRef.current.getBoundingClientRect() const container = containerRef.current.getBoundingClientRect() setCursorPos({ left: anchor.left - container.left, top: anchor.top - container.top, }) }, [typedCount, phase]) // Render the typed lines up to typedCount const renderLines = () => { let remaining = typedCount const renderedLines: React.ReactNode[] = [] let cursorPlaced = false for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) { const line = TYPED_LINES[lineIdx] // During typing, render this line if we've started typing into it (or it's the first line with cursor) if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break const charsForLine = Math.min(Math.max(0, remaining), line.totalChars) remaining -= charsForLine // Cursor goes on the line currently being typed, or the last line in non-typing phases const isCursorLine = phase === 'typing' ? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0) : lineIdx === TYPED_LINES.length - 1 // Render segments let charBudget = phase === 'typing' ? charsForLine : line.totalChars const spans: React.ReactNode[] = [] for (let segIdx = 0; segIdx < line.segments.length; segIdx++) { const seg = line.segments[segIdx] if (charBudget <= 0 && phase === 'typing') break const visibleChars = phase === 'typing' ? Math.min(charBudget, seg.text.length) : seg.text.length const visibleText = seg.text.slice(0, visibleChars) charBudget -= visibleChars if (seg.isSeedDot && visibleChars > 0) { spans.push( {visibleText} ) } else if (visibleChars > 0) { spans.push( {visibleText} ) } } // Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper) if (isCursorLine && phase !== 'done') { cursorPlaced = true spans.push( ) } renderedLines.push(
{spans}
) } return renderedLines } // Reduced motion: instant render if (reducedMotion) { return (
{(() => { // Render all lines fully const lines: React.ReactNode[] = [] for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) { const line = TYPED_LINES[lineIdx] const spans: React.ReactNode[] = [] for (let segIdx = 0; segIdx < line.segments.length; segIdx++) { const seg = line.segments[segIdx] spans.push( {seg.text} ) } lines.push(
{spans}
) } return lines })()}
) } return ( {isVisible && ( {/* CRT Scanlines */} {/* Content container */}
{/* Text fades out independently */} {renderLines()} {/* Cursor rendered outside fading wrapper — shrinks independently */} {cursorPos && phase !== 'done' && ( )}
)}
) } export type { BootConfig, BootLine, BootLineType } export { BOOT_CONFIG }