Completed boot loading to ECG, to name written

This commit is contained in:
2026-02-12 22:31:34 +00:00
parent 4eeeb05744
commit 3afadbdc73
10 changed files with 961 additions and 509 deletions
+357 -200
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
// =============================================================================
@@ -25,6 +25,8 @@ interface BootConfig {
cursorBlinkInterval: number
holdAfterComplete: number
fadeOutDuration: number
cursorShrinkDuration: number
ecgStartDelay: number
}
colors: {
bright: string
@@ -38,10 +40,34 @@ interface BootSequenceProps {
onCursorPositionReady?: (position: { x: number; y: number }) => void
}
interface TypedSegment {
text: string
color: string
bold?: boolean
isSeedDot?: boolean
}
interface TypedLine {
segments: TypedSegment[]
totalChars: number
pauseAfter: number // ms to pause after this line completes
speed: number // ms per character (0 = instant)
}
// =============================================================================
// Configuration
// =============================================================================
// Global speed multiplier for typing animation.
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
const TYPING_SPEED = 2
const COLORS = {
bright: '#00ff41',
dim: '#3a6b45',
cyan: '#00e5ff',
}
const BOOT_CONFIG: BootConfig = {
header: 'CLINICAL TERMINAL v3.2.1',
lines: [
@@ -57,195 +83,349 @@ const BOOT_CONFIG: BootConfig = {
{ 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' },
{ type: 'ready', text: 'READY \u2014 Rendering CV..', style: 'bright' },
],
timing: {
lineDelay: 220,
cursorBlinkInterval: 530,
holdAfterComplete: 400,
fadeOutDuration: 800,
},
colors: {
bright: '#00ff41',
dim: '#3a6b45',
cyan: '#00e5ff',
cursorBlinkInterval: 300,
holdAfterComplete: 900,
fadeOutDuration: 600,
cursorShrinkDuration: 600,
ecgStartDelay: 0,
},
colors: COLORS,
}
// =============================================================================
// Helper Functions
// =============================================================================
function getCumulativeDelay(lineIndex: number): number {
return lineIndex * BOOT_CONFIG.timing.lineDelay
// Apply speed multiplier — instant lines (speed=0) stay instant
function s(ms: number): number {
return Math.round(ms * TYPING_SPEED)
}
// =============================================================================
// Line Components
// =============================================================================
// Build typed lines from BOOT_CONFIG
function buildTypedLines(): TypedLine[] {
const lines: TypedLine[] = []
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>
)
}
// Header
const headerText = BOOT_CONFIG.header
lines.push({
segments: [{ text: headerText, color: COLORS.bright, bold: true }],
totalChars: headerText.length,
pauseAfter: s(40),
speed: s(18),
})
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
for (const line of BOOT_CONFIG.lines) {
switch (line.type) {
case 'status': {
const text = line.text || ''
lines.push({
segments: [{ text, color: COLORS.dim }],
totalChars: text.length,
pauseAfter: s(40),
speed: s(14),
})
break
}
case 'separator': {
const text = line.text || '---'
lines.push({
segments: [{ text, color: COLORS.dim }],
totalChars: text.length,
pauseAfter: s(50),
speed: 0, // instant
})
break
}
case 'field': {
const label = (line.label || '').padEnd(9)
const value = line.value || ''
const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
lines.push({
segments: [
{ text: label, color: COLORS.cyan },
{ text: value, color: valueColor },
],
totalChars: label.length + value.length,
pauseAfter: s(30),
speed: s(10),
})
break
}
case 'module': {
const prefix = '[OK] '
const name = line.text || ''
lines.push({
segments: [
{ text: '[OK]', color: COLORS.bright, bold: true },
{ text: ' ', color: COLORS.dim },
{ text: name, color: COLORS.dim },
],
totalChars: prefix.length + name.length,
pauseAfter: s(50),
speed: 0, // instant — stdout output
})
break
}
case 'ready': {
const prefix = '> '
const body = line.text || ''
const seedDot = '.'
lines.push({
segments: [
{ text: prefix + body, color: COLORS.bright, bold: true },
{ text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
],
totalChars: prefix.length + body.length + seedDot.length,
pauseAfter: 0,
speed: s(16),
})
break
}
}
}
return lines
}
const TYPED_LINES = buildTypedLines()
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
// =============================================================================
// Main Component
// =============================================================================
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
const [typedCount, setTypedCount] = useState(0)
const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing')
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
const cursorRef = useRef<HTMLSpanElement>(null)
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const cursorCapturedRef = useRef(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(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
// Capture cursor position for ECG handoff
const captureCursorPosition = useCallback(() => {
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) {
if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) {
const rect = cursorRef.current.getBoundingClientRect()
const position = {
onCursorPositionReady({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
onCursorPositionReady(position)
setCursorCaptured(true)
})
cursorCapturedRef.current = true
}
}, [onCursorPositionReady, cursorCaptured])
}, [onCursorPositionReady])
// Handle completion sequence
// Typing engine — runs as a self-scheduling setTimeout chain
useEffect(() => {
if (reducedMotion) {
// Reduced motion: show everything instantly, then complete
const timer = setTimeout(onComplete, 500)
return () => clearTimeout(timer)
if (reducedMotion || phase !== 'typing') return
// All characters typed
if (typedCount >= TOTAL_CHARS) {
setPhase('holding')
return
}
// Show cursor after all lines are rendered
const cursorTimer = setTimeout(() => {
setShowCursor(true)
}, totalBootTime)
// Find which line the cursor is on and position within it
let lineStart = 0
let lineIdx = 0
for (let i = 0; i < TYPED_LINES.length; i++) {
if (lineStart + TYPED_LINES[i].totalChars > typedCount) {
lineIdx = i
break
}
lineStart += TYPED_LINES[i].totalChars
}
// Capture cursor position and start morph
const morphTimer = setTimeout(() => {
captureCursorPosition()
setIsMorphing(true)
}, fadeStartTime - 100)
const line = TYPED_LINES[lineIdx]
const posInLine = typedCount - lineStart
// Fade out and complete
const fadeTimer = setTimeout(() => {
setIsVisible(false)
}, fadeStartTime)
const completeTimer = setTimeout(() => {
onComplete()
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration)
if (posInLine === 0 && line.speed === 0) {
// Instant line: show all chars at once after a brief pause
timeoutRef.current = setTimeout(() => {
setTypedCount(lineStart + line.totalChars)
}, line.pauseAfter || 10)
} else if (posInLine === 0 && lineIdx > 0) {
// Start of a new typed line — apply previous line's pauseAfter
timeoutRef.current = setTimeout(() => {
setTypedCount(prev => prev + 1)
}, TYPED_LINES[lineIdx - 1].pauseAfter)
} else {
// Type one character at the line's speed
timeoutRef.current = setTimeout(() => {
setTypedCount(prev => prev + 1)
}, line.speed)
}
return () => {
clearTimeout(cursorTimer)
clearTimeout(morphTimer)
clearTimeout(fadeTimer)
clearTimeout(completeTimer)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [onComplete, totalBootTime, fadeStartTime, captureCursorPosition, reducedMotion])
}, [typedCount, phase, reducedMotion])
// Hold phase: capture cursor, then start fading
useEffect(() => {
if (phase !== 'holding') return
captureCursorPosition()
const fadeTimer = setTimeout(() => {
setPhase('fading')
}, BOOT_CONFIG.timing.holdAfterComplete)
return () => clearTimeout(fadeTimer)
}, [phase, captureCursorPosition])
// Fade phase: wait for animations to finish, then complete
useEffect(() => {
if (phase !== 'fading') return
const longestFade = Math.max(
BOOT_CONFIG.timing.fadeOutDuration,
BOOT_CONFIG.timing.cursorShrinkDuration
)
const completeTimer = setTimeout(() => {
setIsVisible(false)
setPhase('done')
onComplete()
}, longestFade + BOOT_CONFIG.timing.ecgStartDelay)
return () => clearTimeout(completeTimer)
}, [phase, onComplete])
// Reduced motion: skip animation
useEffect(() => {
if (!reducedMotion) return
const timer = setTimeout(onComplete, 500)
return () => clearTimeout(timer)
}, [reducedMotion, onComplete])
// Track cursor anchor position relative to the content container
useLayoutEffect(() => {
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
const anchor = cursorAnchorRef.current.getBoundingClientRect()
const container = containerRef.current.getBoundingClientRect()
setCursorPos({
left: anchor.left - container.left,
top: anchor.top - container.top,
})
}, [typedCount, phase])
// Render the typed lines up to typedCount
const renderLines = () => {
let remaining = typedCount
const renderedLines: React.ReactNode[] = []
let cursorPlaced = false
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
const line = TYPED_LINES[lineIdx]
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
remaining -= charsForLine
// Cursor goes on the line currently being typed, or the last line in non-typing phases
const isCursorLine = phase === 'typing'
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
: lineIdx === TYPED_LINES.length - 1
// Render segments
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
const spans: React.ReactNode[] = []
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
const seg = line.segments[segIdx]
if (charBudget <= 0 && phase === 'typing') break
const visibleChars = phase === 'typing'
? Math.min(charBudget, seg.text.length)
: seg.text.length
const visibleText = seg.text.slice(0, visibleChars)
charBudget -= visibleChars
if (seg.isSeedDot && visibleChars > 0) {
spans.push(
<span
key={segIdx}
className={phase === 'holding' ? 'ecg-seed-dot animate-seed-pulse' : 'ecg-seed-dot'}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{visibleText}
</span>
)
} else if (visibleChars > 0) {
spans.push(
<span
key={segIdx}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{visibleText}
</span>
)
}
}
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
if (isCursorLine && phase !== 'done') {
cursorPlaced = true
spans.push(
<span
key="cursor-anchor"
ref={cursorAnchorRef}
className="inline-block align-middle"
style={{ width: 8, height: 16, marginLeft: 1 }}
/>
)
}
renderedLines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
{spans}
</div>
)
}
return renderedLines
}
// 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="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm: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} />
))}
{(() => {
// Render all lines fully
const lines: React.ReactNode[] = []
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
const line = TYPED_LINES[lineIdx]
const spans: React.ReactNode[] = []
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
const seg = line.segments[segIdx]
spans.push(
<span
key={segIdx}
className={seg.isSeedDot ? 'ecg-seed-dot' : undefined}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{seg.text}
</span>
)
}
lines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
{spans}
</div>
)
}
return lines
})()}
</div>
</div>
)
@@ -255,14 +435,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
<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"
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
exit={{ opacity: 1 }}
transition={{ duration: 0 }}
>
{/* CRT Scanlines */}
<div
<motion.div
className="absolute inset-0 pointer-events-none"
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
style={{
background: `repeating-linear-gradient(
0deg,
@@ -274,62 +456,37 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
}}
/>
{/* Content */}
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Header */}
{/* Content container */}
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Text fades out independently */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
>
<BootLineHeader text={BOOT_CONFIG.header} />
{renderLines()}
</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: getCumulativeDelay(index) / 1000,
duration: 0.4,
ease: 'easeOut',
}}
>
<BootLineRenderer line={line} />
</motion.div>
))}
{/* Blinking Cursor */}
{showCursor && (
<motion.div
{/* Cursor rendered outside fading wrapper — shrinks independently */}
{cursorPos && phase !== 'done' && (
<span
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' }}
className="absolute animate-blink"
style={{
height: 16,
backgroundColor: BOOT_CONFIG.colors.bright,
animation: isMorphing ? undefined : 'blink 530ms infinite',
left: cursorPos.left,
top: cursorPos.top + (phase === 'fading' ? 12 : 0),
width: 8,
height: phase === 'fading' ? 4 : 16,
backgroundColor: COLORS.bright,
filter: phase === 'fading' ? 'blur(1px)' : 'none',
boxShadow: phase === 'fading' ? '0 0 12px rgba(0,255,65,0.9)' : 'none',
transition: phase === 'fading'
? `top ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
: 'none',
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
}}
/>
)}
</div>
{/* CSS for blink animation */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
`}</style>
</motion.div>
)}
</AnimatePresence>
+254 -126
View File
@@ -26,13 +26,7 @@ interface LetterLayout {
char: string
startX: number
endX: number
startConnector: number
endConnector: number
}
interface ConnectorProfile {
leftInset: number
rightInset: number
baselineY: number
}
// =============================================================================
@@ -42,24 +36,11 @@ interface ConnectorProfile {
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 HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition
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)
// =============================================================================
@@ -122,17 +103,30 @@ function generateHeartbeatPoints(amplitude: number): Point[] {
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)
// P wave: gentle rounded bump
if (t >= 0.02 && t < 0.14) {
y = 0.06 * Math.sin(((t - 0.02) / 0.12) * Math.PI)
}
// PR segment flat (0.140.24)
// Q wave: small sharp dip
else if (t >= 0.24 && t < 0.28) {
y = -0.08 * Math.sin(((t - 0.24) / 0.04) * Math.PI)
}
// R wave: tall sharp spike
else if (t >= 0.28 && t < 0.36) {
y = 1.0 * Math.sin(((t - 0.28) / 0.08) * Math.PI)
}
// S wave: dip below baseline
else if (t >= 0.36 && t < 0.42) {
y = -0.2 * Math.sin(((t - 0.36) / 0.06) * Math.PI)
}
// ST segment flat (0.420.54)
// T wave: broad rounded bump
else if (t >= 0.54 && t < 0.78) {
y = 0.15 * Math.sin(((t - 0.54) / 0.24) * Math.PI)
}
points.push({ x: t, y: y * amplitude })
}
return points
@@ -160,32 +154,112 @@ function layoutText(
offsetX: number,
letterWidth: number,
letterGap: number,
spaceWidth: number
spaceWidth: number,
baselineY: number,
rowGap: number,
maxRowWidth: number
): LetterLayout[] {
const words = ECG_TEXT.split(' ')
const layout: LetterLayout[] = []
let cursor = offsetX
let currentBaselineY = baselineY
let rowWidth = 0
for (const char of ECG_TEXT) {
if (char === ' ') {
cursor += spaceWidth
continue
for (let w = 0; w < words.length; w++) {
const word = words[w]
const wordWidth = word.length * (letterWidth + letterGap) - letterGap
if (w > 0) {
const withSpace = rowWidth + spaceWidth + wordWidth
if (maxRowWidth > 0 && withSpace > maxRowWidth) {
// Wrap to next row
cursor += spaceWidth
currentBaselineY += rowGap
rowWidth = 0
} else {
cursor += spaceWidth
rowWidth += spaceWidth
}
}
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
for (const char of word) {
layout.push({
char,
startX: cursor,
endX: cursor + letterWidth,
baselineY: currentBaselineY,
})
cursor += letterWidth + letterGap
rowWidth += letterWidth + letterGap
}
rowWidth -= letterGap
}
return layout
}
/** Measure where each character's rendered stroke crosses the baseline.
* Returns left/right ratios (01) within the character cell. */
function measureCharBaselineEdges(
font: string,
lineWidth: number,
charWidth: number
): Map<string, { leftRatio: number; rightRatio: number }> {
const padding = Math.ceil(charWidth)
const width = Math.ceil(charWidth + padding * 2)
const height = Math.ceil(charWidth * 3)
const baseline = Math.ceil(height * 0.6)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')!
const centerX = width / 2
const halfChar = charWidth / 2
const uniqueChars = [...new Set(ECG_TEXT.replace(/ /g, ''))]
const results = new Map<string, { leftRatio: number; rightRatio: number }>()
for (const char of uniqueChars) {
ctx.clearRect(0, 0, width, height)
ctx.font = font
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.strokeStyle = '#fff'
ctx.lineWidth = lineWidth
ctx.strokeText(char, centerX, baseline)
// Scan ±2 rows around baseline for stroke pixels
const y0 = Math.max(0, baseline - 2)
const scanH = 5
const data = ctx.getImageData(0, y0, width, scanH).data
let minX = width
let maxX = 0
for (let r = 0; r < scanH; r++) {
for (let x = 0; x < width; x++) {
if (data[(r * width + x) * 4 + 3] > 10) {
if (x < minX) minX = x
if (x > maxX) maxX = x
}
}
}
const leftEdge = centerX - halfChar
if (minX <= maxX) {
results.set(char, {
leftRatio: Math.max(0, (minX - leftEdge) / charWidth),
rightRatio: Math.min(1, (maxX - leftEdge) / charWidth),
})
} else {
// Fallback: full width
results.set(char, { leftRatio: 0, rightRatio: 1 })
}
}
return results
}
// =============================================================================
// Main Component
// =============================================================================
@@ -238,7 +312,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.scale(dpr, dpr)
// Scale factors based on viewport
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
const scale = Math.min(1.2, Math.max(0.45, vw / 1200))
const LETTER_W = 72 * scale
const LETTER_G = 10 * scale
const SPACE_W = 30 * scale
@@ -246,17 +320,18 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
// Layout parameters
const baselineY = vh * 0.5
const ecgMaxDefl = vh * 0.25
const textMaxDefl = vh * 0.08
// Cap text deflection to letter width so font doesn't overflow cells on mobile
const textMaxDefl = Math.min(vh * 0.08, LETTER_W * 1.15)
// Calculate start offset from cursor position if provided
const startOffsetX = startPosition ? startPosition.x : 0
// Build beats with cursor offset
const beats: Beat[] = [
{ 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 },
{ startTime: 0.6, widthPx: 150 * scale, amplitude: 0.3, startWX: 0 },
{ startTime: 1.4, widthPx: 190 * scale, amplitude: 0.55, startWX: 0 },
{ startTime: 2.3, widthPx: 230 * scale, amplitude: 0.85, startWX: 0 },
{ startTime: 3.2, widthPx: 270 * scale, amplitude: 1.0, startWX: 0 },
]
// Apply start offset to all beats
@@ -264,25 +339,26 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
})
// Calculate text layout
// Calculate text layout — single line, viewport scrolls through
const lastBeat = beats[beats.length - 1]
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
const textEndWX = textStartWX + totalTextW
const textLayout = layoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
const textLayout = layoutText(
textStartWX, LETTER_W, LETTER_G, SPACE_W,
baselineY, 0, Infinity
)
// Calculate timing phases
const textEndTime = textEndWX / TRACE_SPEED
const holdEndTime = textEndTime + HOLD_SECONDS
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
const holdEndTime = textEndTime
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
const fadeEndTime = fadeStartTime + 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 => {
// Check beats
@@ -307,35 +383,11 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
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()
}
}
// Text rendering properties (drawn directly each frame — avoids offscreen canvas DPR/size issues on mobile)
const textFont = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
const textLineWidth = 2 * scale
// Measure where each character's stroke crosses the baseline (for connector lines)
const charEdges = measureCharBaselineEdges(textFont, textLineWidth, LETTER_W)
// Animation loop
const animate = (timestamp: number) => {
@@ -353,8 +405,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
// Calculate current head position
let headWX = elapsed * TRACE_SPEED + startOffsetX
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
const isBgTransitionPhase = elapsed >= fadeEndTime
if (elapsed >= textEndTime) {
@@ -365,18 +417,10 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
let headSX: number
let viewOff: number
const headSXEcg = HEAD_SCREEN_RATIO * vw
if (headWX <= textStartWX) {
viewOff = Math.max(0, headWX - headSXEcg)
headSX = headWX - viewOff
} else if (headWX >= textEndWX || elapsed >= textEndTime) {
viewOff = textEndWX - finalHeadSX
headSX = headWX - viewOff
} else {
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
viewOff = headWX - headSX
}
// Simple continuous scrolling - viewport follows head when it exceeds 75% of screen
viewOff = Math.max(0, headWX - headSXEcg)
headSX = headWX - viewOff
// Calculate fade alpha
let fadeAlpha = 1
@@ -386,8 +430,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
fadeAlpha = 0
}
// Background color transition
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
// Background color transition — delayed until after HOLD
if (!bgTransitionedRef.current && elapsed >= fadeStartTime) {
bgTransitionedRef.current = true
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
container.style.background = loginBgColor
@@ -396,11 +440,13 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.save()
ctx.globalAlpha = fadeAlpha
// Draw ECG trace (beats only, up to text start)
const traceStart = Math.max(0, Math.floor(viewOff))
// Draw ECG trace - always draw from start for continuity
// Performance is fine since we're only drawing ~1000 pixels per frame
const traceStart = Math.floor(startOffsetX)
const traceEnd = Math.min(
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX),
Math.ceil(viewOff + vw)
Math.ceil(viewOff + vw),
Math.ceil(textStartWX) // Stop trace before text — only the dot draws through letters
)
if (traceEnd > traceStart) {
@@ -436,40 +482,122 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.stroke()
}
// 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)
// Draw flatline after text — during flatline draw phase and fade phase
if (isFlatlinePhase || isFadePhase) {
const flatlineProgress = Math.min(1, (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS)
// Use actual head screen position, not finalHeadSX
const flatlineStartSX = headSX
const flatlineEndSX = flatlineStartSX + flatlineProgress * (vw - flatlineStartSX + 50)
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2 * scale
ctx.shadowBlur = 8 * scale
ctx.shadowColor = lineColor
ctx.moveTo(finalHeadSX, baselineY)
ctx.moveTo(flatlineStartSX, baselineY)
ctx.lineTo(flatlineEndSX, baselineY)
ctx.stroke()
}
// Mask-based text reveal
// Text reveal — draw letters directly each frame
const isTextPhase = headWX > textStartWX
const isTextDone = elapsed >= textEndTime
if (isTextPhase && textCtx) {
// Create clipping region based on trace head position
if (isTextPhase) {
ctx.save()
// Clip for progressive reveal
const revealX = isTextDone ? vw : (headWX - viewOff)
ctx.beginPath()
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh)
ctx.rect(0, 0, revealX, vh)
ctx.clip()
// Draw pre-rendered text through the clip
ctx.drawImage(textCanvas, -viewOff, 0)
// Common text properties
ctx.font = textFont
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
// Apply neon glow to text
ctx.globalCompositeOperation = 'source-over'
// Pass 1: Outer glow layer (matches trace glow)
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
ctx.lineWidth = 6 * scale
ctx.shadowColor = lineColor
ctx.shadowBlur = 8 * scale
ctx.shadowBlur = 14 * scale
for (const item of textLayout) {
const screenX = (item.startX + item.endX) / 2 - viewOff
if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue
ctx.strokeText(item.char, screenX, baselineY)
}
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
const currEdge = charEdges.get(curr.char)
const nextEdge = charEdges.get(next.char)
if (!currEdge || !nextEdge) continue
const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff
const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff
if (toX < 0 || fromX > vw) continue
ctx.beginPath()
ctx.moveTo(fromX, baselineY)
ctx.lineTo(toX, baselineY)
ctx.stroke()
}
// Connect last character's right stroke edge to cell edge (glow layer)
{
const lastChar = textLayout[textLayout.length - 1]
const lastEdge = charEdges.get(lastChar.char)
if (lastEdge) {
const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff
const toX = lastChar.endX - viewOff
if (fromX < vw && toX > 0) {
ctx.beginPath()
ctx.moveTo(fromX, baselineY)
ctx.lineTo(toX, baselineY)
ctx.stroke()
}
}
}
// Pass 2: Main line layer (matches trace line)
ctx.strokeStyle = lineColor
ctx.lineWidth = textLineWidth
ctx.shadowBlur = 4 * scale
for (const item of textLayout) {
const screenX = (item.startX + item.endX) / 2 - viewOff
if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue
ctx.strokeText(item.char, screenX, baselineY)
}
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
const currEdge = charEdges.get(curr.char)
const nextEdge = charEdges.get(next.char)
if (!currEdge || !nextEdge) continue
const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff
const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff
if (toX < 0 || fromX > vw) continue
ctx.beginPath()
ctx.moveTo(fromX, baselineY)
ctx.lineTo(toX, baselineY)
ctx.stroke()
}
// Connect last character's right stroke edge to its cell edge (bridges gap to flatline)
const lastChar = textLayout[textLayout.length - 1]
const lastEdge = charEdges.get(lastChar.char)
if (lastEdge) {
const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff
const toX = lastChar.endX - viewOff
if (fromX < vw && toX > 0) {
ctx.beginPath()
ctx.moveTo(fromX, baselineY)
ctx.lineTo(toX, baselineY)
ctx.stroke()
}
}
ctx.restore()
}
+87 -65
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { motion } from 'framer-motion'
import { Shield } from 'lucide-react'
import { useAccessibility } from '../contexts/AccessibilityContext'
@@ -11,19 +11,23 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true)
const [isTypingUsername, setIsTypingUsername] = useState(true)
const [isTypingPassword, setIsTypingPassword] = useState(false)
const [activeField, setActiveField] = useState<'username' | 'password' | null>('username')
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'A.CHARLWOOD'
const passwordLength = 8
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Refs for interval cleanup
const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const triggerComplete = useCallback(() => {
setIsExiting(true)
setTimeout(() => {
@@ -36,6 +40,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
if (prefersReducedMotion) {
setUsername(fullUsername)
setPasswordDots(passwordLength)
setActiveField(null)
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
@@ -45,33 +50,37 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
return
}
setIsTypingUsername(true)
// Username typing: 30ms per character
let usernameIndex = 0
const usernameInterval = setInterval(() => {
usernameIntervalRef.current = setInterval(() => {
if (usernameIndex <= fullUsername.length) {
setUsername(fullUsername.slice(0, usernameIndex))
usernameIndex++
} else {
clearInterval(usernameInterval)
setIsTypingUsername(false)
setIsTypingPassword(true)
if (usernameIntervalRef.current) {
clearInterval(usernameIntervalRef.current)
}
setActiveField('password')
// Password dots: 20ms per dot, after 150ms pause
setTimeout(() => {
let dotCount = 0
const passwordInterval = setInterval(() => {
passwordIntervalRef.current = setInterval(() => {
if (dotCount <= passwordLength) {
setPasswordDots(dotCount)
dotCount++
} else {
clearInterval(passwordInterval)
setIsTypingPassword(false)
if (passwordIntervalRef.current) {
clearInterval(passwordIntervalRef.current)
}
setActiveField(null)
// Button press: after 150ms pause
setTimeout(() => {
setButtonPressed(true)
setTimeout(() => {
triggerComplete()
}, 100)
}, 200)
}, 150)
}
}, 20)
@@ -81,47 +90,66 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}, [triggerComplete, prefersReducedMotion])
useEffect(() => {
const cursorInterval = setInterval(() => {
// Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => {
setShowCursor(prev => !prev)
}, 530)
startLoginSequence()
return () => clearInterval(cursorInterval)
// Delay start slightly for card entrance
const startTimeout = setTimeout(() => {
startLoginSequence()
}, 200)
return () => {
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
clearTimeout(startTimeout)
}
}, [startLoginSequence])
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }}
role="status"
aria-label="Clinical system login"
>
<motion.div
className="bg-white p-8"
className="bg-white"
style={{
width: '320px',
padding: '32px',
borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)',
border: '1px solid #E5E7EB',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.03)',
}}
initial={{ opacity: 0 }}
initial={{ opacity: 0, scale: 0.98 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{/* Branding */}
<div className="flex flex-col items-center mb-8">
{/* Branding Header */}
<div
className="flex flex-col items-center"
style={{ marginBottom: '28px' }}
>
<div
className="p-3 rounded-lg mb-3"
style={{ backgroundColor: 'rgba(0, 94, 184, 0.08)' }}
style={{
padding: '10px',
borderRadius: '8px',
backgroundColor: 'rgba(0, 94, 184, 0.07)',
marginBottom: '10px',
}}
>
<Shield
size={28}
size={26}
style={{ color: '#005EB8' }}
strokeWidth={2.5}
/>
</div>
<span
style={{
fontFamily: 'Inter, sans-serif',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '13px',
fontWeight: 600,
color: '#64748B',
@@ -132,7 +160,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</span>
<span
style={{
fontFamily: 'Inter, sans-serif',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px',
fontWeight: 400,
color: '#94A3B8',
@@ -144,13 +172,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</div>
{/* Login Form */}
<div className="space-y-5">
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Username Field */}
<div>
<label
style={{
display: 'block',
fontFamily: 'Inter, sans-serif',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '12px',
fontWeight: 500,
color: '#64748B',
@@ -162,26 +190,24 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div
style={{
width: '100%',
padding: '10px 12px',
fontFamily: "'Geist Mono', 'Courier New', monospace",
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px',
backgroundColor: '#FFFFFF',
border: '1px solid #D1D5DB',
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
borderRadius: '4px',
color: '#111827',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}}
>
<span>{username}</span>
{isTypingUsername && (
{activeField === 'username' && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
marginLeft: '1px',
}}
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
aria-hidden="true"
>
|
</span>
@@ -194,7 +220,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<label
style={{
display: 'block',
fontFamily: 'Inter, sans-serif',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '12px',
fontWeight: 500,
color: '#64748B',
@@ -206,27 +232,25 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div
style={{
width: '100%',
padding: '10px 12px',
fontFamily: "'Geist Mono', 'Courier New', monospace",
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px',
backgroundColor: '#FFFFFF',
border: '1px solid #D1D5DB',
backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
border: activeField === 'password' ? '1px solid #005EB8' : '1px solid #E5E7EB',
borderRadius: '4px',
color: '#111827',
letterSpacing: '0.15em',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}}
>
<span>{'\u2022'.repeat(passwordDots)}</span>
{isTypingPassword && (
{activeField === 'password' && (
<span
style={{
opacity: showCursor ? 1 : 0,
color: '#005EB8',
marginLeft: '2px',
}}
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
aria-hidden="true"
>
|
</span>
@@ -238,8 +262,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<button
style={{
width: '100%',
padding: '11px 16px',
fontFamily: 'Inter, sans-serif',
padding: '10px 16px',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '14px',
fontWeight: 600,
color: '#FFFFFF',
@@ -248,7 +272,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 100ms ease-out',
marginTop: '8px',
}}
>
Log In
@@ -258,18 +281,17 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
{/* Footer */}
<div
style={{
marginTop: '24px',
paddingTop: '20px',
marginTop: '22px',
paddingTop: '18px',
borderTop: '1px solid #E5E7EB',
}}
>
<p
style={{
fontFamily: 'Inter, sans-serif',
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px',
color: '#94A3B8',
textAlign: 'center',
lineHeight: '1.4',
}}
>
Secure clinical system login