Files
portfolio/Ralph/completed/ref-boot-ecg.md
T

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 cursor
  • src/components/ECGAnimation.tsx → canvas-based heartbeat + name tracing + flatline + bg transition
  • App.tsx phases: 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 getYAtX function 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_W values — 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_LETTERS point 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:

  1. Name holds with glow (300ms)
  2. Glow fades, flatline extends right
  3. Canvas fades to black (200ms)
  4. Background transitions to dark blue-gray #1E293B (200ms)
  5. 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-design at 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:

  1. 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.
  2. 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)