Compare commits

..

3 Commits

Author SHA1 Message Date
admin 3afadbdc73 Completed boot loading to ECG, to name written 2026-02-12 22:31:34 +00:00
admin 4eeeb05744 Ralph iteration 1: work in progress 2026-02-11 22:55:02 +00:00
admin 959f0e1842 Task 1b: Rebuild boot sequence and ECG animation
- Refactored BootSequence to config-driven architecture with type-safe line components
- Added cursor position capture and smooth cursor-to-dot morph transition
- Rebuilt ECGAnimation with mask-based text reveal technique
- Implemented connector lines between letters with per-character profiles
- ECG trace now starts from cursor position (no teleport)
- Added prefers-reduced-motion support for both phases
- Updated App.tsx to pass cursor position between components

Quality checks: typecheck ✓, lint ✓, build ✓
2026-02-11 22:54:44 +00:00
12 changed files with 1343 additions and 330 deletions
+4 -1
View File
@@ -8,7 +8,10 @@
"Bash(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")",
"Bash(npx skills find:*)",
"WebSearch",
"Bash(ls \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\New CV website\\\\designs\"\" 2>nul || echo \"Directory does not exist \")"
"Bash(ls \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\New CV website\\\\designs\"\" 2>nul || echo \"Directory does not exist \")",
"Bash(npm run typecheck:*)",
"Bash(npm run dev:*)",
"Bash(npm run build:*)"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"iterations": [
{
"iteration": 1,
"startedAt": "2026-02-11T22:50:15.397Z",
"endedAt": "2026-02-11T22:55:02.081Z",
"durationMs": 283525,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/BootSequence.tsx",
"src/components/ECGAnimation.tsx"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
}
],
"totalDurationMs": 283525,
"struggleIndicators": {
"repeatedErrors": {},
"noProgressIterations": 0,
"shortIterations": 0
}
}
File diff suppressed because one or more lines are too long
+116 -34
View File
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Interactive CV/portfolio website for Andy Charlwood with a distinctive three-phase loading experience: terminal boot sequence → ECG canvas animation → main content. Built as a React SPA with TypeScript and Vite.
Interactive CV/portfolio for Andy Charlwood, presented as a premium clinical information system. The concept: *what if a GP surgery's patient record system were redesigned by a luxury product studio?* The structure and metaphor of a real clinical system (patient banner, sidebar navigation, record sections) — but elevated with refined typography, considered motion, and atmospheric depth.
**This is NOT a faithful NHS system clone.** It's a showcase portfolio that *evokes* the feel of clinical software while being distinctly beautiful. The clinical metaphor is the creative conceit; the execution should feel premium and elegant.
Built as a React SPA with TypeScript and Vite.
## Commands
@@ -18,57 +22,135 @@ No test framework is configured.
## Architecture
### Three-Phase UI Flow
### Four-Phase UI Flow
`App.tsx` manages a `Phase` state (`'boot'``'ecg'``'content'`). Each phase renders exclusively:
`App.tsx` manages a `Phase` state (`'boot'``'ecg'``'login'``'pmr'`). Each phase renders exclusively:
1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic
2. **ECGAnimation** — Canvas-based heartbeat animation (~5-6s) with letter tracing, background transitions from black to white
3. **Content**FloatingNav + all CV sections (Hero, Skills, Experience, Education, Projects, Contact, Footer)
Total boot-to-content time must be ≤10 seconds.
1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic. Fira Code font, matrix-green palette. **Locked — do not change.**
2. **ECGAnimation** — Canvas-based heartbeat animation with mask-based letter tracing. Background transitions from black to `#1E293B`. **Locked — do not change.**
3. **LoginScreen**Animated login card on dark background. Auto-types credentials, transitions to PMR. This phase onward is open to design evolution.
4. **PMRInterface** — The main portfolio experience: patient banner + clinical sidebar + scrollable content views.
### Key Patterns
- **Scroll reveals**: `useScrollReveal` hook wraps IntersectionObserver with trigger-once semantics. Used by every content section. Never use scroll event listeners.
- **Active nav tracking**: `useActiveSection` hook tracks which section is in viewport for FloatingNav highlighting.
- **Staggered animations**: Components use index-based delays (`baseDelay + index * 100`) with Framer Motion.
- **SVG skill circles**: `Skills.tsx` uses `strokeDashoffset = circumference * (1 - level / 100)` with `-90deg` rotation to start from 12 o'clock.
- **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → letter tracing → exit.
- **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → mask-based letter tracing → exit.
- **Clinical sidebar navigation**: `ClinicalSidebar.tsx` provides hash-routed view switching with keyboard shortcuts (Alt+1-7, arrow keys, "/" for search).
- **Patient banner condensation**: `PatientBanner.tsx` uses IntersectionObserver via `useScrollCondensation` hook — full banner (80px) condenses to 48px on scroll.
- **Staggered entrance animations**: Framer Motion variants with sequenced delays (banner → sidebar → content).
- **View switching**: Instant — no crossfade or slide between views. Content fades in once on initial load only.
- **Expandable rows**: Consultation entries, medication rows, and problem entries expand in-place with height animation.
- **Responsive breakpoints**: Desktop (full sidebar + banner), Tablet (icon-only sidebar), Mobile (bottom nav bar).
### Path Aliases
`@/` maps to `./src/` (configured in both `vite.config.ts` and `tsconfig.json`).
### Styling
Tailwind CSS with custom design tokens in `tailwind.config.js`:
- **Colors**: teal `#00897B` (primary), coral `#FF6B6B` (accent), ECG palette (green/cyan/dim)
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono/terminal)
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
- Inline styles only for dynamic values that Tailwind can't express (e.g., computed `strokeDashoffset`).
### Type System
All data types live in `src/types/index.ts`. Strict TypeScript — no `any` types. One component per file with typed props interfaces.
All data types live in `src/types/index.ts` and `src/types/pmr.ts`. Strict TypeScript — no `any` types. One component per file with typed props interfaces.
## Design Direction: Clinical Luxury
The aesthetic direction is **"Clinical Luxury"** — the precision and information density of a medical records system, married to the refinement of high-end product design. Think Bloomberg Terminal redesigned by a Swiss design house.
### Tone
- **Precise, not cold.** Every element has a reason. Spacing is generous but intentional.
- **Structured, not rigid.** The grid and hierarchy of clinical software, but with room to breathe.
- **Technical, not sterile.** Monospace data, status indicators, and coded entries create authentic texture.
- **Elegant, not decorative.** No gratuitous ornament. Beauty comes from proportion, contrast, and type.
### Typography
Typography is the primary vehicle for premium feel. Avoid generic system fonts.
- **UI / Body**: Use a distinctive geometric or humanist sans-serif with character — **not** Inter, Roboto, or system defaults. Choose something with personality that still reads cleanly at small sizes (11-14px range). Candidates: Satoshi, General Sans, Outfit, DM Sans, or similar. The chosen font should feel "designed" rather than "default."
- **Monospace / Data**: Geist Mono for timestamps, coded entries, registration numbers, and tabular data. This creates the "technical texture" that sells the clinical metaphor.
- **Terminal phase**: Fira Code — locked, do not change.
- **Type scale**: Keep it tight. Clinical systems use small text. Headings 15-18px, body 13-14px, labels 11-12px. Precision over drama.
- **Weight hierarchy**: Use weight (400/500/600/700) rather than size to establish hierarchy. Bold section headers, medium labels, regular body.
### Color Palette
The palette anchors on NHS Blue as the institutional accent, with a predominantly dark sidebar + light content split that creates natural drama.
- **NHS Blue `#005EB8`** — The single strong accent color. Used for active states, links, buttons, interactive elements. This IS the brand color of the clinical metaphor.
- **Dark sidebar `#1E293B`** — Creates gravitas. The "serious software" feel comes from this dark chrome.
- **Patient banner `#334155`** — Slightly lighter than sidebar. The information-dense header bar.
- **Content background** — Not flat gray. Consider a very subtle warm tint, or a faint noise/grain texture overlay on `#F5F7FA` to add depth. The content area should feel like paper, not a spreadsheet.
- **Cards `#FFFFFF`** — Clean white with refined shadows (layered, not single-value). Cards should feel like they float slightly above the content surface.
- **Status colors**: Green `#22C55E`, Amber `#F59E0B`, Red `#EF4444` — used sparingly for traffic-light indicators. Always paired with text labels, never as sole signifier.
- **Text**: Primary `#111827`, Secondary `#6B7280`, Muted `#94A3B8`. Use the full range for hierarchy.
### Shadows & Depth
Real clinical software is flat and border-heavy. This project should use shadows to create subtle layered depth:
- **Cards**: Multi-layered shadow — e.g., `0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)`. Gentle, not Material Design dramatic.
- **Sidebar**: Optional very subtle inner shadow or glow at the right edge where it meets content.
- **Patient banner**: Subtle drop shadow below to separate from content.
- **Hover states**: Cards may lift very slightly on hover (1-2px translate + shadow deepen). Keep it restrained.
### Motion
Motion should feel considered and premium, never flashy:
- **Entrance animations**: The PMR interface materializes in sequence — banner slides down → sidebar slides from left → content fades in. Quick (200-300ms) with easing.
- **Login transition**: Card scales slightly and fades. Background carries over to PMR (both are `#1E293B`-derived).
- **View switching**: Instant, no transition between views. This preserves the "software application" feel.
- **Expandable content**: Height-only animation, 200ms ease-out. Content grows/shrinks — no opacity fade.
- **Hover states**: Subtle, immediate. Background color shifts, not transforms. Think: OS-level responsiveness.
- **Clinical alert**: Spring animation for entrance (Framer Motion `type: "spring"`). Dismiss: icon crossfade → height collapse.
- **`prefers-reduced-motion`**: All animations skip to final state. No exceptions.
### Spatial Composition
- **Generous but structured.** More whitespace than a real clinical system. Cards have 16-24px padding. Sections breathe.
- **Clear visual hierarchy.** Section headers (uppercase, small, tracked-out) → content. No ambiguity about what's a label vs. data.
- **Two-column summary grid** on desktop, single column on mobile. Cards span full width or half width — no orphan columns.
- **Tables** use proper `<table>` markup with styled headers on a light gray background. Alternating row colors. This is where the clinical authenticity lives.
### What Makes It Memorable
The distinctiveness comes from the *contrast between structure and polish*:
- A dark, serious sidebar next to warm, airy content
- Small, precise monospace data in a field of generous whitespace
- NHS blue punching through an otherwise muted palette
- The clinical metaphor itself — "Patient Record" for a CV is unexpected and charming
- The boot sequence → ECG → login flow is theatrical in a way that real clinical software never is
## Styling
Tailwind CSS with custom design tokens in `tailwind.config.js`:
- **Color tokens**: All PMR-prefixed tokens in Tailwind config (`pmr-sidebar`, `pmr-banner`, `pmr-nhsblue`, etc.)
- **Fonts**: Configured as `font-inter`, `font-geist` (monospace) in Tailwind — these need updating when the primary UI font changes.
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
- **Border radius**: 4px default for cards/inputs (clinical precision). 12px exception for login card only.
- Inline styles only for dynamic values that Tailwind can't express.
- CSS custom properties in `index.css` for both boot/ECG phase tokens and PMR phase tokens.
## Guardrails
- Boot sequence text and colors must match `References/concept.html` exactly (CLINICAL TERMINAL v3.2.1 format).
- ECG animation timing/amplitudes/color transitions must match the concept reference.
- CV content sourced from `References/CV_v4.md` — roles, dates, and achievement numbers must be accurate.
- Icons via `lucide-react`, not unicode symbols.
- **Boot sequence**: Text, colors, and timing must match `References/concept.html` exactly. **Do not modify.**
- **ECG animation**: Timing, amplitudes, color transitions, and mask-based text reveal must match the concept reference. **Do not modify.**
- **CV content**: Sourced from `References/CV_v4.md` — roles, dates, and achievement numbers must be accurate.
- **Icons**: Via `lucide-react`, not unicode symbols.
- **Accessibility**: WCAG 2.1 AA compliance. Semantic HTML, ARIA attributes, keyboard navigation, `prefers-reduced-motion` support throughout.
- **No generic aesthetics**: Every design decision should feel intentional. If a component could appear in any random SaaS template, it needs more character.
## Project Structure
```
src/
├── components/ # One component per file (PascalCase)
├── hooks/ # Custom hooks (camelCase, use* prefix)
├── lib/ # Utility functions
├── types/ # TypeScript interfaces
├── App.tsx # Phase manager (root component)
── index.css # Global styles + Tailwind directives
Ralph/ # Implementation plan, guardrails, progress tracking
References/ # Source content (concept.html, CV_v4.md, ECGVideo/)
├── components/ # One component per file (PascalCase)
│ └── views/ # PMR content views (SummaryView, ConsultationsView, etc.)
├── contexts/ # React contexts (AccessibilityContext)
├── data/ # Static data files (patient, consultations, medications, etc.)
├── hooks/ # Custom hooks (camelCase, use* prefix)
── lib/ # Utility functions
├── types/ # TypeScript interfaces (index.ts, pmr.ts)
├── App.tsx # Phase manager (root component)
└── index.css # Global styles + Tailwind directives
Ralph/ # Implementation plan, guardrails, progress tracking
References/ # Source content (concept.html, CV_v4.md, ECGVideo/)
```
+2 -2
View File
@@ -24,9 +24,9 @@ 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.
- [x] **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.
- [ ] **Task 3: Rebuild PatientBanner component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PatientBanner.tsx` to match the specification exactly: (a) Full banner 80px: background `#334155`, bottom border `1px solid #475569`. Name in Inter 600 **20px** (not 18px), details in Inter 400 14px. Layout must match the ASCII art in the ref file — surname-first format "CHARLWOOD, Andrew (Mr)", DOB/NHS No/Address on second row, phone/email/buttons on third row. (b) Status: green dot + "Active" text. Badge: "Open to opportunities" as blue pill. (c) Action buttons: outlined rectangles with NHS blue text and 1px border, 4px radius. Hover fills with NHS blue bg + white text. (d) Condensed banner 48px: single line with name, NHS number, status, action buttons only. Triggers at 100px scroll via IntersectionObserver. Smooth 200ms height transition. (e) Mobile banner: minimal top bar `CHARLWOOD, A (Mr) | 2211810 | dot` with overflow "..." menu. NHS Number tooltip: "GPhC Registration Number".
+55
View File
@@ -91,3 +91,58 @@ Do NOT invoke the `/frontend-design` skill at runtime — it was pre-run and the
### ECG Reference Implementation
`ECGCombined.tsx` in the project root is a Remotion version of the ECG animation with a superior mask-based text reveal technique. Task 1b references this for the canvas implementation.
### Iteration 2 — Task 1b: Rebuild boot sequence and ECG animation
**Completed:** Task 1b
**Changes made:**
- **BootSequence.tsx**: Completely refactored from hardcoded HTML strings to config-driven architecture
- Created type-safe `BootConfig`, `BootLine`, `BootLineType` interfaces
- Individual line components: `BootLineHeader`, `BootLineStatus`, `BootLineSeparator`, `BootLineField`, `BootLineModule`, `BootLineReady`
- Added CRT scanlines overlay during boot phase
- Cursor now captures its screen position via ref and passes to parent via `onCursorPositionReady` callback
- Cursor morph animation: block cursor scales down to 0 width over 300ms before ECG starts
- Reduced motion support: instant boot completion, skips to ECG immediately
- **ECGAnimation.tsx**: Rebuilt with mask-based text reveal technique from ECGCombined.tsx
- Added `startPosition` prop to receive cursor position from BootSequence
- ECG trace now starts from cursor position (with `startOffsetX`) instead of x=0
- Implemented offscreen canvas pre-rendering for text stroke
- Mask-based text reveal: clipping region follows trace head, revealing pre-rendered text
- Added connector lines between letters at baseline using `CONNECTOR_PROFILES`
- Letter profiles define connector insets for natural-looking baseline connections
- Multi-layer neon glow: outer (6px, 25% opacity), inner (2px solid)
- Flatline draw phase extends to right edge after text completion
- Background transitions from black to `#1E293B` (login background)
- Reduced motion support: instant transition to PMR phase
- **App.tsx**: Updated to pass cursor position between BootSequence and ECGAnimation
- Added `cursorPosition` state
- `handleCursorPositionReady` captures position from BootSequence
- Passed to ECGAnimation as `startPosition` prop
**Codebase patterns discovered:**
- Canvas animation performance: pre-render text to offscreen canvas, then drawImage through clip region
- Cursor-to-dot transition requires DOM ref position capture, not just CSS animation
- World-space coordinates (headWX) vs screen-space coordinates (headSX) separation is critical
- Viewport scrolling logic: offset calculated as `headWX - headSX` keeps trace visible
- Connector profiles per character (C, O, D, L, E have special insets) make letter connections look natural
- Background color transition handled via CSS transition on container, not canvas fill
**Quality checks:** All passed (typecheck, lint, build)
- TypeScript: No errors
- ESLint: 1 pre-existing warning in AccessibilityContext.tsx (not our changes)
- Build: Successful, 388KB bundle
**Visual review:** N/A (animation component — visual verification would require browser screenshot)
**Issues encountered:** None
**Design decisions:**
- Kept Fira Code for terminal/boot phase (it's the authentic clinical terminal aesthetic)
- Used ECGCombined.tsx's mask technique but adapted for canvas API (not SVG like the Remotion version)
- Beat amplitudes: 0.3 → 0.55 → 0.85 → 1.0 (same as original implementation)
- Letter spacing: LETTER_W 72px, LETTER_G 10px, SPACE_W 30px (matches original, tighter than ECGCombined)
- Morph animation uses Framer Motion scaleX/width/opacity for smooth cursor-to-dot transition
**Next task:** Task 2 — Rebuild LoginScreen component
+130 -54
View File
@@ -1,87 +1,163 @@
# Reference: Visual Design System
> Extracted from goal.md — Visual System section. This is the SINGLE SOURCE OF TRUTH for colors, typography, spacing, borders, and motion throughout the Clinical Record PMR.
> The SINGLE SOURCE OF TRUTH for colors, typography, spacing, surfaces, and motion throughout the Clinical Record PMR. Aligned with the **Clinical Luxury** direction defined in CLAUDE.md.
---
## Design Philosophy
This is a **premium portfolio** that uses the structure and metaphor of a GP clinical system — not a faithful NHS software clone. Real clinical systems (EMIS Web, SystmOne) are dense, border-heavy, and purely functional. We keep their *structure* (patient banner, sidebar navigation, record sections, tables, status indicators) but elevate the *execution* with refined typography, atmospheric depth, and considered whitespace.
The goal is contrast: clinical precision married to luxury refinement. The "wow" comes from recognizing the clinical metaphor while being surprised by how good it looks.
---
## Color Palette
This design is **light-mode only**. Clinical record systems operate in light mode — high ambient lighting in consulting rooms demands white backgrounds and dark text. A dark mode would break the metaphor.
**Light-mode only.** The metaphor demands it — clinical systems operate under bright consulting room lights. No dark mode.
**Backgrounds:**
- Main content area: `#F5F7FA` (cool light gray — the content background of EMIS/SystmOne)
- Card/panel surfaces: `#FFFFFF` (white)
- Sidebar: `#1E293B` (dark blue-gray — EMIS-style dark navigation panel)
- Patient banner: `#334155` (lighter blue-gray with white text)
- Login screen background: `#1E293B` (same as sidebar — institutional dark blue-gray)
- Main content area: `#F5F7FA` cool light gray base. Add atmospheric depth: a very faint noise/grain texture overlay, or a subtle warm tint, so it feels like quality paper rather than a flat spreadsheet. The content surface should have *presence*.
- Card/panel surfaces: `#FFFFFF` — clean white. Cards float above the content surface via layered shadows (see Surfaces section).
- Sidebar: `#1E293B` dark blue-gray. The gravitas anchor. This dark chrome is what makes it feel like "serious software."
- Patient banner: `#334155` lighter blue-gray with white text. Subtle drop shadow below to separate from content.
- Login screen background: `#1E293B` same as sidebar. Carries through to PMR entrance seamlessly.
**Text:**
- Primary text: `#111827` (gray-900 — near-black for maximum readability)
- Secondary text: `#6B7280` (gray-500)
- On dark surfaces: `#FFFFFF` (white) and `#94A3B8` (slate-400 for secondary)
- Primary: `#111827` (gray-900) — near-black for maximum readability
- Secondary: `#6B7280` (gray-500) — labels, metadata, supporting text
- Muted: `#94A3B8` (slate-400) — timestamps, tertiary info
- On dark surfaces: `#FFFFFF` (white primary), `#94A3B8` (slate-400 secondary)
**Accent and status colors:**
- NHS blue: `#005EB8`primary interactive color. Used for buttons, active nav states, links, column headers. This is the actual NHS brand blue and will be instantly recognized.
- Green: `#22C55E` — active/resolved/current states. "Active" status dots, resolved problems, current role indicators.
- Amber: `#F59E0B` — alerts, in-progress items, notable achievements. The clinical alert banner uses this as its background.
- Red: `#EF4444` — urgent/critical markers. Used sparingly — only for genuinely important items (e.g., a "priority" flag on the referral form).
- Gray: `#6B7280` — inactive/historical items. Past roles that are no longer current, historical "medications."
- **NHS Blue `#005EB8`**THE accent color. Buttons, active nav states, links, interactive elements. This is the actual NHS brand blue — it will be instantly recognized and is the strongest signal of the clinical metaphor. Use it confidently but not everywhere.
- Green `#22C55E` — active/resolved/current states. Status dots, current role indicators.
- Amber `#F59E0B` — alerts, in-progress items. The clinical alert banner background.
- Red `#EF4444` — urgent/critical. Used very sparingly — only genuinely important items.
- Gray `#6B7280` — inactive/historical items.
**Traffic light system (used throughout):**
- Green circle: Active / Resolved / Current
- Amber circle: In progress / Alert / Notable
- Red circle: Urgent / Critical (rare)
- Gray circle: Inactive / Historical
- Green dot: Active / Resolved / Current
- Amber dot: In progress / Alert / Notable
- Red dot: Urgent / Critical (rare)
- Gray dot: Inactive / Historical
- **Always paired with text labels.** Color is never the sole signifier (WCAG compliance).
---
## Typography
Clinical systems use system fonts — Inter or Segoe UI for general text, monospace for coded entries and data values. No decorative fonts, no variable tracking. Functional typography optimized for scanning dense tables.
Typography is the primary vehicle for the premium feel. The font choice must feel *designed* — intentional and distinctive — while still reading cleanly at small clinical-system sizes (11-14px).
- **Patient banner name:** Inter 600, 20px (not huge — clinical systems don't emphasize the patient name with large type)
- **Patient banner details:** Inter 400, 14px
- **Sidebar navigation labels:** Inter 500, 14px, white
- **Section headings (within main area):** Inter 600, 18px
- **Consultation entry titles:** Inter 600, 16px
- **Body text / descriptions:** Inter 400, 14px, line-height 1.6
- **Table headers:** Inter 600, 13px, uppercase, letter-spacing 0.03em, gray-500
- **Table data cells:** Inter 400, 14px
- **Coded entries / data values:** Geist Mono 400, 13px
- **Clinical codes (SNOMED-style):** Geist Mono 400, 12px, gray-400
- **Timestamps:** Geist Mono 400, 12px
- **Alert banner text:** Inter 500, 14px
**Font selection:**
- **UI / Body font**: Choose a distinctive geometric or humanist sans-serif with character. **Do not use** Inter, Roboto, Arial, or system-ui defaults — these read as generic/AI-generated. Candidates: **Satoshi**, **General Sans**, **Outfit**, **DM Sans**, or similar. The chosen font should have personality at 13px. Whichever is selected, configure it as the primary `font-family` across all UI elements.
- **Monospace / Data font**: **Geist Mono** — for timestamps, coded entries, registration numbers, NHS numbers, tabular data values. This monospace texture is what sells the "clinical software" feel. Falls back to Fira Code.
- **Terminal phase**: **Fira Code** — locked, do not change.
**Type scale (tight, clinical):**
- Patient banner name: [UI font] 600, 20px
- Patient banner details: [UI font] 400, 14px
- Sidebar navigation labels: [UI font] 500, 14px, white
- Section headings (main area): [UI font] 600, 15-18px
- Consultation entry titles: [UI font] 600, 15-16px
- Body text / descriptions: [UI font] 400, 13-14px, line-height 1.6
- Table headers: [UI font] 600, 12-13px, uppercase, letter-spacing 0.03-0.05em
- Table data cells: [UI font] 400, 13-14px
- Labels / metadata: [UI font] 500, 11-12px
- Coded entries / data values: Geist Mono 400, 12-13px
- Clinical codes (SNOMED-style): Geist Mono 400, 11-12px, gray-400
- Timestamps: Geist Mono 400, 11-12px
- Alert banner text: [UI font] 500, 14px
**Hierarchy through weight, not size.** Use 400/500/600/700 weight variations within a narrow size range. Bold section headers, medium labels, regular body. This keeps the clinical density while creating clear scannable hierarchy.
---
## Spacing and Layout
More generous than real clinical software. The clinical metaphor provides structure; the extra breathing room provides luxury.
- **Sidebar width:** 220px (fixed, desktop). Collapses to 56px (icon-only) on tablet.
- **Patient banner height:** 80px (full), 48px (condensed/sticky)
- **Main content max-width:** No max-width — clinical systems fill available space. Content flows within the area between sidebar and viewport edge.
- **Main content padding:** 24px
- **Card padding:** 16px (clinical systems are more compact than marketing sites)
- **Border radius:** 4px for cards and inputs (clinical systems use minimal rounding — 4px, not 12px or 16px)
- **Main content max-width:** None — fills available space between sidebar and viewport edge.
- **Main content padding:** 24px (desktop), 16px (mobile)
- **Card padding:** 16-24px — more generous than real clinical systems. Content should breathe inside cards.
- **Border radius:** 4px default for cards, inputs, buttons (clinical precision). 12px exception for the login card only.
- **Table row height:** 40px
- **Section spacing:** 24px between content blocks
- **Base unit:** 4px — tighter spacing than typical, reflecting clinical system density
- **Base unit:** 4px grid — but use it with more generosity than a real clinical system would
## Borders and Surfaces
---
Borders are the dominant visual structuring element. Clinical systems do not rely on shadows or negative space — they use explicit borders to delineate every element.
## Surfaces & Depth
- **All cards:** `1px solid #E5E7EB` (gray-200) border, `4px` border-radius, no shadow (or at most `0 1px 2px rgba(0,0,0,0.03)`)
- **Table cells:** `1px solid #E5E7EB` borders (all sides)
- **Sidebar border:** `1px solid #334155` (subtle right border in a slightly lighter shade)
- **Patient banner border:** `1px solid #475569` bottom border
- **Input fields:** `1px solid #D1D5DB` border, `4px` radius, `#FFFFFF` background, `8px 12px` padding
- **Active/selected rows:** `#EFF6FF` background (very subtle blue tint) — this is how EMIS highlights the selected row
This is where we diverge most from real clinical software. Real systems are flat and border-heavy. This project uses **shadows and layering** to create premium depth — while keeping borders where they're authentically clinical (tables, input fields).
**Cards:**
- Border: `1px solid #E5E7EB` (keep the clinical border — it's authentic)
- Shadow: Multi-layered — `0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03)`. Gentle float, not Material Design dramatic.
- Border-radius: `4px`
- Hover: Cards may lift very slightly — 1-2px translateY + shadow deepens to `0 2px 4px rgba(0,0,0,0.06), 0 8px 16px rgba(0,0,0,0.04)`. Restrained, not bouncy.
- Card headers: Light gray `#F9FAFB` background with `1px solid #E5E7EB` bottom border. Uppercase title in [UI font] 600, 12-13px. This is the most "clinical" element — keep it precise.
**Tables:**
- Full `<table>` markup with styled headers — this is where clinical authenticity lives.
- Table headers: `#F9FAFB` background, `1px solid #E5E7EB` borders.
- Alternating rows: `#FFFFFF` / `#F9FAFB` — subtle but scannable.
- Row hover: `#EFF6FF` background (blue tint).
- Cell borders: `1px solid #E5E7EB` — keep full borders on tables. This is authentic.
**Sidebar:**
- Background: `#1E293B`
- Right edge: `1px solid #334155` + optional very subtle glow/shadow where it meets the content area.
- The sidebar should feel solid and authoritative against the lighter content.
**Patient banner:**
- Background: `#334155`
- Bottom: Subtle drop shadow `0 2px 8px rgba(0,0,0,0.12)` to separate from content below.
- Bottom border: `1px solid #475569`
**Input fields:**
- Border: `1px solid #D1D5DB`, `4px` radius, `#FFFFFF` background, `8px 12px` padding
- Focus: NHS blue border + `box-shadow: 0 0 0 3px rgba(0,94,184,0.15)` — refined focus ring.
---
## Motion
Clinical systems are fast and functional. Animations are minimal and purposeful — no spring physics, no bouncy transitions. Everything is immediate or uses simple ease-out.
Motion should feel **considered and premium** — never flashy, never gratuitous. Every animation has a purpose: to orient the user, to reward interaction, or to create a moment of polish.
- **Navigation switches:** Instant content swap. No crossfade, no slide. When you click a sidebar item, the main content area replaces immediately — just like clicking a tab in EMIS.
- **Consultation expand/collapse:** Height animation, 200ms, `ease-out`. No opacity fade — the content simply grows/shrinks.
- **Alert banner entrance:** Slide down from top, 250ms, with a subtle spring overshoot (this is the one exception — alerts are meant to demand attention).
- **Alert acknowledge:** The alert shrinks in height to zero (200ms) with a small green checkmark that flashes briefly.
- **Hover states:** Background-color transitions, 100ms. No transforms, no lifts. Just color.
- **Login typing:** Character-by-character reveal using `setInterval` (30ms per character for username, 20ms per dot for password).
- **Patient banner scroll condensation:** Smooth height transition (200ms) from full (80px) to condensed (48px) as user scrolls past the first 100px of content.
- **`prefers-reduced-motion`:** Typing animation completes instantly (full text appears), alert slides are replaced with fade-in, expand/collapse is instant.
**PMR entrance sequence (login → PMR transition):**
- Patient banner slides down: 200ms, ease-out
- Sidebar slides from left: 250ms, ease-out, 50ms delay
- Content fades in: 300ms, 100ms delay after sidebar
- This staggered materialization is the single most impactful animation moment.
**Navigation switches:** Instant content swap. No crossfade, no slide. This preserves the "software application" feel — clinical systems switch tabs instantly.
**Expandable content:** Height-only animation, 200ms, `ease-out`. Content grows/shrinks — no opacity fade.
**Clinical alert entrance:** Spring animation (Framer Motion `type: "spring"`, moderate damping). This is the one element that *demands attention* — the spring overshoot is earned here.
**Alert acknowledge:** Warning icon cross-fades to green checkmark (200ms) → hold 200ms → alert height collapses (200ms ease-out).
**Hover states:** Subtle and immediate. Background-color transitions at 100ms. Card lifts are 1-2px max with shadow deepening. Think: OS-level responsiveness, not playful bouncing.
**Login typing:** Character-by-character reveal: 30ms/char for username, 20ms/dot for password. Cursor blink at 530ms.
**Patient banner condensation:** Smooth height transition (200ms) from 80px → 48px as user scrolls past 100px. Buttery smooth, no jank.
**`prefers-reduced-motion`:** All animations skip to final state instantly. Typing completes immediately. Alert appears without slide. Expand/collapse is instant. No exceptions.
---
## What Makes This Design Distinctive
The memorability comes from **contrasts**:
- Dark, serious sidebar next to warm, airy content
- Small, precise monospace data in generous whitespace fields
- NHS blue punching through an otherwise muted, restrained palette
- Clinical structure (tables, status dots, coded entries) executed with luxury refinement (shadows, spacing, typography)
- The boot → ECG → login theatrical sequence, then suddenly: a premium application
If any component could be dropped into a generic SaaS dashboard without looking out of place, it needs more character.
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+12 -5
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import type { Phase } from './types'
import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation'
@@ -8,17 +8,24 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
function App() {
const [phase, setPhase] = useState<Phase>('boot')
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
return (
<AccessibilityProvider>
<div className="min-h-screen bg-black">
{phase === 'boot' && (
<BootSequence onComplete={() => setPhase('ecg')} />
<BootSequence
onComplete={() => setPhase('ecg')}
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
/>
)}
{phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('login')} />
)}
<ECGAnimation
onComplete={() => setPhase('login')}
startPosition={cursorPositionRef.current}
/>
)}
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
+466 -66
View File
@@ -1,97 +1,497 @@
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useEffect, useState } from 'react'
// =============================================================================
// Types
// =============================================================================
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
type BootLineStyle = 'bright' | 'dim' | 'cyan'
interface BootLine {
html: string
delay: number
type: BootLineType
text?: string
label?: string
value?: string
style?: BootLineStyle
}
const bootLines: BootLine[] = [
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk &amp; Waveney ICB</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health &amp; Data Analysis</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">&gt; READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
]
// Precompute cumulative delays so the first render can use them
const bootLineDelays: number[] = (() => {
const delays: number[] = []
let total = 0
bootLines.forEach((line) => {
delays.push(total)
total += line.delay
})
return delays
})()
interface BootConfig {
header: string
lines: BootLine[]
timing: {
lineDelay: number
cursorBlinkInterval: number
holdAfterComplete: number
fadeOutDuration: number
cursorShrinkDuration: number
ecgStartDelay: number
}
colors: {
bright: string
dim: string
cyan: string
}
}
interface BootSequenceProps {
onComplete: () => void
onCursorPositionReady?: (position: { x: number; y: number }) => void
}
export function BootSequence({ onComplete }: BootSequenceProps) {
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: [
{ 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 \u2014 Rendering CV..', style: 'bright' },
],
timing: {
lineDelay: 220,
cursorBlinkInterval: 300,
holdAfterComplete: 900,
fadeOutDuration: 600,
cursorShrinkDuration: 600,
ecgStartDelay: 0,
},
colors: COLORS,
}
// Apply speed multiplier — instant lines (speed=0) stay instant
function s(ms: number): number {
return Math.round(ms * TYPING_SPEED)
}
// Build typed lines from BOOT_CONFIG
function buildTypedLines(): TypedLine[] {
const lines: TypedLine[] = []
// 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),
})
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 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
// Capture cursor position for ECG handoff
const captureCursorPosition = useCallback(() => {
if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) {
const rect = cursorRef.current.getBoundingClientRect()
onCursorPositionReady({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
cursorCapturedRef.current = true
}
}, [onCursorPositionReady])
// Typing engine — runs as a self-scheduling setTimeout chain
useEffect(() => {
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
const fadeStartTime = totalBootTime + 400
if (reducedMotion || phase !== 'typing') return
const fadeTimer = setTimeout(() => {
setIsVisible(false)
}, fadeStartTime)
// All characters typed
if (typedCount >= TOTAL_CHARS) {
setPhase('holding')
return
}
const completeTimer = setTimeout(() => {
onComplete()
}, fadeStartTime+2000)
// 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
}
const line = TYPED_LINES[lineIdx]
const posInLine = typedCount - lineStart
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(fadeTimer)
clearTimeout(completeTimer)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [onComplete])
}, [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 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">
{(() => {
// 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>
)
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
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: 1 }}
transition={{ delay: 2, duration: 0.8, ease: 'easeOut' }}
transition={{ duration: 0 }}
>
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
{bootLines.map((line, index) => (
<motion.div
key={index}
className="whitespace-nowrap leading-relaxed"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: (bootLineDelays[index] ?? 0) / 1000,
duration: 0.4,
ease: 'easeOut',
}}
dangerouslySetInnerHTML={{ __html: line.html }}
/>
))}
{/* CRT Scanlines */}
<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,
rgba(0, 0, 0, 0.15) 0px,
transparent 1px,
transparent 2px,
rgba(0, 0, 0, 0.15) 3px
)`,
}}
/>
{/* 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
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
/>
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
>
{renderLines()}
</motion.div>
{/* Cursor rendered outside fading wrapper — shrinks independently */}
{cursorPos && phase !== 'done' && (
<span
ref={cursorRef}
className="absolute animate-blink"
style={{
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>
</motion.div>
)}
</AnimatePresence>
)
}
export type { BootConfig, BootLine, BootLineType }
export { BOOT_CONFIG }
+431 -103
View File
@@ -1,8 +1,13 @@
import { useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
// =============================================================================
// Types
// =============================================================================
interface ECGAnimationProps {
onComplete: () => void
startPosition?: { x: number; y: number } | null
}
interface Point {
@@ -21,35 +26,107 @@ interface LetterLayout {
char: string
startX: number
endX: number
centerX: number
baselineY: 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 = 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
// =============================================================================
// Letter Definitions (ECG waveform shapes for each letter)
// =============================================================================
const ECG_LETTERS: Record<string, Point[]> = {
A: [{x:0,y:0},{x:0.48,y:1},{x:0.53,y:0.42},{x:0.6,y:0.42},{x:1,y:0}],
N: [{x:0,y:0},{x:0.12,y:1},{x:0.72,y:0},{x:0.88,y:1},{x:1,y:0}],
D: [{x:0,y:0},{x:0.1,y:1},{x:0.5,y:1},{x:0.85,y:0.55},{x:1,y:0}],
R: [{x:0,y:0},{x:0.1,y:1},{x:0.35,y:1},{x:0.5,y:0.6},{x:0.55,y:0.45},{x:1,y:0}],
E: [{x:0,y:0},{x:0.1,y:1},{x:0.4,y:1},{x:0.45,y:0.5},{x:0.65,y:0.5},{x:0.7,y:0},{x:1,y:0}],
W: [{x:0,y:0},{x:0.05,y:1},{x:0.27,y:0},{x:0.5,y:0.65},{x:0.73,y:0},{x:0.95,y:1},{x:1,y:0}],
C: [{x:0,y:0},{x:0.08,y:0.6},{x:0.18,y:1},{x:0.6,y:1},{x:0.8,y:0.5},{x:0.95,y:0.1},{x:1,y:0}],
H: [{x:0,y:0},{x:0.1,y:1},{x:0.18,y:0.5},{x:0.82,y:0.5},{x:0.9,y:1},{x:1,y:0}],
L: [{x:0,y:0},{x:0.12,y:1},{x:0.3,y:1},{x:0.38,y:0},{x:1,y:0}],
O: [{x:0,y:0},{x:0.2,y:0.85},{x:0.35,y:1},{x:0.65,y:1},{x:0.8,y:0.85},{x:1,y:0}],
A: [
{ x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 },
{ x: 0.6, y: 0.42 }, { x: 1, y: 0 },
],
N: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, y: 0 },
{ x: 0.88, y: 1 }, { x: 1, y: 0 },
],
D: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.5, y: 1 },
{ x: 0.85, y: 0.55 }, { x: 1, y: 0 },
],
R: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.35, y: 1 },
{ x: 0.5, y: 0.6 }, { x: 0.55, y: 0.45 }, { x: 1, y: 0 },
],
E: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.4, y: 1 },
{ x: 0.45, y: 0.5 }, { x: 0.65, y: 0.5 }, { x: 0.7, y: 0 },
{ x: 1, y: 0 },
],
W: [
{ x: 0, y: 0 }, { x: 0.05, y: 1 }, { x: 0.27, y: 0 },
{ x: 0.5, y: 0.65 }, { x: 0.73, y: 0 }, { x: 0.95, y: 1 },
{ x: 1, y: 0 },
],
C: [
{ x: 0, y: 0 }, { x: 0.08, y: 0.6 }, { x: 0.18, y: 1 },
{ x: 0.6, y: 1 }, { x: 0.8, y: 0.5 }, { x: 0.95, y: 0.1 },
{ x: 1, y: 0 },
],
H: [
{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.18, y: 0.5 },
{ x: 0.82, y: 0.5 }, { x: 0.9, y: 1 }, { x: 1, y: 0 },
],
L: [
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.3, y: 1 },
{ x: 0.38, y: 0 }, { x: 1, y: 0 },
],
O: [
{ x: 0, y: 0 }, { x: 0.2, y: 0.85 }, { x: 0.35, y: 1 },
{ x: 0.65, y: 1 }, { x: 0.8, y: 0.85 }, { x: 1, y: 0 },
],
}
const ECG_TEXT = 'ANDREW CHARLWOOD'
// =============================================================================
// Helper Functions
// =============================================================================
function generateHeartbeatPoints(amplitude: number): Point[] {
const points: Point[] = []
const steps = 200
for (let i = 0; i <= steps; i++) {
const t = i / steps
let y = 0
if (t >= 0.05 && t < 0.2) { y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) }
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
else if (t >= 0.55 && t < 0.75) { y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI) }
// 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
@@ -67,25 +144,127 @@ 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,
baselineY: number,
rowGap: number,
maxRowWidth: number
): LetterLayout[] {
const words = ECG_TEXT.split(' ')
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
let currentBaselineY = baselineY
let rowWidth = 0
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
}
}
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
}
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
/** 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
// =============================================================================
export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
@@ -93,6 +272,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 +289,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 +302,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 +311,58 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
canvas.height = vh * dpr
ctx.scale(dpr, dpr)
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
// Scale factors based on viewport
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
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'
// 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.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: 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 },
]
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 — single line, viewport scrolls through
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,
baselineY, 0, Infinity
)
const headScreenRatio = 0.75
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
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
// Calculate timing phases
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
// 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,80 +370,95 @@ 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
}
// 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) => {
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
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
// Calculate current head position
let headWX = elapsed * TRACE_SPEED + startOffsetX
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
const isBgTransitionPhase = elapsed >= fadeEndTime
if (elapsed >= textEndTime) {
headWX = textEndWX
}
// Calculate viewport and head screen position
let headSX: number
let viewOff: number
const headSXEcg = headScreenRatio * 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
}
const headSXEcg = HEAD_SCREEN_RATIO * vw
// 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
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
}
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 * 1000}ms ease-out`
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
container.style.background = loginBgColor
}
ctx.save()
ctx.globalAlpha = fadeAlpha
const traceStart = Math.max(0, Math.floor(viewOff))
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), Math.ceil(viewOff + vw))
// 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(textStartWX) // Stop trace before text — only the dot draws through letters
)
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 +467,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 +482,134 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
ctx.stroke()
}
if (isFlatlinePhase) {
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
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
ctx.shadowBlur = 8
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()
}
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
// Text reveal — draw letters directly each frame
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) {
ctx.save()
// Clip for progressive reveal
const revealX = isTextDone ? vw : (headWX - viewOff)
ctx.beginPath()
ctx.rect(0, 0, revealX, vh)
ctx.clip()
// Common text properties
ctx.font = textFont
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
// 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 = 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()
}
// 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 +619,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 +651,20 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
cancelAnimationFrame(animationRef.current)
}
}
}, [finishAnimation])
}, [startPosition, finishAnimation, reducedMotion])
// Reduced motion fallback
if (reducedMotion) {
return (
<motion.div
ref={containerRef}
className="fixed inset-0 z-50 bg-[#1E293B]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)
}
return (
<AnimatePresence>
@@ -357,3 +683,5 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
</AnimatePresence>
)
}
export type { ECGAnimationProps }
+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