Task 3: Build LoginScreen component with typing animation

- Created LoginScreen.tsx with character-by-character username typing (30ms/char)
- Password dots fill at 20ms per dot
- Button shows pressed state before transition
- Added 'login' phase to App.tsx flow
- Added PMR colors and fonts to tailwind.config.js
- Added Inter font family to index.html
- Respects prefers-reduced-motion: instant completion in ~500ms
This commit is contained in:
2026-02-11 00:54:48 +00:00
parent 02d7dcabd9
commit 8ee9046bb3
5 changed files with 214 additions and 3 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
<title>Andy Charlwood — MPharm | CV</title> <title>Andy Charlwood — MPharm | CV</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { Phase } from './types' import type { Phase } from './types'
import { BootSequence } from './components/BootSequence' import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation' import { ECGAnimation } from './components/ECGAnimation'
import { LoginScreen } from './components/LoginScreen'
import { FloatingNav } from './components/FloatingNav' import { FloatingNav } from './components/FloatingNav'
import { Hero } from './components/Hero' import { Hero } from './components/Hero'
import { Skills } from './components/Skills' import { Skills } from './components/Skills'
@@ -21,7 +22,11 @@ function App() {
)} )}
{phase === 'ecg' && ( {phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('content')} /> <ECGAnimation onComplete={() => setPhase('login')} />
)}
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('content')} />
)} )}
{phase === 'content' && ( {phase === 'content' && (
+194
View File
@@ -0,0 +1,194 @@
import { useState, useEffect, useCallback } from 'react'
import { Shield } from 'lucide-react'
interface LoginScreenProps {
onComplete: () => void
}
export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true)
const [isTypingUsername, setIsTypingUsername] = useState(true)
const [isTypingPassword, setIsTypingPassword] = useState(false)
const [buttonPressed, setButtonPressed] = useState(false)
const fullUsername = 'A.CHARLWOOD'
const passwordLength = 8
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) {
setUsername(fullUsername)
setPasswordDots(passwordLength)
setTimeout(() => {
setButtonPressed(true)
setTimeout(onComplete, 200)
}, 300)
return
}
setIsTypingUsername(true)
let usernameIndex = 0
const usernameInterval = setInterval(() => {
if (usernameIndex <= fullUsername.length) {
setUsername(fullUsername.slice(0, usernameIndex))
usernameIndex++
} else {
clearInterval(usernameInterval)
setIsTypingUsername(false)
setIsTypingPassword(true)
setTimeout(() => {
let dotCount = 0
const passwordInterval = setInterval(() => {
if (dotCount <= passwordLength) {
setPasswordDots(dotCount)
dotCount++
} else {
clearInterval(passwordInterval)
setIsTypingPassword(false)
setTimeout(() => {
setButtonPressed(true)
setTimeout(onComplete, 200)
}, 150)
}
}, 20)
}, 150)
}
}, 30)
}, [onComplete, prefersReducedMotion])
useEffect(() => {
const cursorInterval = setInterval(() => {
setShowCursor(prev => !prev)
}, 530)
startLoginSequence()
return () => clearInterval(cursorInterval)
}, [startLoginSequence])
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }}
>
<div
className="bg-white rounded-xl shadow-lg p-8"
style={{
width: '320px',
borderRadius: '12px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.3)',
}}
>
<div className="flex flex-col items-center mb-6">
<div
className="p-3 rounded-lg mb-4"
style={{ backgroundColor: 'rgba(0, 94, 184, 0.1)' }}
>
<Shield
size={32}
style={{ color: '#005EB8' }}
strokeWidth={2}
/>
</div>
<span
className="text-sm font-medium"
style={{ color: '#6B7280' }}
>
CareerRecord PMR
</span>
</div>
<div className="space-y-4">
<div>
<label
className="block text-xs font-medium mb-1.5"
style={{ color: '#6B7280' }}
>
Username
</label>
<div
className="w-full px-3 py-2.5 rounded text-sm"
style={{
fontFamily: "'Fira Code', monospace",
backgroundColor: '#F9FAFB',
border: '1px solid #E5E7EB',
color: '#111827',
}}
>
<span>{username}</span>
{isTypingUsername && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
}}
>
|
</span>
)}
</div>
</div>
<div>
<label
className="block text-xs font-medium mb-1.5"
style={{ color: '#6B7280' }}
>
Password
</label>
<div
className="w-full px-3 py-2.5 rounded text-sm"
style={{
fontFamily: "'Fira Code', monospace",
backgroundColor: '#F9FAFB',
border: '1px solid #E5E7EB',
color: '#111827',
letterSpacing: '0.1em',
}}
>
<span>{'\u2022'.repeat(passwordDots)}</span>
{isTypingPassword && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
}}
>
|
</span>
)}
</div>
</div>
<button
className="w-full py-2.5 rounded text-sm font-semibold text-white transition-all duration-100"
style={{
backgroundColor: buttonPressed ? '#004494' : '#005EB8',
borderRadius: '4px',
transform: buttonPressed ? 'scale(0.98)' : 'scale(1)',
}}
>
Log In
</button>
</div>
<div className="mt-6 pt-4 border-t border-gray-100">
<p
className="text-xs text-center"
style={{ color: '#9CA3AF' }}
>
Secure clinical system login
</p>
</div>
</div>
</div>
)
}
+1 -1
View File
@@ -33,7 +33,7 @@ export interface ContactItem {
href?: string href?: string
} }
export type Phase = 'boot' | 'ecg' | 'content' export type Phase = 'boot' | 'ecg' | 'login' | 'content'
export interface BootLine { export interface BootLine {
html: string html: string
+11
View File
@@ -33,11 +33,22 @@ export default {
dim: '#3a6b45', dim: '#3a6b45',
grey: '#666666', grey: '#666666',
}, },
pmr: {
sidebar: '#1E293B',
banner: '#334155',
content: '#F5F7FA',
nhsblue: '#005EB8',
green: '#22C55E',
amber: '#F59E0B',
red: '#EF4444',
},
}, },
fontFamily: { fontFamily: {
primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'], primary: ['Plus Jakarta Sans', 'system-ui', 'sans-serif'],
secondary: ['Inter Tight', 'system-ui', 'sans-serif'], secondary: ['Inter Tight', 'system-ui', 'sans-serif'],
mono: ['Fira Code', 'monospace'], mono: ['Fira Code', 'monospace'],
inter: ['Inter', 'system-ui', 'sans-serif'],
geist: ['Geist Mono', 'Fira Code', 'monospace'],
}, },
boxShadow: { boxShadow: {
'sm': '0 1px 3px rgba(0,0,0,0.06)', 'sm': '0 1px 3px rgba(0,0,0,0.06)',