From f75a6b9a5fd13d5238ccd6fbb7142c8fce1905e4 Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Fri, 13 Feb 2026 00:16:20 +0000 Subject: [PATCH] Task 4: Rebuild PatientBanner with premium fonts, tooltip, and animations - Replace font-inter with font-ui (Elvaro Grotesque) throughout banner - Add custom NHSNumberWithTooltip with Framer Motion animated reveal - Add AnimatePresence crossfade between full/condensed banner states - Animate mobile overflow menu enter/exit - Add SkipButton to App.tsx for boot/ECG phase skip - Add shadow-pmr-banner, focus ring styles, prefers-reduced-motion support - Fix mobile banner to use patient data instead of hardcoded values Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 48 +++++++- src/components/PatientBanner.tsx | 203 ++++++++++++++++++++++++------- 2 files changed, 204 insertions(+), 47 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 30c73b9..09743f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import type { Phase } from './types' import { BootSequence } from './components/BootSequence' import { ECGAnimation } from './components/ECGAnimation' @@ -6,10 +6,48 @@ import { LoginScreen } from './components/LoginScreen' import { PMRInterface } from './components/PMRInterface' import { AccessibilityProvider } from './contexts/AccessibilityContext' +function SkipButton({ onSkip }: { onSkip: () => void }) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setVisible(true), 1500) + return () => clearTimeout(timer) + }, []) + + return ( + + ) +} + function App() { const [phase, setPhase] = useState('boot') const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) + const skipToLogin = () => setPhase('login') + return (
@@ -26,12 +64,16 @@ function App() { startPosition={cursorPositionRef.current} /> )} - + {phase === 'login' && ( setPhase('pmr')} /> )} - + {phase === 'pmr' && } + + {(phase === 'boot' || phase === 'ecg') && ( + + )}
) diff --git a/src/components/PatientBanner.tsx b/src/components/PatientBanner.tsx index ca9c0d8..9df71ff 100644 --- a/src/components/PatientBanner.tsx +++ b/src/components/PatientBanner.tsx @@ -1,5 +1,6 @@ import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react' -import { useState } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' import { patient } from '@/data/patient' import { useScrollCondensation } from '@/hooks/useScrollCondensation' @@ -11,6 +12,10 @@ interface PatientBannerProps { export function PatientBanner({ isMobile = false, isTablet = false }: PatientBannerProps) { const { isCondensed, sentinelRef } = useScrollCondensation({ threshold: 100 }) + const prefersReducedMotion = typeof window !== 'undefined' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false + if (isMobile) { return ( <> @@ -37,16 +42,37 @@ export function PatientBanner({ isMobile = false, isTablet = false }: PatientBan className={` sticky top-0 z-40 w-full bg-pmr-banner border-b border-slate-600 + shadow-pmr-banner transition-all duration-200 ease-out ${shouldCondense ? 'h-12' : 'h-20'} `} role="banner" > - {shouldCondense ? ( - - ) : ( - - )} + + {shouldCondense ? ( + + + + ) : ( + + + + )} + ) @@ -54,24 +80,38 @@ export function PatientBanner({ isMobile = false, isTablet = false }: PatientBan function MobileBanner() { const [showOverflow, setShowOverflow] = useState(false) + const menuRef = useRef(null) + + const handleClickOutside = useCallback((e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setShowOverflow(false) + } + }, []) + + useEffect(() => { + if (showOverflow) { + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + } + }, [showOverflow, handleClickOutside]) return (
@@ -129,29 +170,35 @@ function FullBanner() {
+ {/* Row 1: Name, status, badge */}
-

+

{patient.name}

- - {patient.status} - +
+ + {patient.status} +
+ {patient.badge && }
-
+ + {/* Row 2: Demographics with pipe separators */} +
- DOB: {patient.dob} + DOB:{' '} + {patient.dob} | NHS No:{' '} - - {patient.nhsNumber} - + | {patient.address}
-
+ + {/* Row 3: Contact details */} +
+ + {/* Action buttons */}
} @@ -194,18 +243,19 @@ function CondensedBanner() { return (
-

+

{patient.name}

| - NHS No:{' '} - - {patient.nhsNumber} - + NHS No:{' '} + | - +
+ + {patient.status} +
| null>(null) + + const handleMouseEnter = () => { + timeoutRef.current = setTimeout(() => setShowTooltip(true), 300) + } + + const handleMouseLeave = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + setShowTooltip(false) + } + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + return ( + setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + > + + {patient.nhsNumber} + + + {showTooltip && ( + + {patient.nhsNumberTooltip} + + + )} + + + ) +} + interface StatusDotProps { status: string } @@ -245,7 +359,7 @@ interface StatusBadgeProps { function StatusBadge({ badge }: StatusBadgeProps) { return ( - + {badge} ) @@ -269,10 +383,11 @@ function ActionButton({ icon, label, href, external, compact }: ActionButtonProp inline-flex items-center gap-1.5 border border-pmr-nhsblue text-pmr-nhsblue hover:bg-pmr-nhsblue hover:text-white - transition-colors duration-100 + transition-colors duration-150 rounded + font-ui font-medium + focus:outline-none focus:ring-2 focus:ring-pmr-nhsblue/40 focus:ring-offset-1 focus:ring-offset-pmr-banner ${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'} - font-inter font-medium `} > {icon}