diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8672239..f340049 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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:*)" ] } } diff --git a/.ralph/ralph-loop.state.json b/.ralph/ralph-loop.state.json index 2c5bb28..305b368 100644 --- a/.ralph/ralph-loop.state.json +++ b/.ralph/ralph-loop.state.json @@ -1,6 +1,6 @@ { "active": true, - "iteration": 1, + "iteration": 2, "minIterations": 1, "maxIterations": 0, "completionPromise": "COMPLETE", diff --git a/CLAUDE.md b/CLAUDE.md index 36e61e2..08de8b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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/) ``` diff --git a/Ralph/IMPLEMENTATION_PLAN.md b/Ralph/IMPLEMENTATION_PLAN.md index cc4ff22..c7c96c6 100644 --- a/Ralph/IMPLEMENTATION_PLAN.md +++ b/Ralph/IMPLEMENTATION_PLAN.md @@ -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". diff --git a/Ralph/refs/ref-design-system.md b/Ralph/refs/ref-design-system.md index ada248e..dcc5eee 100644 --- a/Ralph/refs/ref-design-system.md +++ b/Ralph/refs/ref-design-system.md @@ -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 `
` 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. diff --git a/Screenshot 2026-02-12 001926.png b/Screenshot 2026-02-12 001926.png new file mode 100644 index 0000000..2cc959a Binary files /dev/null and b/Screenshot 2026-02-12 001926.png differ diff --git a/src/App.tsx b/src/App.tsx index 9165614..30c73b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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 (
{phase === 'boot' && ( - setPhase('ecg')} + onCursorPositionReady={(pos) => { cursorPositionRef.current = pos }} /> )} - + {phase === 'ecg' && ( - setPhase('login')} + startPosition={cursorPositionRef.current} /> - )} + )} {phase === 'login' && ( - + setPhase('pmr')} /> )} {phase === 'pmr' && } diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 6f50d4d..8112482 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -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 ( -
- - {text} - -
- ) -} + // 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 ( -
- {line.text} -
- ) -} - -function BootLineSeparator({ line }: { line: BootLine }) { - const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim - return ( -
- {line.text || '---'} -
- ) -} - -function BootLineField({ line }: { line: BootLine }) { - const valueColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright - return ( -
- - {(line.label || '').padEnd(9)} - - {line.value} -
- ) -} - -function BootLineModule({ line }: { line: BootLine }) { - const textColor = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.dim - return ( -
- - [OK] - {' '} - {line.text} -
- ) -} - -function BootLineReady({ line }: { line: BootLine }) { - const color = line.style ? BOOT_CONFIG.colors[line.style] : BOOT_CONFIG.colors.bright - return ( -
- - > {line.text} - . - -
- ) -} - -function BootLineRenderer({ line }: { line: BootLine }) { - switch (line.type) { - case 'header': - return - case 'status': - return - case 'separator': - return - case 'field': - return - case 'module': - return - case 'ready': - return - 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(null) - const reducedMotion = typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + const cursorRef = useRef(null) + const cursorAnchorRef = useRef(null) + const containerRef = useRef(null) + const cursorCapturedRef = useRef(false) + const timeoutRef = useRef | 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( + + {visibleText} + + ) + } else if (visibleChars > 0) { + spans.push( + + {visibleText} + + ) + } + } + + // Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper) + if (isCursorLine && phase !== 'done') { + cursorPlaced = true + spans.push( + + ) + } + + renderedLines.push( +
+ {spans} +
+ ) + } + + return renderedLines + } // Reduced motion: instant render if (reducedMotion) { return ( -
+
- - {BOOT_CONFIG.lines.map((line, index) => ( - - ))} + {(() => { + // 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( + + {seg.text} + + ) + } + lines.push( +
+ {spans} +
+ ) + } + return lines + })()}
) @@ -255,14 +435,16 @@ export function BootSequence({ onComplete, onCursorPositionReady }: BootSequence {isVisible && ( {/* CRT Scanlines */} -
- {/* Content */} -
- {/* Header */} + {/* Content container */} +
+ {/* Text fades out independently */} - + {renderLines()} - {/* Lines */} - {BOOT_CONFIG.lines.map((line, index) => ( - - - - ))} - - {/* Blinking Cursor */} - {showCursor && ( - )}
- - {/* CSS for blink animation */} - )} diff --git a/src/components/ECGAnimation.tsx b/src/components/ECGAnimation.tsx index f9c3409..dd2db48 100644 --- a/src/components/ECGAnimation.tsx +++ b/src/components/ECGAnimation.tsx @@ -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 = { - 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.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 }) } 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 (0–1) within the character cell. */ +function measureCharBaselineEdges( + font: string, + lineWidth: number, + charWidth: number +): Map { + 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() + + 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) { @@ -365,18 +417,10 @@ export function ECGAnimation({ onComplete, startPosition }: ECGAnimationProps) { let headSX: number 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() } diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index 4994886..5fc7248 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { motion } from 'framer-motion' import { Shield } from 'lucide-react' import { useAccessibility } from '../contexts/AccessibilityContext' @@ -11,19 +11,23 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { const [username, setUsername] = useState('') const [passwordDots, setPasswordDots] = useState(0) const [showCursor, setShowCursor] = useState(true) - const [isTypingUsername, setIsTypingUsername] = useState(true) - const [isTypingPassword, setIsTypingPassword] = useState(false) + const [activeField, setActiveField] = useState<'username' | 'password' | null>('username') const [buttonPressed, setButtonPressed] = useState(false) const [isExiting, setIsExiting] = useState(false) const { requestFocusAfterLogin } = useAccessibility() - + const fullUsername = 'A.CHARLWOOD' const passwordLength = 8 - - const prefersReducedMotion = typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + + const prefersReducedMotion = typeof window !== 'undefined' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false + // Refs for interval cleanup + const usernameIntervalRef = useRef | null>(null) + const passwordIntervalRef = useRef | null>(null) + const cursorIntervalRef = useRef | null>(null) + const triggerComplete = useCallback(() => { setIsExiting(true) setTimeout(() => { @@ -36,6 +40,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { if (prefersReducedMotion) { setUsername(fullUsername) setPasswordDots(passwordLength) + setActiveField(null) setTimeout(() => { setButtonPressed(true) setTimeout(() => { @@ -45,33 +50,37 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { return } - setIsTypingUsername(true) + // Username typing: 30ms per character let usernameIndex = 0 - - const usernameInterval = setInterval(() => { + usernameIntervalRef.current = setInterval(() => { if (usernameIndex <= fullUsername.length) { setUsername(fullUsername.slice(0, usernameIndex)) usernameIndex++ } else { - clearInterval(usernameInterval) - setIsTypingUsername(false) - setIsTypingPassword(true) - + if (usernameIntervalRef.current) { + clearInterval(usernameIntervalRef.current) + } + setActiveField('password') + + // Password dots: 20ms per dot, after 150ms pause setTimeout(() => { let dotCount = 0 - const passwordInterval = setInterval(() => { + passwordIntervalRef.current = setInterval(() => { if (dotCount <= passwordLength) { setPasswordDots(dotCount) dotCount++ } else { - clearInterval(passwordInterval) - setIsTypingPassword(false) - + if (passwordIntervalRef.current) { + clearInterval(passwordIntervalRef.current) + } + setActiveField(null) + + // Button press: after 150ms pause setTimeout(() => { setButtonPressed(true) setTimeout(() => { triggerComplete() - }, 100) + }, 200) }, 150) } }, 20) @@ -81,47 +90,66 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { }, [triggerComplete, prefersReducedMotion]) useEffect(() => { - const cursorInterval = setInterval(() => { + // Cursor blink: 530ms interval + cursorIntervalRef.current = setInterval(() => { setShowCursor(prev => !prev) }, 530) - - startLoginSequence() - - return () => clearInterval(cursorInterval) + + // Delay start slightly for card entrance + const startTimeout = setTimeout(() => { + startLoginSequence() + }, 200) + + return () => { + if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current) + if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current) + if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current) + clearTimeout(startTimeout) + } }, [startLoginSequence]) return (
- {/* Branding */} -
+ {/* Branding Header */} +
{/* Login Form */} -
+
{/* Username Field */}