Merge branch 'ralph/login-screen-rework'

# Conflicts:
#	Ralph/prd.json
#	Ralph/progress.txt
#	src/components/TopBar.tsx
This commit is contained in:
2026-02-15 02:20:32 +00:00
37 changed files with 4933 additions and 395 deletions
+5 -5
View File
@@ -73,16 +73,16 @@ function App() {
/>
)}
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
{phase === 'pmr' && (
{(phase === 'login' || phase === 'pmr') && (
<DetailPanelProvider>
<DashboardLayout />
</DetailPanelProvider>
)}
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
{(phase === 'boot' || phase === 'ecg') && (
<SkipButton onSkip={skipToLogin} />
)}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
interface CvmisLogoProps {
size?: number
cssHeight?: string
animated?: boolean
className?: string
}
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
const prefersReducedMotion = useReducedMotion()
const [animationPhase, setAnimationPhase] = useState<'rise' | 'fan' | 'done'>(
animated && !prefersReducedMotion ? 'rise' : 'done'
)
useEffect(() => {
if (!animated || prefersReducedMotion) return
const riseTimer = setTimeout(() => setAnimationPhase('fan'), 500)
const fanTimer = setTimeout(() => setAnimationPhase('done'), 1000)
return () => {
clearTimeout(riseTimer)
clearTimeout(fanTimer)
}
}, [animated, prefersReducedMotion])
const skipAnimation = !animated || prefersReducedMotion
const showAll = animationPhase === 'fan' || animationPhase === 'done'
// The original SVG viewBox is 600pt x 506pt with an internal transform of
// scale(0.05, -0.05) translate(0, 506). We keep the original coordinate
// system and let the outer viewBox handle scaling.
return (
<svg
viewBox="0 0 600 506"
height={cssHeight ? undefined : size}
className={className}
role="img"
aria-label="CVMIS logo"
style={{ overflow: 'visible', ...(cssHeight ? { height: cssHeight, width: 'auto' } : {}) }}
>
<g transform="translate(0,506) scale(0.05,-0.05)" stroke="none">
{/* Capsule: Rx (Pharmacy) - Left — teal, tilts left in fan */}
<motion.g
id="capsule-rx"
fill="#0b7979"
initial={skipAnimation ? false : { opacity: 0 }}
animate={
skipAnimation
? { opacity: 1, rotate: 0, x: 0, y: 0 }
: showAll
? { opacity: 1, rotate: 0, x: 0, y: 0 }
: { opacity: 0 }
}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ transformOrigin: '2500px 3000px' }}
>
<path d="M2060 6850 c-914 -249 -1279 -1334 -697 -2071 47 -60 198 -225 336 -366 138 -141 256 -265 262 -275 6 -10 150 -160 320 -333 300 -306 1129 -1163 1490 -1542 549 -575 1246 -700 1772 -318 l86 62 -105 35 c-506 172 -872 557 -1036 1089 l-55 179 -11 1003 -12 1003 -550 567 c-780 805 -801 822 -1095 932 -172 65 -531 82 -705 35z m610 -1228 c80 -45 128 -177 97 -261 l-23 -59 99 -21 c147 -31 144 -33 131 87 -10 101 -7 111 54 170 86 83 87 82 102 -73 24 -254 8 -234 213 -270 99 -18 187 -38 194 -45 22 -23 -136 -153 -173 -143 -19 6 -71 16 -115 23 l-82 12 9 -122 c9 -117 6 -126 -57 -191 -80 -83 -99 -74 -99 47 0 52 -6 141 -13 198 l-12 104 -137 31 c-180 41 -244 39 -288 -9 -41 -45 -37 -52 98 -195 l81 -85 -66 -65 -66 -65 -189 200 c-105 110 -232 248 -283 307 l-93 106 159 150 c236 222 314 251 459 169z" />
<path d="M2395 5412 c-112 -103 -113 -108 -37 -180 65 -63 93 -57 185 41 73 77 81 140 26 196 -47 46 -70 39 -174 -57z" />
</motion.g>
{/* Capsule: Terminal (Code) - Centre — amber, stays upright */}
<motion.g
id="capsule-terminal"
fill="#d97706"
initial={skipAnimation ? false : { opacity: 0 }}
animate={
skipAnimation
? { opacity: 1, rotate: 0, x: 0, y: 0 }
: showAll
? { opacity: 1, rotate: 0, x: 0, y: 0 }
: { opacity: 0 }
}
transition={{ duration: 0.5, ease: 'easeOut', delay: skipAnimation ? 0 : 0.05 }}
style={{ transformOrigin: '5500px 5000px' }}
>
<path d="M5740 8362 c-476 -105 -891 -512 -1015 -997 -45 -173 -54 -3865 -11 -4070 50 -233 182 -483 355 -671 185 -201 701 -447 777 -371 11 11 -100 221 -119 267 -19 46 -18 106 -37 200 -66 317 -11 705 143 1010 120 237 111 226 917 1060 255 264 493 513 528 554 l65 74 -8 916 c-9 1115 -24 1196 -286 1542 -286 377 -851 587 -1309 486z m31 -1595 c115 -118 209 -223 209 -236 0 -12 -97 -118 -215 -236 l-215 -215 -55 48 c-75 64 -76 62 103 244 l159 162 -159 158 c-175 174 -176 176 -115 242 62 65 57 68 288 -167z m825 -613 l-6 -64 -295 -6 -295 -5 0 75 0 76 301 -6 301 -6 -6 -64z" />
</motion.g>
{/* Capsule: Data (Analytics) - Right — green, the "rising" capsule */}
<motion.g
id="capsule-data"
fill="#059669"
initial={
skipAnimation
? false
: { opacity: 0, scale: 0, y: 2000 }
}
animate={
skipAnimation
? { opacity: 1, scale: 1, y: 0, rotate: 0 }
: animationPhase === 'rise'
? { opacity: 1, scale: 1, y: 0 }
: { opacity: 1, scale: 1, y: 0, rotate: 0 }
}
transition={{
duration: 0.5,
ease: 'easeOut',
delay: skipAnimation ? 0 : (animationPhase === 'fan' ? 0.1 : 0),
}}
style={{ transformOrigin: '9000px 3000px' }}
>
<path d="M9380 6850 c-351 -63 -390 -94 -1322 -1027 -1753 -1757 -1929 -1943 -2039 -2162 -455 -906 300 -1962 1305 -1822 381 53 567 178 1165 785 2249 2284 2186 2217 2302 2468 432 933 -380 1945 -1411 1758z m35 -1254 l83 -86 -325 -325 -325 -325 -89 91 -89 91 319 319 c369 370 320 342 426 235z m-502 -59 c88 -86 90 -81 -108 -280 l-175 -176 -86 85 -85 84 175 174 c201 201 192 197 279 113z m1036 -132 c11 -8 47 -44 79 -80 l60 -65 -409 -409 -409 -409 -86 84 -86 84 405 405 c223 224 410 406 416 406 5 0 19 -7 30 -16z m-460 -164 l79 -81 -254 -254 -254 -254 -81 79 c-99 97 -115 63 168 349 276 279 243 263 342 161z" />
</motion.g>
</g>
</svg>
)
}
+87 -58
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { motion } from 'framer-motion'
import { Shield } from 'lucide-react'
import { CvmisLogo } from './CvmisLogo'
import { useAccessibility } from '../contexts/AccessibilityContext'
interface LoginScreenProps {
@@ -18,6 +18,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [typingComplete, setTypingComplete] = useState(false)
const [buttonHovered, setButtonHovered] = useState(false)
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
const [dotCount, setDotCount] = useState(0)
const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'a.recruiter'
@@ -32,6 +33,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
const dotIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loginButtonRef = useRef<HTMLButtonElement>(null)
const addTimeout = useCallback((fn: () => void, delay: number) => {
@@ -49,10 +51,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
setIsLoading(true)
addTimeout(() => {
setIsExiting(true)
// After dissolve completes (~600ms), remove overlay and reveal dashboard
addTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 200)
}, prefersReducedMotion ? 0 : 600)
}, prefersReducedMotion ? 0 : 600)
}, 100)
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
@@ -107,21 +110,41 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}
}, [canLogin])
// Connection transitions to green 500ms after typing completes
useEffect(() => {
if (!typingComplete) return
const timeout = addTimeout(() => {
setConnectionState('connected')
}, prefersReducedMotion ? 0 : 500)
return () => clearTimeout(timeout)
}, [typingComplete, addTimeout, prefersReducedMotion])
// Animated trailing dots while connecting
useEffect(() => {
if (connectionState === 'connected' || prefersReducedMotion) {
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
return
}
dotIntervalRef.current = setInterval(() => {
setDotCount(prev => (prev + 1) % 4)
}, 500)
return () => {
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
}
}, [connectionState, prefersReducedMotion])
useEffect(() => {
// Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => {
setShowCursor(prev => !prev)
}, 530)
// Connection status: transitions to connected after ~2000ms
const connectionTimeout = addTimeout(() => {
setConnectionState('connected')
}, 2000)
// Delay start slightly for card entrance animation
// Delay start to allow card entrance + logo animation to complete
// Reduced motion: logo shows instantly, so use original 400ms delay
// Full motion: 400ms card entrance + 1000ms logo animation + 100ms pause = 1500ms
const startTimeout = addTimeout(() => {
startLoginSequence()
}, 400)
}, prefersReducedMotion ? 400 : 1500)
// Capture ref value for cleanup
const pendingTimeouts = timeoutRefs.current
@@ -130,11 +153,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
clearTimeout(startTimeout)
clearTimeout(connectionTimeout)
pendingTimeouts.forEach(id => clearTimeout(id))
}
}, [startLoginSequence, addTimeout])
}, [startLoginSequence, addTimeout, prefersReducedMotion])
const buttonBg = buttonPressed
? '#085858'
@@ -143,25 +166,36 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
: '#0D6E6E'
return (
<div
<motion.div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1A2B2A' }}
style={{
backgroundColor: 'rgba(240, 245, 244, 0.7)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
animate={isExiting ? {
backgroundColor: 'rgba(240, 245, 244, 0)',
backdropFilter: 'blur(0px)',
WebkitBackdropFilter: 'blur(0px)',
} : {}}
transition={isExiting ? { duration: 0.6, ease: 'easeOut' } : {}}
role="dialog"
aria-label="Clinical system login"
aria-modal="true"
>
<motion.div
className="bg-white"
style={{
width: '320px',
padding: '32px',
width: 'clamp(320px, 28vw, 480px)',
maxWidth: 'calc(100vw - 32px)',
padding: 'clamp(24px, 2.5vw, 40px)',
borderRadius: '12px',
border: '1px solid #E5E7EB',
boxShadow: '0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)',
border: '1px solid #E4EDEB',
boxShadow: '0 1px 2px rgba(26,43,42,0.05)',
backgroundColor: 'var(--surface, #FFFFFF)',
}}
initial={{ opacity: 0, scale: 0.98 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
transition={isExiting ? { duration: 0.4, ease: 'easeOut' } : { duration: 0.2, ease: 'easeOut' }}
>
{isLoading ? (
<div
@@ -179,7 +213,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
width: '32px',
height: '32px',
border: '3px solid #E5E7EB',
border: '3px solid #E4EDEB',
borderTopColor: '#0D6E6E',
borderRadius: '50%',
}}
@@ -203,18 +237,10 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
className="flex flex-col items-center"
style={{ marginBottom: '28px' }}
>
<div
style={{
padding: '10px',
borderRadius: '8px',
backgroundColor: 'rgba(13, 110, 110, 0.08)',
marginBottom: '10px',
}}
>
<Shield
size={26}
style={{ color: '#0D6E6E' }}
strokeWidth={2.5}
<div style={{ marginBottom: '10px' }}>
<CvmisLogo
cssHeight="clamp(48px, 4vw, 64px)"
animated={true}
/>
</div>
<span
@@ -222,22 +248,22 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
fontFamily: "var(--font-ui)",
fontSize: '13px',
fontWeight: 600,
color: '#64748B',
color: 'var(--text-secondary, #5B7A78)',
letterSpacing: '0.01em',
}}
>
CareerRecord PMR
CVMIS
</span>
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: '11px',
fontWeight: 400,
color: '#94A3B8',
color: 'var(--text-tertiary, #8DA8A5)',
marginTop: '2px',
}}
>
Clinical Information System
CV Management Information System
</span>
</div>
@@ -249,9 +275,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: '12px',
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 500,
color: '#64748B',
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
>
@@ -262,9 +288,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: '100%',
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px',
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
border: activeField === 'username' ? '1px solid #0D6E6E' : '1px solid #E5E7EB',
fontSize: 'clamp(13px, 1.1vw, 15px)',
backgroundColor: activeField === 'username' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'username' ? '1px solid var(--accent, #0D6E6E)' : '1px solid #E4EDEB',
borderRadius: '4px',
color: '#111827',
minHeight: '38px',
@@ -291,9 +317,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: '12px',
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 500,
color: '#64748B',
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
>
@@ -304,9 +330,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: '100%',
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px',
backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
border: activeField === 'password' ? '1px solid #0D6E6E' : '1px solid #E5E7EB',
fontSize: 'clamp(13px, 1.1vw, 15px)',
backgroundColor: activeField === 'password' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'password' ? '1px solid var(--accent, #0D6E6E)' : '1px solid #E4EDEB',
borderRadius: '4px',
color: '#111827',
letterSpacing: '0.15em',
@@ -335,12 +361,12 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
disabled={!canLogin}
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
className="focus-visible:ring-2 focus-visible:ring-[#0D6E6E]/40 focus-visible:ring-offset-2 focus:outline-none"
className={`focus-visible:ring-2 focus-visible:ring-[#0D6E6E]/40 focus-visible:ring-offset-2 focus:outline-none${canLogin && !buttonPressed ? ' login-pulse-active' : ''}`}
style={{
width: '100%',
padding: '10px 16px',
fontFamily: "var(--font-ui)",
fontSize: '14px',
fontSize: 'clamp(14px, 1.1vw, 16px)',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: buttonBg,
@@ -366,25 +392,28 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
>
<span
style={{
width: '6px',
height: '6px',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: connectionState === 'connected' ? '#059669' : '#DC2626',
transition: prefersReducedMotion ? 'none' : 'background-color 300ms ease',
boxShadow: connectionState === 'connected'
? '0 0 6px 1px rgba(5,150,105,0.4)'
: '0 0 6px 1px rgba(220,38,38,0.4)',
transition: prefersReducedMotion ? 'none' : 'background-color 300ms ease, box-shadow 300ms ease',
flexShrink: 0,
}}
/>
<span
style={{
fontFamily: "var(--font-geist-mono, 'Geist Mono', monospace)",
fontSize: '10px',
color: connectionState === 'connected' ? '#059669' : '#8DA8A5',
fontSize: '12px',
color: connectionState === 'connected' ? '#059669' : '#DC2626',
transition: prefersReducedMotion ? 'none' : 'color 300ms ease',
}}
>
{connectionState === 'connected'
? 'Secure connection established'
: 'Awaiting secure connection...'}
? 'Secure connection established, awaiting login'
: `Awaiting secure connection${'.'.repeat(dotCount)}`}
</span>
</div>
</div>
@@ -394,14 +423,14 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
marginTop: '22px',
paddingTop: '18px',
borderTop: '1px solid #E5E7EB',
borderTop: '1px solid #E4EDEB',
}}
>
<p
style={{
fontFamily: "var(--font-ui)",
fontSize: '11px',
color: '#94A3B8',
color: 'var(--text-tertiary, #8DA8A5)',
textAlign: 'center',
}}
>
@@ -411,6 +440,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</>
)}
</motion.div>
</div>
</motion.div>
)
}
+3 -6
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Home, Search } from 'lucide-react'
import { Search } from 'lucide-react'
import { CvmisLogo } from './CvmisLogo'
interface TopBarProps {
onSearchClick?: () => void
@@ -54,11 +55,7 @@ export function TopBar({ onSearchClick }: TopBarProps) {
</a>
{/* Brand */}
<div className="flex items-center gap-2 shrink-0">
<Home
size={20}
style={{ color: 'var(--accent)' }}
aria-hidden="true"
/>
<CvmisLogo size={24} />
<span
className="font-ui hidden sm:inline"
style={{
+19
View File
@@ -235,6 +235,20 @@ html {
animation: login-spin 0.8s linear infinite;
}
/* Login button pulse — draws attention when button becomes clickable */
@keyframes login-pulse {
0%, 60%, 100% { transform: scale(1); }
30% { transform: scale(1.03); }
}
.login-pulse-active {
animation: login-pulse 3s ease-in-out infinite;
}
.login-pulse-active:hover {
animation: none;
}
/* Custom scrollbar for sidebar */
.pmr-scrollbar {
scrollbar-width: thin;
@@ -412,6 +426,11 @@ textarea:focus-visible {
border-top-color: #0D6E6E;
}
/* No pulse animation */
.login-pulse-active {
animation: none;
}
/* Instant SubNav transitions */
.subnav-scroll button {
transition: none !important;