Task 3: Rebuild LoginScreen with interactive login and premium font

- Typing speed: 80ms/char username, 60ms/dot password (was 30ms/20ms)
- Login button is now user-interactive (not auto-triggered)
- Button disabled/dimmed during typing, fully interactive after
- Hover state on button (darkens to #004D9F)
- Font changed from Inter to Elvaro Grotesque (var(--font-ui))
- Card shadow upgraded to multi-layered per design system
- Added 'done' activeField state for post-typing phase
- Proper timer cleanup via tracked timeout refs
- Reduced motion: typing instant, button immediately clickable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 23:44:33 +00:00
parent 556940c3c8
commit 5e1c96edfa
+61 -43
View File
@@ -11,9 +11,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0) const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true) const [showCursor, setShowCursor] = useState(true)
const [activeField, setActiveField] = useState<'username' | 'password' | null>('username') const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
const [buttonPressed, setButtonPressed] = useState(false) const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false) const [isExiting, setIsExiting] = useState(false)
const [typingComplete, setTypingComplete] = useState(false)
const [buttonHovered, setButtonHovered] = useState(false)
const { requestFocusAfterLogin } = useAccessibility() const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'A.CHARLWOOD' const fullUsername = 'A.CHARLWOOD'
@@ -23,34 +25,41 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : false
// Refs for interval cleanup // Refs for interval/timeout cleanup
const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
const triggerComplete = useCallback(() => { const addTimeout = useCallback((fn: () => void, delay: number) => {
const id = setTimeout(fn, delay)
timeoutRefs.current.push(id)
return id
}, [])
const handleLogin = useCallback(() => {
if (!typingComplete || isExiting) return
setButtonPressed(true)
addTimeout(() => {
setIsExiting(true) setIsExiting(true)
setTimeout(() => { addTimeout(() => {
requestFocusAfterLogin() requestFocusAfterLogin()
onComplete() onComplete()
}, prefersReducedMotion ? 0 : 200) }, prefersReducedMotion ? 0 : 200)
}, [onComplete, requestFocusAfterLogin, prefersReducedMotion]) }, 100)
}, [typingComplete, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
const startLoginSequence = useCallback(() => { const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) { if (prefersReducedMotion) {
setUsername(fullUsername) setUsername(fullUsername)
setPasswordDots(passwordLength) setPasswordDots(passwordLength)
setActiveField(null) setActiveField('done')
setTimeout(() => { setTypingComplete(true)
setButtonPressed(true) // Button is immediately available for user to click
setTimeout(() => {
triggerComplete()
}, 100)
}, 300)
return return
} }
// Username typing: 30ms per character // Username typing: 80ms per character
let usernameIndex = 0 let usernameIndex = 0
usernameIntervalRef.current = setInterval(() => { usernameIntervalRef.current = setInterval(() => {
if (usernameIndex <= fullUsername.length) { if (usernameIndex <= fullUsername.length) {
@@ -62,8 +71,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
} }
setActiveField('password') setActiveField('password')
// Password dots: 20ms per dot, after 150ms pause // Password dots: 60ms per dot, after 300ms pause
setTimeout(() => { addTimeout(() => {
let dotCount = 0 let dotCount = 0
passwordIntervalRef.current = setInterval(() => { passwordIntervalRef.current = setInterval(() => {
if (dotCount <= passwordLength) { if (dotCount <= passwordLength) {
@@ -73,21 +82,15 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
if (passwordIntervalRef.current) { if (passwordIntervalRef.current) {
clearInterval(passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
} }
setActiveField(null) setActiveField('done')
setTypingComplete(true)
// Button press: after 150ms pause // Button becomes interactive — user clicks to proceed
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
triggerComplete()
}, 200)
}, 150)
} }
}, 20) }, 60)
}, 150) }, 300)
} }
}, 30) }, 80)
}, [triggerComplete, prefersReducedMotion]) }, [prefersReducedMotion, addTimeout])
useEffect(() => { useEffect(() => {
// Cursor blink: 530ms interval // Cursor blink: 530ms interval
@@ -95,18 +98,28 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setShowCursor(prev => !prev) setShowCursor(prev => !prev)
}, 530) }, 530)
// Delay start slightly for card entrance // Delay start slightly for card entrance animation
const startTimeout = setTimeout(() => { const startTimeout = addTimeout(() => {
startLoginSequence() startLoginSequence()
}, 200) }, 400)
// Capture ref value for cleanup
const pendingTimeouts = timeoutRefs.current
return () => { return () => {
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current) if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current) if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current) if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
clearTimeout(startTimeout) clearTimeout(startTimeout)
pendingTimeouts.forEach(id => clearTimeout(id))
} }
}, [startLoginSequence]) }, [startLoginSequence, addTimeout])
const buttonBg = buttonPressed
? '#004494'
: buttonHovered && typingComplete
? '#004D9F'
: '#005EB8'
return ( return (
<div <div
@@ -122,7 +135,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
padding: '32px', padding: '32px',
borderRadius: '12px', borderRadius: '12px',
border: '1px solid #E5E7EB', border: '1px solid #E5E7EB',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)', boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)',
}} }}
initial={{ opacity: 0, scale: 0.98 }} initial={{ opacity: 0, scale: 0.98 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }} animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
@@ -149,7 +162,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</div> </div>
<span <span
style={{ style={{
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '13px', fontSize: '13px',
fontWeight: 600, fontWeight: 600,
color: '#64748B', color: '#64748B',
@@ -160,7 +173,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</span> </span>
<span <span
style={{ style={{
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '11px', fontSize: '11px',
fontWeight: 400, fontWeight: 400,
color: '#94A3B8', color: '#94A3B8',
@@ -178,7 +191,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<label <label
style={{ style={{
display: 'block', display: 'block',
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: '#64748B', color: '#64748B',
@@ -220,7 +233,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<label <label
style={{ style={{
display: 'block', display: 'block',
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: '#64748B', color: '#64748B',
@@ -258,20 +271,25 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</div> </div>
</div> </div>
{/* Log In Button */} {/* Log In Button — user clicks to proceed */}
<button <button
onClick={handleLogin}
disabled={!typingComplete}
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 16px', padding: '10px 16px',
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '14px', fontSize: '14px',
fontWeight: 600, fontWeight: 600,
color: '#FFFFFF', color: '#FFFFFF',
backgroundColor: buttonPressed ? '#004494' : '#005EB8', backgroundColor: buttonBg,
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: typingComplete ? 'pointer' : 'default',
transition: 'background-color 100ms ease-out', opacity: typingComplete ? 1 : 0.6,
transition: 'background-color 150ms, opacity 300ms',
}} }}
> >
Log In Log In
@@ -288,7 +306,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
> >
<p <p
style={{ style={{
fontFamily: "'Inter', system-ui, sans-serif", fontFamily: "var(--font-ui)",
fontSize: '11px', fontSize: '11px',
color: '#94A3B8', color: '#94A3B8',
textAlign: 'center', textAlign: 'center',