Compare commits

..

13 Commits

10 changed files with 1129 additions and 276 deletions
@@ -0,0 +1,200 @@
{
"project": "Portfolio — Login Screen Rework",
"branchName": "ralph/login-screen-rework",
"description": "Rework the login screen: responsive sizing, dashboard style alignment, CVMIS rebrand, animated capsule logo, live blurred dashboard background, connection status indicator UX, button pulse, and dissolve transition.",
"userStories": [
{
"id": "US-001",
"title": "Skip to login phase for dev iteration",
"description": "As a developer, I want to skip boot/ECG and land directly on the login screen so I can iterate on login changes quickly.",
"acceptanceCriteria": [
"In src/App.tsx, change the initial Phase state from 'boot' to 'login'",
"The boot, ECG, and login phases remain in code — only the initial state changes",
"App loads directly to the login screen on refresh",
"Typecheck passes"
],
"priority": 1,
"passes": true,
"notes": "Temporary — final story reverts this. Phase state is on line 47 of App.tsx."
},
{
"id": "US-002",
"title": "Create CvmisLogo React SVG component",
"description": "As a developer, I need a reusable CvmisLogo component that renders the CVMIS capsule logo from cvmis-logo.svg, supporting both static and animated modes.",
"acceptanceCriteria": [
"Create src/components/CvmisLogo.tsx as a React component",
"Component accepts props: size (number, controls height in px), animated (boolean, default false), className (optional string)",
"SVG paths are inlined from cvmis-logo.svg — three <g> groups: capsule-rx (teal #0b7979), capsule-terminal (amber #d97706), capsule-data (green #059669)",
"The SVG viewBox is preserved so the logo scales correctly at any size",
"When animated=false, all three capsules render in their final fanned-out positions (matching the original SVG layout)",
"When animated=true, the component plays a two-phase reveal using framer-motion: Phase 1 (Rise ~500ms): green data capsule scales from 0 to 1 and translates upward into center position, other capsules hidden. Phase 2 (Fan-out ~500ms): all three capsules appear and rotate/translate to their final fanned-out positions with staggered easing",
"Animation reference: LogoReveal/frame 1-5.jpg — frame 1-3 show green capsule rising, frame 4-5 show all three fanning out",
"Each capsule group uses transform-origin at its base/bottom so fan-out looks like cards spreading from a hand",
"prefers-reduced-motion: skip animation, render final state immediately",
"Typecheck passes"
],
"priority": 2,
"passes": true,
"notes": "The SVG uses a transform with scale(0.05, -0.05) and translate — you'll need to simplify the viewBox and transforms for React. The three <g> IDs are capsule-rx, capsule-terminal, capsule-data. Framer Motion is already installed (11.15.0). Look at LogoReveal/frame 1-5.jpg for the animation sequence. The fan-out in frames 4-5 shows: teal Rx tilts left, amber terminal stays center, green data tilts right."
},
{
"id": "US-003",
"title": "Responsive login card sizing and dashboard style alignment",
"description": "As a visitor on a 1440p or 4K display, I want the login card to be proportionate to my screen and styled consistently with the GP dashboard.",
"acceptanceCriteria": [
"Login card width changes from fixed 320px to responsive: clamp(320px, 28vw, 480px)",
"Card padding scales from fixed 32px to clamp(24px, 2.5vw, 40px)",
"Input field font size scales proportionally (minimum 13px, up to 15px on large viewports)",
"Button font size scales proportionally (minimum 14px, up to 16px)",
"Label font size scales proportionally (minimum 12px, up to 14px)",
"Card uses dashboard color tokens via CSS variables: background var(--surface), border color var(--border-card) or #E4EDEB, text colors var(--text-primary) and var(--text-secondary)",
"Input fields use var(--accent) (#0D6E6E) for focus border, #E4EDEB for default border, var(--bg-dashboard) for inactive background",
"Card shadow uses the project shadow tokens: 0 1px 2px rgba(26,43,42,0.05) resting, 0 2px 8px rgba(26,43,42,0.08) elevated",
"Card border radius remains 12px",
"Card still looks good on mobile (≤480px) — should not exceed viewport width minus 32px margin",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": true,
"notes": "LoginScreen.tsx currently uses inline styles with hardcoded colors (#E5E7EB borders, #64748B text, etc). Replace these with the dashboard CSS custom properties defined in index.css (--surface, --accent, --border, --text-primary, --text-secondary, --text-tertiary). Font family vars: var(--font-ui) for labels/buttons, var(--font-geist-mono) for input monospace."
},
{
"id": "US-004",
"title": "Rebrand to CVMIS and integrate animated logo",
"description": "As the portfolio owner, I want the login to say CVMIS with the capsule logo replacing the Shield icon.",
"acceptanceCriteria": [
"Title text changed from 'CareerRecord PMR' to 'CVMIS'",
"Subtitle changed from 'Clinical Information System' to 'CV Management Information System'",
"The Shield icon import and its teal background container are removed from the branding section",
"CvmisLogo component is imported and rendered in the branding section with animated=true",
"Logo height is proportional to the responsive card size (roughly 48-64px depending on viewport, use clamp)",
"Logo animation completes before the typing animation starts — adjust the startLoginSequence delay (currently 400ms) to account for logo animation duration (~1000ms total)",
"Footer text 'Secure clinical system login' remains unchanged",
"prefers-reduced-motion: logo shows instantly in final state, typing starts after original 400ms delay",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 4,
"passes": true,
"notes": "The Shield icon is at LoginScreen.tsx lines 213-218. The logo animation is ~1000ms (500ms rise + 500ms fan-out). Increase the startLoginSequence delay from 400ms to ~1500ms (400ms card entrance + 1000ms logo + 100ms pause). CvmisLogo component from US-002."
},
{
"id": "US-005",
"title": "Replace Home icon with CVMIS logo on TopBar",
"description": "As a visitor on the dashboard, I want to see the CVMIS brand logo in the top-left corner instead of the generic Home icon.",
"acceptanceCriteria": [
"In src/components/TopBar.tsx, remove the Home import from lucide-react",
"Import CvmisLogo from ./CvmisLogo",
"Replace the <Home> element with <CvmisLogo size={24} /> (static, no animation)",
"Logo colors match SVG source: teal #0b7979, amber #d97706, green #059669",
"Logo maintains aspect ratio and fits within the TopBar height",
"The 'Headhunt Medical Center' brand text and all other TopBar elements remain unchanged",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 5,
"passes": true,
"notes": "TopBar.tsx line 57-61 has the Home icon. Simple swap — CvmisLogo with animated=false (the default). If Home is the only lucide icon used in the import, clean up the import. Check: Search is also imported from lucide-react on line 2."
},
{
"id": "US-006",
"title": "Render live dashboard behind login with blur overlay",
"description": "As a visitor, I want to see the GP dashboard blurred behind the login card, creating visual continuity.",
"acceptanceCriteria": [
"In App.tsx, during the 'login' phase, render DashboardLayout underneath the login overlay (both visible simultaneously)",
"DashboardLayout is wrapped in DetailPanelProvider (as it is in the 'pmr' phase)",
"DashboardLayout renders at scroll position 0 (showing patient summary header area)",
"LoginScreen becomes an overlay: fixed position, full viewport, semi-transparent background rgba(240, 245, 244, 0.7) with backdrop-filter: blur(20px)",
"Dashboard content is non-interactive while login overlay is present (the overlay captures all pointer events)",
"The login card remains centered on top of the blurred overlay",
"backdrop-filter blur is constant from the moment login appears (no ease-in)",
"prefers-reduced-motion: blur still applies (static visual treatment), only entrance animations are skipped",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 6,
"passes": true,
"notes": "Currently App.tsx renders phases exclusively (only one at a time). Change so that login phase renders: <DetailPanelProvider><DashboardLayout /></DetailPanelProvider> + <LoginScreen overlay on top>. LoginScreen.tsx already has 'fixed inset-0 z-50' — just change its backgroundColor from solid #1A2B2A to the semi-transparent value with backdrop-filter. Consider adding will-change: backdrop-filter for performance."
},
{
"id": "US-007",
"title": "Connection status indicator with animated dots and typing-linked timing",
"description": "As a visitor, I want to see a clear red-to-green status transition tied to the typing sequence, not an arbitrary timer.",
"acceptanceCriteria": [
"Status indicator LED dot size increased from 6px to 10px",
"LED dot has a subtle glow effect: box-shadow 0 0 6px 1px in the LED color (red or green)",
"Status text size increased from 10px to 12px",
"Initial state: RED LED + 'Awaiting secure connection' in red (#DC2626) with animated trailing dots",
"The trailing dots animate: dots cycle through '.', '..', '...' repeating every ~1.5 seconds",
"Remove the existing independent 2000ms connectionTimeout timer",
"Instead, connection transitions to green exactly 500ms after typingComplete becomes true",
"Green state: GREEN LED (#059669) + 'Secure connection established, awaiting login' in green",
"Transition between red and green states has a smooth 300ms color/shadow transition",
"prefers-reduced-motion: no dot cycling animation, state changes happen instantly",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 7,
"passes": true,
"notes": "The connectionTimeout is set on line 117 of LoginScreen.tsx (2000ms independent timer). Remove it and add a useEffect that watches typingComplete — when true, setTimeout 500ms then setConnectionState('connected'). The dot animation can use a simple interval cycling dotCount 0→1→2→0. The LED glow box-shadow: '0 0 6px 1px rgba(220,38,38,0.4)' for red, '0 0 6px 1px rgba(5,150,105,0.4)' for green."
},
{
"id": "US-008",
"title": "Login button pulse animation on activation",
"description": "As a visitor, I want the login button to pulse subtly when it becomes clickable so I know to click it.",
"acceptanceCriteria": [
"Add a CSS @keyframes animation 'login-pulse' in index.css: scale 1 → 1.03 → 1, ease-in-out, duration 1.5s",
"When canLogin becomes true (button enabled), apply the pulse animation repeating every 3 seconds (1.5s animation + 1.5s pause via animation-delay or longer duration with keyframe percentages)",
"Pulse animation stops when button is hovered (animation: none on hover)",
"Pulse animation stops immediately on click (remove animation class on buttonPressed)",
"Button opacity transitions from 0.6 to 1.0 when enabled (existing behavior, preserve)",
"prefers-reduced-motion: no pulse animation, button just becomes enabled with opacity 1",
"Button still receives keyboard focus when it becomes enabled (existing behavior)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": true,
"notes": "The canLogin variable is on line 43 of LoginScreen.tsx. Add a CSS class 'login-pulse-active' that applies the animation, and conditionally apply it when canLogin && !buttonPressed && !buttonHovered. The @keyframes could use: 0%,100% { transform: scale(1) } 50% { transform: scale(1.03) } with animation: login-pulse 1.5s ease-in-out infinite and a wrapper that adds 1.5s gaps (or use 0%,35%,65%,100% keyframe percentages to build in the pause)."
},
{
"id": "US-009",
"title": "Login dissolve transition to reveal dashboard",
"description": "As a visitor, I want the login card and blur overlay to dissolve smoothly on login, revealing the dashboard underneath.",
"acceptanceCriteria": [
"On login click: existing pressed state + loading spinner behavior is preserved",
"After loading spinner phase, the login card fades out (opacity 0) with slight scale up (1.03)",
"Simultaneously, the overlay backdrop-filter blur animates from 20px to 0px",
"Overlay background opacity fades from 0.7 to 0",
"Total dissolve duration: ~600ms from card exit to fully revealed dashboard",
"After dissolve completes, the login overlay is removed from DOM and dashboard becomes interactive",
"In App.tsx, transition from login to pmr phase after the overlay dissolve completes (use a callback from LoginScreen)",
"prefers-reduced-motion: instant transition, no dissolve animation",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 9,
"passes": true,
"notes": "Currently LoginScreen has isExiting state that scales card to 1.03 and fades to opacity 0 (line 163). Extend this to also animate the overlay container. The overlay is the outer div with 'fixed inset-0' — animate its backdrop-filter and background-color. Use framer-motion animate for coordinated exit. The onComplete callback should fire after the full dissolve, not after the card fade."
},
{
"id": "US-010",
"title": "Re-enable boot sequence",
"description": "As a user, I want the full boot → ECG → login → dashboard experience restored.",
"acceptanceCriteria": [
"In src/App.tsx, change the initial Phase state back from 'login' to 'boot'",
"Boot → ECG → Login → Dashboard sequence works end to end",
"Login screen shows blurred dashboard behind it",
"Logo animation plays, typing animation follows, connection indicator transitions, button pulses",
"Clicking login dissolves the overlay to reveal the dashboard",
"No other changes to App.tsx beyond reverting the initial state",
"Typecheck passes",
"Verify in browser using dev-browser skill: app starts at boot, progresses through ECG, login with blur background and logo animation, arrives at dashboard"
],
"priority": 10,
"passes": true,
"notes": "Simple revert of US-001. Phase state is on line 47 of App.tsx."
}
]
}
@@ -0,0 +1,208 @@
# Progress Log — Login Screen Rework
# Branch: ralph/login-screen-rework
# Started: 2026-02-15
## Codebase Patterns
### Project Structure
- Components in `src/components/`, tiles in `src/components/tiles/`
- Data files in `src/data/`
- Types in `src/types/pmr.ts` and `src/types/index.ts`
- Hooks in `src/hooks/`, Contexts in `src/contexts/`, Lib in `src/lib/`
- Path alias: `@/` maps to `./src/`
### Phase Management
- App.tsx controls phase: 'boot' -> 'ecg' -> 'login' -> 'pmr'
- BootSequence.tsx, ECGAnimation.tsx — LOCKED, do not modify
- LoginScreen.tsx bridges to dashboard
### Typography
- Elvaro Grotesque (`font-ui`, `var(--font-ui)`) — primary UI font
- Blumir (`font-ui-alt`) — alternative variable font
- Geist Mono (`font-geist`, `var(--font-geist-mono)`) — timestamps, data values
- Fira Code (`font-mono`) — boot/ECG terminal only
- Do NOT use Inter, Roboto, DM Sans, or system defaults
### Design Tokens (index.css CSS variables)
- --surface: #FFFFFF (card/topbar background)
- --bg-dashboard: #F0F5F4 (warm sage content background)
- --accent: #0D6E6E (teal primary)
- --accent-hover: #0A8080
- --accent-light: rgba(10,128,128,0.08)
- --border: #D4E0DE (structural borders)
- --border-card: #E4EDEB (card/inner borders)
- --text-primary: #1A2B2A
- --text-secondary: #5B7A78
- --text-tertiary: #8DA8A5
- --sidebar-width: 304px
- --topbar-height: 56px
### Known Dependencies
- React 18.3.1, TypeScript, Vite, Tailwind CSS
- Framer Motion 11.15.0, Lucide React 0.468.0, fuse.js 7.0.0
### Phase Rendering (post US-006)
- Login phase now renders BOTH DashboardLayout and LoginScreen overlay simultaneously
- DashboardLayout is wrapped in DetailPanelProvider for both 'login' and 'pmr' phases
- LoginScreen overlay: `fixed inset-0 z-50` with `rgba(240, 245, 244, 0.7)` + `backdrop-filter: blur(20px)`
### Key Files for This Feature
- src/App.tsx — phase management, will need restructuring for blur overlay
- src/components/LoginScreen.tsx — main login screen (416 lines)
- src/components/TopBar.tsx — Home icon replacement target (line 57)
- src/components/DashboardLayout.tsx — rendered behind login blur
- src/contexts/DetailPanelContext.tsx — wraps DashboardLayout
- cvmis-logo.svg — source SVG with 3 capsule groups
- LogoReveal/frame 1-5.jpg — animation reference frames
### CvmisLogo Component
- `size` prop: numeric, sets SVG height attribute directly
- `cssHeight` prop: string, sets height via CSS style (use for clamp/responsive values)
- `animated` prop: boolean, enables framer-motion reveal animation (1000ms total)
- Logo animation: 500ms rise (green capsule) + 500ms fan-out (all three) = 1000ms total
### LoginScreen.tsx Key Lines (post US-007)
- Line 20: connectionState useState
- Line 21: dotCount useState (for animated trailing dots)
- Line 43: canLogin derived state
- Line 60-101: startLoginSequence (typing animation)
- Line 110-115: useEffect — connection transitions to green 500ms after typingComplete
- Line 118-126: useEffect — animated dot cycling (500ms interval) while connecting
- Line 128-150: useEffect — cursor blink + startLoginSequence delay (no more connectionTimeout)
- Line 370-405: Connection status indicator (10px LED dot with glow, 12px text)
---
## 2026-02-15 - US-010
- Reverted initial Phase state from 'login' back to 'boot' in App.tsx line 47
- Full flow verified: boot → ECG → login (with blur, logo, typing, connection indicator, pulse) → dissolve → dashboard
- Files changed: src/App.tsx
- **Learnings for future iterations:**
- Simple one-line revert as planned in US-001
- The full boot→ECG→login sequence takes ~20 seconds before login screen appears
---
## 2026-02-15 - US-009
- Changed outer overlay container from plain `<div>` to `<motion.div>` for animated exit
- On isExiting: overlay animates backgroundColor to transparent, backdropFilter from blur(20px) to blur(0px) over 600ms
- Card exit animation extended from 200ms to 400ms for smoother dissolve feel
- onComplete callback fires after 600ms dissolve (previously 200ms card exit)
- After dissolve completes, overlay removed from DOM and dashboard becomes interactive
- prefers-reduced-motion: instant transition (0ms for all timers)
- Files changed: src/components/LoginScreen.tsx
- Verified in browser: clicked login → spinner → card fades + overlay blur dissolves → dashboard revealed
- **Learnings for future iterations:**
- framer-motion can animate backdropFilter and backgroundColor on a motion.div via the animate prop
- The onComplete timeout (600ms) must match the overlay dissolve duration, not the card fade duration
- Card fade (400ms) finishes before overlay dissolve (600ms), creating a layered reveal effect
- WebkitBackdropFilter needs to be animated alongside backdropFilter for Safari
---
## 2026-02-15 - US-008
- Added @keyframes login-pulse in index.css: scale 1→1.03→1 over 3s cycle (1.5s animation built into keyframe percentages with 1.5s pause)
- Added .login-pulse-active class that applies the animation infinitely
- Hover removes animation via CSS rule (.login-pulse-active:hover { animation: none })
- Button gets login-pulse-active class when canLogin && !buttonPressed
- prefers-reduced-motion: .login-pulse-active { animation: none } in reduced motion media query
- Button opacity 0.6→1.0 transition preserved (existing behavior)
- Button still receives keyboard focus when enabled (existing behavior)
- Files changed: src/index.css, src/components/LoginScreen.tsx
- Verified in browser: button has login-pulse animation running (3s ease-in-out infinite), class applied correctly
- **Learnings for future iterations:**
- Used keyframe percentages (0%,60%,100% at scale(1), 30% at scale(1.03)) to build pause into a single animation rather than animation-delay
- CSS handles hover removal — no need for buttonHovered state in the class condition
- buttonPressed removes the class entirely (not just pauses), which is cleaner
---
## 2026-02-15 - US-007
- Reworked connection status indicator: LED dot 6px→10px with glow box-shadow, text 10px→12px
- Removed independent 2000ms connectionTimeout timer
- Added useEffect that transitions to green 500ms after typingComplete becomes true
- Added animated trailing dots cycling '.', '..', '...' every 500ms while connecting
- Initial state: red LED + red text "Awaiting secure connection" with animated dots
- Connected state: green LED + green text "Secure connection established, awaiting login"
- 300ms smooth transition for color and box-shadow between states
- prefers-reduced-motion: no dot cycling, instant state changes
- Files changed: src/components/LoginScreen.tsx
- Verified in browser: red indicator with cycling dots visible during typing, transitions to green after typing completes
- **Learnings for future iterations:**
- dotCount state cycles 0→1→2→3→0 (4 states: no dots, '.', '..', '...') via modulo arithmetic
- Connection transition is now tied to typingComplete state, not an arbitrary timer
- The dot interval cleanup needs to happen in both the dedicated useEffect and the main cleanup
- LED glow uses rgba with 0.4 alpha for subtle effect matching project shadow conventions
---
## 2026-02-15 - US-006
- Rendered DashboardLayout (wrapped in DetailPanelProvider) behind LoginScreen during login phase in App.tsx
- Changed LoginScreen overlay from solid #1A2B2A background to semi-transparent rgba(240, 245, 244, 0.7) with backdrop-filter: blur(20px)
- Dashboard is non-interactive during login (overlay captures pointer events via fixed inset-0 z-50)
- After login click, phase transitions to 'pmr' and overlay is removed from DOM, dashboard becomes interactive
- Files changed: src/App.tsx, src/components/LoginScreen.tsx
- Verified in browser: blur overlay shows dashboard content behind login card, login click transitions to interactive dashboard
- **Learnings for future iterations:**
- App.tsx phase rendering changed from exclusive (one phase at a time) to overlapping (login + pmr render DashboardLayout)
- DetailPanelProvider now wraps DashboardLayout for both 'login' and 'pmr' phases — condition is `(phase === 'login' || phase === 'pmr')`
- LoginScreen already had `fixed inset-0 z-50` which makes it a full-viewport overlay — just needed background/blur changes
- WebkitBackdropFilter needed for Safari compatibility alongside backdropFilter
---
## 2026-02-15 - US-005
- Replaced Home icon with CvmisLogo (size={24}, static/no animation) in TopBar.tsx
- Removed Home from lucide-react import (Search still used)
- Imported CvmisLogo component
- Files changed: src/components/TopBar.tsx
- Verified in browser: logo renders correctly with teal/amber/green capsule colors, fits TopBar height
- **Learnings for future iterations:**
- TopBar uses inline styles throughout, consistent with LoginScreen pattern
- Search is the only remaining lucide-react icon in TopBar.tsx
- CvmisLogo default `animated=false` means no animation prop needed for static usage
---
## 2026-02-15 - US-004
- Rebranded login from "CareerRecord PMR" to "CVMIS" with subtitle "CV Management Information System"
- Replaced Shield icon with CvmisLogo component (animated=true, responsive cssHeight)
- Added `cssHeight` prop to CvmisLogo for CSS clamp-based responsive sizing: clamp(48px, 4vw, 64px)
- Increased startLoginSequence delay from 400ms to 1500ms to let logo animation complete before typing begins
- prefers-reduced-motion: keeps original 400ms delay since logo renders instantly
- Fixed lint warning: added prefersReducedMotion to useEffect dependency array
- Files changed: src/components/LoginScreen.tsx, src/components/CvmisLogo.tsx
- **Learnings for future iterations:**
- CvmisLogo `size` prop is numeric (SVG height attribute) — use `cssHeight` string prop for CSS clamp values
- Logo animation is 1000ms total (500ms rise + 500ms fan-out) — typing delay must account for this
- The committed LoginScreen from US-003 still had Shield icon — US-003 only committed responsive sizing, not branding changes
---
## 2026-02-15 - US-003
- Responsive card: width clamp(320px,28vw,480px), maxWidth calc(100vw-32px), padding clamp(24px,2.5vw,40px)
- Replaced hardcoded colors with CSS variables: --surface, --bg-dashboard, --accent, --text-secondary, --text-tertiary
- Input fields: #E4EDEB default border, var(--accent) focus border, var(--bg-dashboard) inactive bg
- Font sizes: labels clamp(12px,1vw,14px), inputs clamp(13px,1.1vw,15px), button clamp(14px,1.1vw,16px)
- Card shadow: 0 1px 2px rgba(26,43,42,0.05) matching project shadow tokens
- Files changed: src/components/LoginScreen.tsx
- **Learnings for future iterations:**
- No --border-card CSS variable exists in index.css — use #E4EDEB directly
- LoginScreen uses inline styles throughout, not Tailwind classes (except for focus-visible ring on button)
- The card used className="bg-white" which needed to be replaced with inline style for consistency
---
## 2026-02-15 - US-002
- Created CvmisLogo.tsx component with inlined SVG paths from cvmis-logo.svg
- Three capsule groups: capsule-rx (teal #0b7979), capsule-terminal (amber #d97706), capsule-data (green #059669)
- Props: size (height px), animated (boolean, default false), className (optional)
- Framer Motion animation: Phase 1 (rise 500ms) — green data capsule scales from 0, Phase 2 (fan-out 500ms) — all three appear
- prefers-reduced-motion: skips animation, renders final state immediately
- Files changed: src/components/CvmisLogo.tsx (new)
- **Learnings for future iterations:**
- The SVG uses viewBox="0 0 600 506" with internal g transform scale(0.05,-0.05) — keep this coordinate system intact
- framer-motion's useReducedMotion() hook is the simplest way to handle reduced motion
- transform-origin in SVG needs px units when using framer-motion on g elements
---
## 2026-02-15 - US-001
- Changed initial Phase state from 'boot' to 'login' in App.tsx line 47
- Files changed: src/App.tsx
- **Learnings for future iterations:**
- Phase state is a simple string union type on line 47 of App.tsx
- US-010 will revert this exact change back to 'boot'
---
+86 -101
View File
@@ -1,7 +1,7 @@
{
"project": "Portfolio — Login Screen Rework",
"branchName": "ralph/login-screen-rework",
"description": "Rework the login screen: responsive sizing, dashboard style alignment, CVMIS rebrand, animated capsule logo, live blurred dashboard background, connection status indicator UX, button pulse, and dissolve transition.",
"project": "Portfolio — Login Logo & Blur Refinements",
"branchName": "ralph/login-logo-refinements",
"description": "Refine the login screen's CVMIS logo animation, backdrop blur coverage/intensity, and align visual details (border radius, shadows, colors, typography) with the dashboard design system.",
"userStories": [
{
"id": "US-001",
@@ -19,182 +19,167 @@
},
{
"id": "US-002",
"title": "Create CvmisLogo React SVG component",
"description": "As a developer, I need a reusable CvmisLogo component that renders the CVMIS capsule logo from cvmis-logo.svg, supporting both static and animated modes.",
"title": "Extract animation timing into named constants",
"description": "As a developer, I want all animation timing values in CvmisLogo.tsx exposed as named constants at the top of the file so I can quickly tune rise speed, fan speed, fan delay, and easing.",
"acceptanceCriteria": [
"Create src/components/CvmisLogo.tsx as a React component",
"Component accepts props: size (number, controls height in px), animated (boolean, default false), className (optional string)",
"SVG paths are inlined from cvmis-logo.svg — three <g> groups: capsule-rx (teal #0b7979), capsule-terminal (amber #d97706), capsule-data (green #059669)",
"The SVG viewBox is preserved so the logo scales correctly at any size",
"When animated=false, all three capsules render in their final fanned-out positions (matching the original SVG layout)",
"When animated=true, the component plays a two-phase reveal using framer-motion: Phase 1 (Rise ~500ms): green data capsule scales from 0 to 1 and translates upward into center position, other capsules hidden. Phase 2 (Fan-out ~500ms): all three capsules appear and rotate/translate to their final fanned-out positions with staggered easing",
"Animation reference: LogoReveal/frame 1-5.jpg — frame 1-3 show green capsule rising, frame 4-5 show all three fanning out",
"Each capsule group uses transform-origin at its base/bottom so fan-out looks like cards spreading from a hand",
"prefers-reduced-motion: skip animation, render final state immediately",
"Named constants at the top of CvmisLogo.tsx for: rise duration (currently 500ms), fan delay after rise (currently 500ms), fan duration (currently 600ms), fan easing curve, fan rotation angle (currently ±50°), fan horizontal spacing (currently ±16px), right pill stagger delay (currently 30ms)",
"Additional named constants for overlap blend: OVERLAY_BLEND_START_PROGRESS (target 0.5), OVERLAP_BLEND_MAX_OPACITY (target 0.2), OVERLAP_BLEND_TRANSITION_DURATION",
"Component behaviour unchanged when constants retain current values",
"Constants are clearly named and grouped with a brief comment block",
"Typecheck passes"
],
"priority": 2,
"passes": true,
"notes": "The SVG uses a transform with scale(0.05, -0.05) and translate — you'll need to simplify the viewBox and transforms for React. The three <g> IDs are capsule-rx, capsule-terminal, capsule-data. Framer Motion is already installed (11.15.0). Look at LogoReveal/frame 1-5.jpg for the animation sequence. The fan-out in frames 4-5 shows: teal Rx tilts left, amber terminal stays center, green data tilts right."
"notes": "Read CvmisLogo.tsx carefully first — some timing values are inline in useEffect/motion props. Extract them ALL to top-level constants. The blend constants are new (for US-004) but should be defined now with sensible defaults."
},
{
"id": "US-003",
"title": "Responsive login card sizing and dashboard style alignment",
"description": "As a visitor on a 1440p or 4K display, I want the login card to be proportionate to my screen and styled consistently with the GP dashboard.",
"title": "Scale logo and branding block to ~50% of login card height",
"description": "As a visitor, I want the CVMIS logo and branding text to be larger and more prominent, occupying roughly half the login card's height.",
"acceptanceCriteria": [
"Login card width changes from fixed 320px to responsive: clamp(320px, 28vw, 480px)",
"Card padding scales from fixed 32px to clamp(24px, 2.5vw, 40px)",
"Input field font size scales proportionally (minimum 13px, up to 15px on large viewports)",
"Button font size scales proportionally (minimum 14px, up to 16px)",
"Label font size scales proportionally (minimum 12px, up to 14px)",
"Card uses dashboard color tokens via CSS variables: background var(--surface), border color var(--border-card) or #E4EDEB, text colors var(--text-primary) and var(--text-secondary)",
"Input fields use var(--accent) (#0D6E6E) for focus border, #E4EDEB for default border, var(--bg-dashboard) for inactive background",
"Card shadow uses the project shadow tokens: 0 1px 2px rgba(26,43,42,0.05) resting, 0 2px 8px rgba(26,43,42,0.08) elevated",
"Card border radius remains 12px",
"Card still looks good on mobile (≤480px) — should not exceed viewport width minus 32px margin",
"Logo cssHeight scaled up from current clamp(48px, 4vw, 64px) — target approximately clamp(160px, 18vw, 280px), tune visually for balance",
"Width scales proportionally (SVG viewBox preserves aspect ratio)",
"The branding block (logo + CVMIS title + subtitle + spacing) occupies approximately 50% of the total login card height",
"Logo does not overflow or clip on mobile viewports (>=375px wide)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 3,
"passes": true,
"notes": "LoginScreen.tsx currently uses inline styles with hardcoded colors (#E5E7EB borders, #64748B text, etc). Replace these with the dashboard CSS custom properties defined in index.css (--surface, --accent, --border, --text-primary, --text-secondary, --text-tertiary). Font family vars: var(--font-ui) for labels/buttons, var(--font-geist-mono) for input monospace."
"notes": "CvmisLogo accepts cssHeight prop (string) for CSS clamp values. The branding block is in LoginScreen.tsx — the logo, title, and subtitle are in a flex column container. Adjust the cssHeight prop on the CvmisLogo component and check the ratio visually."
},
{
"id": "US-004",
"title": "Rebrand to CVMIS and integrate animated logo",
"description": "As the portfolio owner, I want the login to say CVMIS with the capsule logo replacing the Shield icon.",
"title": "Increase branding text to match dashboard typography scale",
"description": "As a visitor, I want the CVMIS title and subtitle on the login screen to be larger and more in line with the dashboard's typography scale.",
"acceptanceCriteria": [
"Title text changed from 'CareerRecord PMR' to 'CVMIS'",
"Subtitle changed from 'Clinical Information System' to 'CV Management Information System'",
"The Shield icon import and its teal background container are removed from the branding section",
"CvmisLogo component is imported and rendered in the branding section with animated=true",
"Logo height is proportional to the responsive card size (roughly 48-64px depending on viewport, use clamp)",
"Logo animation completes before the typing animation starts — adjust the startLoginSequence delay (currently 400ms) to account for logo animation duration (~1000ms total)",
"Footer text 'Secure clinical system login' remains unchanged",
"prefers-reduced-motion: logo shows instantly in final state, typing starts after original 400ms delay",
"CVMIS title font size increased from 13px — target approximately 18-20px to match dashboard heading scale",
"CV Management Information System subtitle font size increased from 11px — target approximately 13-14px",
"Both remain in font-ui (Elvaro Grotesque) with appropriate weight hierarchy",
"Text remains visually balanced with the larger logo above and the login form below",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 4,
"passes": true,
"notes": "The Shield icon is at LoginScreen.tsx lines 213-218. The logo animation is ~1000ms (500ms rise + 500ms fan-out). Increase the startLoginSequence delay from 400ms to ~1500ms (400ms card entrance + 1000ms logo + 100ms pause). CvmisLogo component from US-002."
"notes": "The title and subtitle are in LoginScreen.tsx in the branding section. Look for the CVMIS text and its fontSize style. Use clamp() for responsive sizing consistent with the card's responsive approach."
},
{
"id": "US-005",
"title": "Replace Home icon with CVMIS logo on TopBar",
"description": "As a visitor on the dashboard, I want to see the CVMIS brand logo in the top-left corner instead of the generic Home icon.",
"title": "Add overlap blend effect on fanning capsules",
"description": "As a visitor, I want to see a subtle color blend where the fanning capsules overlap, matching the multiply-blend effect from the Remotion animation.",
"acceptanceCriteria": [
"In src/components/TopBar.tsx, remove the Home import from lucide-react",
"Import CvmisLogo from ./CvmisLogo",
"Replace the <Home> element with <CvmisLogo size={24} /> (static, no animation)",
"Logo colors match SVG source: teal #0b7979, amber #d97706, green #059669",
"Logo maintains aspect ratio and fits within the TopBar height",
"The 'Headhunt Medical Center' brand text and all other TopBar elements remain unchanged",
"CSS mix-blend-mode: multiply applied to the fanning pill elements in CvmisLogo.tsx",
"Blend effect is not visible at the start of the fan animation",
"Blend fades in starting at ~50% of fan animation progress (using OVERLAY_BLEND_START_PROGRESS constant from US-002)",
"Blend reaches max intensity by end of fan (using OVERLAP_BLEND_MAX_OPACITY constant from US-002)",
"Max blend opacity approximately 0.2 (20%)",
"Blend is only perceptible where capsules actually overlap on light backgrounds",
"Blend transition feels smooth, not abrupt",
"Respects prefers-reduced-motion (no animation, show final state)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 5,
"passes": true,
"notes": "TopBar.tsx line 57-61 has the Home icon. Simple swap — CvmisLogo with animated=false (the default). If Home is the only lucide icon used in the import, clean up the import. Check: Search is also imported from lucide-react on line 2."
"notes": "Use framer-motion's useTransform or a progress-based approach to derive blend opacity from fan animation progress. The pill elements are <g> groups inside the SVG. Apply mixBlendMode: 'multiply' as a style and animate the group's opacity using the timing constants from US-002. The blend should only be visible during/after the fan phase, not during the rise phase."
},
{
"id": "US-006",
"title": "Render live dashboard behind login with blur overlay",
"description": "As a visitor, I want to see the GP dashboard blurred behind the login card, creating visual continuity.",
"title": "Extend backdrop blur to cover full dashboard including TopBar",
"description": "As a visitor, I want the frosted-glass blur behind the login card to cover the entire dashboard including the TopBar, so nothing behind the overlay is sharp.",
"acceptanceCriteria": [
"In App.tsx, during the 'login' phase, render DashboardLayout underneath the login overlay (both visible simultaneously)",
"DashboardLayout is wrapped in DetailPanelProvider (as it is in the 'pmr' phase)",
"DashboardLayout renders at scroll position 0 (showing patient summary header area)",
"LoginScreen becomes an overlay: fixed position, full viewport, semi-transparent background rgba(240, 245, 244, 0.7) with backdrop-filter: blur(20px)",
"Dashboard content is non-interactive while login overlay is present (the overlay captures all pointer events)",
"The login card remains centered on top of the blurred overlay",
"backdrop-filter blur is constant from the moment login appears (no ease-in)",
"prefers-reduced-motion: blur still applies (static visual treatment), only entrance animations are skipped",
"Blur overlay z-index raised above TopBar z-index (TopBar is zIndex: 100, overlay is currently z-50). Overlay must be >= zIndex: 110 or similar",
"TopBar, Sidebar, and all dashboard content are uniformly blurred behind the overlay",
"Login card itself remains crisp and unblurred (card z-index above overlay)",
"Blur still fades out during the dissolve/exit transition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 6,
"passes": true,
"notes": "Currently App.tsx renders phases exclusively (only one at a time). Change so that login phase renders: <DetailPanelProvider><DashboardLayout /></DetailPanelProvider> + <LoginScreen overlay on top>. LoginScreen.tsx already has 'fixed inset-0 z-50' — just change its backgroundColor from solid #1A2B2A to the semi-transparent value with backdrop-filter. Consider adding will-change: backdrop-filter for performance."
"notes": "LoginScreen outer overlay currently has 'fixed inset-0 z-50'. TopBar is zIndex: 100. The overlay needs z-index > 100 to cover it. The login card inside the overlay doesn't need its own z-index since it's a child of the overlay. Check that the dissolve exit animation (isExiting) still works after the z-index change."
},
{
"id": "US-007",
"title": "Connection status indicator with animated dots and typing-linked timing",
"description": "As a visitor, I want to see a clear red-to-green status transition tied to the typing sequence, not an arbitrary timer.",
"title": "Reduce backdrop blur intensity by ~50%",
"description": "As a visitor, I want the backdrop blur to be softer so the dashboard behind is slightly more visible while still providing contrast for the login card.",
"acceptanceCriteria": [
"Status indicator LED dot size increased from 6px to 10px",
"LED dot has a subtle glow effect: box-shadow 0 0 6px 1px in the LED color (red or green)",
"Status text size increased from 10px to 12px",
"Initial state: RED LED + 'Awaiting secure connection' in red (#DC2626) with animated trailing dots",
"The trailing dots animate: dots cycle through '.', '..', '...' repeating every ~1.5 seconds",
"Remove the existing independent 2000ms connectionTimeout timer",
"Instead, connection transitions to green exactly 500ms after typingComplete becomes true",
"Green state: GREEN LED (#059669) + 'Secure connection established, awaiting login' in green",
"Transition between red and green states has a smooth 300ms color/shadow transition",
"prefers-reduced-motion: no dot cycling animation, state changes happen instantly",
"Blur value reduced from blur(20px) to approximately blur(10px)",
"The blur value is a named constant co-located with other LoginScreen timing constants for easy adjustment",
"Login card remains clearly readable against the softened backdrop",
"The dissolve exit animation still animates blur from 10px to 0px",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 7,
"passes": true,
"notes": "The connectionTimeout is set on line 117 of LoginScreen.tsx (2000ms independent timer). Remove it and add a useEffect that watches typingComplete — when true, setTimeout 500ms then setConnectionState('connected'). The dot animation can use a simple interval cycling dotCount 0→1→2→0. The LED glow box-shadow: '0 0 6px 1px rgba(220,38,38,0.4)' for red, '0 0 6px 1px rgba(5,150,105,0.4)' for green."
"notes": "The blur is in two places in LoginScreen.tsx: the initial style (backdropFilter: blur(20px)) and the exit animation (animates from blur(20px) to blur(0px)). Extract the blur value to a constant like BACKDROP_BLUR_PX = 10, then reference it in both places."
},
{
"id": "US-008",
"title": "Login button pulse animation on activation",
"description": "As a visitor, I want the login button to pulse subtly when it becomes clickable so I know to click it.",
"title": "Align login card border radius and shadow with dashboard design system",
"description": "As a visitor, I want the login card to feel like it belongs to the same design system as the dashboard by matching border radius and shadow tokens.",
"acceptanceCriteria": [
"Add a CSS @keyframes animation 'login-pulse' in index.css: scale 1 → 1.03 → 1, ease-in-out, duration 1.5s",
"When canLogin becomes true (button enabled), apply the pulse animation repeating every 3 seconds (1.5s animation + 1.5s pause via animation-delay or longer duration with keyframe percentages)",
"Pulse animation stops when button is hovered (animation: none on hover)",
"Pulse animation stops immediately on click (remove animation class on buttonPressed)",
"Button opacity transitions from 0.6 to 1.0 when enabled (existing behavior, preserve)",
"prefers-reduced-motion: no pulse animation, button just becomes enabled with opacity 1",
"Button still receives keyboard focus when it becomes enabled (existing behavior)",
"Login card border radius changed from 12px to 8px (matching var(--radius-card) / dashboard cards)",
"Login input fields and button border radius changed from 4px to 6px (matching var(--radius-sm) / dashboard inner elements)",
"Login card shadow upgraded from shadow-sm to shadow-lg (0 8px 32px rgba(26,43,42,0.12)) — appropriate for a floating modal over blurred backdrop",
"Use CSS custom property references (var(--radius-card), var(--radius-sm)) where available rather than hardcoded values",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": true,
"notes": "The canLogin variable is on line 43 of LoginScreen.tsx. Add a CSS class 'login-pulse-active' that applies the animation, and conditionally apply it when canLogin && !buttonPressed && !buttonHovered. The @keyframes could use: 0%,100% { transform: scale(1) } 50% { transform: scale(1.03) } with animation: login-pulse 1.5s ease-in-out infinite and a wrapper that adds 1.5s gaps (or use 0%,35%,65%,100% keyframe percentages to build in the pause)."
"notes": "Check index.css for whether --radius-card and --radius-sm exist as CSS custom properties. If not, use the hardcoded values (8px and 6px) directly. The card shadow is currently set via inline style — update to the shadow-lg value. The login card borderRadius is in the card's inline style object."
},
{
"id": "US-009",
"title": "Login dissolve transition to reveal dashboard",
"description": "As a visitor, I want the login card and blur overlay to dissolve smoothly on login, revealing the dashboard underneath.",
"title": "Replace hardcoded colors with design tokens",
"description": "As a developer, I want the login screen to reference the same CSS custom properties as the dashboard so palette changes propagate consistently.",
"acceptanceCriteria": [
"On login click: existing pressed state + loading spinner behavior is preserved",
"After loading spinner phase, the login card fades out (opacity 0) with slight scale up (1.03)",
"Simultaneously, the overlay backdrop-filter blur animates from 20px to 0px",
"Overlay background opacity fades from 0.7 to 0",
"Total dissolve duration: ~600ms from card exit to fully revealed dashboard",
"After dissolve completes, the login overlay is removed from DOM and dashboard becomes interactive",
"In App.tsx, transition from login to pmr phase after the overlay dissolve completes (use a callback from LoginScreen)",
"prefers-reduced-motion: instant transition, no dissolve animation",
"Typecheck passes",
"Verify in browser using dev-browser skill"
"Input text color changed from hardcoded #111827 to var(--text-primary, #1A2B2A)",
"Cursor/caret color changed from hardcoded #0D6E6E to var(--accent, #0D6E6E)",
"Button background colors changed from hardcoded #0D6E6E / #0A8080 / #085858 to var(--accent) / var(--accent-hover) / appropriate pressed variant using token references",
"Any other hardcoded color values in LoginScreen.tsx that have corresponding CSS custom properties use the token instead",
"No visual change (token values resolve to same colors currently)",
"Typecheck passes"
],
"priority": 9,
"passes": true,
"notes": "Currently LoginScreen has isExiting state that scales card to 1.03 and fades to opacity 0 (line 163). Extend this to also animate the overlay container. The overlay is the outer div with 'fixed inset-0' — animate its backdrop-filter and background-color. Use framer-motion animate for coordinated exit. The onComplete callback should fire after the full dissolve, not after the card fade."
"notes": "Search LoginScreen.tsx for all hex color values (#xxxxxx) and check whether a corresponding CSS custom property exists in index.css. Some colors were already tokenized in the previous login rework (US-003 of previous run) — verify which ones are still hardcoded. The button has multiple color states (default, hover, pressed) — check all three."
},
{
"id": "US-010",
"title": "Fix minor typography inconsistencies",
"description": "As a visitor, I want the login screen's typography weight and sizing to feel consistent with the dashboard's conventions.",
"acceptanceCriteria": [
"Form label font weight increased from 500 to 600 (matching dashboard card header weight convention)",
"Input text mid-value aligned to ~14px to match dashboard body text",
"Button text mid-value aligned to ~15px",
"Connection status indicator gap increased from 6px to 8px (matching dashboard CardHeader gap)",
"No dramatic visual change — these are subtle alignment fixes",
"Typecheck passes"
],
"priority": 10,
"passes": true,
"notes": "These are small inline style tweaks in LoginScreen.tsx. The labels, inputs, and button already use clamp() for responsive sizing — just adjust the mid-values. The connection indicator gap is in the flex container styling near the bottom of the component."
},
{
"id": "US-011",
"title": "Re-enable boot sequence",
"description": "As a user, I want the full boot → ECG → login → dashboard experience restored.",
"acceptanceCriteria": [
"In src/App.tsx, change the initial Phase state back from 'login' to 'boot'",
"Boot → ECG → Login → Dashboard sequence works end to end",
"Login screen shows blurred dashboard behind it",
"Logo animation plays, typing animation follows, connection indicator transitions, button pulses",
"Login screen shows blurred dashboard behind it with reduced blur and full TopBar coverage",
"Logo animation plays with blend effect, typing animation follows, connection indicator transitions, button pulses",
"Clicking login dissolves the overlay to reveal the dashboard",
"No other changes to App.tsx beyond reverting the initial state",
"Typecheck passes",
"Verify in browser using dev-browser skill: app starts at boot, progresses through ECG, login with blur background and logo animation, arrives at dashboard"
"Verify in browser using dev-browser skill"
],
"priority": 10,
"priority": 11,
"passes": true,
"notes": "Simple revert of US-001. Phase state is on line 47 of App.tsx."
}
]
}
}
+121 -127
View File
@@ -1,5 +1,5 @@
# Progress Log — Login Screen Rework
# Branch: ralph/login-screen-rework
# Progress Log — Login Logo & Blur Refinements
# Branch: ralph/login-logo-refinements
# Started: 2026-02-15
## Codebase Patterns
@@ -28,6 +28,7 @@
- --bg-dashboard: #F0F5F4 (warm sage content background)
- --accent: #0D6E6E (teal primary)
- --accent-hover: #0A8080
- --accent-pressed: #085858
- --accent-light: rgba(10,128,128,0.08)
- --border: #D4E0DE (structural borders)
- --border-card: #E4EDEB (card/inner borders)
@@ -41,168 +42,161 @@
- React 18.3.1, TypeScript, Vite, Tailwind CSS
- Framer Motion 11.15.0, Lucide React 0.468.0, fuse.js 7.0.0
### Phase Rendering (post US-006)
- Login phase now renders BOTH DashboardLayout and LoginScreen overlay simultaneously
- DashboardLayout is wrapped in DetailPanelProvider for both 'login' and 'pmr' phases
- LoginScreen overlay: `fixed inset-0 z-50` with `rgba(240, 245, 244, 0.7)` + `backdrop-filter: blur(20px)`
### Key Files for This Feature
- src/App.tsx — phase management, will need restructuring for blur overlay
- src/components/LoginScreen.tsx — main login screen (416 lines)
- src/components/TopBar.tsx — Home icon replacement target (line 57)
- src/components/DashboardLayout.tsx — rendered behind login blur
- src/contexts/DetailPanelContext.tsx — wraps DashboardLayout
- cvmis-logo.svg — source SVG with 3 capsule groups
- LogoReveal/frame 1-5.jpg — animation reference frames
- src/components/CvmisLogo.tsx — logo component with animation (timing constants to extract)
- src/components/LoginScreen.tsx — main login screen (overlay, blur, card styling)
- src/App.tsx — phase management (skip/restore boot sequence)
- src/index.css — CSS custom properties, design tokens
### CvmisLogo Component
### CvmisLogo Component (from previous run)
- `size` prop: numeric, sets SVG height attribute directly
- `cssHeight` prop: string, sets height via CSS style (use for clamp/responsive values)
- `animated` prop: boolean, enables framer-motion reveal animation (1000ms total)
- Logo animation: 500ms rise (green capsule) + 500ms fan-out (all three) = 1000ms total
- All timing values are named constants at top of file — tune there, not inline
- Blend constants (OVERLAY_BLEND_*) are exported for use by other components (US-005)
### LoginScreen.tsx Key Lines (post US-007)
- Line 20: connectionState useState
- Line 21: dotCount useState (for animated trailing dots)
- Line 43: canLogin derived state
- Line 60-101: startLoginSequence (typing animation)
- Line 110-115: useEffect — connection transitions to green 500ms after typingComplete
- Line 118-126: useEffect — animated dot cycling (500ms interval) while connecting
- Line 128-150: useEffect — cursor blink + startLoginSequence delay (no more connectionTimeout)
- Line 370-405: Connection status indicator (10px LED dot with glow, 12px text)
### LoginScreen.tsx State (from previous run)
- Overlay: fixed inset-0 zIndex: 110, rgba(240, 245, 244, 0.7) + backdrop-filter: blur(20px)
- TopBar is zIndex: 100 — overlay now correctly covers it (fixed in US-006)
- Card borderRadius: 12px, inputs/button borderRadius: 4px
- Some colors already tokenized (--surface, --accent, --bg-dashboard) from previous run
- Some colors still hardcoded (#111827 input text, button bg states, caret color)
---
## 2026-02-15 - US-010
- Reverted initial Phase state from 'login' back to 'boot' in App.tsx line 47
- Full flow verified: boot → ECG → login (with blur, logo, typing, connection indicator, pulse) → dissolve → dashboard
- Files changed: src/App.tsx
## 2026-02-15 - US-001: Skip to login phase for dev iteration
- Changed initial Phase state from `'boot'` to `'login'` in `src/App.tsx` line 47
- Files changed: `src/App.tsx`
- **Learnings for future iterations:**
- Simple one-line revert as planned in US-001
- The full boot→ECG→login sequence takes ~20 seconds before login screen appears
- Phase state is a simple `useState<Phase>` on line 47 of App.tsx
- All phase rendering logic (`boot`, `ecg`, `login`, `pmr`) remains intact — only initial value changes
- US-011 will revert this exact change back to `'boot'`
---
## 2026-02-15 - US-009
- Changed outer overlay container from plain `<div>` to `<motion.div>` for animated exit
- On isExiting: overlay animates backgroundColor to transparent, backdropFilter from blur(20px) to blur(0px) over 600ms
- Card exit animation extended from 200ms to 400ms for smoother dissolve feel
- onComplete callback fires after 600ms dissolve (previously 200ms card exit)
- After dissolve completes, overlay removed from DOM and dashboard becomes interactive
- prefers-reduced-motion: instant transition (0ms for all timers)
- Files changed: src/components/LoginScreen.tsx
- Verified in browser: clicked login → spinner → card fades + overlay blur dissolves → dashboard revealed
## 2026-02-15 - US-002: Extract animation timing into named constants
- Extracted all inline timing values in CvmisLogo.tsx to named constants at top of file
- Constants added: RISE_DURATION_MS, RISE_DURATION_S, RISE_OPACITY_DURATION_S, RISE_EASING, RISE_START_Y, FAN_DELAY_AFTER_RISE_MS, FAN_DURATION_S, FAN_ROTATION_DEG, FAN_HORIZONTAL_PX, FAN_RIGHT_STAGGER_S, TOTAL_ANIMATION_MS
- Added overlap blend constants for US-005: OVERLAY_BLEND_START_PROGRESS, OVERLAP_BLEND_MAX_OPACITY, OVERLAP_BLEND_TRANSITION_DURATION_S (exported)
- Files changed: `src/components/CvmisLogo.tsx`
- **Learnings for future iterations:**
- framer-motion can animate backdropFilter and backgroundColor on a motion.div via the animate prop
- The onComplete timeout (600ms) must match the overlay dissolve duration, not the card fade duration
- Card fade (400ms) finishes before overlay dissolve (600ms), creating a layered reveal effect
- WebkitBackdropFilter needs to be animated alongside backdropFilter for Safari
- Blend constants are `export`ed because TypeScript strict mode flags unused `const` declarations — exporting avoids the TS6133 error while making them available for US-005
- TOTAL_ANIMATION_MS is computed from FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000, so changing rise or fan timing automatically updates the done-timer
- FAN_EASING was already a named constant before this story; it was left in place and grouped with the new fan constants
---
## 2026-02-15 - US-008
- Added @keyframes login-pulse in index.css: scale 1→1.03→1 over 3s cycle (1.5s animation built into keyframe percentages with 1.5s pause)
- Added .login-pulse-active class that applies the animation infinitely
- Hover removes animation via CSS rule (.login-pulse-active:hover { animation: none })
- Button gets login-pulse-active class when canLogin && !buttonPressed
- prefers-reduced-motion: .login-pulse-active { animation: none } in reduced motion media query
- Button opacity 0.6→1.0 transition preserved (existing behavior)
- Button still receives keyboard focus when enabled (existing behavior)
- Files changed: src/index.css, src/components/LoginScreen.tsx
- Verified in browser: button has login-pulse animation running (3s ease-in-out infinite), class applied correctly
## 2026-02-15 - US-003: Scale logo and branding block to ~50% of login card height
- Scaled CvmisLogo `cssHeight` prop from `clamp(80px, 8vw, 120px)` to `clamp(160px, 18vw, 280px)`
- Adjusted logo wrapper marginBottom from 10px to 12px for spacing balance
- Browser-verified: desktop ratio 51.3% (target 50% ±10%), mobile (375px) ratio 41.1% — both within tolerance
- No overflow or clipping on mobile viewport
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- Used keyframe percentages (0%,60%,100% at scale(1), 30% at scale(1.03)) to build pause into a single animation rather than animation-delay
- CSS handles hover removal — no need for buttonHovered state in the class condition
- buttonPressed removes the class entirely (not just pauses), which is cleaner
- The CvmisLogo `cssHeight` prop maps directly to a CSS `height` style on the SVG — `clamp()` values work well for responsive scaling
- At 375px viewport, `18vw = 67.5px` which triggers the clamp minimum of 160px — the logo remains a comfortable size on small screens
- The branding block ratio can be measured by comparing `brandingBlock.getBoundingClientRect().height + marginBottom` against `card.innerHeight - padding`
- The branding block container has class `flex flex-col items-center` — use this selector for programmatic measurement
---
## 2026-02-15 - US-007
- Reworked connection status indicator: LED dot 6px→10px with glow box-shadow, text 10px→12px
- Removed independent 2000ms connectionTimeout timer
- Added useEffect that transitions to green 500ms after typingComplete becomes true
- Added animated trailing dots cycling '.', '..', '...' every 500ms while connecting
- Initial state: red LED + red text "Awaiting secure connection" with animated dots
- Connected state: green LED + green text "Secure connection established, awaiting login"
- 300ms smooth transition for color and box-shadow between states
- prefers-reduced-motion: no dot cycling, instant state changes
- Files changed: src/components/LoginScreen.tsx
- Verified in browser: red indicator with cycling dots visible during typing, transitions to green after typing completes
## 2026-02-15 - US-004: Increase branding text to match dashboard typography scale
- Increased CVMIS title fontSize from `13px` to `clamp(16px, 1.4vw, 20px)` — renders 20px on desktop
- Increased subtitle fontSize from `11px` to `clamp(12px, 1vw, 14px)` — renders 14px on desktop
- Increased subtitle marginTop from `2px` to `3px` for better spacing with larger text
- Both remain in font-ui (Elvaro Grotesque) with weight 600 (title) and 400 (subtitle)
- Browser-verified: text is visually balanced with the larger logo and login form
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- dotCount state cycles 0→1→2→3→0 (4 states: no dots, '.', '..', '...') via modulo arithmetic
- Connection transition is now tied to typingComplete state, not an arbitrary timer
- The dot interval cleanup needs to happen in both the dedicated useEffect and the main cleanup
- LED glow uses rgba with 0.4 alpha for subtle effect matching project shadow conventions
- The branding text clamp values use the same responsive pattern as the logo `cssHeight` — mid-values around 1-1.5vw work well for text
- Title and subtitle are `<span>` elements inside the `.flex.flex-col.items-center` branding container
- Weight hierarchy (600 title, 400 subtitle) provides sufficient visual differentiation without needing size contrast as large
---
## 2026-02-15 - US-006
- Rendered DashboardLayout (wrapped in DetailPanelProvider) behind LoginScreen during login phase in App.tsx
- Changed LoginScreen overlay from solid #1A2B2A background to semi-transparent rgba(240, 245, 244, 0.7) with backdrop-filter: blur(20px)
- Dashboard is non-interactive during login (overlay captures pointer events via fixed inset-0 z-50)
- After login click, phase transitions to 'pmr' and overlay is removed from DOM, dashboard becomes interactive
- Files changed: src/App.tsx, src/components/LoginScreen.tsx
- Verified in browser: blur overlay shows dashboard content behind login card, login click transitions to interactive dashboard
## 2026-02-15 - US-005: Add overlap blend effect on fanning capsules
- Added `blendActive` state to CvmisLogo, triggered by timer at `blendStartMs` (50% through fan animation)
- Added two blend overlay `<g>` elements after the main pills: copies of left/right pill shapes with `mixBlendMode: 'multiply'` and opacity transitioning from 0 to 0.2
- Blend overlays share the same `transform` and `transition` as their corresponding original pills, plus an opacity transition using `OVERLAP_BLEND_TRANSITION_DURATION_S`
- Reduced motion: `blendActive` starts `true`, `transition: 'none'` — final blend state shown immediately
- Browser-verified: blend darkening visible at pill overlap areas, opacity confirmed at 0.2
- Files changed: `src/components/CvmisLogo.tsx`
- **Learnings for future iterations:**
- App.tsx phase rendering changed from exclusive (one phase at a time) to overlapping (login + pmr render DashboardLayout)
- DetailPanelProvider now wraps DashboardLayout for both 'login' and 'pmr' phases — condition is `(phase === 'login' || phase === 'pmr')`
- LoginScreen already had `fixed inset-0 z-50` which makes it a full-viewport overlay — just needed background/blur changes
- WebkitBackdropFilter needed for Safari compatibility alongside backdropFilter
- `mix-blend-mode` is not CSS-animatable — use overlay elements with animated opacity instead of trying to transition the blend mode
- Blend overlay approach: duplicate the pill shapes (rect only, no icons) as separate `<g>` elements with `mixBlendMode: 'multiply'` and low opacity
- The `useMemo` for `blendStartMs` avoids recalculation — all timing constants are module-level so this is stable
- Combined CSS transition strings work in SVG `<g>` style: `transform 0.6s cubic-bezier(...), opacity 0.3s ease-out`
---
## 2026-02-15 - US-005
- Replaced Home icon with CvmisLogo (size={24}, static/no animation) in TopBar.tsx
- Removed Home from lucide-react import (Search still used)
- Imported CvmisLogo component
- Files changed: src/components/TopBar.tsx
- Verified in browser: logo renders correctly with teal/amber/green capsule colors, fits TopBar height
## 2026-02-15 - US-006: Extend backdrop blur to cover full dashboard including TopBar
- Changed overlay from Tailwind `z-50` class to inline `zIndex: 110` to sit above TopBar (`zIndex: 100`)
- Browser-verified: TopBar, sidebar, and all content uniformly blurred; login card remains crisp
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- TopBar uses inline styles throughout, consistent with LoginScreen pattern
- Search is the only remaining lucide-react icon in TopBar.tsx
- CvmisLogo default `animated=false` means no animation prop needed for static usage
- TopBar uses inline `zIndex: 100` (not a Tailwind class), so overlay needs inline zIndex > 100
- Tailwind's `z-50` = z-index 50, which was below the TopBar — switched to inline style for precise control
- The login card doesn't need its own z-index since it's a child of the overlay and inherits stacking context
---
## 2026-02-15 - US-004
- Rebranded login from "CareerRecord PMR" to "CVMIS" with subtitle "CV Management Information System"
- Replaced Shield icon with CvmisLogo component (animated=true, responsive cssHeight)
- Added `cssHeight` prop to CvmisLogo for CSS clamp-based responsive sizing: clamp(48px, 4vw, 64px)
- Increased startLoginSequence delay from 400ms to 1500ms to let logo animation complete before typing begins
- prefers-reduced-motion: keeps original 400ms delay since logo renders instantly
- Fixed lint warning: added prefersReducedMotion to useEffect dependency array
- Files changed: src/components/LoginScreen.tsx, src/components/CvmisLogo.tsx
## 2026-02-15 - US-007: Reduce backdrop blur intensity by ~50%
- Added `BACKDROP_BLUR_PX = 10` constant at top of LoginScreen.tsx
- Replaced hardcoded `blur(20px)` in initial style with template literal using constant
- Exit animation still targets `blur(0px)` — Framer Motion interpolates from current 10px to 0px
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- CvmisLogo `size` prop is numeric (SVG height attribute) — use `cssHeight` string prop for CSS clamp values
- Logo animation is 1000ms total (500ms rise + 500ms fan-out) — typing delay must account for this
- The committed LoginScreen from US-003 still had Shield icon — US-003 only committed responsive sizing, not branding changes
- The `BACKDROP_BLUR_PX` constant is in the "Login screen timing & visual constants" block at top of LoginScreen.tsx
- Framer Motion's `animate` prop interpolates from the element's current computed style, so the exit blur animation doesn't need the starting value explicitly
- Only the initial style needs the constant; the exit target (`blur(0px)`) is always 0
---
## 2026-02-15 - US-003
- Responsive card: width clamp(320px,28vw,480px), maxWidth calc(100vw-32px), padding clamp(24px,2.5vw,40px)
- Replaced hardcoded colors with CSS variables: --surface, --bg-dashboard, --accent, --text-secondary, --text-tertiary
- Input fields: #E4EDEB default border, var(--accent) focus border, var(--bg-dashboard) inactive bg
- Font sizes: labels clamp(12px,1vw,14px), inputs clamp(13px,1.1vw,15px), button clamp(14px,1.1vw,16px)
- Card shadow: 0 1px 2px rgba(26,43,42,0.05) matching project shadow tokens
- Files changed: src/components/LoginScreen.tsx
## 2026-02-15 - US-008: Align login card border radius and shadow with dashboard design system
- Changed card borderRadius from `12px` to `var(--radius-card, 8px)`
- Changed card boxShadow from `shadow-sm` (`0 1px 2px rgba(26,43,42,0.05)`) to `var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))`
- Changed username input, password input, and button borderRadius from `4px` to `var(--radius-sm, 6px)`
- All values use CSS custom property references with fallbacks
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- No --border-card CSS variable exists in index.css — use #E4EDEB directly
- LoginScreen uses inline styles throughout, not Tailwind classes (except for focus-visible ring on button)
- The card used className="bg-white" which needed to be replaced with inline style for consistency
- CSS tokens `--radius-card`, `--radius-sm`, `--shadow-sm`, `--shadow-md`, `--shadow-lg` are all defined in `index.css` `:root` — use `var()` references with fallback values
- The button has 18-space indentation vs 20-space for inputs — `replace_all` may not catch all instances if matching on indentation
- The spinner (`borderRadius: '50%'`) and status indicator dot should NOT be changed — they're circles, not card elements
---
## 2026-02-15 - US-002
- Created CvmisLogo.tsx component with inlined SVG paths from cvmis-logo.svg
- Three capsule groups: capsule-rx (teal #0b7979), capsule-terminal (amber #d97706), capsule-data (green #059669)
- Props: size (height px), animated (boolean, default false), className (optional)
- Framer Motion animation: Phase 1 (rise 500ms) — green data capsule scales from 0, Phase 2 (fan-out 500ms) — all three appear
- prefers-reduced-motion: skips animation, renders final state immediately
- Files changed: src/components/CvmisLogo.tsx (new)
## 2026-02-15 - US-009: Replace hardcoded colors with design tokens
- Replaced all hardcoded hex colors in LoginScreen.tsx with CSS custom property references (`var()` with fallbacks)
- Input text color: `#111827` → `var(--text-primary, #1A2B2A)`
- Cursor/caret color: `#0D6E6E` → `var(--accent, #0D6E6E)` (2 instances)
- Button backgrounds: `#0D6E6E` → `var(--accent)`, `#0A8080` → `var(--accent-hover)`, `#085858` → `var(--accent-pressed)`
- Spinner border/top: `#E4EDEB` → `var(--border-light)`, `#0D6E6E` → `var(--accent)`
- Input inactive borders: `#E4EDEB` → `var(--border-light)` (username + password fields)
- Card border: `#E4EDEB` → `var(--border-light)`
- Footer border: `#E4EDEB` → `var(--border-light)`
- Connection status colors: `#059669` → `var(--success)`, `#DC2626` → `var(--alert)` (dot bg + text)
- Focus ring: `ring-[#0D6E6E]/40` → `ring-accent/40` (Tailwind token)
- Added `--accent-pressed: #085858` token to `index.css` `:root` (completes the accent state trio)
- Files changed: `src/components/LoginScreen.tsx`, `src/index.css`
- **Learnings for future iterations:**
- The SVG uses viewBox="0 0 600 506" with internal g transform scale(0.05,-0.05) — keep this coordinate system intact
- framer-motion's useReducedMotion() hook is the simplest way to handle reduced motion
- transform-origin in SVG needs px units when using framer-motion on g elements
- `#FFFFFF` on button text is intentional for contrast — no `--text-on-accent` token exists; leave as hardcoded white
- `rgba(240, 245, 244, 0.7)` overlay bg is `--bg-dashboard` at 70% opacity — no token for this; leave as rgba
- Status dot glow `boxShadow` uses rgba variants of success/alert colors at 40% opacity — no token for these glow effects
- Tailwind config has `accent` and `accent-hover` color tokens, so `ring-accent/40` works in class names
- Always add fallback values in `var()` references (e.g., `var(--accent, #0D6E6E)`) for resilience
---
## 2026-02-15 - US-001
- Changed initial Phase state from 'boot' to 'login' in App.tsx line 47
- Files changed: src/App.tsx
## 2026-02-15 - US-010: Fix minor typography inconsistencies
- Changed form label fontWeight from 500 to 600 (both Username and Password labels) to match dashboard card header weight convention
- Adjusted input text fontSize mid-value from `clamp(13px, 1.1vw, 15px)` to `clamp(13px, 1.2vw, 15px)` — renders ~14-15px on standard viewports
- Adjusted button text fontSize mid-value from `clamp(14px, 1.1vw, 16px)` to `clamp(14px, 1.2vw, 16px)` — renders ~15px on standard viewports
- Changed connection status indicator gap from 6px to 8px (matching dashboard CardHeader gap)
- Files changed: `src/components/LoginScreen.tsx`
- **Learnings for future iterations:**
- Phase state is a simple string union type on line 47 of App.tsx
- US-010 will revert this exact change back to 'boot'
- These are all subtle alignment tweaks — the clamp mid-value change from 1.1vw to 1.2vw shifts rendering by ~1px on 1280px viewports
- The label fontWeight 600 matches the dashboard's `CardHeader` convention (seen in `Card.tsx`)
- The connection indicator gap of 8px matches the standard icon-text gap used in dashboard card headers
---
## 2026-02-15 - US-011: Re-enable boot sequence
- Changed initial Phase state from `'login'` back to `'boot'` in `src/App.tsx` line 47
- Reverts the dev shortcut from US-001, restoring the full boot → ECG → login → dashboard flow
- Typecheck and lint pass cleanly
- Files changed: `src/App.tsx`
- **Learnings for future iterations:**
- This is a simple one-line revert — the phase state controls the entire UI flow
- All 11 stories in this PRD are now complete
---
+1 -1
View File
@@ -44,7 +44,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) {
}
function App() {
const [phase, setPhase] = useState<Phase>('boot')
const [phase, setPhase] = useState<Phase>('login')
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const skipToLogin = () => setPhase('login')
+79 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useMemo } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
interface CvmisLogoProps {
@@ -8,6 +8,30 @@ interface CvmisLogoProps {
className?: string
}
// ── Animation timing constants ──────────────────────────────────────
// Rise phase: all pills rise together from below
const RISE_DURATION_MS = 2500 // duration of the upward rise (ms)
const RISE_DURATION_S = RISE_DURATION_MS / 1000
const RISE_OPACITY_DURATION_S = 0.25 // opacity fade-in during rise (s)
const RISE_EASING: [number, number, number, number] = [0.33, 1, 0.68, 1]
const RISE_START_Y = 350 // initial Y offset (viewBox units)
// Fan phase: left and right pills fan outward
const FAN_DELAY_AFTER_RISE_MS = RISE_DURATION_MS - 100 // delay before fan begins (ms from mount)
const FAN_DURATION_S = 1 // duration of fan-out (s)
const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const FAN_ROTATION_DEG = 55 // rotation angle for fanned pills (±degrees)
const FAN_HORIZONTAL_PX = 10 // horizontal offset for fanned pills (±px)
const FAN_RIGHT_STAGGER_S = 0.0 // stagger delay for right pill (s)
// Total animation = rise delay + fan duration
const TOTAL_ANIMATION_MS = FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000
// Overlap blend: multiply blend on fanning capsules (used by US-005)
export const OVERLAY_BLEND_START_PROGRESS = 0.2 // fan progress at which blend fades in
export const OVERLAP_BLEND_MAX_OPACITY = 0.3 // max blend opacity (20%)
export const OVERLAP_BLEND_TRANSITION_DURATION_S = FAN_DURATION_S * (1 - OVERLAY_BLEND_START_PROGRESS)
// Pivot point: bottom-center of the pill stack (in viewBox coords)
const PX = 300
const PY = 275
@@ -23,34 +47,42 @@ function fanTransform(rotation: number, dx: number): string {
}
const IDENTITY_TRANSFORM = fanTransform(0, 0)
const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
const prefersReducedMotion = useReducedMotion()
const [phase, setPhase] = useState<'rising' | 'fanning' | 'done'>(
animated && !prefersReducedMotion ? 'rising' : 'done'
)
const [blendActive, setBlendActive] = useState(!animated || !!prefersReducedMotion)
// Blend starts at OVERLAY_BLEND_START_PROGRESS through the fan animation
const blendStartMs = useMemo(
() => FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000 * OVERLAY_BLEND_START_PROGRESS,
[]
)
useEffect(() => {
if (!animated || prefersReducedMotion) return
const fanTimer = setTimeout(() => setPhase('fanning'), 500)
const doneTimer = setTimeout(() => setPhase('done'), 1000)
const fanTimer = setTimeout(() => setPhase('fanning'), FAN_DELAY_AFTER_RISE_MS)
const doneTimer = setTimeout(() => setPhase('done'), TOTAL_ANIMATION_MS)
const blendTimer = setTimeout(() => setBlendActive(true), blendStartMs)
return () => {
clearTimeout(fanTimer)
clearTimeout(doneTimer)
clearTimeout(blendTimer)
}
}, [animated, prefersReducedMotion])
}, [animated, prefersReducedMotion, blendStartMs])
const skip = !animated || prefersReducedMotion
const isFanned = phase === 'fanning' || phase === 'done'
const fanTarget = isFanned || skip
const leftTransform = fanTarget ? fanTransform(-50, -16) : IDENTITY_TRANSFORM
const rightTransform = fanTarget ? fanTransform(50, 16) : IDENTITY_TRANSFORM
const fanTransition = skip ? 'none' : `transform 0.6s ${FAN_EASING}`
const fanTransitionDelayed = skip ? 'none' : `transform 0.6s ${FAN_EASING} 0.03s`
const leftTransform = fanTarget ? fanTransform(-FAN_ROTATION_DEG, -FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
const rightTransform = fanTarget ? fanTransform(FAN_ROTATION_DEG, FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
const fanTransition = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING}`
const fanTransitionDelayed = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING} ${FAN_RIGHT_STAGGER_S}s`
return (
<svg
@@ -64,13 +96,18 @@ export function CvmisLogo({ size, cssHeight, animated = false, className }: Cvmi
...(cssHeight ? { height: cssHeight, width: 'auto' } : {}),
}}
>
<defs>
<clipPath id="center-pill-clip">
<rect x="250" y="50" width="100" height="225" rx="50" />
</clipPath>
</defs>
{/* Rise group — all pills rise together from below */}
<motion.g
initial={skip ? false : { y: 350, opacity: 0 }}
initial={skip ? false : { y: RISE_START_Y, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
y: { duration: 0.5, ease: [0.33, 1, 0.68, 1] },
opacity: { duration: 0.25 },
y: { duration: RISE_DURATION_S, ease: RISE_EASING },
opacity: { duration: RISE_OPACITY_DURATION_S },
}}
>
{/* Rx pill — teal, fans left (bottom layer) */}
@@ -126,6 +163,36 @@ export function CvmisLogo({ size, cssHeight, animated = false, className }: Cvmi
/>
</g>
</g>
{/* Blend overlays — multiply-blend copies of fanning pills, clipped to center pill overlap */}
<g clipPath="url(#center-pill-clip)">
<g
style={{
transform: leftTransform,
transition: skip ? 'none' : `${fanTransition}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
mixBlendMode: 'multiply',
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
}}
>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
</g>
</g>
</g>
<g clipPath="url(#center-pill-clip)">
<g
style={{
transform: rightTransform,
transition: skip ? 'none' : `${fanTransitionDelayed}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
mixBlendMode: 'multiply',
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
}}
>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#109E6C" />
</g>
</g>
</g>
</motion.g>
</svg>
)
+39 -35
View File
@@ -3,6 +3,9 @@ import { motion } from 'framer-motion'
import { CvmisLogo } from './CvmisLogo'
import { useAccessibility } from '../contexts/AccessibilityContext'
// ── Login screen timing & visual constants ──────────────────────────
const BACKDROP_BLUR_PX = 10
interface LoginScreenProps {
onComplete: () => void
}
@@ -160,18 +163,19 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
}, [startLoginSequence, addTimeout, prefersReducedMotion])
const buttonBg = buttonPressed
? '#085858'
? 'var(--accent-pressed, #085858)'
: buttonHovered && canLogin
? '#0A8080'
: '#0D6E6E'
? 'var(--accent-hover, #0A8080)'
: 'var(--accent, #0D6E6E)'
return (
<motion.div
className="fixed inset-0 flex items-center justify-center z-50"
className="fixed inset-0 flex items-center justify-center"
style={{
zIndex: 110,
backgroundColor: 'rgba(240, 245, 244, 0.7)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
backdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
WebkitBackdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
}}
animate={isExiting ? {
backgroundColor: 'rgba(240, 245, 244, 0)',
@@ -188,9 +192,9 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: 'clamp(320px, 28vw, 480px)',
maxWidth: 'calc(100vw - 32px)',
padding: 'clamp(24px, 2.5vw, 40px)',
borderRadius: '12px',
border: '1px solid #E4EDEB',
boxShadow: '0 1px 2px rgba(26,43,42,0.05)',
borderRadius: 'var(--radius-card, 8px)',
border: '1px solid var(--border-light, #E4EDEB)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
backgroundColor: 'var(--surface, #FFFFFF)',
}}
initial={{ opacity: 0, scale: 0.98 }}
@@ -213,8 +217,8 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
width: '32px',
height: '32px',
border: '3px solid #E4EDEB',
borderTopColor: '#0D6E6E',
border: '3px solid var(--border-light, #E4EDEB)',
borderTopColor: 'var(--accent, #0D6E6E)',
borderRadius: '50%',
}}
role="status"
@@ -237,16 +241,16 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
className="flex flex-col items-center"
style={{ marginBottom: '28px' }}
>
<div style={{ marginBottom: '10px' }}>
<div style={{ marginBottom: '12px', overflow: 'hidden' }}>
<CvmisLogo
cssHeight="clamp(80px, 8vw, 120px)"
cssHeight="clamp(160px, 18vw, 280px)"
animated={true}
/>
</div>
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: '13px',
fontSize: 'clamp(16px, 1.4vw, 20px)',
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
letterSpacing: '0.01em',
@@ -257,10 +261,10 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: '11px',
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 400,
color: 'var(--text-tertiary, #8DA8A5)',
marginTop: '2px',
marginTop: '3px',
}}
>
CV Management Information System
@@ -276,7 +280,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 500,
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
@@ -288,11 +292,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: '100%',
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: 'clamp(13px, 1.1vw, 15px)',
fontSize: 'clamp(13px, 1.2vw, 15px)',
backgroundColor: activeField === 'username' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'username' ? '1px solid var(--accent, #0D6E6E)' : '1px solid #E4EDEB',
borderRadius: '4px',
color: '#111827',
border: activeField === 'username' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
borderRadius: 'var(--radius-sm, 6px)',
color: 'var(--text-primary, #1A2B2A)',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
@@ -302,7 +306,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<span>{username}</span>
{activeField === 'username' && (
<span
style={{ opacity: showCursor ? 1 : 0, color: '#0D6E6E' }}
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
aria-hidden="true"
>
|
@@ -318,7 +322,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 500,
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
@@ -330,11 +334,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: '100%',
padding: '9px 11px',
fontFamily: "'Geist Mono', 'Fira Code', monospace",
fontSize: 'clamp(13px, 1.1vw, 15px)',
fontSize: 'clamp(13px, 1.2vw, 15px)',
backgroundColor: activeField === 'password' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'password' ? '1px solid var(--accent, #0D6E6E)' : '1px solid #E4EDEB',
borderRadius: '4px',
color: '#111827',
border: activeField === 'password' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
borderRadius: 'var(--radius-sm, 6px)',
color: 'var(--text-primary, #1A2B2A)',
letterSpacing: '0.15em',
minHeight: '38px',
display: 'flex',
@@ -345,7 +349,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
<span>{'\u2022'.repeat(passwordDots)}</span>
{activeField === 'password' && (
<span
style={{ opacity: showCursor ? 1 : 0, color: '#0D6E6E' }}
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
aria-hidden="true"
>
|
@@ -361,17 +365,17 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
disabled={!canLogin}
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
className={`focus-visible:ring-2 focus-visible:ring-[#0D6E6E]/40 focus-visible:ring-offset-2 focus:outline-none${canLogin && !buttonPressed ? ' login-pulse-active' : ''}`}
className={`focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus:outline-none${canLogin && !buttonPressed ? ' login-pulse-active' : ''}`}
style={{
width: '100%',
padding: '10px 16px',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(14px, 1.1vw, 16px)',
fontSize: 'clamp(14px, 1.2vw, 16px)',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: buttonBg,
border: 'none',
borderRadius: '4px',
borderRadius: 'var(--radius-sm, 6px)',
cursor: canLogin ? 'pointer' : 'default',
opacity: canLogin ? 1 : 0.6,
transition: 'background-color 150ms, opacity 300ms',
@@ -386,7 +390,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
gap: '8px',
marginTop: '4px',
}}
>
@@ -395,7 +399,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: connectionState === 'connected' ? '#059669' : '#DC2626',
backgroundColor: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
boxShadow: connectionState === 'connected'
? '0 0 6px 1px rgba(5,150,105,0.4)'
: '0 0 6px 1px rgba(220,38,38,0.4)',
@@ -407,7 +411,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
fontFamily: "var(--font-geist-mono, 'Geist Mono', monospace)",
fontSize: '12px',
color: connectionState === 'connected' ? '#059669' : '#DC2626',
color: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
transition: prefersReducedMotion ? 'none' : 'color 300ms ease',
}}
>
@@ -423,7 +427,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
style={{
marginTop: '22px',
paddingTop: '18px',
borderTop: '1px solid #E4EDEB',
borderTop: '1px solid var(--border-light, #E4EDEB)',
}}
>
<p
+1
View File
@@ -106,6 +106,7 @@
--text-tertiary: #8DA8A5;
--accent: #0D6E6E;
--accent-hover: #0A8080;
--accent-pressed: #085858;
--accent-light: rgba(10,128,128,0.08);
--accent-border: rgba(10,128,128,0.18);
--amber: #D97706;
+183
View File
@@ -0,0 +1,183 @@
# PRD: Login Screen Logo & Blur Refinements
## Introduction
Refine the login screen's CVMIS logo animation and backdrop blur to match the quality of the original Remotion reference, and align the login screen's visual details with the dashboard's design system. The logo and branding text need to be larger (the logo + title + subtitle block should occupy ~50% of the login card's height), the fan animation needs tunable timing variables, overlapping capsules need a multiply-blend effect on their intersection areas, the backdrop blur should cover the full dashboard (including TopBar) at reduced intensity, and several visual inconsistencies between the login screen and the dashboard should be resolved.
## Goals
- Scale logo + branding text so the branding block is ~50% of the login card height
- Increase branding text ("CVMIS" title and "CV Management Information System" subtitle) to match dashboard-level typography
- Add configurable animation timing constants for easy tuning of rise/fan speeds
- Implement CSS `mix-blend-mode: multiply` overlap effect matching the Remotion reference
- Extend backdrop blur to cover TopBar and all dashboard content uniformly
- Reduce blur intensity by ~50% (from 20px to ~10px)
- Align login card border radius, shadow, and hardcoded colors with dashboard design tokens
- Fix minor typography weight and sizing inconsistencies
## User Stories
### US-001: Scale logo and branding block to ~50% of login card height
**Description:** As a visitor, I want the CVMIS logo and branding text to be larger and more prominent, occupying roughly half the login card's height so the brand has real visual presence.
**Acceptance Criteria:**
- [ ] Logo height scaled up ~22.5x from current `clamp(80px, 8vw, 120px)` — target approximately `clamp(160px, 18vw, 280px)` (tune visually for balance)
- [ ] Width scales proportionally (maintains aspect ratio via SVG viewBox)
- [ ] The branding block (logo + "CVMIS" title + "CV Management Information System" subtitle + spacing) occupies approximately 50% of the total login card height
- [ ] Logo does not overflow or clip on mobile viewports (≥375px wide)
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** measure the branding block height vs total login card height and confirm it is approximately 50% (±10%)
### US-002: Increase branding text to match dashboard typography scale
**Description:** As a visitor, I want the "CVMIS" title and "CV Management Information System" subtitle on the login screen to be larger and more in line with the text scale used in the GP dashboard (TopBar brand text is 15px/600 weight).
**Acceptance Criteria:**
- [ ] "CVMIS" title font size increased from 13px — target approximately 1820px to match dashboard heading scale
- [ ] "CV Management Information System" subtitle font size increased from 11px — target approximately 1314px
- [ ] Both remain in `font-ui` (Elvaro Grotesque) with appropriate weight hierarchy
- [ ] Text remains visually balanced with the larger logo above and the login form below
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** confirm text is rendered at the expected sizes
### US-003: Extract animation timing into named constants
**Description:** As a developer, I want all animation timing values in CvmisLogo.tsx exposed as named constants at the top of the file so I can quickly tune rise speed, fan speed, fan delay, and easing without digging through the component logic.
**Acceptance Criteria:**
- [ ] Named constants at the top of `CvmisLogo.tsx` for at minimum:
- Rise duration (currently 500ms)
- Fan delay after rise (currently 500ms)
- Fan duration (currently 600ms)
- Fan easing curve
- Fan rotation angle (currently ±50°)
- Fan horizontal spacing (currently ±16px)
- Right pill stagger delay (currently 30ms)
- Overlap blend start progress (target: 0.5 = 50% into fan)
- Overlap blend max opacity (target: 0.2 matching Remotion)
- Overlap blend transition duration
- [ ] Component behaviour unchanged when constants retain current values
- [ ] Constants are clearly named and grouped with a brief comment block
- [ ] Typecheck passes
### US-004: Add overlap blend effect on fanning capsules
**Description:** As a visitor, I want to see a subtle color blend where the fanning capsules overlap, matching the multiply-blend effect from the original Remotion animation.
**Acceptance Criteria:**
- [ ] CSS `mix-blend-mode: multiply` hardcoded on the fanning pill elements
- [ ] Blend effect is not visible at the start of the fan animation
- [ ] Blend fades in starting at ~50% of the fan animation progress (matching `OVERLAY_BLEND_START_PROGRESS = 0.5` from Remotion)
- [ ] Blend reaches max intensity by the end of the fan (or configurable transition window)
- [ ] Max blend opacity approximately 0.2 (20%), matching `OVERLAP_BLEND_MAX_OPACITY` from Remotion
- [ ] On a light background, the blend is only perceptible where capsules actually overlap
- [ ] Blend transition feels smooth, not abrupt
- [ ] Respects `prefers-reduced-motion` (no animation, show final state)
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** visually confirm blend is visible in overlap areas during/after fan animation
### US-005: Extend backdrop blur to cover full dashboard including TopBar
**Description:** As a visitor, I want the frosted-glass blur behind the login card to cover the entire dashboard — including the TopBar and sidebar — so nothing behind the overlay is sharp.
**Acceptance Criteria:**
- [ ] Blur overlay z-index raised above TopBar's z-index (currently TopBar is `zIndex: 100`, login overlay is `z-50`). Overlay must be ≥ `zIndex: 110` or similar.
- [ ] TopBar, Sidebar, and all dashboard content are uniformly blurred behind the overlay
- [ ] Login card itself remains crisp and unblurred (card z-index above overlay)
- [ ] Blur still fades out during the dissolve/exit transition
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** confirm TopBar is blurred behind the overlay (not rendered sharply above it)
### US-006: Reduce backdrop blur intensity by ~50%
**Description:** As a visitor, I want the backdrop blur to be softer/less aggressive so the dashboard behind is slightly more visible while still providing contrast for the login card.
**Acceptance Criteria:**
- [ ] Blur value reduced from `blur(20px)` to approximately `blur(10px)`
- [ ] The blur value should be a named constant (co-located with other LoginScreen timing constants) for easy adjustment
- [ ] Login card remains clearly readable against the softened backdrop
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** confirm blur is visibly softer than before
### US-007: Align login card border radius and shadow with dashboard design system
**Description:** As a visitor, I want the login card to feel like it belongs to the same design system as the dashboard. Currently the border radius and shadow diverge from the dashboard tokens.
**Acceptance Criteria:**
- [ ] Login card border radius changed from hardcoded `12px` to `8px` (matching `var(--radius-card)` / dashboard cards)
- [ ] Login input fields and button border radius changed from hardcoded `4px` to `6px` (matching `var(--radius-sm)` / dashboard inner elements)
- [ ] Login card shadow upgraded from `shadow-sm` (`0 1px 2px rgba(26,43,42,0.05)`) to `shadow-lg` (`0 8px 32px rgba(26,43,42,0.12)`) — appropriate for a floating modal over a blurred backdrop, consistent with the command palette
- [ ] Use CSS custom property references (`var(--radius-card)`, `var(--radius-sm)`, `var(--shadow-lg)`) where available rather than hardcoded values
- [ ] Typecheck passes
- [ ] **Verify in browser using Playwright:** confirm card corners and shadow match dashboard card styling
### US-008: Replace hardcoded colors with design tokens
**Description:** As a developer, I want the login screen to reference the same CSS custom properties as the dashboard so that any future palette changes propagate consistently.
**Acceptance Criteria:**
- [ ] Input text color changed from hardcoded `#111827` to `var(--text-primary, #1A2B2A)` (the project's actual primary text color)
- [ ] Cursor/caret color changed from hardcoded `#0D6E6E` to `var(--accent, #0D6E6E)`
- [ ] Button background colors changed from hardcoded `#0D6E6E` / `#0A8080` / `#085858` to `var(--accent)` / `var(--accent-hover)` / appropriate pressed variant using token references
- [ ] Any other hardcoded color values in LoginScreen.tsx that have corresponding CSS custom properties should use the token instead
- [ ] No visual change (token values currently resolve to the same colors)
- [ ] Typecheck passes
### US-009: Fix minor typography inconsistencies
**Description:** As a visitor, I want the login screen's typography weight and sizing to feel consistent with the dashboard's conventions.
**Acceptance Criteria:**
- [ ] Form label font weight increased from 500 to 600 (matching dashboard card header weight convention)
- [ ] `clamp()` typography on inputs and button is acceptable (responsive approach for a modal), but ensure the base/mid values align with dashboard equivalents where practical (e.g., input text mid-value ~14px to match dashboard body, button mid-value ~15px)
- [ ] Connection status indicator gap increased from 6px to 8px (matching dashboard `CardHeader` gap)
- [ ] No dramatic visual change — these are subtle alignment fixes
- [ ] Typecheck passes
## Functional Requirements
- FR-1: CvmisLogo `cssHeight` prop updated to approximately `clamp(160px, 18vw, 280px)` — final values to be tuned visually
- FR-2: "CVMIS" title font size increased from 13px to ~1820px; subtitle from 11px to ~1314px
- FR-3: All animation timing values in `CvmisLogo.tsx` extracted to named constants at the top of the file
- FR-4: CSS `mix-blend-mode: multiply` (hardcoded) applied to left and right pill elements during the fan animation
- FR-5: Blend effect opacity animated from 0 to ~0.2, beginning at 50% fan progress, using a smooth transition
- FR-6: Login screen blur overlay z-index raised above TopBar (`zIndex: 100`) to cover full viewport
- FR-7: Blur intensity reduced from `blur(20px)` to `blur(10px)` via a named constant
- FR-8: Login card border radius aligned to `var(--radius-card)` (8px), inputs/button to `var(--radius-sm)` (6px)
- FR-9: Login card shadow upgraded to `shadow-lg` for proper modal elevation
- FR-10: All hardcoded color values replaced with CSS custom property references where tokens exist
- FR-11: Form label weight aligned to 600, minor spacing/sizing adjustments to match dashboard conventions
- FR-12: All changes respect `prefers-reduced-motion` — animations skip to final state
## Non-Goals
- No canvas-based compositing (using CSS `mix-blend-mode` approach, not replicating the Remotion canvas pipeline)
- No changes to the boot sequence or ECG animation (locked)
- No changes to the login typing animation timing or credentials
- No changes to the dissolve/exit transition behaviour (beyond blur fading and z-index)
- No changes to the dashboard layout or content behind the overlay
- Blend mode is hardcoded to `multiply` — no configurable blend mode switching
- No conversion of login `<div>` fields to `<input>` elements (the login is theatrical animation, not a real form — divs are intentional)
## Design Considerations
- **Remotion reference:** `LogoAnimation/src/Composition.tsx` — the blend uses `globalCompositeOperation: "multiply"` at 20% opacity, masked to intersection areas, starting at 50% fan progress. The CSS `mix-blend-mode: multiply` approach approximates this; on light backgrounds, multiply blending is only perceptible where elements overlap.
- **Branding block proportions:** The branding block (logo + title + subtitle + internal spacing) should take up ~50% of the total login card height. The login form (fields + button + connection indicator) occupies the other ~50%. This creates a strong brand-first impression.
- **Text scale reference:** TopBar brand text is 15px/600w. The login "CVMIS" title should be larger than the TopBar (it's the hero brand moment) — ~1820px. The subtitle can be ~1314px, matching dashboard label scale.
- **Blur coverage:** Currently the overlay is `z-50` and TopBar is `zIndex: 100`, so the TopBar renders above the blur. Fix: raise the overlay's z-index above 100 while keeping the login card above the overlay.
- **Design system alignment:** The login card should use the same border radius, shadow, and color tokens as dashboard components. The card is a modal (like the command palette) so `shadow-lg` is the correct elevation tier. Border radius should match `var(--radius-card)` (8px) not exceed it.
## Technical Considerations
- The `mix-blend-mode` property is well-supported in modern browsers. Framer Motion's `style` prop can animate opacity on elements with `mixBlendMode` set.
- The blend opacity animation should be driven by the same fan animation progress value, using Framer Motion's `useTransform` or similar to derive the blend opacity from fan progress.
- The backdrop blur overlay's z-index must be higher than the TopBar's z-index (100) to cover it, while the login card's z-index must be higher still.
- Named constants should follow the existing Remotion naming convention (e.g., `OVERLAY_BLEND_START_PROGRESS`, `OVERLAP_BLEND_MAX_OPACITY`) for consistency.
- Check whether `var(--accent-hover)` exists as a token or needs to be added. If not, a hardcoded hover shade is acceptable as a local constant.
- **Playwright verification:** Use Playwright MCP to measure the branding block vs card height ratio, confirm text sizes, and visually verify blur coverage and blend effects.
## Success Metrics
- Logo + branding block is ~50% of login card height (verified via Playwright measurement)
- Branding text is noticeably larger, matching dashboard-level typography
- Overlap blend is perceptible but subtle — matches the understated feel of the Remotion reference
- Developer can tune all animation timings by editing constants at the top of one file
- Backdrop blur is uniform across the full viewport including TopBar (no sharp elements above the overlay)
- Blur feels softer/more transparent than before
- Login card visually belongs to the same design system as the dashboard (matching radius, shadow, color tokens)
## Open Questions
None — all questions resolved.
+211
View File
@@ -0,0 +1,211 @@
# PRD: Semantic Search & AI Chat
## Introduction
The portfolio's command palette currently uses Fuse.js for fuzzy string matching across ~40 palette items. While it handles typos, it doesn't understand intent — searching "NHS leadership" won't surface relevant roles unless those exact words appear in the keywords field. This PRD covers two complementary features:
1. **Phase 1 — Semantic Vector Search**: Replace Fuse.js with pre-computed embeddings and cosine similarity, enabling meaning-based search in the existing command palette. Zero runtime API cost.
2. **Phase 2 — AI Chat Widget**: A floating chat button (bottom-right, like a support chat) powered by Google Gemini Flash. Visitors can ask natural language questions about Andy's experience. Hybrid responses: conversational answer + relevant portfolio items.
## Goals
- Enable meaning-based search (e.g., "data visualization" matches Power BI dashboards, analytics roles)
- Maintain instant search performance (<50ms) in the command palette via client-side vectors
- Add a conversational "Ask about me" chat widget powered by Gemini Flash
- Keep the existing command palette UX (Ctrl+K, keyboard nav, grouped results) intact
- Hybrid chat responses: short natural language answer + clickable portfolio items
## User Stories
### Phase 1: Semantic Vector Search
#### US-001: Generate embeddings at build time
**Description:** As a developer, I want a build script that generates embeddings for all palette items so they ship as a static asset.
**Acceptance Criteria:**
- [ ] Node script `scripts/generate-embeddings.ts` reads all palette data from `src/lib/search.ts`
- [ ] Calls OpenAI `text-embedding-3-small` API with a rich text representation of each item (title + subtitle + keywords + any extended context from data files)
- [ ] Outputs `src/data/embeddings.json` — array of `{ id: string, embedding: number[] }`
- [ ] Script is runnable via `npm run generate-embeddings`
- [ ] Script requires `OPENAI_API_KEY` env var; fails gracefully with clear error if missing
- [ ] Embeddings file is committed to repo (static asset, not generated per-build)
- [ ] Typecheck passes
#### US-002: Client-side cosine similarity search
**Description:** As a visitor, I want the command palette to understand what I mean, not just match strings.
**Acceptance Criteria:**
- [ ] New `src/lib/semantic-search.ts` module with cosine similarity function
- [ ] Loads `embeddings.json` and provides a `semanticSearch(query: string, items: PaletteItem[])` function
- [ ] Query embedding is computed client-side using a lightweight approach (see Technical Considerations)
- [ ] Returns ranked `PaletteItem[]` with similarity scores
- [ ] Typecheck passes
#### US-003: Integrate semantic search into command palette
**Description:** As a visitor, I want the command palette to use semantic search with Fuse.js as a fallback.
**Acceptance Criteria:**
- [ ] Command palette uses semantic search as primary ranking when embeddings are available
- [ ] Falls back to Fuse.js if embeddings fail to load
- [ ] Search latency remains <100ms for all queries
- [ ] Existing keyboard navigation, grouping, and action routing unchanged
- [ ] Typecheck passes
- [ ] Verify in browser: search "data analysis" surfaces analytics-related roles/skills, not just items with "data" in the title
#### US-004: Enrich embedding content with deep context
**Description:** As a developer, I want embeddings to capture rich context beyond just titles, so semantic search is truly useful.
**Acceptance Criteria:**
- [ ] Consultation embeddings include: role, org, duration, history narrative, examination bullets, coded entry descriptions
- [ ] Skill embeddings include: name, category, frequency, proficiency, years
- [ ] KPI embeddings include: value, label, explanation, story context/outcomes
- [ ] Investigation embeddings include: name, methodology, tech stack, results
- [ ] Education embeddings include: title, institution, type, research detail
- [ ] Each item's embedding text is a natural-language paragraph, not a keyword list
- [ ] Typecheck passes
---
### Phase 2: AI Chat Widget
#### US-005: Chat widget UI — floating button
**Description:** As a visitor, I see a floating chat button at the bottom-right of the dashboard that opens a chat panel.
**Acceptance Criteria:**
- [ ] Floating circular button, bottom-right corner, consistent with design system (teal accent, shadow-md)
- [ ] Button shows a chat/message icon (lucide-react)
- [ ] Click toggles the chat panel open/closed
- [ ] Button has a subtle entrance animation after dashboard loads (delayed ~1s)
- [ ] Button respects `prefers-reduced-motion`
- [ ] Button is above all dashboard content but below command palette overlay (z-index layering)
- [ ] Typecheck passes
- [ ] Verify in browser using dev server
#### US-006: Chat panel UI
**Description:** As a visitor, I want a chat panel that feels like a support chat — compact, positioned above the floating button.
**Acceptance Criteria:**
- [ ] Panel opens above the chat button, anchored to bottom-right
- [ ] Panel dimensions: ~380px wide, ~480px tall max, with scroll for overflow
- [ ] Header with title ("Ask about Andy" or similar), close button
- [ ] Message area showing conversation history (user messages right-aligned, AI responses left-aligned)
- [ ] Input area at bottom with text field and send button
- [ ] AI responses show: natural language answer paragraph, then clickable portfolio item cards below (hybrid format)
- [ ] Clicking a portfolio item card triggers the same action routing as command palette (scroll, panel, link, etc.)
- [ ] Panel entrance/exit animation (scale + fade, 200ms)
- [ ] Respects `prefers-reduced-motion`
- [ ] Responsive: on mobile (<640px), panel goes full-width with adjusted height
- [ ] Typecheck passes
- [ ] Verify in browser using dev server
#### US-007: Gemini Flash integration
**Description:** As a visitor, I can ask natural language questions and get intelligent answers about Andy's experience.
**Acceptance Criteria:**
- [ ] API calls to Google Gemini Flash model
- [ ] System prompt includes full CV context (structured from data files) so the model can answer accurately
- [ ] API key sourced from environment variable `VITE_GEMINI_API_KEY` (exposed to client via Vite)
- [ ] Responses are streamed token-by-token for perceived speed
- [ ] Response format: JSON with `{ answer: string, relevantItems: string[] }` where items are palette item IDs
- [ ] If API key is missing or call fails, show a graceful fallback message ("Chat unavailable" or similar)
- [ ] Loading state shown while waiting for response
- [ ] Typecheck passes
#### US-008: Chat context and conversation history
**Description:** As a visitor, I want multi-turn conversation so I can ask follow-up questions.
**Acceptance Criteria:**
- [ ] Conversation history maintained in component state (not persisted across page loads)
- [ ] Previous messages included in Gemini API calls for context
- [ ] History capped at last 10 messages to manage token usage
- [ ] "Clear conversation" option available (button or typing /clear)
- [ ] Typecheck passes
## Functional Requirements
### Phase 1
- FR-1: Build script generates OpenAI `text-embedding-3-small` embeddings for all palette items
- FR-2: Embeddings stored as committed static JSON (`src/data/embeddings.json`)
- FR-3: Client-side cosine similarity ranks items by semantic relevance
- FR-4: Command palette uses semantic search as primary, Fuse.js as fallback
- FR-5: Query embedding must be computed without a runtime API call (see Technical Considerations)
### Phase 2
- FR-6: Floating chat button rendered in DashboardLayout, bottom-right, above content
- FR-7: Chat panel opens/closes on button click with animation
- FR-8: User messages sent to Gemini Flash API with CV context as system prompt
- FR-9: Gemini responses parsed into answer text + relevant item IDs
- FR-10: Relevant items rendered as clickable cards using existing palette item styling and action routing
- FR-11: Streaming responses displayed progressively
- FR-12: Conversation state managed per-session (cleared on page reload)
## Non-Goals
- No server-side search infrastructure (everything client-side or direct API calls)
- No persistent chat history across sessions
- No user authentication or rate limiting (API key cost is accepted)
- No voice input or speech-to-text
- No training or fine-tuning of models
- Chat widget does not replace the command palette — they coexist
- No analytics or tracking of search queries
## Design Considerations
### Command Palette (Phase 1)
- No visual changes to the command palette UI
- Semantic search is a drop-in replacement for the ranking logic
- Same grouped sections, icons, keyboard navigation, and action routing
### Chat Widget (Phase 2)
- **Button**: 48px circle, teal bg (`var(--accent)`), white icon, `shadow-md`. Hover: `shadow-lg` + slight scale
- **Panel**: White surface, 12px border-radius, `shadow-lg`. Same card/border tokens as rest of design system
- **Messages**: User messages in teal-tinted bubbles (right). AI messages in light gray bubbles (left) with `font-ui`
- **Item cards**: Reuse icon/color mapping from command palette results. Compact horizontal layout
- **Typography**: Body text 13px `font-ui`, timestamps 11px `font-geist`
- **Position**: Fixed, `bottom: 24px, right: 24px`. Panel above button with 8px gap
- **Mobile**: Button smaller (40px), panel full-width with `bottom: 0, right: 0` and rounded top corners only
### Existing components to reuse
- `iconByType` and `iconColorStyles` mappings from `CommandPalette.tsx`
- `PaletteItem`, `PaletteAction` types from `src/lib/search.ts`
- `buildPaletteData()` for building the searchable dataset
- `handlePaletteAction()` in `DashboardLayout.tsx` for action routing
- Design tokens from `index.css` and `tailwind.config.js`
## Technical Considerations
### Phase 1: Query Embedding Challenge
The main challenge is computing a query embedding client-side without an API call. Options:
- **Option A (Recommended):** Pre-compute embeddings for items only. At query time, use a lightweight client-side text similarity approach (e.g., TF-IDF or BM25 on the enriched text) combined with the embedding vectors for re-ranking. This avoids shipping a model to the browser.
- **Option B:** Use a small ONNX model in the browser (e.g., `all-MiniLM-L6-v2` via Transformers.js). ~23MB download, but gives true semantic matching. Could be lazy-loaded.
- **Option C:** Call OpenAI embedding API at query time. Adds latency (~200ms) and runtime cost, but simplest implementation.
**Decision needed at implementation time** — Option B gives the best semantic search quality. Option A is simpler but less semantic. Option C is simplest but has runtime costs.
### Phase 2: Gemini Flash
- Use `gemini-2.0-flash` (or latest) — fast, cheap, good for short-form Q&A
- System prompt should be a structured summary of all CV data, not raw data dumps
- Response schema enforced via Gemini's JSON mode or structured output
- `VITE_GEMINI_API_KEY` exposed to client — acceptable for a portfolio (low traffic, low cost)
- Consider a soft rate limit in the UI (e.g., 1 request per 2 seconds) to prevent abuse
### Shared
- Both features use `buildPaletteData()` as the canonical item dataset
- Action routing through `handlePaletteAction()` is shared
- `DetailPanelContent` union type supports all drill-down destinations
## Success Metrics
- Semantic search returns relevant results for intent-based queries (e.g., "healthcare leadership" surfaces ICB roles)
- Command palette search latency stays <100ms
- Chat widget responds within 2-3 seconds for typical questions
- Chat answers are factually accurate to the CV content (no hallucinated roles or dates)
- Both features degrade gracefully when APIs are unavailable
## Open Questions
- **Phase 1 query embedding**: Which approach (A/B/C) gives the best tradeoff of quality vs. bundle size vs. complexity? This should be prototyped early.
- **Gemini API key exposure**: Is direct client-side exposure acceptable, or should we add a minimal edge function proxy? (User chose direct exposure — revisit if abuse becomes an issue.)
- **Chat widget on mobile**: Should the chat panel be a full-screen modal on small screens, or a bottom sheet?
- **Suggested questions**: Should the chat widget show 2-3 starter questions when first opened (e.g., "What's Andy's NHS experience?", "Tell me about his data skills")?