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
+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(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")",
"Bash(npx skills find:*)", "Bash(npx skills find:*)",
"WebSearch", "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:*)"
] ]
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"active": true, "active": true,
"iteration": 1, "iteration": 2,
"minIterations": 1, "minIterations": 1,
"maxIterations": 0, "maxIterations": 0,
"completionPromise": "COMPLETE", "completionPromise": "COMPLETE",
+116 -34
View File
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Commands
@@ -18,57 +22,135 @@ No test framework is configured.
## Architecture ## 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 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 (~5-6s) with letter tracing, background transitions from black to white 2. **ECGAnimation** — Canvas-based heartbeat animation with mask-based letter tracing. Background transitions from black to `#1E293B`. **Locked — do not change.**
3. **Content**FloatingNav + all CV sections (Hero, Skills, Experience, Education, Projects, Contact, Footer) 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.
Total boot-to-content time must be ≤10 seconds.
### Key Patterns ### Key Patterns
- **Scroll reveals**: `useScrollReveal` hook wraps IntersectionObserver with trigger-once semantics. Used by every content section. Never use scroll event listeners. - **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → mask-based letter tracing → exit.
- **Active nav tracking**: `useActiveSection` hook tracks which section is in viewport for FloatingNav highlighting. - **Clinical sidebar navigation**: `ClinicalSidebar.tsx` provides hash-routed view switching with keyboard shortcuts (Alt+1-7, arrow keys, "/" for search).
- **Staggered animations**: Components use index-based delays (`baseDelay + index * 100`) with Framer Motion. - **Patient banner condensation**: `PatientBanner.tsx` uses IntersectionObserver via `useScrollCondensation` hook — full banner (80px) condenses to 48px on scroll.
- **SVG skill circles**: `Skills.tsx` uses `strokeDashoffset = circumference * (1 - level / 100)` with `-90deg` rotation to start from 12 o'clock. - **Staggered entrance animations**: Framer Motion variants with sequenced delays (banner → sidebar → content).
- **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → letter tracing → exit. - **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 ### Path Aliases
`@/` maps to `./src/` (configured in both `vite.config.ts` and `tsconfig.json`). `@/` 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 ### 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 ## Guardrails
- Boot sequence text and colors must match `References/concept.html` exactly (CLINICAL TERMINAL v3.2.1 format). - **Boot sequence**: Text, colors, and timing must match `References/concept.html` exactly. **Do not modify.**
- ECG animation timing/amplitudes/color transitions must match the concept reference. - **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. - **CV content**: Sourced from `References/CV_v4.md` — roles, dates, and achievement numbers must be accurate.
- Icons via `lucide-react`, not unicode symbols. - **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 ## Project Structure
``` ```
src/ src/
├── components/ # One component per file (PascalCase) ├── components/ # One component per file (PascalCase)
├── hooks/ # Custom hooks (camelCase, use* prefix) │ └── views/ # PMR content views (SummaryView, ConsultationsView, etc.)
├── lib/ # Utility functions ├── contexts/ # React contexts (AccessibilityContext)
├── types/ # TypeScript interfaces ├── data/ # Static data files (patient, consultations, medications, etc.)
├── App.tsx # Phase manager (root component) ├── hooks/ # Custom hooks (camelCase, use* prefix)
── index.css # Global styles + Tailwind directives ── lib/ # Utility functions
Ralph/ # Implementation plan, guardrails, progress tracking ├── types/ # TypeScript interfaces (index.ts, pmr.ts)
References/ # Source content (concept.html, CV_v4.md, ECGVideo/) ├── 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/)
``` ```
+1 -1
View File
@@ -26,7 +26,7 @@ Each task below references a specific file in `Ralph/refs/` — read ONLY that f
- [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. - [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". - [ ] **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".
+130 -54
View File
@@ -1,87 +1,163 @@
# Reference: Visual Design System # 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 ## 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:** **Backgrounds:**
- Main content area: `#F5F7FA` (cool light gray — the content background of EMIS/SystmOne) - 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` (white) - Card/panel surfaces: `#FFFFFF` — clean white. Cards float above the content surface via layered shadows (see Surfaces section).
- Sidebar: `#1E293B` (dark blue-gray — EMIS-style dark navigation panel) - 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) - Patient banner: `#334155` lighter blue-gray with white text. Subtle drop shadow below to separate from content.
- Login screen background: `#1E293B` (same as sidebar — institutional dark blue-gray) - Login screen background: `#1E293B` same as sidebar. Carries through to PMR entrance seamlessly.
**Text:** **Text:**
- Primary text: `#111827` (gray-900 — near-black for maximum readability) - Primary: `#111827` (gray-900) — near-black for maximum readability
- Secondary text: `#6B7280` (gray-500) - Secondary: `#6B7280` (gray-500) — labels, metadata, supporting text
- On dark surfaces: `#FFFFFF` (white) and `#94A3B8` (slate-400 for secondary) - Muted: `#94A3B8` (slate-400) — timestamps, tertiary info
- On dark surfaces: `#FFFFFF` (white primary), `#94A3B8` (slate-400 secondary)
**Accent and status colors:** **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. - **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. "Active" status dots, resolved problems, current role indicators. - Green `#22C55E` — active/resolved/current states. Status dots, current role indicators.
- Amber: `#F59E0B` — alerts, in-progress items, notable achievements. The clinical alert banner uses this as its background. - Amber `#F59E0B` — alerts, in-progress items. The clinical alert banner background.
- Red: `#EF4444` — urgent/critical markers. Used sparingly — only for genuinely important items (e.g., a "priority" flag on the referral form). - Red `#EF4444` — urgent/critical. Used very sparingly — only genuinely important items.
- Gray: `#6B7280` — inactive/historical items. Past roles that are no longer current, historical "medications." - Gray `#6B7280` — inactive/historical items.
**Traffic light system (used throughout):** **Traffic light system (used throughout):**
- Green circle: Active / Resolved / Current - Green dot: Active / Resolved / Current
- Amber circle: In progress / Alert / Notable - Amber dot: In progress / Alert / Notable
- Red circle: Urgent / Critical (rare) - Red dot: Urgent / Critical (rare)
- Gray circle: Inactive / Historical - Gray dot: Inactive / Historical
- **Always paired with text labels.** Color is never the sole signifier (WCAG compliance).
---
## Typography ## 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) **Font selection:**
- **Patient banner details:** Inter 400, 14px - **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.
- **Sidebar navigation labels:** Inter 500, 14px, white - **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.
- **Section headings (within main area):** Inter 600, 18px - **Terminal phase**: **Fira Code** — locked, do not change.
- **Consultation entry titles:** Inter 600, 16px
- **Body text / descriptions:** Inter 400, 14px, line-height 1.6 **Type scale (tight, clinical):**
- **Table headers:** Inter 600, 13px, uppercase, letter-spacing 0.03em, gray-500 - Patient banner name: [UI font] 600, 20px
- **Table data cells:** Inter 400, 14px - Patient banner details: [UI font] 400, 14px
- **Coded entries / data values:** Geist Mono 400, 13px - Sidebar navigation labels: [UI font] 500, 14px, white
- **Clinical codes (SNOMED-style):** Geist Mono 400, 12px, gray-400 - Section headings (main area): [UI font] 600, 15-18px
- **Timestamps:** Geist Mono 400, 12px - Consultation entry titles: [UI font] 600, 15-16px
- **Alert banner text:** Inter 500, 14px - 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 ## 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. - **Sidebar width:** 220px (fixed, desktop). Collapses to 56px (icon-only) on tablet.
- **Patient banner height:** 80px (full), 48px (condensed/sticky) - **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 max-width:** None — fills available space between sidebar and viewport edge.
- **Main content padding:** 24px - **Main content padding:** 24px (desktop), 16px (mobile)
- **Card padding:** 16px (clinical systems are more compact than marketing sites) - **Card padding:** 16-24px — more generous than real clinical systems. Content should breathe inside cards.
- **Border radius:** 4px for cards and inputs (clinical systems use minimal rounding — 4px, not 12px or 16px) - **Border radius:** 4px default for cards, inputs, buttons (clinical precision). 12px exception for the login card only.
- **Table row height:** 40px - **Table row height:** 40px
- **Section spacing:** 24px between content blocks - **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)`) 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).
- **Table cells:** `1px solid #E5E7EB` borders (all sides)
- **Sidebar border:** `1px solid #334155` (subtle right border in a slightly lighter shade) **Cards:**
- **Patient banner border:** `1px solid #475569` bottom border - Border: `1px solid #E5E7EB` (keep the clinical border — it's authentic)
- **Input fields:** `1px solid #D1D5DB` border, `4px` radius, `#FFFFFF` background, `8px 12px` padding - 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.
- **Active/selected rows:** `#EFF6FF` background (very subtle blue tint) — this is how EMIS highlights the selected row - 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 ## 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. **PMR entrance sequence (login → PMR transition):**
- **Consultation expand/collapse:** Height animation, 200ms, `ease-out`. No opacity fade — the content simply grows/shrinks. - Patient banner slides down: 200ms, ease-out
- **Alert banner entrance:** Slide down from top, 250ms, with a subtle spring overshoot (this is the one exception — alerts are meant to demand attention). - Sidebar slides from left: 250ms, ease-out, 50ms delay
- **Alert acknowledge:** The alert shrinks in height to zero (200ms) with a small green checkmark that flashes briefly. - Content fades in: 300ms, 100ms delay after sidebar
- **Hover states:** Background-color transitions, 100ms. No transforms, no lifts. Just color. - This staggered materialization is the single most impactful animation moment.
- **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. **Navigation switches:** Instant content swap. No crossfade, no slide. This preserves the "software application" feel — clinical systems switch tabs instantly.
- **`prefers-reduced-motion`:** Typing animation completes instantly (full text appears), alert slides are replaced with fade-in, expand/collapse is instant.
**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

+8 -24
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react' import { useState, useRef } from 'react'
import type { Phase } from './types' import type { Phase } from './types'
import { BootSequence } from './components/BootSequence' import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation' import { ECGAnimation } from './components/ECGAnimation'
@@ -8,43 +8,27 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
function App() { function App() {
const [phase, setPhase] = useState<Phase>('boot') const [phase, setPhase] = useState<Phase>('boot')
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null) const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const handleBootComplete = useCallback(() => {
setPhase('ecg')
}, [])
const handleCursorPositionReady = useCallback((position: { x: number; y: number }) => {
setCursorPosition(position)
}, [])
const handleECGComplete = useCallback(() => {
setPhase('login')
}, [])
const handleLoginComplete = useCallback(() => {
setPhase('pmr')
}, [])
return ( return (
<AccessibilityProvider> <AccessibilityProvider>
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
{phase === 'boot' && ( {phase === 'boot' && (
<BootSequence <BootSequence
onComplete={handleBootComplete} onComplete={() => setPhase('ecg')}
onCursorPositionReady={handleCursorPositionReady} onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
/> />
)} )}
{phase === 'ecg' && ( {phase === 'ecg' && (
<ECGAnimation <ECGAnimation
onComplete={handleECGComplete} onComplete={() => setPhase('login')}
startPosition={cursorPosition} startPosition={cursorPositionRef.current}
/> />
)} )}
{phase === 'login' && ( {phase === 'login' && (
<LoginScreen onComplete={handleLoginComplete} /> <LoginScreen onComplete={() => setPhase('pmr')} />
)} )}
{phase === 'pmr' && <PMRInterface />} {phase === 'pmr' && <PMRInterface />}
+355 -198
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' import { motion, AnimatePresence } from 'framer-motion'
// ============================================================================= // =============================================================================
@@ -25,6 +25,8 @@ interface BootConfig {
cursorBlinkInterval: number cursorBlinkInterval: number
holdAfterComplete: number holdAfterComplete: number
fadeOutDuration: number fadeOutDuration: number
cursorShrinkDuration: number
ecgStartDelay: number
} }
colors: { colors: {
bright: string bright: string
@@ -38,10 +40,34 @@ interface BootSequenceProps {
onCursorPositionReady?: (position: { x: number; y: number }) => void 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 // 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 = { const BOOT_CONFIG: BootConfig = {
header: 'CLINICAL TERMINAL v3.2.1', header: 'CLINICAL TERMINAL v3.2.1',
lines: [ lines: [
@@ -57,195 +83,349 @@ const BOOT_CONFIG: BootConfig = {
{ type: 'module', text: 'population_health.mod', style: 'dim' }, { type: 'module', text: 'population_health.mod', style: 'dim' },
{ type: 'module', text: 'data_analytics.eng', style: 'dim' }, { type: 'module', text: 'data_analytics.eng', style: 'dim' },
{ type: 'separator', text: '---', 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: { timing: {
lineDelay: 220, lineDelay: 220,
cursorBlinkInterval: 530, cursorBlinkInterval: 300,
holdAfterComplete: 400, holdAfterComplete: 900,
fadeOutDuration: 800, fadeOutDuration: 600,
}, cursorShrinkDuration: 600,
colors: { ecgStartDelay: 0,
bright: '#00ff41',
dim: '#3a6b45',
cyan: '#00e5ff',
}, },
colors: COLORS,
} }
// ============================================================================= // Apply speed multiplier — instant lines (speed=0) stay instant
// Helper Functions function s(ms: number): number {
// ============================================================================= return Math.round(ms * TYPING_SPEED)
function getCumulativeDelay(lineIndex: number): number {
return lineIndex * BOOT_CONFIG.timing.lineDelay
} }
// ============================================================================= // Build typed lines from BOOT_CONFIG
// Line Components function buildTypedLines(): TypedLine[] {
// ============================================================================= const lines: TypedLine[] = []
function BootLineHeader({ text }: { text: string }) { // Header
return ( const headerText = BOOT_CONFIG.header
<div className="font-mono text-sm leading-relaxed"> lines.push({
<span segments: [{ text: headerText, color: COLORS.bright, bold: true }],
className="font-bold" totalChars: headerText.length,
style={{ color: BOOT_CONFIG.colors.bright }} pauseAfter: s(40),
> speed: s(18),
{text} })
</span>
</div>
)
}
function BootLineStatus({ line }: { line: BootLine }) { for (const line of BOOT_CONFIG.lines) {
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim switch (line.type) {
return ( case 'status': {
<div className="font-mono text-sm leading-relaxed" style={{ color }}> const text = line.text || ''
{line.text} lines.push({
</div> segments: [{ text, color: COLORS.dim }],
) totalChars: text.length,
} pauseAfter: s(40),
speed: s(14),
function BootLineSeparator({ line }: { line: BootLine }) { })
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim break
return ( }
<div className="font-mono text-sm leading-relaxed" style={{ color }}> case 'separator': {
{line.text || '---'} const text = line.text || '---'
</div> lines.push({
) segments: [{ text, color: COLORS.dim }],
} totalChars: text.length,
pauseAfter: s(50),
function BootLineField({ line }: { line: BootLine }) { speed: 0, // instant
const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright })
return ( break
<div className="font-mono text-sm leading-relaxed"> }
<span style={{ color: BOOT_CONFIG.colors.cyan }}> case 'field': {
{(line.label || '').padEnd(9)} const label = (line.label || '').padEnd(9)
</span> const value = line.value || ''
<span style={{ color: valueColor }}>{line.value}</span> const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
</div> lines.push({
) segments: [
} { text: label, color: COLORS.cyan },
{ text: value, color: valueColor },
function BootLineModule({ line }: { line: BootLine }) { ],
const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim totalChars: label.length + value.length,
return ( pauseAfter: s(30),
<div className="font-mono text-sm leading-relaxed"> speed: s(10),
<span className="font-bold" style={{ color: BOOT_CONFIG.colors.bright }}> })
[OK] break
</span>{' '} }
<span style={{ color: textColor }}>{line.text}</span> case 'module': {
</div> const prefix = '[OK] '
) const name = line.text || ''
} lines.push({
segments: [
function BootLineReady({ line }: { line: BootLine }) { { text: '[OK]', color: COLORS.bright, bold: true },
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright { text: ' ', color: COLORS.dim },
return ( { text: name, color: COLORS.dim },
<div className="font-mono text-sm leading-relaxed"> ],
<span className="font-bold" style={{ color }}> totalChars: prefix.length + name.length,
&gt; {line.text} pauseAfter: s(50),
<span className="ecg-seed-dot">.</span> speed: 0, // instant — stdout output
</span> })
</div> break
) }
} case 'ready': {
const prefix = '> '
function BootLineRenderer({ line }: { line: BootLine }) { const body = line.text || ''
switch (line.type) { const seedDot = '.'
case 'header': lines.push({
return <BootLineHeader text={line.text || ''} /> segments: [
case 'status': { text: prefix + body, color: COLORS.bright, bold: true },
return <BootLineStatus line={line} /> { text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
case 'separator': ],
return <BootLineSeparator line={line} /> totalChars: prefix.length + body.length + seedDot.length,
case 'field': pauseAfter: 0,
return <BootLineField line={line} /> speed: s(16),
case 'module': })
return <BootLineModule line={line} /> break
case 'ready': }
return <BootLineReady line={line} /> }
default:
return null
} }
return lines
} }
const TYPED_LINES = buildTypedLines()
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
// ============================================================================= // =============================================================================
// Main Component // Main Component
// ============================================================================= // =============================================================================
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) { 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 [isVisible, setIsVisible] = useState(true)
const [showCursor, setShowCursor] = useState(false) const cursorRef = useRef<HTMLSpanElement>(null)
const [cursorCaptured, setCursorCaptured] = useState(false) const cursorAnchorRef = useRef<HTMLSpanElement>(null)
const [isMorphing, setIsMorphing] = useState(false) const containerRef = useRef<HTMLDivElement>(null)
const cursorRef = 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' const reducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : false
// Calculate total boot time // Capture cursor position for ECG handoff
const totalBootTime = BOOT_CONFIG.lines.length * BOOT_CONFIG.timing.lineDelay
const fadeStartTime = totalBootTime + BOOT_CONFIG.timing.holdAfterComplete
// Capture cursor position when boot completes
const captureCursorPosition = useCallback(() => { const captureCursorPosition = useCallback(() => {
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) { if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) {
const rect = cursorRef.current.getBoundingClientRect() const rect = cursorRef.current.getBoundingClientRect()
const position = { onCursorPositionReady({
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2, y: rect.top + rect.height / 2,
} })
onCursorPositionReady(position) cursorCapturedRef.current = true
setCursorCaptured(true)
} }
}, [onCursorPositionReady, cursorCaptured]) }, [onCursorPositionReady])
// Handle completion sequence // Typing engine — runs as a self-scheduling setTimeout chain
useEffect(() => { useEffect(() => {
if (reducedMotion) { if (reducedMotion || phase !== 'typing') return
// Reduced motion: show everything instantly, then complete
const timer = setTimeout(onComplete, 500) // All characters typed
return () => clearTimeout(timer) if (typedCount >= TOTAL_CHARS) {
setPhase('holding')
return
} }
// Show cursor after all lines are rendered // Find which line the cursor is on and position within it
const cursorTimer = setTimeout(() => { let lineStart = 0
setShowCursor(true) let lineIdx = 0
}, totalBootTime) 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 line = TYPED_LINES[lineIdx]
const morphTimer = setTimeout(() => { const posInLine = typedCount - lineStart
captureCursorPosition()
setIsMorphing(true)
}, fadeStartTime - 100)
// Fade out and complete if (posInLine === 0 && line.speed === 0) {
const fadeTimer = setTimeout(() => { // Instant line: show all chars at once after a brief pause
setIsVisible(false) timeoutRef.current = setTimeout(() => {
}, fadeStartTime) setTypedCount(lineStart + line.totalChars)
}, line.pauseAfter || 10)
const completeTimer = setTimeout(() => { } else if (posInLine === 0 && lineIdx > 0) {
onComplete() // Start of a new typed line — apply previous line's pauseAfter
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration) 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 () => { return () => {
clearTimeout(cursorTimer) if (timeoutRef.current) clearTimeout(timeoutRef.current)
clearTimeout(morphTimer)
clearTimeout(fadeTimer)
clearTimeout(completeTimer)
} }
}, [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 // Reduced motion: instant render
if (reducedMotion) { if (reducedMotion) {
return ( 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"> <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) => ( // Render all lines fully
<BootLineRenderer key={index} line={line} /> 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>
</div> </div>
) )
@@ -255,14 +435,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
<AnimatePresence> <AnimatePresence>
{isVisible && ( {isVisible && (
<motion.div <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 }} initial={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }} transition={{ duration: 0 }}
> >
{/* CRT Scanlines */} {/* CRT Scanlines */}
<div <motion.div
className="absolute inset-0 pointer-events-none" 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={{ style={{
background: `repeating-linear-gradient( background: `repeating-linear-gradient(
0deg, 0deg,
@@ -274,62 +456,37 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
}} }}
/> />
{/* Content */} {/* Content container */}
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10"> <div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
{/* Header */} {/* Text fades out independently */}
<motion.div <motion.div
initial={{ opacity: 0, y: 8 }} animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
animate={{ opacity: 1, y: 0 }} transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
transition={{ duration: 0.4, ease: 'easeOut' }}
> >
<BootLineHeader text={BOOT_CONFIG.header} /> {renderLines()}
</motion.div> </motion.div>
{/* Lines */} {/* Cursor rendered outside fading wrapper — shrinks independently */}
{BOOT_CONFIG.lines.map((line, index) => ( {cursorPos && phase !== 'done' && (
<motion.div <span
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
ref={cursorRef} ref={cursorRef}
className="inline-block ml-1" className="absolute animate-blink"
initial={{ opacity: 0 }}
animate={{
opacity: isMorphing ? 0 : 1,
scaleX: isMorphing ? 0 : 1,
width: isMorphing ? 0 : 8,
}}
transition={{ duration: 0.3, ease: 'easeOut' }}
style={{ style={{
height: 16, left: cursorPos.left,
backgroundColor: BOOT_CONFIG.colors.bright, top: cursorPos.top + (phase === 'fading' ? 12 : 0),
animation: isMorphing ? undefined : 'blink 530ms infinite', 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> </div>
{/* CSS for blink animation */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
`}</style>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
+253 -125
View File
@@ -26,13 +26,7 @@ interface LetterLayout {
char: string char: string
startX: number startX: number
endX: number endX: number
startConnector: number baselineY: number
endConnector: number
}
interface ConnectorProfile {
leftInset: number
rightInset: number
} }
// ============================================================================= // =============================================================================
@@ -42,24 +36,11 @@ interface ConnectorProfile {
const TRACE_SPEED = 350 // pixels per second const TRACE_SPEED = 350 // pixels per second
const HEAD_SCREEN_RATIO = 0.75 // Head stays at 75% of screen during ECG 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 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 FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
const BG_TRANSITION_SECONDS = 0.2 // Background color transition 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) // Letter Definitions (ECG waveform shapes for each letter)
// ============================================================================= // =============================================================================
@@ -122,17 +103,30 @@ function generateHeartbeatPoints(amplitude: number): Point[] {
for (let i = 0; i <= steps; i++) { for (let i = 0; i <= steps; i++) {
const t = i / steps const t = i / steps
let y = 0 let y = 0
if (t >= 0.05 && t < 0.2) {
y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) // P wave: gentle rounded bump
} else if (t >= 0.25 && t < 0.32) { if (t >= 0.02 && t < 0.14) {
y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) y = 0.06 * Math.sin(((t - 0.02) / 0.12) * 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)
} }
// 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 }) points.push({ x: t, y: y * amplitude })
} }
return points return points
@@ -160,32 +154,112 @@ function layoutText(
offsetX: number, offsetX: number,
letterWidth: number, letterWidth: number,
letterGap: number, letterGap: number,
spaceWidth: number spaceWidth: number,
baselineY: number,
rowGap: number,
maxRowWidth: number
): LetterLayout[] { ): LetterLayout[] {
const words = ECG_TEXT.split(' ')
const layout: LetterLayout[] = [] const layout: LetterLayout[] = []
let cursor = offsetX let cursor = offsetX
let currentBaselineY = baselineY
let rowWidth = 0
for (const char of ECG_TEXT) { for (let w = 0; w < words.length; w++) {
if (char === ' ') { const word = words[w]
cursor += spaceWidth const wordWidth = word.length * (letterWidth + letterGap) - letterGap
continue
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 for (const char of word) {
const endX = cursor + letterWidth layout.push({
layout.push({ char,
char, startX: cursor,
startX, endX: cursor + letterWidth,
endX, baselineY: currentBaselineY,
startConnector: startX + BASE_LEFT_INSET + profile.leftInset, })
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset, cursor += letterWidth + letterGap
}) rowWidth += letterWidth + letterGap
cursor += letterWidth + letterGap }
rowWidth -= letterGap
} }
return layout 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 // Main Component
// ============================================================================= // =============================================================================
@@ -238,7 +312,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr)
// Scale factors based on viewport // 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_W = 72 * scale
const LETTER_G = 10 * scale const LETTER_G = 10 * scale
const SPACE_W = 30 * scale const SPACE_W = 30 * scale
@@ -246,17 +320,18 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
// Layout parameters // Layout parameters
const baselineY = vh * 0.5 const baselineY = vh * 0.5
const ecgMaxDefl = vh * 0.25 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 // Calculate start offset from cursor position if provided
const startOffsetX = startPosition ? startPosition.x : 0 const startOffsetX = startPosition ? startPosition.x : 0
// Build beats with cursor offset // Build beats with cursor offset
const beats: Beat[] = [ const beats: Beat[] = [
{ startTime: 0.6, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 }, { startTime: 0.6, widthPx: 150 * scale, amplitude: 0.3, startWX: 0 },
{ startTime: 1.4, widthPx: 80 * scale, amplitude: 0.55, startWX: 0 }, { startTime: 1.4, widthPx: 190 * scale, amplitude: 0.55, startWX: 0 },
{ startTime: 2.3, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 }, { startTime: 2.3, widthPx: 230 * scale, amplitude: 0.85, startWX: 0 },
{ startTime: 3.2, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 }, { startTime: 3.2, widthPx: 270 * scale, amplitude: 1.0, startWX: 0 },
] ]
// Apply start offset to all beats // Apply start offset to all beats
@@ -264,25 +339,26 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
b.startWX = b.startTime * TRACE_SPEED + startOffsetX 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 lastBeat = beats[beats.length - 1]
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W) const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
const textEndWX = textStartWX + totalTextW 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 // Calculate timing phases
const textEndTime = textEndWX / TRACE_SPEED const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
const holdEndTime = textEndTime + HOLD_SECONDS const holdEndTime = textEndTime
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS const fadeStartTime = flatlineEndTime + HOLD_SECONDS
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
const exitEndTime = bgTransitionEndTime const exitEndTime = bgTransitionEndTime
// Final head position (centered text end)
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
// Get Y at a given world X position // Get Y at a given world X position
const getYAtX = (wx: number): number => { const getYAtX = (wx: number): number => {
// Check beats // Check beats
@@ -307,35 +383,11 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
return baselineY return baselineY
} }
// Offscreen canvas for pre-rendering text // Text rendering properties (drawn directly each frame — avoids offscreen canvas DPR/size issues on mobile)
const textCanvas = document.createElement('canvas') const textFont = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
textCanvas.width = vw * dpr const textLineWidth = 2 * scale
textCanvas.height = vh * dpr // Measure where each character's stroke crosses the baseline (for connector lines)
const textCtx = textCanvas.getContext('2d') const charEdges = measureCharBaselineEdges(textFont, textLineWidth, LETTER_W)
if (textCtx) {
textCtx.scale(dpr, dpr)
textCtx.font = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
textCtx.textAlign = 'center'
textCtx.textBaseline = 'alphabetic'
textCtx.strokeStyle = lineColor
textCtx.lineWidth = 1.5 * scale
// Pre-render all letters
for (const item of textLayout) {
const centerX = (item.startX + item.endX) / 2
textCtx.strokeText(item.char, centerX, baselineY)
}
// Draw connector lines
for (let i = 0; i < textLayout.length - 1; i++) {
const curr = textLayout[i]
const next = textLayout[i + 1]
textCtx.beginPath()
textCtx.moveTo(curr.endConnector, baselineY)
textCtx.lineTo(next.startConnector, baselineY)
textCtx.stroke()
}
}
// Animation loop // Animation loop
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
@@ -353,8 +405,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
// Calculate current head position // Calculate current head position
let headWX = elapsed * TRACE_SPEED + startOffsetX let headWX = elapsed * TRACE_SPEED + startOffsetX
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
const isBgTransitionPhase = elapsed >= fadeEndTime const isBgTransitionPhase = elapsed >= fadeEndTime
if (elapsed >= textEndTime) { if (elapsed >= textEndTime) {
@@ -366,17 +418,9 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
let viewOff: number let viewOff: number
const headSXEcg = HEAD_SCREEN_RATIO * vw const headSXEcg = HEAD_SCREEN_RATIO * vw
if (headWX <= textStartWX) { // Simple continuous scrolling - viewport follows head when it exceeds 75% of screen
viewOff = Math.max(0, headWX - headSXEcg) viewOff = Math.max(0, headWX - headSXEcg)
headSX = headWX - viewOff 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
}
// Calculate fade alpha // Calculate fade alpha
let fadeAlpha = 1 let fadeAlpha = 1
@@ -386,8 +430,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
fadeAlpha = 0 fadeAlpha = 0
} }
// Background color transition // Background color transition — delayed until after HOLD
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) { if (!bgTransitionedRef.current && elapsed >= fadeStartTime) {
bgTransitionedRef.current = true bgTransitionedRef.current = true
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out` container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
container.style.background = loginBgColor container.style.background = loginBgColor
@@ -396,11 +440,13 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.save() ctx.save()
ctx.globalAlpha = fadeAlpha ctx.globalAlpha = fadeAlpha
// Draw ECG trace (beats only, up to text start) // Draw ECG trace - always draw from start for continuity
const traceStart = Math.max(0, Math.floor(viewOff)) // Performance is fine since we're only drawing ~1000 pixels per frame
const traceStart = Math.floor(startOffsetX)
const traceEnd = Math.min( const traceEnd = Math.min(
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), 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) { if (traceEnd > traceStart) {
@@ -436,40 +482,122 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
ctx.stroke() ctx.stroke()
} }
// Draw flatline after text // Draw flatline after text — during flatline draw phase and fade phase
if (isFlatlinePhase || (elapsed >= holdEndTime && elapsed < textEndTime)) { if (isFlatlinePhase || isFadePhase) {
const flatlineProgress = isFlatlinePhase const flatlineProgress = Math.min(1, (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS)
? (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS // Use actual head screen position, not finalHeadSX
: 1 const flatlineStartSX = headSX
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50) const flatlineEndSX = flatlineStartSX + flatlineProgress * (vw - flatlineStartSX + 50)
ctx.beginPath() ctx.beginPath()
ctx.strokeStyle = lineColor ctx.strokeStyle = lineColor
ctx.lineWidth = 2 * scale ctx.lineWidth = 2 * scale
ctx.shadowBlur = 8 * scale ctx.shadowBlur = 8 * scale
ctx.shadowColor = lineColor ctx.shadowColor = lineColor
ctx.moveTo(finalHeadSX, baselineY) ctx.moveTo(flatlineStartSX, baselineY)
ctx.lineTo(flatlineEndSX, baselineY) ctx.lineTo(flatlineEndSX, baselineY)
ctx.stroke() ctx.stroke()
} }
// Mask-based text reveal // Text reveal — draw letters directly each frame
const isTextPhase = headWX > textStartWX const isTextPhase = headWX > textStartWX
const isTextDone = elapsed >= textEndTime const isTextDone = elapsed >= textEndTime
if (isTextPhase && textCtx) { if (isTextPhase) {
// Create clipping region based on trace head position
ctx.save() ctx.save()
// Clip for progressive reveal
const revealX = isTextDone ? vw : (headWX - viewOff)
ctx.beginPath() ctx.beginPath()
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh) ctx.rect(0, 0, revealX, vh)
ctx.clip() ctx.clip()
// Draw pre-rendered text through the clip // Common text properties
ctx.drawImage(textCanvas, -viewOff, 0) ctx.font = textFont
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
// Apply neon glow to text // Pass 1: Outer glow layer (matches trace glow)
ctx.globalCompositeOperation = 'source-over' ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
ctx.lineWidth = 6 * scale
ctx.shadowColor = lineColor 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() ctx.restore()
} }
+79 -57
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 { motion } from 'framer-motion'
import { Shield } from 'lucide-react' import { Shield } from 'lucide-react'
import { useAccessibility } from '../contexts/AccessibilityContext' import { useAccessibility } from '../contexts/AccessibilityContext'
@@ -11,8 +11,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0) const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true) const [showCursor, setShowCursor] = useState(true)
const [isTypingUsername, setIsTypingUsername] = useState(true) const [activeField, setActiveField] = useState<'username' | 'password' | null>('username')
const [isTypingPassword, setIsTypingPassword] = useState(false)
const [buttonPressed, setButtonPressed] = useState(false) const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false) const [isExiting, setIsExiting] = useState(false)
const { requestFocusAfterLogin } = useAccessibility() const { requestFocusAfterLogin } = useAccessibility()
@@ -24,6 +23,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
? window.matchMedia('(prefers-reduced-motion: reduce)').matches ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false : 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(() => { const triggerComplete = useCallback(() => {
setIsExiting(true) setIsExiting(true)
setTimeout(() => { setTimeout(() => {
@@ -36,6 +40,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
if (prefersReducedMotion) { if (prefersReducedMotion) {
setUsername(fullUsername) setUsername(fullUsername)
setPasswordDots(passwordLength) setPasswordDots(passwordLength)
setActiveField(null)
setTimeout(() => { setTimeout(() => {
setButtonPressed(true) setButtonPressed(true)
setTimeout(() => { setTimeout(() => {
@@ -45,33 +50,37 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
return return
} }
setIsTypingUsername(true) // Username typing: 30ms per character
let usernameIndex = 0 let usernameIndex = 0
usernameIntervalRef.current = setInterval(() => {
const usernameInterval = setInterval(() => {
if (usernameIndex <= fullUsername.length) { if (usernameIndex <= fullUsername.length) {
setUsername(fullUsername.slice(0, usernameIndex)) setUsername(fullUsername.slice(0, usernameIndex))
usernameIndex++ usernameIndex++
} else { } else {
clearInterval(usernameInterval) if (usernameIntervalRef.current) {
setIsTypingUsername(false) clearInterval(usernameIntervalRef.current)
setIsTypingPassword(true) }
setActiveField('password')
// Password dots: 20ms per dot, after 150ms pause
setTimeout(() => { setTimeout(() => {
let dotCount = 0 let dotCount = 0
const passwordInterval = setInterval(() => { passwordIntervalRef.current = setInterval(() => {
if (dotCount <= passwordLength) { if (dotCount <= passwordLength) {
setPasswordDots(dotCount) setPasswordDots(dotCount)
dotCount++ dotCount++
} else { } else {
clearInterval(passwordInterval) if (passwordIntervalRef.current) {
setIsTypingPassword(false) clearInterval(passwordIntervalRef.current)
}
setActiveField(null)
// Button press: after 150ms pause
setTimeout(() => { setTimeout(() => {
setButtonPressed(true) setButtonPressed(true)
setTimeout(() => { setTimeout(() => {
triggerComplete() triggerComplete()
}, 100) }, 200)
}, 150) }, 150)
} }
}, 20) }, 20)
@@ -81,47 +90,66 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}, [triggerComplete, prefersReducedMotion]) }, [triggerComplete, prefersReducedMotion])
useEffect(() => { useEffect(() => {
const cursorInterval = setInterval(() => { // Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => {
setShowCursor(prev => !prev) setShowCursor(prev => !prev)
}, 530) }, 530)
startLoginSequence() // Delay start slightly for card entrance
const startTimeout = setTimeout(() => {
startLoginSequence()
}, 200)
return () => clearInterval(cursorInterval) return () => {
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
clearTimeout(startTimeout)
}
}, [startLoginSequence]) }, [startLoginSequence])
return ( return (
<div <div
className="fixed inset-0 flex items-center justify-center z-50" className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: '#1E293B' }} style={{ backgroundColor: '#1E293B' }}
role="status"
aria-label="Clinical system login"
> >
<motion.div <motion.div
className="bg-white p-8" className="bg-white"
style={{ style={{
width: '320px', width: '320px',
padding: '32px',
borderRadius: '12px', borderRadius: '12px',
border: '1px solid rgba(255, 255, 255, 0.1)', border: '1px solid #E5E7EB',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.1)', 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 }} animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={{ duration: 0.2, ease: 'easeOut' }} transition={{ duration: 0.2, ease: 'easeOut' }}
> >
{/* Branding */} {/* Branding Header */}
<div className="flex flex-col items-center mb-8"> <div
className="flex flex-col items-center"
style={{ marginBottom: '28px' }}
>
<div <div
className="p-3 rounded-lg mb-3" style={{
style={{ backgroundColor: 'rgba(0, 94, 184, 0.08)' }} padding: '10px',
borderRadius: '8px',
backgroundColor: 'rgba(0, 94, 184, 0.07)',
marginBottom: '10px',
}}
> >
<Shield <Shield
size={28} size={26}
style={{ color: '#005EB8' }} style={{ color: '#005EB8' }}
strokeWidth={2.5} strokeWidth={2.5}
/> />
</div> </div>
<span <span
style={{ style={{
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '13px', fontSize: '13px',
fontWeight: 600, fontWeight: 600,
color: '#64748B', color: '#64748B',
@@ -132,7 +160,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</span> </span>
<span <span
style={{ style={{
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px', fontSize: '11px',
fontWeight: 400, fontWeight: 400,
color: '#94A3B8', color: '#94A3B8',
@@ -144,13 +172,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
</div> </div>
{/* Login Form */} {/* Login Form */}
<div className="space-y-5"> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Username Field */} {/* Username Field */}
<div> <div>
<label <label
style={{ style={{
display: 'block', display: 'block',
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: '#64748B', color: '#64748B',
@@ -162,26 +190,24 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div <div
style={{ style={{
width: '100%', width: '100%',
padding: '10px 12px', padding: '9px 11px',
fontFamily: "'Geist Mono', 'Courier New', monospace", fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px', fontSize: '13px',
backgroundColor: '#FFFFFF', backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
border: '1px solid #D1D5DB', border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
borderRadius: '4px', borderRadius: '4px',
color: '#111827', color: '#111827',
minHeight: '38px', minHeight: '38px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}} }}
> >
<span>{username}</span> <span>{username}</span>
{isTypingUsername && ( {activeField === 'username' && (
<span <span
style={{ style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
opacity: showCursor ? 1 : 0, aria-hidden="true"
color: '#005EB8',
marginLeft: '1px',
}}
> >
| |
</span> </span>
@@ -194,7 +220,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<label <label
style={{ style={{
display: 'block', display: 'block',
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: '#64748B', color: '#64748B',
@@ -206,27 +232,25 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<div <div
style={{ style={{
width: '100%', width: '100%',
padding: '10px 12px', padding: '9px 11px',
fontFamily: "'Geist Mono', 'Courier New', monospace", fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: '13px', fontSize: '13px',
backgroundColor: '#FFFFFF', backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
border: '1px solid #D1D5DB', border: activeField === 'password' ? '1px solid #005EB8' : '1px solid #E5E7EB',
borderRadius: '4px', borderRadius: '4px',
color: '#111827', color: '#111827',
letterSpacing: '0.15em', letterSpacing: '0.15em',
minHeight: '38px', minHeight: '38px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}} }}
> >
<span>{'\u2022'.repeat(passwordDots)}</span> <span>{'\u2022'.repeat(passwordDots)}</span>
{isTypingPassword && ( {activeField === 'password' && (
<span <span
style={{ style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
opacity: showCursor ? 1 : 0, aria-hidden="true"
color: '#005EB8',
marginLeft: '2px',
}}
> >
| |
</span> </span>
@@ -238,8 +262,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<button <button
style={{ style={{
width: '100%', width: '100%',
padding: '11px 16px', padding: '10px 16px',
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '14px', fontSize: '14px',
fontWeight: 600, fontWeight: 600,
color: '#FFFFFF', color: '#FFFFFF',
@@ -248,7 +272,6 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
transition: 'background-color 100ms ease-out', transition: 'background-color 100ms ease-out',
marginTop: '8px',
}} }}
> >
Log In Log In
@@ -258,18 +281,17 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
{/* Footer */} {/* Footer */}
<div <div
style={{ style={{
marginTop: '24px', marginTop: '22px',
paddingTop: '20px', paddingTop: '18px',
borderTop: '1px solid #E5E7EB', borderTop: '1px solid #E5E7EB',
}} }}
> >
<p <p
style={{ style={{
fontFamily: 'Inter, sans-serif', fontFamily: "'Inter', system-ui, sans-serif",
fontSize: '11px', fontSize: '11px',
color: '#94A3B8', color: '#94A3B8',
textAlign: 'center', textAlign: 'center',
lineHeight: '1.4',
}} }}
> >
Secure clinical system login Secure clinical system login