From 3afadbdc7356ae55ff54dc1d5104ca8b3b43c0cd Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Thu, 12 Feb 2026 22:31:34 +0000 Subject: [PATCH] Completed boot loading to ECG, to name written --- .claude/settings.local.json | 5 +- .ralph/ralph-loop.state.json | 2 +- CLAUDE.md | 150 +++++++-- Ralph/IMPLEMENTATION_PLAN.md | 2 +- Ralph/refs/ref-design-system.md | 184 +++++++--- Screenshot 2026-02-12 001926.png | Bin 0 -> 67189 bytes src/App.tsx | 38 +-- src/components/BootSequence.tsx | 557 ++++++++++++++++++++----------- src/components/ECGAnimation.tsx | 380 ++++++++++++++------- src/components/LoginScreen.tsx | 152 +++++---- 10 files changed, 961 insertions(+), 509 deletions(-) create mode 100644 Screenshot 2026-02-12 001926.png 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 0000000000000000000000000000000000000000..2cc959a29a0dd01186808aab3d3a07579d10093f GIT binary patch literal 67189 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'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 */}