Task 1b: Rebuild boot sequence and ECG animation

- Refactored BootSequence to config-driven architecture with type-safe line components
- Added cursor position capture and smooth cursor-to-dot morph transition
- Rebuilt ECGAnimation with mask-based text reveal technique
- Implemented connector lines between letters with per-character profiles
- ECG trace now starts from cursor position (no teleport)
- Added prefers-reduced-motion support for both phases
- Updated App.tsx to pass cursor position between components

Quality checks: typecheck ✓, lint ✓, build ✓
This commit is contained in:
2026-02-11 22:54:44 +00:00
parent cfd0283c78
commit 959f0e1842
5 changed files with 661 additions and 140 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ Each task below references a specific file in `Ralph/refs/` — read ONLY that f
- [x] **Task 1: Design system foundation and font setup.** Read `Ralph/refs/ref-design-system.md`. Audit and fix the Tailwind config (`tailwind.config.js`) and global CSS (`src/index.css`) to ensure ALL PMR color tokens, typography, and spacing match the design system spec exactly. Specific fixes needed: (a) Ensure Geist Mono font is loaded via Google Fonts or local import — currently the project uses Fira Code for monospace but the spec requires Geist Mono for coded entries, timestamps, and data values. (b) Verify all PMR color tokens exist in Tailwind config: main content `#F5F7FA`, cards `#FFFFFF`, sidebar `#1E293B`, patient banner `#334155`, NHS blue `#005EB8`, green `#22C55E`, amber `#F59E0B`, red `#EF4444`, text primary `#111827`, text secondary `#6B7280`. (c) Ensure border-radius defaults to 4px for cards/inputs (not 8px or 12px — clinical systems use minimal rounding). (d) Add a `.pmr-theme` class or CSS custom properties layer for PMR-specific tokens if not already present. (e) Verify Inter font is loaded and configured as the primary font family. Do NOT invoke /frontend-design for this task — it's pure configuration.
- [ ] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
- [x] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
- [ ] **Task 2: Rebuild LoginScreen component.** Read `Ralph/refs/ref-transition-login.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/LoginScreen.tsx` to match the login sequence specification exactly: (a) Dark blue-gray `#1E293B` background. (b) White card: 320px wide, **12px border-radius** (exception to the 4px rule — login cards can be rounder), subtle shadow. (c) NHS-blue shield icon at top with "CareerRecord PMR" branding text. (d) Username field types `A.CHARLWOOD` at 30ms/char in **Geist Mono** font. (e) Password field fills 8 dots at 20ms/dot. (f) Blinking cursor (530ms interval) in active field. (g) "Log In" button: NHS blue `#005EB8`, full width, pressed state darkens to `#004494`. (h) After submit: card scales to 103% and fades out over 200ms. (i) Respect `prefers-reduced-motion`. The login must feel like actually logging into NHS software at 8am on a Monday.
+55
View File
@@ -91,3 +91,58 @@ Do NOT invoke the `/frontend-design` skill at runtime — it was pre-run and the
### ECG Reference Implementation
`ECGCombined.tsx` in the project root is a Remotion version of the ECG animation with a superior mask-based text reveal technique. Task 1b references this for the canvas implementation.
### Iteration 2 — Task 1b: Rebuild boot sequence and ECG animation
**Completed:** Task 1b
**Changes made:**
- **BootSequence.tsx**: Completely refactored from hardcoded HTML strings to config-driven architecture
- Created type-safe `BootConfig`, `BootLine`, `BootLineType` interfaces
- Individual line components: `BootLineHeader`, `BootLineStatus`, `BootLineSeparator`, `BootLineField`, `BootLineModule`, `BootLineReady`
- Added CRT scanlines overlay during boot phase
- Cursor now captures its screen position via ref and passes to parent via `onCursorPositionReady` callback
- Cursor morph animation: block cursor scales down to 0 width over 300ms before ECG starts
- Reduced motion support: instant boot completion, skips to ECG immediately
- **ECGAnimation.tsx**: Rebuilt with mask-based text reveal technique from ECGCombined.tsx
- Added `startPosition` prop to receive cursor position from BootSequence
- ECG trace now starts from cursor position (with `startOffsetX`) instead of x=0
- Implemented offscreen canvas pre-rendering for text stroke
- Mask-based text reveal: clipping region follows trace head, revealing pre-rendered text
- Added connector lines between letters at baseline using `CONNECTOR_PROFILES`
- Letter profiles define connector insets for natural-looking baseline connections
- Multi-layer neon glow: outer (6px, 25% opacity), inner (2px solid)
- Flatline draw phase extends to right edge after text completion
- Background transitions from black to `#1E293B` (login background)
- Reduced motion support: instant transition to PMR phase
- **App.tsx**: Updated to pass cursor position between BootSequence and ECGAnimation
- Added `cursorPosition` state
- `handleCursorPositionReady` captures position from BootSequence
- Passed to ECGAnimation as `startPosition` prop
**Codebase patterns discovered:**
- Canvas animation performance: pre-render text to offscreen canvas, then drawImage through clip region
- Cursor-to-dot transition requires DOM ref position capture, not just CSS animation
- World-space coordinates (headWX) vs screen-space coordinates (headSX) separation is critical
- Viewport scrolling logic: offset calculated as `headWX - headSX` keeps trace visible
- Connector profiles per character (C, O, D, L, E have special insets) make letter connections look natural
- Background color transition handled via CSS transition on container, not canvas fill
**Quality checks:** All passed (typecheck, lint, build)
- TypeScript: No errors
- ESLint: 1 pre-existing warning in AccessibilityContext.tsx (not our changes)
- Build: Successful, 388KB bundle
**Visual review:** N/A (animation component — visual verification would require browser screenshot)
**Issues encountered:** None
**Design decisions:**
- Kept Fira Code for terminal/boot phase (it's the authentic clinical terminal aesthetic)
- Used ECGCombined.tsx's mask technique but adapted for canvas API (not SVG like the Remotion version)
- Beat amplitudes: 0.3 → 0.55 → 0.85 → 1.0 (same as original implementation)
- Letter spacing: LETTER_W 72px, LETTER_G 10px, SPACE_W 30px (matches original, tighter than ECGCombined)
- Morph animation uses Framer Motion scaleX/width/opacity for smooth cursor-to-dot transition
**Next task:** Task 2 — Rebuild LoginScreen component
+27 -4
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useCallback } from 'react'
import type { Phase } from './types'
import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation'
@@ -8,20 +8,43 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
function App() {
const [phase, setPhase] = useState<Phase>('boot')
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
const handleBootComplete = useCallback(() => {
setPhase('ecg')
}, [])
const handleCursorPositionReady = useCallback((position: { x: number; y: number }) => {
setCursorPosition(position)
}, [])
const handleECGComplete = useCallback(() => {
setPhase('login')
}, [])
const handleLoginComplete = useCallback(() => {
setPhase('pmr')
}, [])
return (
<AccessibilityProvider>
<div className="min-h-screen bg-black">
{phase === 'boot' && (
<BootSequence onComplete={() => setPhase('ecg')} />
<BootSequence
onComplete={handleBootComplete}
onCursorPositionReady={handleCursorPositionReady}
/>
)}
{phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('login')} />
<ECGAnimation
onComplete={handleECGComplete}
startPosition={cursorPosition}
/>
)}
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
<LoginScreen onComplete={handleLoginComplete} />
)}
{phase === 'pmr' && <PMRInterface />}
+295 -52
View File
@@ -1,97 +1,340 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useEffect, useState } from 'react'
// =============================================================================
// Types
// =============================================================================
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
type BootLineStyle = 'bright' | 'dim' | 'cyan'
interface BootLine {
html: string
delay: number
type: BootLineType
text?: string
label?: string
value?: string
style?: BootLineStyle
}
const bootLines: BootLine[] = [
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk &amp; Waveney ICB</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health &amp; Data Analysis</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">&gt; READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
]
// Precompute cumulative delays so the first render can use them
const bootLineDelays: number[] = (() => {
const delays: number[] = []
let total = 0
bootLines.forEach((line) => {
delays.push(total)
total += line.delay
})
return delays
})()
interface BootConfig {
header: string
lines: BootLine[]
timing: {
lineDelay: number
cursorBlinkInterval: number
holdAfterComplete: number
fadeOutDuration: number
}
colors: {
bright: string
dim: string
cyan: string
}
}
interface BootSequenceProps {
onComplete: () => void
onCursorPositionReady?: (position: { x: number; y: number }) => void
}
export function BootSequence({ onComplete }: BootSequenceProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
const fadeStartTime = totalBootTime + 400
// =============================================================================
// Configuration
// =============================================================================
const BOOT_CONFIG: BootConfig = {
header: 'CLINICAL TERMINAL v3.2.1',
lines: [
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis', style: 'bright' },
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK', style: 'bright' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'status', text: 'Loading modules...', style: 'dim' },
{ type: 'module', text: 'pharmacist_core.sys', style: 'dim' },
{ type: 'module', text: 'population_health.mod', style: 'dim' },
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'ready', text: 'READY — Rendering CV..', style: 'bright' },
],
timing: {
lineDelay: 220,
cursorBlinkInterval: 530,
holdAfterComplete: 400,
fadeOutDuration: 800,
},
colors: {
bright: '#00ff41',
dim: '#3a6b45',
cyan: '#00e5ff',
},
}
// =============================================================================
// Helper Functions
// =============================================================================
function getCumulativeDelay(lineIndex: number): number {
return lineIndex * BOOT_CONFIG.timing.lineDelay
}
// =============================================================================
// Line Components
// =============================================================================
function BootLineHeader({ text }: { text: string }) {
return (
<div className="font-mono text-sm leading-relaxed">
<span
className="font-bold"
style={{ color: BOOT_CONFIG.colors.bright }}
>
{text}
</span>
</div>
)
}
function BootLineStatus({ line }: { line: BootLine }) {
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
return (
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
{line.text}
</div>
)
}
function BootLineSeparator({ line }: { line: BootLine }) {
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
return (
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
{line.text || '---'}
</div>
)
}
function BootLineField({ line }: { line: BootLine }) {
const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
return (
<div className="font-mono text-sm leading-relaxed">
<span style={{ color: BOOT_CONFIG.colors.cyan }}>
{(line.label || '').padEnd(9)}
</span>
<span style={{ color: valueColor }}>{line.value}</span>
</div>
)
}
function BootLineModule({ line }: { line: BootLine }) {
const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
return (
<div className="font-mono text-sm leading-relaxed">
<span className="font-bold" style={{ color: BOOT_CONFIG.colors.bright }}>
[OK]
</span>{' '}
<span style={{ color: textColor }}>{line.text}</span>
</div>
)
}
function BootLineReady({ line }: { line: BootLine }) {
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
return (
<div className="font-mono text-sm leading-relaxed">
<span className="font-bold" style={{ color }}>
&gt; {line.text}
<span className="ecg-seed-dot">.</span>
</span>
</div>
)
}
function BootLineRenderer({ line }: { line: BootLine }) {
switch (line.type) {
case 'header':
return <BootLineHeader text={line.text || ''} />
case 'status':
return <BootLineStatus line={line} />
case 'separator':
return <BootLineSeparator line={line} />
case 'field':
return <BootLineField line={line} />
case 'module':
return <BootLineModule line={line} />
case 'ready':
return <BootLineReady line={line} />
default:
return null
}
}
// =============================================================================
// Main Component
// =============================================================================
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
const [isVisible, setIsVisible] = useState(true)
const [showCursor, setShowCursor] = useState(false)
const [cursorCaptured, setCursorCaptured] = useState(false)
const [isMorphing, setIsMorphing] = useState(false)
const cursorRef = useRef<HTMLDivElement>(null)
const reducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Calculate total boot time
const totalBootTime = BOOT_CONFIG.lines.length * BOOT_CONFIG.timing.lineDelay
const fadeStartTime = totalBootTime + BOOT_CONFIG.timing.holdAfterComplete
// Capture cursor position when boot completes
const captureCursorPosition = useCallback(() => {
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) {
const rect = cursorRef.current.getBoundingClientRect()
const position = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
onCursorPositionReady(position)
setCursorCaptured(true)
}
}, [onCursorPositionReady, cursorCaptured])
// Handle completion sequence
useEffect(() => {
if (reducedMotion) {
// Reduced motion: show everything instantly, then complete
const timer = setTimeout(onComplete, 500)
return () => clearTimeout(timer)
}
// Show cursor after all lines are rendered
const cursorTimer = setTimeout(() => {
setShowCursor(true)
}, totalBootTime)
// Capture cursor position and start morph
const morphTimer = setTimeout(() => {
captureCursorPosition()
setIsMorphing(true)
}, fadeStartTime - 100)
// Fade out and complete
const fadeTimer = setTimeout(() => {
setIsVisible(false)
}, fadeStartTime)
const completeTimer = setTimeout(() => {
onComplete()
}, fadeStartTime+2000)
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration)
return () => {
clearTimeout(cursorTimer)
clearTimeout(morphTimer)
clearTimeout(fadeTimer)
clearTimeout(completeTimer)
}
}, [onComplete])
}, [onComplete, totalBootTime, fadeStartTime, captureCursorPosition, reducedMotion])
// Reduced motion: instant render
if (reducedMotion) {
return (
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden">
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
<BootLineHeader text={BOOT_CONFIG.header} />
{BOOT_CONFIG.lines.map((line, index) => (
<BootLineRenderer key={index} line={line} />
))}
</div>
</div>
)
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
initial={{ opacity: 1 }}
exit={{ opacity: 1 }}
transition={{ delay: 2, duration: 0.8, ease: 'easeOut' }}
exit={{ opacity: 0 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
>
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
{bootLines.map((line, index) => (
{/* CRT Scanlines */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: `repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
transparent 1px,
transparent 2px,
rgba(0, 0, 0, 0.15) 3px
)`,
}}
/>
{/* Content */}
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
<BootLineHeader text={BOOT_CONFIG.header} />
</motion.div>
{/* Lines */}
{BOOT_CONFIG.lines.map((line, index) => (
<motion.div
key={index}
className="whitespace-nowrap leading-relaxed"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: (bootLineDelays[index] ?? 0) / 1000,
delay: getCumulativeDelay(index) / 1000,
duration: 0.4,
ease: 'easeOut',
}}
dangerouslySetInnerHTML={{ __html: line.html }}
/>
>
<BootLineRenderer line={line} />
</motion.div>
))}
<motion.div
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
/>
{/* Blinking Cursor */}
{showCursor && (
<motion.div
ref={cursorRef}
className="inline-block ml-1"
initial={{ opacity: 0 }}
animate={{
opacity: isMorphing ? 0 : 1,
scaleX: isMorphing ? 0 : 1,
width: isMorphing ? 0 : 8,
}}
transition={{ duration: 0.3, ease: 'easeOut' }}
style={{
height: 16,
backgroundColor: BOOT_CONFIG.colors.bright,
animation: isMorphing ? undefined : 'blink 530ms infinite',
}}
/>
)}
</div>
{/* CSS for blink animation */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
`}</style>
</motion.div>
)}
</AnimatePresence>
)
}
export type { BootConfig, BootLine, BootLineType }
export { BOOT_CONFIG }
+283 -83
View File
@@ -1,8 +1,13 @@
import { useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
// =============================================================================
// Types
// =============================================================================
interface ECGAnimationProps {
onComplete: () => void
startPosition?: { x: number; y: number } | null
}
interface Point {
@@ -21,35 +26,113 @@ interface LetterLayout {
char: string
startX: number
endX: number
centerX: number
startConnector: number
endConnector: number
}
interface ConnectorProfile {
leftInset: number
rightInset: number
}
// =============================================================================
// Constants
// =============================================================================
const TRACE_SPEED = 350 // pixels per second
const HEAD_SCREEN_RATIO = 0.75 // Head stays at 75% of screen during ECG
const FLAT_GAP_SECONDS = 0.5 // Gap after last beat before text
const HOLD_SECONDS = 0.3 // Hold after text completes
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
const CONNECTOR_PROFILES: Record<string, ConnectorProfile> = {
C: { leftInset: 20, rightInset: 8 },
O: { leftInset: 17, rightInset: 7 },
D: { leftInset: 0, rightInset: 13 },
L: { leftInset: 5, rightInset: 0 },
E: { leftInset: 5, rightInset: 0 },
}
const DEFAULT_PROFILE: ConnectorProfile = { leftInset: 0, rightInset: 0 }
const BASE_LEFT_INSET = 9
const BASE_RIGHT_INSET = 0
// =============================================================================
// Letter Definitions (ECG waveform shapes for each letter)
// =============================================================================
const ECG_LETTERS: Record<string, Point[]> = {
A: [{x:0,y:0},{x:0.48,y:1},{x:0.53,y:0.42},{x:0.6,y:0.42},{x:1,y:0}],
N: [{x:0,y:0},{x:0.12,y:1},{x:0.72,y:0},{x:0.88,y:1},{x:1,y:0}],
D: [{x:0,y:0},{x:0.1,y:1},{x:0.5,y:1},{x:0.85,y:0.55},{x:1,y:0}],
R: [{x:0,y:0},{x:0.1,y:1},{x:0.35,y:1},{x:0.5,y:0.6},{x:0.55,y:0.45},{x:1,y:0}],
E: [{x:0,y:0},{x:0.1,y:1},{x:0.4,y:1},{x:0.45,y:0.5},{x:0.65,y:0.5},{x:0.7,y:0},{x:1,y:0}],
W: [{x:0,y:0},{x:0.05,y:1},{x:0.27,y:0},{x:0.5,y:0.65},{x:0.73,y:0},{x:0.95,y:1},{x:1,y:0}],
C: [{x:0,y:0},{x:0.08,y:0.6},{x:0.18,y:1},{x:0.6,y:1},{x:0.8,y:0.5},{x:0.95,y:0.1},{x:1,y:0}],
H: [{x:0,y:0},{x:0.1,y:1},{x:0.18,y:0.5},{x:0.82,y:0.5},{x:0.9,y:1},{x:1,y:0}],
L: [{x:0,y:0},{x:0.12,y:1},{x:0.3,y:1},{x:0.38,y:0},{x:1,y:0}],
O: [{x:0,y:0},{x:0.2,y:0.85},{x:0.35,y:1},{x:0.65,y:1},{x:0.8,y:0.85},{x:1,y:0}],
A: [
{ x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 },
{ x: 0.6, y: 0.42 }, { x: 1, y: 0 },
],
N: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, y: 0 },
{ x: 0.88, y: 1 }, { x: 1, y: 0 },
],
D: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.5, y: 1 },
{ x: 0.85, y: 0.55 }, { x: 1, y: 0 },
],
R: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.35, y: 1 },
{ x: 0.5, y: 0.6 }, { x: 0.55, y: 0.45 }, { x: 1, y: 0 },
],
E: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.4, y: 1 },
{ x: 0.45, y: 0.5 }, { x: 0.65, y: 0.5 }, { x: 0.7, y: 0 },
{ x: 1, y: 0 },
],
W: [
{ x: 0, y: 0 }, { x: 0.05, y: 1 }, { x: 0.27, y: 0 },
{ x: 0.5, y: 0.65 }, { x: 0.73, y: 0 }, { x: 0.95, y: 1 },
{ x: 1, y: 0 },
],
C: [
{ x: 0, y: 0 }, { x: 0.08, y: 0.6 }, { x: 0.18, y: 1 },
{ x: 0.6, y: 1 }, { x: 0.8, y: 0.5 }, { x: 0.95, y: 0.1 },
{ x: 1, y: 0 },
],
H: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.18, y: 0.5 },
{ x: 0.82, y: 0.5 }, { x: 0.9, y: 1 }, { x: 1, y: 0 },
],
L: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.3, y: 1 },
{ x: 0.38, y: 0 }, { x: 1, y: 0 },
],
O: [
{ x: 0, y: 0 }, { x: 0.2, y: 0.85 }, { x: 0.35, y: 1 },
{ x: 0.65, y: 1 }, { x: 0.8, y: 0.85 }, { x: 1, y: 0 },
],
}
const ECG_TEXT = 'ANDREW CHARLWOOD'
// =============================================================================
// Helper Functions
// =============================================================================
function generateHeartbeatPoints(amplitude: number): Point[] {
const points: Point[] = []
const steps = 200
for (let i = 0; i <= steps; i++) {
const t = i / steps
let y = 0
if (t >= 0.05 && t < 0.2) { y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) }
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
else if (t >= 0.55 && t < 0.75) { y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI) }
if (t >= 0.05 && t < 0.2) {
y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI)
} else if (t >= 0.25 && t < 0.32) {
y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI)
} else if (t >= 0.32 && t < 0.42) {
y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI)
} else if (t >= 0.42 && t < 0.5) {
y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI)
} else if (t >= 0.55 && t < 0.75) {
y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI)
}
points.push({ x: t, y: y * amplitude })
}
return points
@@ -67,25 +150,47 @@ function interpolateLetterY(points: Point[], t: number): number {
return 0
}
function ecgGetTextWidth(lw: number, lg: number, sw: number): number {
function getTextTotalWidth(letterWidth: number, letterGap: number, spaceWidth: number): number {
const chars = ECG_TEXT.replace(/ /g, '').length
const spaces = ECG_TEXT.split(' ').length - 1
return chars * (lw + lg) - lg + spaces * sw
return chars * (letterWidth + letterGap) - letterGap + spaces * spaceWidth
}
function ecgLayoutText(offsetX: number, lw: number, lg: number, sw: number): LetterLayout[] {
function layoutText(
offsetX: number,
letterWidth: number,
letterGap: number,
spaceWidth: number
): LetterLayout[] {
const layout: LetterLayout[] = []
let cursor = offsetX
for (let i = 0; i < ECG_TEXT.length; i++) {
const ch = ECG_TEXT[i]
if (ch === ' ') { cursor += sw; continue }
layout.push({ char: ch, startX: cursor, endX: cursor + lw, centerX: cursor + lw / 2 })
cursor += lw + lg
for (const char of ECG_TEXT) {
if (char === ' ') {
cursor += spaceWidth
continue
}
const profile = CONNECTOR_PROFILES[char] ?? DEFAULT_PROFILE
const startX = cursor
const endX = cursor + letterWidth
layout.push({
char,
startX,
endX,
startConnector: startX + BASE_LEFT_INSET + profile.leftInset,
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset,
})
cursor += letterWidth + letterGap
}
return layout
}
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
// =============================================================================
// Main Component
// =============================================================================
export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
@@ -93,6 +198,13 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
const bgTransitionedRef = useRef(false)
const completedRef = useRef(false)
const lineColor = '#00ff41'
const loginBgColor = '#1E293B'
const reducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
const finishAnimation = useCallback(() => {
if (completedRef.current) return
completedRef.current = true
@@ -103,6 +215,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
}, [onComplete])
useEffect(() => {
// Reduced motion: skip to end immediately
if (reducedMotion) {
const timer = setTimeout(finishAnimation, 100)
return () => clearTimeout(timer)
}
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
@@ -110,6 +228,7 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
const ctx = canvas.getContext('2d')
if (!ctx) return
// Setup canvas dimensions
const vw = window.innerWidth
const vh = window.innerHeight
const dpr = window.devicePixelRatio || 1
@@ -118,50 +237,56 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
canvas.height = vh * dpr
ctx.scale(dpr, dpr)
// Scale factors based on viewport
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
const LETTER_W = 72 * scale
const LETTER_G = 10 * scale
const SPACE_W = 30 * scale
const TRACE_SPEED = 450 * scale
const FLAT_GAP = 0.4
const FLATLINE_HOLD = 0.3
const FLATLINE_DRAW = 0.3
const FADE_TO_BLACK = 0.2
const BG_TRANSITION = 0.2
// Layout parameters
const baselineY = vh * 0.5
const ecgMaxDefl = vh * 0.25
const textMaxDefl = vh * 0.08
const lineColor = '#00ff41'
const loginBgColor = '#1E293B'
// Calculate start offset from cursor position if provided
const startOffsetX = startPosition ? startPosition.x : 0
// Build beats with cursor offset
const beats: Beat[] = [
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
{ startTime: 1.2, widthPx: 90 * scale, amplitude: 0.55, startWX: 0 },
{ startTime: 2.0, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
{ startTime: 2.8, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
{ startTime: 0.6, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
{ startTime: 1.4, widthPx: 80 * scale, amplitude: 0.55, startWX: 0 },
{ startTime: 2.3, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
{ startTime: 3.2, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
]
beats.forEach((b) => { b.startWX = b.startTime * TRACE_SPEED })
// Apply start offset to all beats
beats.forEach((b) => {
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
})
// Calculate text layout
const lastBeat = beats[beats.length - 1]
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
const textStartWX = lastBeatEndWX + FLAT_GAP * TRACE_SPEED
const totalTextW = ecgGetTextWidth(LETTER_W, LETTER_G, SPACE_W)
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
const textEndWX = textStartWX + totalTextW
const textLayout = ecgLayoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
const fontSize = Math.round(textMaxDefl / 0.715)
const textLayout = layoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
const headScreenRatio = 0.75
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
// Calculate timing phases
const textEndTime = textEndWX / TRACE_SPEED
const holdEndTime = textEndTime + FLATLINE_HOLD
const flatlineEndTime = holdEndTime + FLATLINE_DRAW
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION
const holdEndTime = textEndTime + HOLD_SECONDS
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
const exitEndTime = bgTransitionEndTime
// Final head position (centered text end)
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
// Get Y at a given world X position
const getYAtX = (wx: number): number => {
for (let i = 0; i < beats.length; i++) {
const b = beats[i]
// Check beats
for (const b of beats) {
if (wx >= b.startWX && wx <= b.startWX + b.widthPx) {
const prog = (wx - b.startWX) / b.widthPx
const pts = generateHeartbeatPoints(b.amplitude)
@@ -169,29 +294,65 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
return baselineY - pts[idx].y * ecgMaxDefl
}
}
for (let j = 0; j < textLayout.length; j++) {
const item = textLayout[j]
// Check text letters
for (const item of textLayout) {
if (wx >= item.startX && wx <= item.endX) {
const t = (wx - item.startX) / (item.endX - item.startX)
const ld = ECG_LETTERS[item.char]
if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl
}
}
return baselineY
}
// Offscreen canvas for pre-rendering text
const textCanvas = document.createElement('canvas')
textCanvas.width = vw * dpr
textCanvas.height = vh * dpr
const textCtx = textCanvas.getContext('2d')
if (textCtx) {
textCtx.scale(dpr, dpr)
textCtx.font = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
textCtx.textAlign = 'center'
textCtx.textBaseline = 'alphabetic'
textCtx.strokeStyle = lineColor
textCtx.lineWidth = 1.5 * scale
// Pre-render all letters
for (const item of textLayout) {
const centerX = (item.startX + item.endX) / 2
textCtx.strokeText(item.char, centerX, baselineY)
}
// Draw connector lines
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
textCtx.beginPath()
textCtx.moveTo(curr.endConnector, baselineY)
textCtx.lineTo(next.startConnector, baselineY)
textCtx.stroke()
}
}
// Animation loop
const animate = (timestamp: number) => {
if (!startTsRef.current) startTsRef.current = timestamp
const elapsed = (timestamp - startTsRef.current) / 1000
// Check for animation completion
if (elapsed >= exitEndTime) {
finishAnimation()
return
}
// Clear canvas
ctx.clearRect(0, 0, vw, vh)
let headWX = elapsed * TRACE_SPEED
// Calculate current head position
let headWX = elapsed * TRACE_SPEED + startOffsetX
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
const isBgTransitionPhase = elapsed >= fadeEndTime
@@ -200,9 +361,10 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
headWX = textEndWX
}
// Calculate viewport and head screen position
let headSX: number
let viewOff: number
const headSXEcg = headScreenRatio * vw
const headSXEcg = HEAD_SCREEN_RATIO * vw
if (headWX <= textStartWX) {
viewOff = Math.max(0, headWX - headSXEcg)
@@ -216,33 +378,41 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
viewOff = headWX - headSX
}
// Calculate fade alpha
let fadeAlpha = 1
if (isFadePhase) {
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK)
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK_SECONDS)
} else if (isBgTransitionPhase) {
fadeAlpha = 0
}
// Background color transition
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
bgTransitionedRef.current = true
container.style.transition = `background ${BG_TRANSITION * 1000}ms ease-out`
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
container.style.background = loginBgColor
}
ctx.save()
ctx.globalAlpha = fadeAlpha
// Draw ECG trace (beats only, up to text start)
const traceStart = Math.max(0, Math.floor(viewOff))
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), Math.ceil(viewOff + vw))
const traceEnd = Math.min(
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX),
Math.ceil(viewOff + vw)
)
if (traceEnd > traceStart) {
// Outer glow layer
ctx.beginPath()
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
ctx.lineWidth = 6
ctx.lineWidth = 6 * scale
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.shadowColor = lineColor
ctx.shadowBlur = 14
ctx.shadowBlur = 14 * scale
for (let wx = traceStart; wx <= traceEnd; wx++) {
const sx = wx - viewOff
const sy = getYAtX(wx)
@@ -251,10 +421,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
}
ctx.stroke()
// Main trace layer
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2
ctx.shadowBlur = 4
ctx.lineWidth = 2 * scale
ctx.shadowBlur = 4 * scale
for (let wx = traceStart; wx <= traceEnd; wx++) {
const sx = wx - viewOff
const sy = getYAtX(wx)
@@ -264,42 +436,52 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.stroke()
}
if (isFlatlinePhase) {
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
// Draw flatline after text
if (isFlatlinePhase || (elapsed >= holdEndTime && elapsed < textEndTime)) {
const flatlineProgress = isFlatlinePhase
? (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS
: 1
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2
ctx.shadowBlur = 8
ctx.lineWidth = 2 * scale
ctx.shadowBlur = 8 * scale
ctx.shadowColor = lineColor
ctx.moveTo(finalHeadSX, baselineY)
ctx.lineTo(flatlineEndSX, baselineY)
ctx.stroke()
}
ctx.shadowColor = lineColor
ctx.shadowBlur = 8
ctx.font = `bold ${fontSize}px Arial, Helvetica, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.lineWidth = 1.5 * scale
ctx.strokeStyle = lineColor
// Mask-based text reveal
const isTextPhase = headWX > textStartWX
const isTextDone = elapsed >= textEndTime
for (let k = 0; k < textLayout.length; k++) {
const item = textLayout[k]
const letterProgress = (headWX - item.startX) / (item.endX - item.startX)
if (letterProgress > 0.3) {
const alpha = Math.min(1, (letterProgress - 0.3) * 1.43)
ctx.globalAlpha = fadeAlpha * alpha
const lsx = item.centerX - viewOff
ctx.strokeText(item.char, lsx, baselineY)
}
if (isTextPhase && textCtx) {
// Create clipping region based on trace head position
ctx.save()
ctx.beginPath()
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh)
ctx.clip()
// Draw pre-rendered text through the clip
ctx.drawImage(textCanvas, -viewOff, 0)
// Apply neon glow to text
ctx.globalCompositeOperation = 'source-over'
ctx.shadowColor = lineColor
ctx.shadowBlur = 8 * scale
ctx.restore()
}
// Draw dot/head
ctx.globalAlpha = fadeAlpha
ctx.shadowBlur = 0
if (headSX >= -20 && headSX <= vw + 20 && elapsed < flatlineEndTime) {
const headY = isFlatlinePhase ? baselineY : getYAtX(headWX)
// Glow gradient
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
grad.addColorStop(0.3, 'rgba(0,255,65,0.6)')
@@ -309,19 +491,22 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.arc(headSX, headY, 20 * scale, 0, Math.PI * 2)
ctx.fill()
// Core dot
ctx.fillStyle = lineColor
ctx.beginPath()
ctx.arc(headSX, headY, 3, 0, Math.PI * 2)
ctx.arc(headSX, headY, 3 * scale, 0, Math.PI * 2)
ctx.fill()
}
ctx.restore()
// Scanlines
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
for (let sly = 0; sly < vh; sly += 4) {
ctx.fillRect(0, sly + 2, vw, 2)
}
// Vignette
const vig = ctx.createRadialGradient(vw / 2, vh / 2, vh * 0.3, vw / 2, vh / 2, vh * 0.85)
vig.addColorStop(0, 'rgba(0,0,0,0)')
vig.addColorStop(1, 'rgba(0,0,0,0.4)')
@@ -338,7 +523,20 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
cancelAnimationFrame(animationRef.current)
}
}
}, [finishAnimation])
}, [startPosition, finishAnimation, reducedMotion])
// Reduced motion fallback
if (reducedMotion) {
return (
<motion.div
ref={containerRef}
className="fixed inset-0 z-50 bg-[#1E293B]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)
}
return (
<AnimatePresence>
@@ -357,3 +555,5 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
</AnimatePresence>
)
}
export type { ECGAnimationProps }