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:
@@ -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`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user