feat: redesign boot-to-login transition

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)
This commit is contained in:
2026-02-17 03:28:00 +00:00
parent b266f1f149
commit 150b452bb5
+99 -32
View File
@@ -24,9 +24,9 @@ interface BootConfig {
lineDelay: number lineDelay: number
cursorBlinkInterval: number cursorBlinkInterval: number
holdAfterComplete: number holdAfterComplete: number
loadingDuration: number
fadeOutDuration: number fadeOutDuration: number
cursorShrinkDuration: number cursorShrinkDuration: number
completionDelay: number
} }
colors: { colors: {
bright: string bright: string
@@ -82,15 +82,15 @@ const BOOT_CONFIG: BootConfig = {
{ type: 'module', text: 'population_health.mod', style: 'dim' }, { type: 'module', text: 'population_health.mod', style: 'dim' },
{ type: 'module', text: 'data_analytics.eng', style: 'dim' }, { type: 'module', text: 'data_analytics.eng', style: 'dim' },
{ type: 'separator', text: '---', 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: { timing: {
lineDelay: 220, lineDelay: 220,
cursorBlinkInterval: 300, cursorBlinkInterval: 300,
holdAfterComplete: 1000, holdAfterComplete: 600,
fadeOutDuration: 600, loadingDuration: 1200,
cursorShrinkDuration: 600, fadeOutDuration: 500,
completionDelay: 0, cursorShrinkDuration: 400,
}, },
colors: COLORS, colors: COLORS,
} }
@@ -189,13 +189,62 @@ function buildTypedLines(): TypedLine[] {
const TYPED_LINES = buildTypedLines() const TYPED_LINES = buildTypedLines()
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0) 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 (
<div
style={{
marginTop: 16,
height: 2,
backgroundColor: 'rgba(0, 255, 65, 0.1)',
borderRadius: 1,
overflow: 'hidden',
maxWidth: 280,
}}
>
<div
style={{
height: '100%',
width: `${progress * 100}%`,
backgroundColor: COLORS.bright,
boxShadow: `0 0 8px ${COLORS.bright}40`,
borderRadius: 1,
transition: 'none',
}}
/>
</div>
)
}
// ============================================================================= // =============================================================================
// Main Component // Main Component
// ============================================================================= // =============================================================================
export function BootSequence({ onComplete }: BootSequenceProps) { export function BootSequence({ onComplete }: BootSequenceProps) {
const [typedCount, setTypedCount] = useState(0) 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 [isVisible, setIsVisible] = useState(true)
const cursorAnchorRef = useRef<HTMLSpanElement>(null) const cursorAnchorRef = useRef<HTMLSpanElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -206,7 +255,6 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : false
// Typing engine — runs as a self-scheduling setTimeout chain // Typing engine — runs as a self-scheduling setTimeout chain
useEffect(() => { useEffect(() => {
if (reducedMotion || phase !== 'typing') return if (reducedMotion || phase !== 'typing') return
@@ -253,31 +301,37 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
} }
}, [typedCount, phase, reducedMotion]) }, [typedCount, phase, reducedMotion])
// Hold phase: then start fading // Hold phase → loading
useEffect(() => { useEffect(() => {
if (phase !== 'holding') return if (phase !== 'holding') return
const fadeTimer = setTimeout(() => { const timer = setTimeout(() => {
setPhase('fading') setPhase('loading')
}, BOOT_CONFIG.timing.holdAfterComplete) }, 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]) }, [phase])
// Fade phase: wait for animations to finish, then complete // Fade phase: wait for animations to finish, then complete
useEffect(() => { useEffect(() => {
if (phase !== 'fading') return if (phase !== 'fading') return
const longestFade = Math.max(
BOOT_CONFIG.timing.fadeOutDuration,
BOOT_CONFIG.timing.cursorShrinkDuration
)
const completeTimer = setTimeout(() => { const completeTimer = setTimeout(() => {
setIsVisible(false) setIsVisible(false)
setPhase('done') setPhase('done')
onComplete() onComplete()
}, longestFade + BOOT_CONFIG.timing.completionDelay) }, BOOT_CONFIG.timing.fadeOutDuration)
return () => clearTimeout(completeTimer) return () => clearTimeout(completeTimer)
}, [phase, onComplete]) }, [phase, onComplete])
@@ -379,6 +433,8 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
return renderedLines return renderedLines
} }
const isFadingOut = phase === 'fading' || phase === 'done'
// Reduced motion: instant render // Reduced motion: instant render
if (reducedMotion) { if (reducedMotion) {
return ( return (
@@ -421,13 +477,13 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
<motion.div <motion.div
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden" className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
initial={{ opacity: 1 }} initial={{ opacity: 1 }}
exit={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0 }} transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
> >
{/* CRT Scanlines */} {/* CRT Scanlines */}
<motion.div <motion.div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }} animate={{ opacity: isFadingOut ? 0 : 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }} transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
style={{ style={{
background: `repeating-linear-gradient( background: `repeating-linear-gradient(
@@ -442,28 +498,39 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
{/* Content container */} {/* Content container */}
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10"> <div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Text fades out independently */} {/* Text content — slides up and fades during exit */}
<motion.div <motion.div
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }} animate={{
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }} opacity: isFadingOut ? 0 : 1,
y: isFadingOut ? -20 : 0,
}}
transition={{
duration: BOOT_CONFIG.timing.fadeOutDuration / 1000,
ease: 'easeIn',
}}
> >
{renderLines()} {renderLines()}
{/* Progress bar — appears during loading phase */}
{(phase === 'loading' || phase === 'fading') && (
<ProgressBar active={phase === 'loading'} />
)}
</motion.div> </motion.div>
{/* Cursor rendered outside fading wrapper — shrinks independently */} {/* Cursor rendered outside fading wrapper — shrinks into progress bar */}
{cursorPos && phase !== 'done' && ( {cursorPos && !isFadingOut && (
<span <span
className="absolute animate-blink" className="absolute animate-blink"
style={{ style={{
left: cursorPos.left, left: cursorPos.left,
top: cursorPos.top + (phase === 'fading' ? 12 : 0), top: cursorPos.top,
width: 8, width: 8,
height: phase === 'fading' ? 4 : 16, height: phase === 'loading' ? 4 : 16,
backgroundColor: COLORS.bright, backgroundColor: COLORS.bright,
filter: phase === 'fading' ? 'blur(1px)' : 'none', filter: phase === 'loading' ? 'blur(1px)' : 'none',
boxShadow: phase === 'fading' ? '0 0 12px rgba(0,255,65,0.9)' : 'none', boxShadow: phase === 'loading' ? `0 0 12px ${COLORS.bright}E6` : 'none',
transition: phase === 'fading' transition: phase === 'loading'
? `top ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out` ? `height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
: 'none', : 'none',
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`, animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
}} }}