diff --git a/Ralph/IMPLEMENTATION_PLAN.md b/Ralph/IMPLEMENTATION_PLAN.md index 96ce751..cc4ff22 100644 --- a/Ralph/IMPLEMENTATION_PLAN.md +++ b/Ralph/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 4bd74c7..0d6a17c 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -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 + diff --git a/src/App.tsx b/src/App.tsx index 52a2123..9165614 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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 (
{phase === 'boot' && ( - setPhase('ecg')} /> + )} {phase === 'ecg' && ( - setPhase('login')} /> + )} {phase === 'login' && ( - setPhase('pmr')} /> + )} {phase === 'pmr' && } diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 78cde9f..6f50d4d 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -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: 'CLINICAL TERMINAL v3.2.1', delay: 0 }, - { html: 'Initialising pharmacist profile...', delay: 220 }, - { html: '---', delay: 220 }, - { html: 'SYSTEM NHS Norfolk & Waveney ICB', delay: 220 }, - { html: 'USER Andy Charlwood', delay: 220 }, - { html: 'ROLE Deputy Head of Population Health & Data Analysis', delay: 220 }, - { html: 'LOCATION Norwich, UK', delay: 220 }, - { html: '---', delay: 220 }, - { html: 'Loading modules...', delay: 220 }, - { html: '[OK] pharmacist_core.sys', delay: 220 }, - { html: '[OK] population_health.mod', delay: 220 }, - { html: '[OK] data_analytics.eng', delay: 220 }, - { html: '---', delay: 220 }, - { html: '> READY — Rendering CV...', 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 ( +
+ + {text} + +
+ ) +} + +function BootLineStatus({ line }: { line: BootLine }) { + const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim + return ( +
+ {line.text} +
+ ) +} + +function BootLineSeparator({ line }: { line: BootLine }) { + const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim + return ( +
+ {line.text || '---'} +
+ ) +} + +function BootLineField({ line }: { line: BootLine }) { + const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright + return ( +
+ + {(line.label || '').padEnd(9)} + + {line.value} +
+ ) +} + +function BootLineModule({ line }: { line: BootLine }) { + const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim + return ( +
+ + [OK] + {' '} + {line.text} +
+ ) +} + +function BootLineReady({ line }: { line: BootLine }) { + const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright + return ( +
+ + > {line.text} + . + +
+ ) +} + +function BootLineRenderer({ line }: { line: BootLine }) { + switch (line.type) { + case 'header': + return + case 'status': + return + case 'separator': + return + case 'field': + return + case 'module': + return + case 'ready': + return + 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(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 ( +
+
+ + {BOOT_CONFIG.lines.map((line, index) => ( + + ))} +
+
+ ) + } + return ( {isVisible && ( -
- {bootLines.map((line, index) => ( + {/* CRT Scanlines */} +
+ + {/* Content */} +
+ {/* Header */} + + + + + {/* Lines */} + {BOOT_CONFIG.lines.map((line, index) => ( + > + + ))} - + + {/* Blinking Cursor */} + {showCursor && ( + + )}
+ + {/* CSS for blink animation */} + )} ) } + +export type { BootConfig, BootLine, BootLineType } +export { BOOT_CONFIG } diff --git a/src/components/ECGAnimation.tsx b/src/components/ECGAnimation.tsx index 45ec3db..f9c3409 100644 --- a/src/components/ECGAnimation.tsx +++ b/src/components/ECGAnimation.tsx @@ -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 = { + 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 = { - 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(null) const containerRef = useRef(null) const animationRef = useRef(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 ( + + ) + } return ( @@ -357,3 +555,5 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) { ) } + +export type { ECGAnimationProps }