Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3afadbdc73 | |||
| 4eeeb05744 | |||
| 959f0e1842 |
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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/)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
- [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".
|
- [ ] **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".
|
||||||
|
|
||||||
|
|||||||
@@ -91,3 +91,58 @@ Do NOT invoke the `/frontend-design` skill at runtime — it was pre-run and the
|
|||||||
|
|
||||||
### ECG Reference Implementation
|
### 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.
|
`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
@@ -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 |
+11
-4
@@ -1,4 +1,4 @@
|
|||||||
import { useState } 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,17 +8,24 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
|
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibilityProvider>
|
<AccessibilityProvider>
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
{phase === 'boot' && (
|
{phase === 'boot' && (
|
||||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
<BootSequence
|
||||||
|
onComplete={() => setPhase('ecg')}
|
||||||
|
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'ecg' && (
|
{phase === 'ecg' && (
|
||||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
<ECGAnimation
|
||||||
)}
|
onComplete={() => setPhase('login')}
|
||||||
|
startPosition={cursorPositionRef.current}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === 'login' && (
|
{phase === 'login' && (
|
||||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
|
|||||||
+465
-65
@@ -1,97 +1,497 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 {
|
interface BootLine {
|
||||||
html: string
|
type: BootLineType
|
||||||
delay: number
|
text?: string
|
||||||
|
label?: string
|
||||||
|
value?: string
|
||||||
|
style?: BootLineStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootLines: BootLine[] = [
|
interface BootConfig {
|
||||||
|
header: string
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
lines: BootLine[]
|
||||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
timing: {
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
lineDelay: number
|
||||||
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk & Waveney ICB</span>', delay: 220 },
|
cursorBlinkInterval: number
|
||||||
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
|
holdAfterComplete: number
|
||||||
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health & Data Analysis</span>', delay: 220 },
|
fadeOutDuration: number
|
||||||
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
|
cursorShrinkDuration: number
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
ecgStartDelay: number
|
||||||
{ 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 },
|
colors: {
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
|
bright: string
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
|
dim: string
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
cyan: string
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">> 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 BootSequenceProps {
|
interface BootSequenceProps {
|
||||||
onComplete: () => void
|
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 [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(() => {
|
useEffect(() => {
|
||||||
const totalBootTime = bootLines.reduce((sum, l) => sum + l.delay, 0)
|
if (reducedMotion || phase !== 'typing') return
|
||||||
const fadeStartTime = totalBootTime + 400
|
|
||||||
|
|
||||||
const fadeTimer = setTimeout(() => {
|
// All characters typed
|
||||||
setIsVisible(false)
|
if (typedCount >= TOTAL_CHARS) {
|
||||||
}, fadeStartTime)
|
setPhase('holding')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const completeTimer = setTimeout(() => {
|
// Find which line the cursor is on and position within it
|
||||||
onComplete()
|
let lineStart = 0
|
||||||
}, fadeStartTime+2000)
|
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 () => {
|
return () => {
|
||||||
clearTimeout(fadeTimer)
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
clearTimeout(completeTimer)
|
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<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: 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">
|
{/* CRT Scanlines */}
|
||||||
{bootLines.map((line, index) => (
|
<motion.div
|
||||||
<motion.div
|
className="absolute inset-0 pointer-events-none"
|
||||||
key={index}
|
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||||
className="whitespace-nowrap leading-relaxed"
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
style={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
background: `repeating-linear-gradient(
|
||||||
transition={{
|
0deg,
|
||||||
delay: (bootLineDelays[index] ?? 0) / 1000,
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
duration: 0.4,
|
transparent 1px,
|
||||||
ease: 'easeOut',
|
transparent 2px,
|
||||||
}}
|
rgba(0, 0, 0, 0.15) 3px
|
||||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
)`,
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
|
|
||||||
|
{/* 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
|
<motion.div
|
||||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||||
initial={{ opacity: 0 }}
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
animate={{ opacity: 1 }}
|
>
|
||||||
transition={{ delay: 2 + (bootLineDelays[bootLineDelays.length + 1] ?? 0) / 1000 }}
|
{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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { BootConfig, BootLine, BootLineType }
|
||||||
|
export { BOOT_CONFIG }
|
||||||
|
|||||||
+430
-102
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
interface ECGAnimationProps {
|
interface ECGAnimationProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
|
startPosition?: { x: number; y: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
@@ -21,35 +26,107 @@ interface LetterLayout {
|
|||||||
char: string
|
char: string
|
||||||
startX: number
|
startX: number
|
||||||
endX: 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[]> = {
|
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}],
|
A: [
|
||||||
N: [{x:0,y:0},{x:0.12,y:1},{x:0.72,y:0},{x:0.88,y:1},{x:1,y:0}],
|
{ x: 0, y: 0 }, { x: 0.48, y: 1 }, { x: 0.53, y: 0.42 },
|
||||||
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}],
|
{ x: 0.6, y: 0.42 }, { 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}],
|
N: [
|
||||||
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}],
|
{ x: 0, y: 0 }, { x: 0.12, y: 1 }, { x: 0.72, 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}],
|
{ x: 0.88, y: 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}],
|
D: [
|
||||||
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}],
|
{ 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'
|
const ECG_TEXT = 'ANDREW CHARLWOOD'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
function generateHeartbeatPoints(amplitude: number): Point[] {
|
function generateHeartbeatPoints(amplitude: number): Point[] {
|
||||||
const points: Point[] = []
|
const points: Point[] = []
|
||||||
const steps = 200
|
const steps = 200
|
||||||
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) }
|
|
||||||
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
|
// P wave: gentle rounded bump
|
||||||
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
|
if (t >= 0.02 && t < 0.14) {
|
||||||
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
|
y = 0.06 * Math.sin(((t - 0.02) / 0.12) * 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.14–0.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.42–0.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
|
||||||
@@ -67,25 +144,127 @@ function interpolateLetterY(points: Point[], t: number): number {
|
|||||||
return 0
|
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 chars = ECG_TEXT.replace(/ /g, '').length
|
||||||
const spaces = ECG_TEXT.split(' ').length - 1
|
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[] = []
|
const layout: LetterLayout[] = []
|
||||||
let cursor = offsetX
|
let cursor = offsetX
|
||||||
for (let i = 0; i < ECG_TEXT.length; i++) {
|
let currentBaselineY = baselineY
|
||||||
const ch = ECG_TEXT[i]
|
let rowWidth = 0
|
||||||
if (ch === ' ') { cursor += sw; continue }
|
|
||||||
layout.push({ char: ch, startX: cursor, endX: cursor + lw, centerX: cursor + lw / 2 })
|
for (let w = 0; w < words.length; w++) {
|
||||||
cursor += lw + lg
|
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
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
/** Measure where each character's rendered stroke crosses the baseline.
|
||||||
|
* Returns left/right ratios (0–1) 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 canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const animationRef = useRef<number | null>(null)
|
const animationRef = useRef<number | null>(null)
|
||||||
@@ -93,6 +272,13 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
const bgTransitionedRef = useRef(false)
|
const bgTransitionedRef = useRef(false)
|
||||||
const completedRef = 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(() => {
|
const finishAnimation = useCallback(() => {
|
||||||
if (completedRef.current) return
|
if (completedRef.current) return
|
||||||
completedRef.current = true
|
completedRef.current = true
|
||||||
@@ -103,6 +289,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
}, [onComplete])
|
}, [onComplete])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Reduced motion: skip to end immediately
|
||||||
|
if (reducedMotion) {
|
||||||
|
const timer = setTimeout(finishAnimation, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!canvas || !container) return
|
if (!canvas || !container) return
|
||||||
@@ -110,6 +302,7 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Setup canvas dimensions
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
const dpr = window.devicePixelRatio || 1
|
const dpr = window.devicePixelRatio || 1
|
||||||
@@ -118,50 +311,58 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
canvas.height = vh * dpr
|
canvas.height = vh * dpr
|
||||||
ctx.scale(dpr, 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_W = 72 * scale
|
||||||
const LETTER_G = 10 * scale
|
const LETTER_G = 10 * scale
|
||||||
const SPACE_W = 30 * scale
|
const SPACE_W = 30 * scale
|
||||||
const TRACE_SPEED = 450 * scale
|
|
||||||
const FLAT_GAP = 0.4
|
// Layout parameters
|
||||||
const FLATLINE_HOLD = 0.3
|
|
||||||
const FLATLINE_DRAW = 0.3
|
|
||||||
const FADE_TO_BLACK = 0.2
|
|
||||||
const BG_TRANSITION = 0.2
|
|
||||||
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 lineColor = '#00ff41'
|
const textMaxDefl = Math.min(vh * 0.08, LETTER_W * 1.15)
|
||||||
const loginBgColor = '#1E293B'
|
|
||||||
|
|
||||||
|
// Calculate start offset from cursor position if provided
|
||||||
|
const startOffsetX = startPosition ? startPosition.x : 0
|
||||||
|
|
||||||
|
// Build beats with cursor offset
|
||||||
const beats: Beat[] = [
|
const beats: Beat[] = [
|
||||||
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
|
{ startTime: 0.6, widthPx: 150 * scale, amplitude: 0.3, startWX: 0 },
|
||||||
{ startTime: 1.2, widthPx: 90 * scale, amplitude: 0.55, startWX: 0 },
|
{ startTime: 1.4, widthPx: 190 * scale, amplitude: 0.55, startWX: 0 },
|
||||||
{ startTime: 2.0, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
|
{ startTime: 2.3, widthPx: 230 * scale, amplitude: 0.85, startWX: 0 },
|
||||||
{ startTime: 2.8, widthPx: 140 * scale, amplitude: 1.0, 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 lastBeat = beats[beats.length - 1]
|
||||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||||
const textStartWX = lastBeatEndWX + FLAT_GAP * TRACE_SPEED
|
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||||
const totalTextW = ecgGetTextWidth(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 = ecgLayoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
const textLayout = layoutText(
|
||||||
const fontSize = Math.round(textMaxDefl / 0.715)
|
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||||
|
baselineY, 0, Infinity
|
||||||
|
)
|
||||||
|
|
||||||
const headScreenRatio = 0.75
|
// Calculate timing phases
|
||||||
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
|
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||||
const textEndTime = textEndWX / TRACE_SPEED
|
const holdEndTime = textEndTime
|
||||||
const holdEndTime = textEndTime + FLATLINE_HOLD
|
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
||||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW
|
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
|
||||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK
|
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
|
||||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION
|
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||||
const exitEndTime = bgTransitionEndTime
|
const exitEndTime = bgTransitionEndTime
|
||||||
|
|
||||||
|
// Get Y at a given world X position
|
||||||
const getYAtX = (wx: number): number => {
|
const getYAtX = (wx: number): number => {
|
||||||
for (let i = 0; i < beats.length; i++) {
|
// Check beats
|
||||||
const b = beats[i]
|
for (const b of beats) {
|
||||||
if (wx >= b.startWX && wx <= b.startWX + b.widthPx) {
|
if (wx >= b.startWX && wx <= b.startWX + b.widthPx) {
|
||||||
const prog = (wx - b.startWX) / b.widthPx
|
const prog = (wx - b.startWX) / b.widthPx
|
||||||
const pts = generateHeartbeatPoints(b.amplitude)
|
const pts = generateHeartbeatPoints(b.amplitude)
|
||||||
@@ -169,80 +370,95 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
return baselineY - pts[idx].y * ecgMaxDefl
|
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) {
|
if (wx >= item.startX && wx <= item.endX) {
|
||||||
const t = (wx - item.startX) / (item.endX - item.startX)
|
const t = (wx - item.startX) / (item.endX - item.startX)
|
||||||
const ld = ECG_LETTERS[item.char]
|
const ld = ECG_LETTERS[item.char]
|
||||||
if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl
|
if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return baselineY
|
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) => {
|
const animate = (timestamp: number) => {
|
||||||
if (!startTsRef.current) startTsRef.current = timestamp
|
if (!startTsRef.current) startTsRef.current = timestamp
|
||||||
const elapsed = (timestamp - startTsRef.current) / 1000
|
const elapsed = (timestamp - startTsRef.current) / 1000
|
||||||
|
|
||||||
|
// Check for animation completion
|
||||||
if (elapsed >= exitEndTime) {
|
if (elapsed >= exitEndTime) {
|
||||||
finishAnimation()
|
finishAnimation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
ctx.clearRect(0, 0, vw, vh)
|
ctx.clearRect(0, 0, vw, vh)
|
||||||
|
|
||||||
let headWX = elapsed * TRACE_SPEED
|
// Calculate current head position
|
||||||
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
|
let headWX = elapsed * TRACE_SPEED + startOffsetX
|
||||||
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
|
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
|
||||||
|
const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
|
||||||
const isBgTransitionPhase = elapsed >= fadeEndTime
|
const isBgTransitionPhase = elapsed >= fadeEndTime
|
||||||
|
|
||||||
if (elapsed >= textEndTime) {
|
if (elapsed >= textEndTime) {
|
||||||
headWX = textEndWX
|
headWX = textEndWX
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate viewport and head screen position
|
||||||
let headSX: number
|
let headSX: number
|
||||||
let viewOff: number
|
let viewOff: number
|
||||||
const headSXEcg = headScreenRatio * 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
|
||||||
let fadeAlpha = 1
|
let fadeAlpha = 1
|
||||||
if (isFadePhase) {
|
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) {
|
} else if (isBgTransitionPhase) {
|
||||||
fadeAlpha = 0
|
fadeAlpha = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
|
// Background color transition — delayed until after HOLD
|
||||||
|
if (!bgTransitionedRef.current && elapsed >= fadeStartTime) {
|
||||||
bgTransitionedRef.current = true
|
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
|
container.style.background = loginBgColor
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.globalAlpha = fadeAlpha
|
ctx.globalAlpha = fadeAlpha
|
||||||
|
|
||||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
// Draw ECG trace - always draw from start for continuity
|
||||||
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? textEndWX : headWX), Math.ceil(viewOff + vw))
|
// 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) {
|
if (traceEnd > traceStart) {
|
||||||
|
// Outer glow layer
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
|
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
|
||||||
ctx.lineWidth = 6
|
ctx.lineWidth = 6 * scale
|
||||||
ctx.lineJoin = 'round'
|
ctx.lineJoin = 'round'
|
||||||
ctx.lineCap = 'round'
|
ctx.lineCap = 'round'
|
||||||
ctx.shadowColor = lineColor
|
ctx.shadowColor = lineColor
|
||||||
ctx.shadowBlur = 14
|
ctx.shadowBlur = 14 * scale
|
||||||
|
|
||||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
||||||
const sx = wx - viewOff
|
const sx = wx - viewOff
|
||||||
const sy = getYAtX(wx)
|
const sy = getYAtX(wx)
|
||||||
@@ -251,10 +467,12 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
}
|
}
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Main trace layer
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.strokeStyle = lineColor
|
ctx.strokeStyle = lineColor
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2 * scale
|
||||||
ctx.shadowBlur = 4
|
ctx.shadowBlur = 4 * scale
|
||||||
|
|
||||||
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
for (let wx = traceStart; wx <= traceEnd; wx++) {
|
||||||
const sx = wx - viewOff
|
const sx = wx - viewOff
|
||||||
const sy = getYAtX(wx)
|
const sy = getYAtX(wx)
|
||||||
@@ -264,42 +482,134 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFlatlinePhase) {
|
// Draw flatline after text — during flatline draw phase and fade phase
|
||||||
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
|
if (isFlatlinePhase || isFadePhase) {
|
||||||
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
|
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.beginPath()
|
||||||
ctx.strokeStyle = lineColor
|
ctx.strokeStyle = lineColor
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2 * scale
|
||||||
ctx.shadowBlur = 8
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.shadowColor = lineColor
|
// Text reveal — draw letters directly each frame
|
||||||
ctx.shadowBlur = 8
|
const isTextPhase = headWX > textStartWX
|
||||||
ctx.font = `bold ${fontSize}px Arial, Helvetica, sans-serif`
|
const isTextDone = elapsed >= textEndTime
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'alphabetic'
|
|
||||||
ctx.lineWidth = 1.5 * scale
|
|
||||||
ctx.strokeStyle = lineColor
|
|
||||||
|
|
||||||
for (let k = 0; k < textLayout.length; k++) {
|
if (isTextPhase) {
|
||||||
const item = textLayout[k]
|
ctx.save()
|
||||||
const letterProgress = (headWX - item.startX) / (item.endX - item.startX)
|
|
||||||
if (letterProgress > 0.3) {
|
// Clip for progressive reveal
|
||||||
const alpha = Math.min(1, (letterProgress - 0.3) * 1.43)
|
const revealX = isTextDone ? vw : (headWX - viewOff)
|
||||||
ctx.globalAlpha = fadeAlpha * alpha
|
ctx.beginPath()
|
||||||
const lsx = item.centerX - viewOff
|
ctx.rect(0, 0, revealX, vh)
|
||||||
ctx.strokeText(item.char, lsx, baselineY)
|
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.globalAlpha = fadeAlpha
|
||||||
ctx.shadowBlur = 0
|
ctx.shadowBlur = 0
|
||||||
|
|
||||||
if (headSX >= -20 && headSX <= vw + 20 && elapsed < flatlineEndTime) {
|
if (headSX >= -20 && headSX <= vw + 20 && elapsed < flatlineEndTime) {
|
||||||
const headY = isFlatlinePhase ? baselineY : getYAtX(headWX)
|
const headY = isFlatlinePhase ? baselineY : getYAtX(headWX)
|
||||||
|
|
||||||
|
// Glow gradient
|
||||||
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
|
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
|
||||||
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
|
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
|
||||||
grad.addColorStop(0.3, 'rgba(0,255,65,0.6)')
|
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.arc(headSX, headY, 20 * scale, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
|
||||||
|
// Core dot
|
||||||
ctx.fillStyle = lineColor
|
ctx.fillStyle = lineColor
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(headSX, headY, 3, 0, Math.PI * 2)
|
ctx.arc(headSX, headY, 3 * scale, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
|
|
||||||
|
// Scanlines
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
|
||||||
for (let sly = 0; sly < vh; sly += 4) {
|
for (let sly = 0; sly < vh; sly += 4) {
|
||||||
ctx.fillRect(0, sly + 2, vw, 2)
|
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)
|
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(0, 'rgba(0,0,0,0)')
|
||||||
vig.addColorStop(1, 'rgba(0,0,0,0.4)')
|
vig.addColorStop(1, 'rgba(0,0,0,0.4)')
|
||||||
@@ -338,7 +651,20 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
cancelAnimationFrame(animationRef.current)
|
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -357,3 +683,5 @@ export function ECGAnimation({ onComplete }: ECGAnimationProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { ECGAnimationProps }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user