From 150b452bb58bdf8ea8085e1087976f4a6ef47d28 Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Tue, 17 Feb 2026 03:28:00 +0000 Subject: [PATCH] feat: redesign boot-to-login transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New boot flow: typing → holding → loading (progress bar) → fade → login - Added ProgressBar component with ease-out animation during loading phase - Terminal text slides up and fades during exit transition - Cursor shrinks during loading phase for visual continuity - Progress bar appears below terminal text, fills over 1.2s - Entire container fades out smoothly before transitioning to login - Reduced motion: instant render, no animation (unchanged) - Changed "Rendering CV" → "Launching CV" for better software-launch feel - Tuned timing: shorter hold (600ms), loading (1200ms), faster fade (500ms) --- src/components/BootSequence.tsx | 131 ++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 32 deletions(-) diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 4b6b138..5c093c4 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -24,9 +24,9 @@ interface BootConfig { lineDelay: number cursorBlinkInterval: number holdAfterComplete: number + loadingDuration: number fadeOutDuration: number cursorShrinkDuration: number - completionDelay: number } colors: { bright: string @@ -82,15 +82,15 @@ const BOOT_CONFIG: BootConfig = { { 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' }, + { type: 'ready', text: 'READY \u2014 Launching CV..', style: 'bright' }, ], timing: { lineDelay: 220, cursorBlinkInterval: 300, - holdAfterComplete: 1000, - fadeOutDuration: 600, - cursorShrinkDuration: 600, - completionDelay: 0, + holdAfterComplete: 600, + loadingDuration: 1200, + fadeOutDuration: 500, + cursorShrinkDuration: 400, }, colors: COLORS, } @@ -189,13 +189,62 @@ function buildTypedLines(): TypedLine[] { const TYPED_LINES = buildTypedLines() const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0) +// ============================================================================= +// Progress Bar Component +// ============================================================================= + +function ProgressBar({ active }: { active: boolean }) { + const [progress, setProgress] = useState(0) + + useEffect(() => { + if (!active) return + const start = performance.now() + let raf: number + + const tick = (now: number) => { + const elapsed = now - start + const pct = Math.min(elapsed / BOOT_CONFIG.timing.loadingDuration, 1) + // Ease-out curve for natural feel + setProgress(1 - Math.pow(1 - pct, 2.5)) + if (pct < 1) raf = requestAnimationFrame(tick) + } + + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [active]) + + return ( +
+
+
+ ) +} + // ============================================================================= // Main Component // ============================================================================= export function BootSequence({ onComplete }: BootSequenceProps) { const [typedCount, setTypedCount] = useState(0) - const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing') + const [phase, setPhase] = useState<'typing' | 'holding' | 'loading' | 'fading' | 'done'>('typing') const [isVisible, setIsVisible] = useState(true) const cursorAnchorRef = useRef(null) const containerRef = useRef(null) @@ -206,7 +255,6 @@ export function BootSequence({ onComplete }: BootSequenceProps) { ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false - // Typing engine — runs as a self-scheduling setTimeout chain useEffect(() => { if (reducedMotion || phase !== 'typing') return @@ -253,31 +301,37 @@ export function BootSequence({ onComplete }: BootSequenceProps) { } }, [typedCount, phase, reducedMotion]) - // Hold phase: then start fading + // Hold phase → loading useEffect(() => { if (phase !== 'holding') return - const fadeTimer = setTimeout(() => { - setPhase('fading') + const timer = setTimeout(() => { + setPhase('loading') }, BOOT_CONFIG.timing.holdAfterComplete) - return () => clearTimeout(fadeTimer) + return () => clearTimeout(timer) + }, [phase]) + + // Loading phase → fading (after progress bar completes) + useEffect(() => { + if (phase !== 'loading') return + + const timer = setTimeout(() => { + setPhase('fading') + }, BOOT_CONFIG.timing.loadingDuration + 100) + + return () => clearTimeout(timer) }, [phase]) // 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.completionDelay) + }, BOOT_CONFIG.timing.fadeOutDuration) return () => clearTimeout(completeTimer) }, [phase, onComplete]) @@ -379,6 +433,8 @@ export function BootSequence({ onComplete }: BootSequenceProps) { return renderedLines } + const isFadingOut = phase === 'fading' || phase === 'done' + // Reduced motion: instant render if (reducedMotion) { return ( @@ -421,13 +477,13 @@ export function BootSequence({ onComplete }: BootSequenceProps) { {/* CRT Scanlines */} - {/* Text fades out independently */} + {/* Text content — slides up and fades during exit */} {renderLines()} + + {/* Progress bar — appears during loading phase */} + {(phase === 'loading' || phase === 'fading') && ( + + )} - {/* Cursor rendered outside fading wrapper — shrinks independently */} - {cursorPos && phase !== 'done' && ( + {/* Cursor rendered outside fading wrapper — shrinks into progress bar */} + {cursorPos && !isFadingOut && (