Completed boot loading to ECG, to name written
This commit is contained in:
@@ -8,7 +8,10 @@
|
|||||||
"Bash(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")",
|
"Bash(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")",
|
||||||
"Bash(npx skills find:*)",
|
"Bash(npx skills find:*)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(ls \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\New CV website\\\\designs\"\" 2>nul || echo \"Directory does not exist \")"
|
"Bash(ls \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\New CV website\\\\designs\"\" 2>nul || echo \"Directory does not exist \")",
|
||||||
|
"Bash(npm run typecheck:*)",
|
||||||
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(npm run build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"active": true,
|
"active": true,
|
||||||
"iteration": 1,
|
"iteration": 2,
|
||||||
"minIterations": 1,
|
"minIterations": 1,
|
||||||
"maxIterations": 0,
|
"maxIterations": 0,
|
||||||
"completionPromise": "COMPLETE",
|
"completionPromise": "COMPLETE",
|
||||||
|
|||||||
@@ -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/)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Each task below references a specific file in `Ralph/refs/` — read ONLY that f
|
|||||||
|
|
||||||
- [x] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
|
- [x] **Task 1b: Rebuild boot sequence and ECG animation.** Read `Ralph/refs/ref-boot-ecg.md` and `Ralph/refs/ref-design-system.md`. Also read `ECGCombined.tsx` in the project root for the Remotion reference implementation of the mask-based text reveal. This task covers the full pre-login animation flow: (a) **Refactor BootSequence.tsx** — replace hardcoded HTML strings with a clean config-driven structure. Each line type (header, field, separator, module, ready) maps to a React component. Keep the same visual output: green-on-black terminal, Fira Code font, 220ms staggered line reveals, `#00ff41` bright green / `#3a6b45` dim green / `#00e5ff` cyan labels. (b) **Cursor → dot transition** — the blinking green cursor at the end of boot must smoothly morph into the ECG's glowing trace dot. Capture the cursor's screen position and pass it to ECGAnimation as a `startPosition` prop. The cursor stops blinking, transitions from block to circular glow (~300ms), then begins moving rightward as the ECG trace dot. (c) **ECG start sync** — ECGAnimation must start its trace from the cursor position (not the far left edge). The first beat begins after a flat gap from the cursor position. Shift the world-space origin so the trace starts where the cursor was. (d) **Mask-based text reveal** — adopt ECGCombined.tsx's technique where pre-rendered stroke-only text is revealed by a wipe mask following the trace head (instead of the current alpha fade approach). Keep the current character spacing (`LETTER_W`, `LETTER_G`, `SPACE_W`) and heartbeat waveform. Add connector lines between letters at baseline. (e) **Keep**: heartbeat shape, beat timing (0.3→0.55→0.85→1.0 amplitude), canvas rendering, viewport scrolling, flatline draw, scanlines, vignette, background transition to `#1E293B`. (f) Respect `prefers-reduced-motion` — with reduced motion, skip animation and show static final frame or jump to login.
|
||||||
|
|
||||||
- [ ] **Task 2: Rebuild LoginScreen component.** Read `Ralph/refs/ref-transition-login.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/LoginScreen.tsx` to match the login sequence specification exactly: (a) Dark blue-gray `#1E293B` background. (b) White card: 320px wide, **12px border-radius** (exception to the 4px rule — login cards can be rounder), subtle shadow. (c) NHS-blue shield icon at top with "CareerRecord PMR" branding text. (d) Username field types `A.CHARLWOOD` at 30ms/char in **Geist Mono** font. (e) Password field fills 8 dots at 20ms/dot. (f) Blinking cursor (530ms interval) in active field. (g) "Log In" button: NHS blue `#005EB8`, full width, pressed state darkens to `#004494`. (h) After submit: card scales to 103% and fades out over 200ms. (i) Respect `prefers-reduced-motion`. The login must feel like actually logging into NHS software at 8am on a Monday.
|
- [x] **Task 2: Rebuild LoginScreen component.** Read `Ralph/refs/ref-transition-login.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/LoginScreen.tsx` to match the login sequence specification exactly: (a) Dark blue-gray `#1E293B` background. (b) White card: 320px wide, **12px border-radius** (exception to the 4px rule — login cards can be rounder), subtle shadow. (c) NHS-blue shield icon at top with "CareerRecord PMR" branding text. (d) Username field types `A.CHARLWOOD` at 30ms/char in **Geist Mono** font. (e) Password field fills 8 dots at 20ms/dot. (f) Blinking cursor (530ms interval) in active field. (g) "Log In" button: NHS blue `#005EB8`, full width, pressed state darkens to `#004494`. (h) After submit: card scales to 103% and fades out over 200ms. (i) Respect `prefers-reduced-motion`. The login must feel like actually logging into NHS software at 8am on a Monday.
|
||||||
|
|
||||||
- [ ] **Task 3: Rebuild PatientBanner component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PatientBanner.tsx` to match the specification exactly: (a) Full banner 80px: background `#334155`, bottom border `1px solid #475569`. Name in Inter 600 **20px** (not 18px), details in Inter 400 14px. Layout must match the ASCII art in the ref file — surname-first format "CHARLWOOD, Andrew (Mr)", DOB/NHS No/Address on second row, phone/email/buttons on third row. (b) Status: green dot + "Active" text. Badge: "Open to opportunities" as blue pill. (c) Action buttons: outlined rectangles with NHS blue text and 1px border, 4px radius. Hover fills with NHS blue bg + white text. (d) Condensed banner 48px: single line with name, NHS number, status, action buttons only. Triggers at 100px scroll via IntersectionObserver. Smooth 200ms height transition. (e) Mobile banner: minimal top bar `CHARLWOOD, A (Mr) | 2211810 | dot` with overflow "..." menu. NHS Number tooltip: "GPhC Registration Number".
|
- [ ] **Task 3: Rebuild PatientBanner component.** Read `Ralph/refs/ref-banner-sidebar.md` and `Ralph/refs/ref-design-system.md`. Rebuild `src/components/PatientBanner.tsx` to match the specification exactly: (a) Full banner 80px: background `#334155`, bottom border `1px solid #475569`. Name in Inter 600 **20px** (not 18px), details in Inter 400 14px. Layout must match the ASCII art in the ref file — surname-first format "CHARLWOOD, Andrew (Mr)", DOB/NHS No/Address on second row, phone/email/buttons on third row. (b) Status: green dot + "Active" text. Badge: "Open to opportunities" as blue pill. (c) Action buttons: outlined rectangles with NHS blue text and 1px border, 4px radius. Hover fills with NHS blue bg + white text. (d) Condensed banner 48px: single line with name, NHS number, status, action buttons only. Triggers at 100px scroll via IntersectionObserver. Smooth 200ms height transition. (e) Mobile banner: minimal top bar `CHARLWOOD, A (Mr) | 2211810 | dot` with overflow "..." menu. NHS Number tooltip: "GPhC Registration Number".
|
||||||
|
|
||||||
|
|||||||
+130
-54
@@ -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
-27
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import type { Phase } from './types'
|
import type { Phase } from './types'
|
||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
import { ECGAnimation } from './components/ECGAnimation'
|
||||||
@@ -8,43 +8,27 @@ import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [phase, setPhase] = useState<Phase>('boot')
|
||||||
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
|
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
const handleBootComplete = useCallback(() => {
|
|
||||||
setPhase('ecg')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCursorPositionReady = useCallback((position: { x: number; y: number }) => {
|
|
||||||
setCursorPosition(position)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleECGComplete = useCallback(() => {
|
|
||||||
setPhase('login')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLoginComplete = useCallback(() => {
|
|
||||||
setPhase('pmr')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccessibilityProvider>
|
<AccessibilityProvider>
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
{phase === 'boot' && (
|
{phase === 'boot' && (
|
||||||
<BootSequence
|
<BootSequence
|
||||||
onComplete={handleBootComplete}
|
onComplete={() => setPhase('ecg')}
|
||||||
onCursorPositionReady={handleCursorPositionReady}
|
onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'ecg' && (
|
{phase === 'ecg' && (
|
||||||
<ECGAnimation
|
<ECGAnimation
|
||||||
onComplete={handleECGComplete}
|
onComplete={() => setPhase('login')}
|
||||||
startPosition={cursorPosition}
|
startPosition={cursorPositionRef.current}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'login' && (
|
{phase === 'login' && (
|
||||||
<LoginScreen onComplete={handleLoginComplete} />
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'pmr' && <PMRInterface />}
|
{phase === 'pmr' && <PMRInterface />}
|
||||||
|
|||||||
+357
-200
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -25,6 +25,8 @@ interface BootConfig {
|
|||||||
cursorBlinkInterval: number
|
cursorBlinkInterval: number
|
||||||
holdAfterComplete: number
|
holdAfterComplete: number
|
||||||
fadeOutDuration: number
|
fadeOutDuration: number
|
||||||
|
cursorShrinkDuration: number
|
||||||
|
ecgStartDelay: number
|
||||||
}
|
}
|
||||||
colors: {
|
colors: {
|
||||||
bright: string
|
bright: string
|
||||||
@@ -38,10 +40,34 @@ interface BootSequenceProps {
|
|||||||
onCursorPositionReady?: (position: { x: number; y: number }) => void
|
onCursorPositionReady?: (position: { x: number; y: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TypedSegment {
|
||||||
|
text: string
|
||||||
|
color: string
|
||||||
|
bold?: boolean
|
||||||
|
isSeedDot?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypedLine {
|
||||||
|
segments: TypedSegment[]
|
||||||
|
totalChars: number
|
||||||
|
pauseAfter: number // ms to pause after this line completes
|
||||||
|
speed: number // ms per character (0 = instant)
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Configuration
|
// Configuration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
// Global speed multiplier for typing animation.
|
||||||
|
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
|
||||||
|
const TYPING_SPEED = 2
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
bright: '#00ff41',
|
||||||
|
dim: '#3a6b45',
|
||||||
|
cyan: '#00e5ff',
|
||||||
|
}
|
||||||
|
|
||||||
const BOOT_CONFIG: BootConfig = {
|
const BOOT_CONFIG: BootConfig = {
|
||||||
header: 'CLINICAL TERMINAL v3.2.1',
|
header: 'CLINICAL TERMINAL v3.2.1',
|
||||||
lines: [
|
lines: [
|
||||||
@@ -57,195 +83,349 @@ const BOOT_CONFIG: BootConfig = {
|
|||||||
{ type: 'module', text: 'population_health.mod', style: 'dim' },
|
{ type: 'module', text: 'population_health.mod', style: 'dim' },
|
||||||
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
|
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
|
||||||
{ type: 'separator', text: '---', style: 'dim' },
|
{ type: 'separator', text: '---', style: 'dim' },
|
||||||
{ type: 'ready', text: 'READY — Rendering CV..', style: 'bright' },
|
{ type: 'ready', text: 'READY \u2014 Rendering CV..', style: 'bright' },
|
||||||
],
|
],
|
||||||
timing: {
|
timing: {
|
||||||
lineDelay: 220,
|
lineDelay: 220,
|
||||||
cursorBlinkInterval: 530,
|
cursorBlinkInterval: 300,
|
||||||
holdAfterComplete: 400,
|
holdAfterComplete: 900,
|
||||||
fadeOutDuration: 800,
|
fadeOutDuration: 600,
|
||||||
},
|
cursorShrinkDuration: 600,
|
||||||
colors: {
|
ecgStartDelay: 0,
|
||||||
bright: '#00ff41',
|
|
||||||
dim: '#3a6b45',
|
|
||||||
cyan: '#00e5ff',
|
|
||||||
},
|
},
|
||||||
|
colors: COLORS,
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// Apply speed multiplier — instant lines (speed=0) stay instant
|
||||||
// Helper Functions
|
function s(ms: number): number {
|
||||||
// =============================================================================
|
return Math.round(ms * TYPING_SPEED)
|
||||||
|
|
||||||
function getCumulativeDelay(lineIndex: number): number {
|
|
||||||
return lineIndex * BOOT_CONFIG.timing.lineDelay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// Build typed lines from BOOT_CONFIG
|
||||||
// Line Components
|
function buildTypedLines(): TypedLine[] {
|
||||||
// =============================================================================
|
const lines: TypedLine[] = []
|
||||||
|
|
||||||
function BootLineHeader({ text }: { text: string }) {
|
// Header
|
||||||
return (
|
const headerText = BOOT_CONFIG.header
|
||||||
<div className="font-mono text-sm leading-relaxed">
|
lines.push({
|
||||||
<span
|
segments: [{ text: headerText, color: COLORS.bright, bold: true }],
|
||||||
className="font-bold"
|
totalChars: headerText.length,
|
||||||
style={{ color: BOOT_CONFIG.colors.bright }}
|
pauseAfter: s(40),
|
||||||
>
|
speed: s(18),
|
||||||
{text}
|
})
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BootLineStatus({ line }: { line: BootLine }) {
|
for (const line of BOOT_CONFIG.lines) {
|
||||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
switch (line.type) {
|
||||||
return (
|
case 'status': {
|
||||||
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
|
const text = line.text || ''
|
||||||
{line.text}
|
lines.push({
|
||||||
</div>
|
segments: [{ text, color: COLORS.dim }],
|
||||||
)
|
totalChars: text.length,
|
||||||
}
|
pauseAfter: s(40),
|
||||||
|
speed: s(14),
|
||||||
function BootLineSeparator({ line }: { line: BootLine }) {
|
})
|
||||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
break
|
||||||
return (
|
}
|
||||||
<div className="font-mono text-sm leading-relaxed" style={{ color }}>
|
case 'separator': {
|
||||||
{line.text || '---'}
|
const text = line.text || '---'
|
||||||
</div>
|
lines.push({
|
||||||
)
|
segments: [{ text, color: COLORS.dim }],
|
||||||
}
|
totalChars: text.length,
|
||||||
|
pauseAfter: s(50),
|
||||||
function BootLineField({ line }: { line: BootLine }) {
|
speed: 0, // instant
|
||||||
const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
|
})
|
||||||
return (
|
break
|
||||||
<div className="font-mono text-sm leading-relaxed">
|
}
|
||||||
<span style={{ color: BOOT_CONFIG.colors.cyan }}>
|
case 'field': {
|
||||||
{(line.label || '').padEnd(9)}
|
const label = (line.label || '').padEnd(9)
|
||||||
</span>
|
const value = line.value || ''
|
||||||
<span style={{ color: valueColor }}>{line.value}</span>
|
const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
|
||||||
</div>
|
lines.push({
|
||||||
)
|
segments: [
|
||||||
}
|
{ text: label, color: COLORS.cyan },
|
||||||
|
{ text: value, color: valueColor },
|
||||||
function BootLineModule({ line }: { line: BootLine }) {
|
],
|
||||||
const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim
|
totalChars: label.length + value.length,
|
||||||
return (
|
pauseAfter: s(30),
|
||||||
<div className="font-mono text-sm leading-relaxed">
|
speed: s(10),
|
||||||
<span className="font-bold" style={{ color: BOOT_CONFIG.colors.bright }}>
|
})
|
||||||
[OK]
|
break
|
||||||
</span>{' '}
|
}
|
||||||
<span style={{ color: textColor }}>{line.text}</span>
|
case 'module': {
|
||||||
</div>
|
const prefix = '[OK] '
|
||||||
)
|
const name = line.text || ''
|
||||||
}
|
lines.push({
|
||||||
|
segments: [
|
||||||
function BootLineReady({ line }: { line: BootLine }) {
|
{ text: '[OK]', color: COLORS.bright, bold: true },
|
||||||
const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright
|
{ text: ' ', color: COLORS.dim },
|
||||||
return (
|
{ text: name, color: COLORS.dim },
|
||||||
<div className="font-mono text-sm leading-relaxed">
|
],
|
||||||
<span className="font-bold" style={{ color }}>
|
totalChars: prefix.length + name.length,
|
||||||
> {line.text}
|
pauseAfter: s(50),
|
||||||
<span className="ecg-seed-dot">.</span>
|
speed: 0, // instant — stdout output
|
||||||
</span>
|
})
|
||||||
</div>
|
break
|
||||||
)
|
}
|
||||||
}
|
case 'ready': {
|
||||||
|
const prefix = '> '
|
||||||
function BootLineRenderer({ line }: { line: BootLine }) {
|
const body = line.text || ''
|
||||||
switch (line.type) {
|
const seedDot = '.'
|
||||||
case 'header':
|
lines.push({
|
||||||
return <BootLineHeader text={line.text || ''} />
|
segments: [
|
||||||
case 'status':
|
{ text: prefix + body, color: COLORS.bright, bold: true },
|
||||||
return <BootLineStatus line={line} />
|
{ text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
|
||||||
case 'separator':
|
],
|
||||||
return <BootLineSeparator line={line} />
|
totalChars: prefix.length + body.length + seedDot.length,
|
||||||
case 'field':
|
pauseAfter: 0,
|
||||||
return <BootLineField line={line} />
|
speed: s(16),
|
||||||
case 'module':
|
})
|
||||||
return <BootLineModule line={line} />
|
break
|
||||||
case 'ready':
|
}
|
||||||
return <BootLineReady line={line} />
|
}
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPED_LINES = buildTypedLines()
|
||||||
|
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main Component
|
// Main Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
|
export function BootSequence({ onComplete, onCursorPositionReady }: BootSequenceProps) {
|
||||||
|
const [typedCount, setTypedCount] = useState(0)
|
||||||
|
const [phase, setPhase] = useState<'typing' | 'holding' | 'fading' | 'done'>('typing')
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
const [showCursor, setShowCursor] = useState(false)
|
const cursorRef = useRef<HTMLSpanElement>(null)
|
||||||
const [cursorCaptured, setCursorCaptured] = useState(false)
|
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
|
||||||
const [isMorphing, setIsMorphing] = useState(false)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const cursorRef = useRef<HTMLDivElement>(null)
|
const cursorCapturedRef = useRef(false)
|
||||||
const reducedMotion = typeof window !== 'undefined'
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
|
const reducedMotion = typeof window !== 'undefined'
|
||||||
|
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
: false
|
: false
|
||||||
|
|
||||||
// Calculate total boot time
|
// Capture cursor position for ECG handoff
|
||||||
const totalBootTime = BOOT_CONFIG.lines.length * BOOT_CONFIG.timing.lineDelay
|
|
||||||
const fadeStartTime = totalBootTime + BOOT_CONFIG.timing.holdAfterComplete
|
|
||||||
|
|
||||||
// Capture cursor position when boot completes
|
|
||||||
const captureCursorPosition = useCallback(() => {
|
const captureCursorPosition = useCallback(() => {
|
||||||
if (cursorRef.current && onCursorPositionReady && !cursorCaptured) {
|
if (cursorRef.current && onCursorPositionReady && !cursorCapturedRef.current) {
|
||||||
const rect = cursorRef.current.getBoundingClientRect()
|
const rect = cursorRef.current.getBoundingClientRect()
|
||||||
const position = {
|
onCursorPositionReady({
|
||||||
x: rect.left + rect.width / 2,
|
x: rect.left + rect.width / 2,
|
||||||
y: rect.top + rect.height / 2,
|
y: rect.top + rect.height / 2,
|
||||||
}
|
})
|
||||||
onCursorPositionReady(position)
|
cursorCapturedRef.current = true
|
||||||
setCursorCaptured(true)
|
|
||||||
}
|
}
|
||||||
}, [onCursorPositionReady, cursorCaptured])
|
}, [onCursorPositionReady])
|
||||||
|
|
||||||
// Handle completion sequence
|
// Typing engine — runs as a self-scheduling setTimeout chain
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reducedMotion) {
|
if (reducedMotion || phase !== 'typing') return
|
||||||
// Reduced motion: show everything instantly, then complete
|
|
||||||
const timer = setTimeout(onComplete, 500)
|
// All characters typed
|
||||||
return () => clearTimeout(timer)
|
if (typedCount >= TOTAL_CHARS) {
|
||||||
|
setPhase('holding')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show cursor after all lines are rendered
|
// Find which line the cursor is on and position within it
|
||||||
const cursorTimer = setTimeout(() => {
|
let lineStart = 0
|
||||||
setShowCursor(true)
|
let lineIdx = 0
|
||||||
}, totalBootTime)
|
for (let i = 0; i < TYPED_LINES.length; i++) {
|
||||||
|
if (lineStart + TYPED_LINES[i].totalChars > typedCount) {
|
||||||
|
lineIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineStart += TYPED_LINES[i].totalChars
|
||||||
|
}
|
||||||
|
|
||||||
// Capture cursor position and start morph
|
const line = TYPED_LINES[lineIdx]
|
||||||
const morphTimer = setTimeout(() => {
|
const posInLine = typedCount - lineStart
|
||||||
captureCursorPosition()
|
|
||||||
setIsMorphing(true)
|
|
||||||
}, fadeStartTime - 100)
|
|
||||||
|
|
||||||
// Fade out and complete
|
if (posInLine === 0 && line.speed === 0) {
|
||||||
const fadeTimer = setTimeout(() => {
|
// Instant line: show all chars at once after a brief pause
|
||||||
setIsVisible(false)
|
timeoutRef.current = setTimeout(() => {
|
||||||
}, fadeStartTime)
|
setTypedCount(lineStart + line.totalChars)
|
||||||
|
}, line.pauseAfter || 10)
|
||||||
const completeTimer = setTimeout(() => {
|
} else if (posInLine === 0 && lineIdx > 0) {
|
||||||
onComplete()
|
// Start of a new typed line — apply previous line's pauseAfter
|
||||||
}, fadeStartTime + BOOT_CONFIG.timing.fadeOutDuration)
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setTypedCount(prev => prev + 1)
|
||||||
|
}, TYPED_LINES[lineIdx - 1].pauseAfter)
|
||||||
|
} else {
|
||||||
|
// Type one character at the line's speed
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setTypedCount(prev => prev + 1)
|
||||||
|
}, line.speed)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(cursorTimer)
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
clearTimeout(morphTimer)
|
|
||||||
clearTimeout(fadeTimer)
|
|
||||||
clearTimeout(completeTimer)
|
|
||||||
}
|
}
|
||||||
}, [onComplete, totalBootTime, fadeStartTime, captureCursorPosition, reducedMotion])
|
}, [typedCount, phase, reducedMotion])
|
||||||
|
|
||||||
|
// Hold phase: capture cursor, then start fading
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'holding') return
|
||||||
|
|
||||||
|
captureCursorPosition()
|
||||||
|
|
||||||
|
const fadeTimer = setTimeout(() => {
|
||||||
|
setPhase('fading')
|
||||||
|
}, BOOT_CONFIG.timing.holdAfterComplete)
|
||||||
|
|
||||||
|
return () => clearTimeout(fadeTimer)
|
||||||
|
}, [phase, captureCursorPosition])
|
||||||
|
|
||||||
|
// Fade phase: wait for animations to finish, then complete
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'fading') return
|
||||||
|
|
||||||
|
const longestFade = Math.max(
|
||||||
|
BOOT_CONFIG.timing.fadeOutDuration,
|
||||||
|
BOOT_CONFIG.timing.cursorShrinkDuration
|
||||||
|
)
|
||||||
|
|
||||||
|
const completeTimer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setPhase('done')
|
||||||
|
onComplete()
|
||||||
|
}, longestFade + BOOT_CONFIG.timing.ecgStartDelay)
|
||||||
|
|
||||||
|
return () => clearTimeout(completeTimer)
|
||||||
|
}, [phase, onComplete])
|
||||||
|
|
||||||
|
// Reduced motion: skip animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reducedMotion) return
|
||||||
|
const timer = setTimeout(onComplete, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [reducedMotion, onComplete])
|
||||||
|
|
||||||
|
// Track cursor anchor position relative to the content container
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
|
||||||
|
const anchor = cursorAnchorRef.current.getBoundingClientRect()
|
||||||
|
const container = containerRef.current.getBoundingClientRect()
|
||||||
|
setCursorPos({
|
||||||
|
left: anchor.left - container.left,
|
||||||
|
top: anchor.top - container.top,
|
||||||
|
})
|
||||||
|
}, [typedCount, phase])
|
||||||
|
|
||||||
|
// Render the typed lines up to typedCount
|
||||||
|
const renderLines = () => {
|
||||||
|
let remaining = typedCount
|
||||||
|
const renderedLines: React.ReactNode[] = []
|
||||||
|
let cursorPlaced = false
|
||||||
|
|
||||||
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
|
||||||
|
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
|
||||||
|
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
|
||||||
|
|
||||||
|
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
|
||||||
|
remaining -= charsForLine
|
||||||
|
|
||||||
|
// Cursor goes on the line currently being typed, or the last line in non-typing phases
|
||||||
|
const isCursorLine = phase === 'typing'
|
||||||
|
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
|
||||||
|
: lineIdx === TYPED_LINES.length - 1
|
||||||
|
|
||||||
|
// Render segments
|
||||||
|
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
if (charBudget <= 0 && phase === 'typing') break
|
||||||
|
|
||||||
|
const visibleChars = phase === 'typing'
|
||||||
|
? Math.min(charBudget, seg.text.length)
|
||||||
|
: seg.text.length
|
||||||
|
const visibleText = seg.text.slice(0, visibleChars)
|
||||||
|
charBudget -= visibleChars
|
||||||
|
|
||||||
|
if (seg.isSeedDot && visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={phase === 'holding' ? 'ecg-seed-dot animate-seed-pulse' : 'ecg-seed-dot'}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
|
||||||
|
if (isCursorLine && phase !== 'done') {
|
||||||
|
cursorPlaced = true
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key="cursor-anchor"
|
||||||
|
ref={cursorAnchorRef}
|
||||||
|
className="inline-block align-middle"
|
||||||
|
style={{ width: 8, height: 16, marginLeft: 1 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedLines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedLines
|
||||||
|
}
|
||||||
|
|
||||||
// Reduced motion: instant render
|
// Reduced motion: instant render
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden">
|
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden">
|
||||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||||
<BootLineHeader text={BOOT_CONFIG.header} />
|
{(() => {
|
||||||
{BOOT_CONFIG.lines.map((line, index) => (
|
// Render all lines fully
|
||||||
<BootLineRenderer key={index} line={line} />
|
const lines: React.ReactNode[] = []
|
||||||
))}
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={seg.isSeedDot ? 'ecg-seed-dot' : undefined}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{seg.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -255,14 +435,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
|
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 1 }}
|
||||||
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
transition={{ duration: 0 }}
|
||||||
>
|
>
|
||||||
{/* CRT Scanlines */}
|
{/* CRT Scanlines */}
|
||||||
<div
|
<motion.div
|
||||||
className="absolute inset-0 pointer-events-none"
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||||
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
style={{
|
style={{
|
||||||
background: `repeating-linear-gradient(
|
background: `repeating-linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
@@ -274,62 +456,37 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content container */}
|
||||||
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
<div ref={containerRef} className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2 relative z-10">
|
||||||
{/* Header */}
|
{/* Text fades out independently */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
animate={{ opacity: phase === 'fading' || phase === 'done' ? 0 : 1 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
|
||||||
>
|
>
|
||||||
<BootLineHeader text={BOOT_CONFIG.header} />
|
{renderLines()}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Lines */}
|
{/* Cursor rendered outside fading wrapper — shrinks independently */}
|
||||||
{BOOT_CONFIG.lines.map((line, index) => (
|
{cursorPos && phase !== 'done' && (
|
||||||
<motion.div
|
<span
|
||||||
key={index}
|
|
||||||
className="whitespace-nowrap leading-relaxed"
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
delay: getCumulativeDelay(index) / 1000,
|
|
||||||
duration: 0.4,
|
|
||||||
ease: 'easeOut',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BootLineRenderer line={line} />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Blinking Cursor */}
|
|
||||||
{showCursor && (
|
|
||||||
<motion.div
|
|
||||||
ref={cursorRef}
|
ref={cursorRef}
|
||||||
className="inline-block ml-1"
|
className="absolute animate-blink"
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{
|
|
||||||
opacity: isMorphing ? 0 : 1,
|
|
||||||
scaleX: isMorphing ? 0 : 1,
|
|
||||||
width: isMorphing ? 0 : 8,
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
||||||
style={{
|
style={{
|
||||||
height: 16,
|
left: cursorPos.left,
|
||||||
backgroundColor: BOOT_CONFIG.colors.bright,
|
top: cursorPos.top + (phase === 'fading' ? 12 : 0),
|
||||||
animation: isMorphing ? undefined : 'blink 530ms infinite',
|
width: 8,
|
||||||
|
height: phase === 'fading' ? 4 : 16,
|
||||||
|
backgroundColor: COLORS.bright,
|
||||||
|
filter: phase === 'fading' ? 'blur(1px)' : 'none',
|
||||||
|
boxShadow: phase === 'fading' ? '0 0 12px rgba(0,255,65,0.9)' : 'none',
|
||||||
|
transition: phase === 'fading'
|
||||||
|
? `top ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, height ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, filter ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out, box-shadow ${BOOT_CONFIG.timing.cursorShrinkDuration}ms ease-out`
|
||||||
|
: 'none',
|
||||||
|
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS for blink animation */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 50% { opacity: 1; }
|
|
||||||
51%, 100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
+254
-126
@@ -26,13 +26,7 @@ interface LetterLayout {
|
|||||||
char: string
|
char: string
|
||||||
startX: number
|
startX: number
|
||||||
endX: number
|
endX: number
|
||||||
startConnector: number
|
baselineY: number
|
||||||
endConnector: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectorProfile {
|
|
||||||
leftInset: number
|
|
||||||
rightInset: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -42,24 +36,11 @@ interface ConnectorProfile {
|
|||||||
const TRACE_SPEED = 350 // pixels per second
|
const TRACE_SPEED = 350 // pixels per second
|
||||||
const HEAD_SCREEN_RATIO = 0.75 // Head stays at 75% of screen during ECG
|
const HEAD_SCREEN_RATIO = 0.75 // Head stays at 75% of screen during ECG
|
||||||
const FLAT_GAP_SECONDS = 0.5 // Gap after last beat before text
|
const FLAT_GAP_SECONDS = 0.5 // Gap after last beat before text
|
||||||
const HOLD_SECONDS = 0.3 // Hold after text completes
|
const HOLD_SECONDS = 2 // Hold after text completes, before flatline/transition
|
||||||
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
|
const FLATLINE_DRAW_SECONDS = 0.3 // Time to draw flatline
|
||||||
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
const FADE_TO_BLACK_SECONDS = 0.2 // Canvas fade out
|
||||||
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
|
const BG_TRANSITION_SECONDS = 0.2 // Background color transition
|
||||||
|
|
||||||
const CONNECTOR_PROFILES: Record<string, ConnectorProfile> = {
|
|
||||||
C: { leftInset: 20, rightInset: 8 },
|
|
||||||
O: { leftInset: 17, rightInset: 7 },
|
|
||||||
D: { leftInset: 0, rightInset: 13 },
|
|
||||||
L: { leftInset: 5, rightInset: 0 },
|
|
||||||
E: { leftInset: 5, rightInset: 0 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PROFILE: ConnectorProfile = { leftInset: 0, rightInset: 0 }
|
|
||||||
|
|
||||||
const BASE_LEFT_INSET = 9
|
|
||||||
const BASE_RIGHT_INSET = 0
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Letter Definitions (ECG waveform shapes for each letter)
|
// Letter Definitions (ECG waveform shapes for each letter)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -122,17 +103,30 @@ function generateHeartbeatPoints(amplitude: number): Point[] {
|
|||||||
for (let i = 0; i <= steps; i++) {
|
for (let i = 0; i <= steps; i++) {
|
||||||
const t = i / steps
|
const t = i / steps
|
||||||
let y = 0
|
let y = 0
|
||||||
if (t >= 0.05 && t < 0.2) {
|
|
||||||
y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI)
|
// P wave: gentle rounded bump
|
||||||
} else if (t >= 0.25 && t < 0.32) {
|
if (t >= 0.02 && t < 0.14) {
|
||||||
y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI)
|
y = 0.06 * Math.sin(((t - 0.02) / 0.12) * Math.PI)
|
||||||
} else if (t >= 0.32 && t < 0.42) {
|
|
||||||
y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI)
|
|
||||||
} else if (t >= 0.42 && t < 0.5) {
|
|
||||||
y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI)
|
|
||||||
} else if (t >= 0.55 && t < 0.75) {
|
|
||||||
y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI)
|
|
||||||
}
|
}
|
||||||
|
// PR segment flat (0.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
|
||||||
@@ -160,32 +154,112 @@ function layoutText(
|
|||||||
offsetX: number,
|
offsetX: number,
|
||||||
letterWidth: number,
|
letterWidth: number,
|
||||||
letterGap: number,
|
letterGap: number,
|
||||||
spaceWidth: number
|
spaceWidth: number,
|
||||||
|
baselineY: number,
|
||||||
|
rowGap: number,
|
||||||
|
maxRowWidth: number
|
||||||
): LetterLayout[] {
|
): LetterLayout[] {
|
||||||
|
const words = ECG_TEXT.split(' ')
|
||||||
const layout: LetterLayout[] = []
|
const layout: LetterLayout[] = []
|
||||||
let cursor = offsetX
|
let cursor = offsetX
|
||||||
|
let currentBaselineY = baselineY
|
||||||
|
let rowWidth = 0
|
||||||
|
|
||||||
for (const char of ECG_TEXT) {
|
for (let w = 0; w < words.length; w++) {
|
||||||
if (char === ' ') {
|
const word = words[w]
|
||||||
cursor += spaceWidth
|
const wordWidth = word.length * (letterWidth + letterGap) - letterGap
|
||||||
continue
|
|
||||||
|
if (w > 0) {
|
||||||
|
const withSpace = rowWidth + spaceWidth + wordWidth
|
||||||
|
if (maxRowWidth > 0 && withSpace > maxRowWidth) {
|
||||||
|
// Wrap to next row
|
||||||
|
cursor += spaceWidth
|
||||||
|
currentBaselineY += rowGap
|
||||||
|
rowWidth = 0
|
||||||
|
} else {
|
||||||
|
cursor += spaceWidth
|
||||||
|
rowWidth += spaceWidth
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const profile = CONNECTOR_PROFILES[char] ?? DEFAULT_PROFILE
|
|
||||||
const startX = cursor
|
for (const char of word) {
|
||||||
const endX = cursor + letterWidth
|
layout.push({
|
||||||
layout.push({
|
char,
|
||||||
char,
|
startX: cursor,
|
||||||
startX,
|
endX: cursor + letterWidth,
|
||||||
endX,
|
baselineY: currentBaselineY,
|
||||||
startConnector: startX + BASE_LEFT_INSET + profile.leftInset,
|
})
|
||||||
endConnector: endX - BASE_RIGHT_INSET - profile.rightInset,
|
cursor += letterWidth + letterGap
|
||||||
})
|
rowWidth += letterWidth + letterGap
|
||||||
cursor += letterWidth + letterGap
|
}
|
||||||
|
rowWidth -= letterGap
|
||||||
}
|
}
|
||||||
|
|
||||||
return layout
|
return layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Measure where each character's rendered stroke crosses the baseline.
|
||||||
|
* Returns left/right ratios (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
|
// Main Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -238,7 +312,7 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
ctx.scale(dpr, dpr)
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
// Scale factors based on viewport
|
// Scale factors based on viewport
|
||||||
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
|
const scale = Math.min(1.2, Math.max(0.45, vw / 1200))
|
||||||
const LETTER_W = 72 * scale
|
const LETTER_W = 72 * scale
|
||||||
const LETTER_G = 10 * scale
|
const LETTER_G = 10 * scale
|
||||||
const SPACE_W = 30 * scale
|
const SPACE_W = 30 * scale
|
||||||
@@ -246,17 +320,18 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
// Layout parameters
|
// Layout parameters
|
||||||
const baselineY = vh * 0.5
|
const baselineY = vh * 0.5
|
||||||
const ecgMaxDefl = vh * 0.25
|
const ecgMaxDefl = vh * 0.25
|
||||||
const textMaxDefl = vh * 0.08
|
// Cap text deflection to letter width so font doesn't overflow cells on mobile
|
||||||
|
const textMaxDefl = Math.min(vh * 0.08, LETTER_W * 1.15)
|
||||||
|
|
||||||
// Calculate start offset from cursor position if provided
|
// Calculate start offset from cursor position if provided
|
||||||
const startOffsetX = startPosition ? startPosition.x : 0
|
const startOffsetX = startPosition ? startPosition.x : 0
|
||||||
|
|
||||||
// Build beats with cursor offset
|
// Build beats with cursor offset
|
||||||
const beats: Beat[] = [
|
const beats: Beat[] = [
|
||||||
{ startTime: 0.6, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
|
{ startTime: 0.6, widthPx: 150 * scale, amplitude: 0.3, startWX: 0 },
|
||||||
{ startTime: 1.4, widthPx: 80 * scale, amplitude: 0.55, startWX: 0 },
|
{ startTime: 1.4, widthPx: 190 * scale, amplitude: 0.55, startWX: 0 },
|
||||||
{ startTime: 2.3, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
|
{ startTime: 2.3, widthPx: 230 * scale, amplitude: 0.85, startWX: 0 },
|
||||||
{ startTime: 3.2, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
|
{ startTime: 3.2, widthPx: 270 * scale, amplitude: 1.0, startWX: 0 },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Apply start offset to all beats
|
// Apply start offset to all beats
|
||||||
@@ -264,25 +339,26 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
|
b.startWX = b.startTime * TRACE_SPEED + startOffsetX
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate text layout
|
// Calculate text layout — single line, viewport scrolls through
|
||||||
const lastBeat = beats[beats.length - 1]
|
const lastBeat = beats[beats.length - 1]
|
||||||
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
|
||||||
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
const textStartWX = lastBeatEndWX + FLAT_GAP_SECONDS * TRACE_SPEED
|
||||||
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
const totalTextW = getTextTotalWidth(LETTER_W, LETTER_G, SPACE_W)
|
||||||
const textEndWX = textStartWX + totalTextW
|
const textEndWX = textStartWX + totalTextW
|
||||||
const textLayout = layoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
|
const textLayout = layoutText(
|
||||||
|
textStartWX, LETTER_W, LETTER_G, SPACE_W,
|
||||||
|
baselineY, 0, Infinity
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate timing phases
|
// Calculate timing phases
|
||||||
const textEndTime = textEndWX / TRACE_SPEED
|
const textEndTime = (textEndWX - startOffsetX) / TRACE_SPEED
|
||||||
const holdEndTime = textEndTime + HOLD_SECONDS
|
const holdEndTime = textEndTime
|
||||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW_SECONDS
|
const flatlineEndTime = textEndTime + FLATLINE_DRAW_SECONDS
|
||||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK_SECONDS
|
const fadeStartTime = flatlineEndTime + HOLD_SECONDS
|
||||||
|
const fadeEndTime = fadeStartTime + FADE_TO_BLACK_SECONDS
|
||||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION_SECONDS
|
||||||
const exitEndTime = bgTransitionEndTime
|
const exitEndTime = bgTransitionEndTime
|
||||||
|
|
||||||
// Final head position (centered text end)
|
|
||||||
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
|
|
||||||
|
|
||||||
// Get Y at a given world X position
|
// Get Y at a given world X position
|
||||||
const getYAtX = (wx: number): number => {
|
const getYAtX = (wx: number): number => {
|
||||||
// Check beats
|
// Check beats
|
||||||
@@ -307,35 +383,11 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
return baselineY
|
return baselineY
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offscreen canvas for pre-rendering text
|
// Text rendering properties (drawn directly each frame — avoids offscreen canvas DPR/size issues on mobile)
|
||||||
const textCanvas = document.createElement('canvas')
|
const textFont = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
|
||||||
textCanvas.width = vw * dpr
|
const textLineWidth = 2 * scale
|
||||||
textCanvas.height = vh * dpr
|
// Measure where each character's stroke crosses the baseline (for connector lines)
|
||||||
const textCtx = textCanvas.getContext('2d')
|
const charEdges = measureCharBaselineEdges(textFont, textLineWidth, LETTER_W)
|
||||||
if (textCtx) {
|
|
||||||
textCtx.scale(dpr, dpr)
|
|
||||||
textCtx.font = `bold ${Math.round(textMaxDefl / 0.715)}px Arial, Helvetica, sans-serif`
|
|
||||||
textCtx.textAlign = 'center'
|
|
||||||
textCtx.textBaseline = 'alphabetic'
|
|
||||||
textCtx.strokeStyle = lineColor
|
|
||||||
textCtx.lineWidth = 1.5 * scale
|
|
||||||
|
|
||||||
// Pre-render all letters
|
|
||||||
for (const item of textLayout) {
|
|
||||||
const centerX = (item.startX + item.endX) / 2
|
|
||||||
textCtx.strokeText(item.char, centerX, baselineY)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw connector lines
|
|
||||||
for (let i = 0; i < textLayout.length - 1; i++) {
|
|
||||||
const curr = textLayout[i]
|
|
||||||
const next = textLayout[i + 1]
|
|
||||||
textCtx.beginPath()
|
|
||||||
textCtx.moveTo(curr.endConnector, baselineY)
|
|
||||||
textCtx.lineTo(next.startConnector, baselineY)
|
|
||||||
textCtx.stroke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animation loop
|
// Animation loop
|
||||||
const animate = (timestamp: number) => {
|
const animate = (timestamp: number) => {
|
||||||
@@ -353,8 +405,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
|
|
||||||
// Calculate current head position
|
// Calculate current head position
|
||||||
let headWX = elapsed * TRACE_SPEED + startOffsetX
|
let headWX = elapsed * TRACE_SPEED + startOffsetX
|
||||||
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
|
const isFlatlinePhase = elapsed >= holdEndTime && elapsed < fadeStartTime
|
||||||
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
|
const isFadePhase = elapsed >= fadeStartTime && elapsed < fadeEndTime
|
||||||
const isBgTransitionPhase = elapsed >= fadeEndTime
|
const isBgTransitionPhase = elapsed >= fadeEndTime
|
||||||
|
|
||||||
if (elapsed >= textEndTime) {
|
if (elapsed >= textEndTime) {
|
||||||
@@ -365,18 +417,10 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
let headSX: number
|
let headSX: number
|
||||||
let viewOff: number
|
let viewOff: number
|
||||||
const headSXEcg = HEAD_SCREEN_RATIO * vw
|
const headSXEcg = HEAD_SCREEN_RATIO * vw
|
||||||
|
|
||||||
if (headWX <= textStartWX) {
|
// Simple continuous scrolling - viewport follows head when it exceeds 75% of screen
|
||||||
viewOff = Math.max(0, headWX - headSXEcg)
|
viewOff = Math.max(0, headWX - headSXEcg)
|
||||||
headSX = headWX - viewOff
|
headSX = headWX - viewOff
|
||||||
} else if (headWX >= textEndWX || elapsed >= textEndTime) {
|
|
||||||
viewOff = textEndWX - finalHeadSX
|
|
||||||
headSX = headWX - viewOff
|
|
||||||
} else {
|
|
||||||
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
|
|
||||||
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
|
|
||||||
viewOff = headWX - headSX
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate fade alpha
|
// Calculate fade alpha
|
||||||
let fadeAlpha = 1
|
let fadeAlpha = 1
|
||||||
@@ -386,8 +430,8 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
fadeAlpha = 0
|
fadeAlpha = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background color transition
|
// Background color transition — delayed until after HOLD
|
||||||
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
|
if (!bgTransitionedRef.current && elapsed >= fadeStartTime) {
|
||||||
bgTransitionedRef.current = true
|
bgTransitionedRef.current = true
|
||||||
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
|
container.style.transition = `background ${BG_TRANSITION_SECONDS * 1000}ms ease-out`
|
||||||
container.style.background = loginBgColor
|
container.style.background = loginBgColor
|
||||||
@@ -396,11 +440,13 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.globalAlpha = fadeAlpha
|
ctx.globalAlpha = fadeAlpha
|
||||||
|
|
||||||
// Draw ECG trace (beats only, up to text start)
|
// Draw ECG trace - always draw from start for continuity
|
||||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
// Performance is fine since we're only drawing ~1000 pixels per frame
|
||||||
|
const traceStart = Math.floor(startOffsetX)
|
||||||
const traceEnd = Math.min(
|
const traceEnd = Math.min(
|
||||||
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX),
|
Math.ceil(elapsed >= textEndTime ? textEndWX : headWX),
|
||||||
Math.ceil(viewOff + vw)
|
Math.ceil(viewOff + vw),
|
||||||
|
Math.ceil(textStartWX) // Stop trace before text — only the dot draws through letters
|
||||||
)
|
)
|
||||||
|
|
||||||
if (traceEnd > traceStart) {
|
if (traceEnd > traceStart) {
|
||||||
@@ -436,40 +482,122 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) {
|
|||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw flatline after text
|
// Draw flatline after text — during flatline draw phase and fade phase
|
||||||
if (isFlatlinePhase || (elapsed >= holdEndTime && elapsed < textEndTime)) {
|
if (isFlatlinePhase || isFadePhase) {
|
||||||
const flatlineProgress = isFlatlinePhase
|
const flatlineProgress = Math.min(1, (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS)
|
||||||
? (elapsed - holdEndTime) / FLATLINE_DRAW_SECONDS
|
// Use actual head screen position, not finalHeadSX
|
||||||
: 1
|
const flatlineStartSX = headSX
|
||||||
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
|
const flatlineEndSX = flatlineStartSX + flatlineProgress * (vw - flatlineStartSX + 50)
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.strokeStyle = lineColor
|
ctx.strokeStyle = lineColor
|
||||||
ctx.lineWidth = 2 * scale
|
ctx.lineWidth = 2 * scale
|
||||||
ctx.shadowBlur = 8 * scale
|
ctx.shadowBlur = 8 * scale
|
||||||
ctx.shadowColor = lineColor
|
ctx.shadowColor = lineColor
|
||||||
ctx.moveTo(finalHeadSX, baselineY)
|
ctx.moveTo(flatlineStartSX, baselineY)
|
||||||
ctx.lineTo(flatlineEndSX, baselineY)
|
ctx.lineTo(flatlineEndSX, baselineY)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mask-based text reveal
|
// Text reveal — draw letters directly each frame
|
||||||
const isTextPhase = headWX > textStartWX
|
const isTextPhase = headWX > textStartWX
|
||||||
const isTextDone = elapsed >= textEndTime
|
const isTextDone = elapsed >= textEndTime
|
||||||
|
|
||||||
if (isTextPhase && textCtx) {
|
if (isTextPhase) {
|
||||||
// Create clipping region based on trace head position
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
|
||||||
|
// Clip for progressive reveal
|
||||||
|
const revealX = isTextDone ? vw : (headWX - viewOff)
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.rect(0, 0, isTextDone ? vw : headSX + 20 * scale, vh)
|
ctx.rect(0, 0, revealX, vh)
|
||||||
ctx.clip()
|
ctx.clip()
|
||||||
|
|
||||||
// Draw pre-rendered text through the clip
|
// Common text properties
|
||||||
ctx.drawImage(textCanvas, -viewOff, 0)
|
ctx.font = textFont
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'alphabetic'
|
||||||
|
ctx.lineJoin = 'round'
|
||||||
|
ctx.lineCap = 'round'
|
||||||
|
|
||||||
// Apply neon glow to text
|
// Pass 1: Outer glow layer (matches trace glow)
|
||||||
ctx.globalCompositeOperation = 'source-over'
|
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
|
||||||
|
ctx.lineWidth = 6 * scale
|
||||||
ctx.shadowColor = lineColor
|
ctx.shadowColor = lineColor
|
||||||
ctx.shadowBlur = 8 * scale
|
ctx.shadowBlur = 14 * scale
|
||||||
|
|
||||||
|
for (const item of textLayout) {
|
||||||
|
const screenX = (item.startX + item.endX) / 2 - viewOff
|
||||||
|
if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue
|
||||||
|
ctx.strokeText(item.char, screenX, baselineY)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < textLayout.length - 1; i++) {
|
||||||
|
const curr = textLayout[i]
|
||||||
|
const next = textLayout[i + 1]
|
||||||
|
const currEdge = charEdges.get(curr.char)
|
||||||
|
const nextEdge = charEdges.get(next.char)
|
||||||
|
if (!currEdge || !nextEdge) continue
|
||||||
|
const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff
|
||||||
|
const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff
|
||||||
|
if (toX < 0 || fromX > vw) continue
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fromX, baselineY)
|
||||||
|
ctx.lineTo(toX, baselineY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect last character's right stroke edge to cell edge (glow layer)
|
||||||
|
{
|
||||||
|
const lastChar = textLayout[textLayout.length - 1]
|
||||||
|
const lastEdge = charEdges.get(lastChar.char)
|
||||||
|
if (lastEdge) {
|
||||||
|
const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff
|
||||||
|
const toX = lastChar.endX - viewOff
|
||||||
|
if (fromX < vw && toX > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fromX, baselineY)
|
||||||
|
ctx.lineTo(toX, baselineY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: Main line layer (matches trace line)
|
||||||
|
ctx.strokeStyle = lineColor
|
||||||
|
ctx.lineWidth = textLineWidth
|
||||||
|
ctx.shadowBlur = 4 * scale
|
||||||
|
|
||||||
|
for (const item of textLayout) {
|
||||||
|
const screenX = (item.startX + item.endX) / 2 - viewOff
|
||||||
|
if (screenX + LETTER_W < 0 || screenX - LETTER_W > vw) continue
|
||||||
|
ctx.strokeText(item.char, screenX, baselineY)
|
||||||
|
}
|
||||||
|
for (let i = 0; i < textLayout.length - 1; i++) {
|
||||||
|
const curr = textLayout[i]
|
||||||
|
const next = textLayout[i + 1]
|
||||||
|
const currEdge = charEdges.get(curr.char)
|
||||||
|
const nextEdge = charEdges.get(next.char)
|
||||||
|
if (!currEdge || !nextEdge) continue
|
||||||
|
const fromX = curr.startX + currEdge.rightRatio * LETTER_W - viewOff
|
||||||
|
const toX = next.startX + nextEdge.leftRatio * LETTER_W - viewOff
|
||||||
|
if (toX < 0 || fromX > vw) continue
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fromX, baselineY)
|
||||||
|
ctx.lineTo(toX, baselineY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect last character's right stroke edge to its cell edge (bridges gap to flatline)
|
||||||
|
const lastChar = textLayout[textLayout.length - 1]
|
||||||
|
const lastEdge = charEdges.get(lastChar.char)
|
||||||
|
if (lastEdge) {
|
||||||
|
const fromX = lastChar.startX + lastEdge.rightRatio * LETTER_W - viewOff
|
||||||
|
const toX = lastChar.endX - viewOff
|
||||||
|
if (fromX < vw && toX > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fromX, baselineY)
|
||||||
|
ctx.lineTo(toX, baselineY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +11,23 @@ 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()
|
||||||
|
|
||||||
const fullUsername = 'A.CHARLWOOD'
|
const fullUsername = 'A.CHARLWOOD'
|
||||||
const passwordLength = 8
|
const passwordLength = 8
|
||||||
|
|
||||||
const prefersReducedMotion = typeof window !== 'undefined'
|
const prefersReducedMotion = typeof window !== 'undefined'
|
||||||
? 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(() => {
|
||||||
return () => clearInterval(cursorInterval)
|
startLoginSequence()
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
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