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:
@@ -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) => {
|
||||||
setIsExiting(true)
|
const id = setTimeout(fn, delay)
|
||||||
setTimeout(() => {
|
timeoutRefs.current.push(id)
|
||||||
requestFocusAfterLogin()
|
return id
|
||||||
onComplete()
|
}, [])
|
||||||
}, prefersReducedMotion ? 0 : 200)
|
|
||||||
}, [onComplete, requestFocusAfterLogin, prefersReducedMotion])
|
const handleLogin = useCallback(() => {
|
||||||
|
if (!typingComplete || isExiting) return
|
||||||
|
setButtonPressed(true)
|
||||||
|
addTimeout(() => {
|
||||||
|
setIsExiting(true)
|
||||||
|
addTimeout(() => {
|
||||||
|
requestFocusAfterLogin()
|
||||||
|
onComplete()
|
||||||
|
}, prefersReducedMotion ? 0 : 200)
|
||||||
|
}, 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',
|
||||||
|
|||||||
Reference in New Issue
Block a user