13 KiB
Reference: Boot Sequence + ECG Animation
Covers the full pre-login flow: terminal boot → cursor transition → ECG heartbeat → name reveal → flatline. The flatline→login transition is covered in
ref-transition-login.md.
Current Architecture
Two components manage the pre-login flow:
src/components/BootSequence.tsx→ terminal text animation, ends with blinking cursorsrc/components/ECGAnimation.tsx→ canvas-based heartbeat + name tracing + flatline + bg transitionApp.tsxphases:boot → ecg → login → pmr
What Needs to Change
1. Boot Sequence — Clean Up for Easy Config
Problem: Boot text lines are hardcoded as HTML strings with inline Tailwind classes. Adding/removing/reordering lines requires editing raw HTML. The dangerouslySetInnerHTML approach is fragile.
Fix: Refactor to a clean config-driven structure:
// Example config structure — easy to customize
const BOOT_CONFIG = {
header: { text: 'CLINICAL TERMINAL v3.2.1', style: 'bright' },
lines: [
{ type: 'status', text: 'Initialising pharmacist profile...' },
{ type: 'separator' },
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB' },
{ type: 'field', label: 'USER', value: 'Andy Charlwood' },
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis' },
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK' },
{ type: 'separator' },
{ type: 'status', text: 'Loading modules...' },
{ type: 'module', name: 'pharmacist_core.sys' },
{ type: 'module', name: 'population_health.mod' },
{ type: 'module', name: 'data_analytics.eng' },
{ type: 'separator' },
{ type: 'ready', text: 'READY — Rendering CV..' },
],
timing: { lineDelay: 220, holdAfterComplete: 400, fadeOutDuration: 800 },
}
- Each line type maps to a React component (not raw HTML)
- Colors remain: bright green
#00ff41, dim green#3a6b45, cyan labels#00e5ff - Staggered reveal timing stays the same (220ms per line)
- Font: Fira Code (this is the terminal phase, NOT the PMR — Fira Code is correct here)
2. Cursor → Dot Transition
Problem: The boot sequence ends with a blinking green block cursor (.animate-blink). The ECG animation starts with a glowing dot that appears at the far left of the screen. There's a visual disconnect — the cursor and dot don't connect.
Fix: The blinking cursor at the end of boot should smoothly transition INTO the ECG's glowing trace dot:
- At end of boot, capture the cursor's screen position (x, y)
- Pass this position to ECGAnimation via props
- ECGAnimation starts with its glowing dot AT the cursor position
- The cursor stops blinking and morphs: block cursor → circular glow (scale down width, increase glow, ~300ms)
- The dot then begins moving rightward, drawing the flatline/heartbeat trace behind it
- This means the ECG trace starts at the cursor position, NOT the far left edge
3. ECG Start Position
Problem: Currently the ECG trace starts at x=0 (far left of viewport). The cursor ends somewhere in the middle-left of the screen. This means the dot "teleports" from cursor position to the left edge.
Fix: The ECG animation should:
- Accept a
startPosition: { x: number, y: number }prop from the boot sequence - Begin the trace from that position
- The first section of trace is a flat line from the cursor position rightward
- Heartbeats begin after the first flat gap (same timing as now, just offset)
- The viewport scroll logic already handles the "head" position — just shift the world-space origin
4. Text Reveal — Mask Technique
Problem: The current ECGAnimation.tsx reveals letters by stroking them with progressive alpha (letterProgress > 0.3 → fade in). This looks like letters fading in, not like the ECG line IS the letter shape.
Reference: ECGCombined.tsx (Remotion version at project root) uses a superior technique:
- The actual text characters are pre-rendered on the canvas (stroke-only, no fill)
- A wipe mask follows the ECG trace head, revealing the text underneath
- The ECG trace line IS the path that forms each letter shape (via the
getYAtXfunction which returns letter Y values when in text region) - Connector lines between letters sit at the baseline
- The neon glow filter applies to both the trace and revealed text
What to adopt from ECGCombined.tsx:
- The mask-based text reveal approach (the trace unveils pre-rendered text)
- The connector lines between letters at baseline
- The neon glow SVG filters (or canvas equivalents)
- The text mask brush that follows the trace head
What to KEEP from current ECGAnimation.tsx:
- The character spacing (current
LETTER_W,LETTER_G,SPACE_Wvalues — preferred over ECGCombined.tsx spacing) - The heartbeat waveform shape (
generateHeartbeatPoints) - The beat timing and amplitude escalation (0.3 → 0.55 → 0.85 → 1.0)
- The canvas rendering approach (not SVG — canvas is correct for this performance-sensitive animation)
- The viewport scrolling/camera logic
- The flatline draw phase
- The scanline and vignette effects
- The background color transition to
#1E293B
5. Text Rendering
- The name is still "ANDREW CHARLWOOD"
- Letters are stroke-only (no fill) in neon green
#00ff41 - Each letter shape is defined by the
ECG_LETTERSpoint arrays (keep these) - The trace line passes through each letter's shape points, making the ECG waveform form recognizable letter shapes
- Between letters, the trace returns to baseline with short connector segments
- Neon glow effect on both trace and revealed text
Transition to Login
After the text is fully revealed and the flatline extends to the right edge, the flow continues as described in ref-transition-login.md:
- Name holds with glow (300ms)
- Glow fades, flatline extends right
- Canvas fades to black (200ms)
- Background transitions to dark blue-gray
#1E293B(200ms) - Login card materializes
prefers-reduced-motion
With reduced motion enabled:
- Boot text appears instantly (no stagger)
- Cursor appears immediately
- ECG animation is skipped entirely — transition straight from boot to login
- Or: show the final frame (name fully revealed, flatline) as a static image for 1 second, then proceed to login
Testing Checklist
- Boot text renders correctly with all lines
- Blinking cursor visible at end of boot
- Cursor smoothly transitions to ECG dot (no teleport)
- ECG trace starts from cursor position
- Heartbeats render with increasing amplitude
- Name letters revealed via mask technique (not alpha fade)
- Connector lines between letters
- Neon glow on trace and text
- Flatline extends to right edge after name
- Background transitions to
#1E293B - Scanlines and vignette present
- Reduced motion: instant/static
- Mobile: scales correctly
Design Guidance (from /frontend-design)
Pre-baked design direction. Do NOT invoke
/frontend-designat runtime — this section contains the output.
Aesthetic Direction: Authentic Clinical Terminal → Medical Monitor Realism
This isn't "medical-themed" design — this IS medical equipment interfaces. Two distinct phases:
- Boot Terminal: Authentic 1990s clinical system boot sequence (think legacy pharmacy dispensing systems, hospital terminal logins). CRT monitor aesthetic with phosphor green, scanlines, slight text glow.
- ECG Monitor: Hospital bedside cardiac monitor realism. The heartbeat isn't decorative — it's a functioning waveform that becomes letterforms through technical precision.
Visual Signature: The cursor-to-dot morphing transition. Most animations have discrete phases; this creates continuous material transformation — the blinking terminal cursor literally becomes the ECG trace point. It's the moment where "loading system" becomes "reading vital signs."
Boot Sequence — Type-Safe Config
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
interface BootLine {
type: BootLineType
text: string
label?: string
value?: string
style?: 'bright' | 'dim' | 'cyan'
}
interface BootConfig {
header: string
lines: BootLine[]
timing: {
lineDelay: number
cursorBlinkInterval: number
holdAfterComplete: number
fadeOutDuration: number
}
colors: {
bright: string
dim: string
cyan: string
}
}
Component architecture:
<BootLine>— renders individual line types with appropriate styling<BootCursor>— separate component for cursor with ref exposure for position capture- Config-driven rendering replaces hardcoded HTML
Example config:
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' },
// ... etc
],
timing: { lineDelay: 220, cursorBlinkInterval: 530, holdAfterComplete: 400, fadeOutDuration: 800 },
colors: { bright: '#00ff41', dim: '#3a6b45', cyan: '#00e5ff' }
}
Example BootLine component:
function BootLine({ line }: { line: BootLine }) {
const colors = BOOT_CONFIG.colors
const color = line.style ? colors[line.style] : colors.dim
if (line.type === 'field') {
return (
<div className="font-mono text-sm leading-relaxed">
<span style={{ color: colors.cyan }}>{line.label?.padEnd(9)}</span>
<span style={{ color }}>{line.value}</span>
</div>
)
}
if (line.type === 'module') {
return (
<div className="font-mono text-sm leading-relaxed">
<span className="font-bold" style={{ color: colors.bright }}>[OK]</span>
{' '}
<span style={{ color: colors.dim }}>{line.text}</span>
</div>
)
}
// ... other types
}
Cursor → Dot Transition — Implementation
const [cursorPosition, setCursorPosition] = useState<{x: number, y: number} | null>(null)
const cursorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (cursorRef.current) {
const rect = cursorRef.current.getBoundingClientRect()
setCursorPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
})
}
}, [/* trigger when boot completes */])
// Pass to ECG:
<ECGAnimation startPosition={cursorPosition} onComplete={...} />
Morph animation: width 8px→0px, height 16px→6px, border-radius 0→50% (300ms ease-out). Simultaneously fade in ECG dot at same position. After morph complete, begin trace movement.
Canvas Mask Reveal — Implementation
// Pre-render text to offscreen canvas
const textCanvas = document.createElement('canvas')
const textCtx = textCanvas.getContext('2d')
textCtx.strokeStyle = lineColor
textCtx.lineWidth = 1.5
textCtx.font = `bold ${fontSize}px Arial`
textLayout.forEach(item => {
textCtx.strokeText(item.char, item.centerX, baselineY)
})
// During animation loop:
ctx.save()
// Create clipping path following trace head
ctx.beginPath()
ctx.rect(0, 0, headSX + 20, vh) // reveal up to head position + lead
ctx.clip()
// Draw pre-rendered text through clip
ctx.drawImage(textCanvas, -viewOff, 0)
ctx.restore()
// Feathered leading edge:
const gradient = ctx.createLinearGradient(headSX - 30, 0, headSX, 0)
gradient.addColorStop(0, 'rgba(0,255,65,0)')
gradient.addColorStop(1, 'rgba(0,255,65,1)')
Connector Lines Between Letters
const connectors: {startX: number, endX: number}[] = []
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
const endInset = CONNECTOR_INSETS[curr.char] || 0
const startInset = CONNECTOR_INSETS[next.char] || 0
connectors.push({
startX: curr.endX - endInset,
endX: next.startX + startInset
})
}
// During draw:
connectors.forEach(conn => {
if (headWX > conn.startX) {
const connectorEndX = Math.min(conn.endX, headWX)
ctx.beginPath()
ctx.moveTo(conn.startX - viewOff, baselineY)
ctx.lineTo(connectorEndX - viewOff, baselineY)
ctx.stroke()
}
})
Visual Enhancement Details
CRT Scanlines (boot phase):
.boot-scanlines {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
transparent 1px,
transparent 2px,
rgba(0, 0, 0, 0.15) 3px
);
pointer-events: none;
animation: scanline-drift 8s linear infinite;
}
Phosphor Glow (terminal text):
.terminal-text {
text-shadow:
0 0 4px currentColor,
0 0 8px currentColor,
0 0 12px rgba(0, 255, 65, 0.3);
}
ECG Neon Glow (canvas — multi-layer):
- Primary trace: 2px solid line
- Glow layer 1: 6px @ 25% opacity + shadowBlur 14px
- Glow layer 2: Inner 3px core for sharpness
- Text: Same multi-layer glow approach
Background Transition (smooth RGB interpolation):
const bgProgress = (elapsed - flatlineStartTime) / BG_TRANSITION
ctx.fillStyle = `rgb(
${Math.round(0 + (30 * bgProgress))},
${Math.round(0 + (41 * bgProgress))},
${Math.round(0 + (59 * bgProgress))}
)` // black → #1E293B
ctx.fillRect(0, 0, vw, vh)