Completed boot loading to ECG, to name written

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

After

Width:  |  Height:  |  Size: 66 KiB

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