diff --git a/Ralph/archive/2026-02-15-login-screen-rework/prd.json b/Ralph/archive/2026-02-15-login-screen-rework/prd.json new file mode 100644 index 0000000..e019eb6 --- /dev/null +++ b/Ralph/archive/2026-02-15-login-screen-rework/prd.json @@ -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 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 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 element with (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: + . 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." + } + ] +} \ No newline at end of file diff --git a/Ralph/archive/2026-02-15-login-screen-rework/progress.txt b/Ralph/archive/2026-02-15-login-screen-rework/progress.txt new file mode 100644 index 0000000..d27a2f7 --- /dev/null +++ b/Ralph/archive/2026-02-15-login-screen-rework/progress.txt @@ -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 `
` to `` 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' +--- + diff --git a/src/App.tsx b/src/App.tsx index b54617f..1ac335c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ function SkipButton({ onSkip }: { onSkip: () => void }) { } function App() { - const [phase, setPhase] = useState('boot') + const [phase, setPhase] = useState('login') const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) const skipToLogin = () => setPhase('login') diff --git a/src/components/CvmisLogo.tsx b/src/components/CvmisLogo.tsx index d091acd..a2cf04b 100644 --- a/src/components/CvmisLogo.tsx +++ b/src/components/CvmisLogo.tsx @@ -10,26 +10,26 @@ interface CvmisLogoProps { // ── Animation timing constants ────────────────────────────────────── // Rise phase: all pills rise together from below -const RISE_DURATION_MS = 500 // duration of the upward rise (ms) +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 = 500 // delay before fan begins (ms from mount) -const FAN_DURATION_S = 0.6 // duration of fan-out (s) +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 = 50 // rotation angle for fanned pills (±degrees) -const FAN_HORIZONTAL_PX = 16 // horizontal offset for fanned pills (±px) -const FAN_RIGHT_STAGGER_S = 0.03 // stagger delay for right pill (s) +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.5 // fan progress at which blend fades in -export const OVERLAP_BLEND_MAX_OPACITY = 0.2 // max blend opacity (20%) +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) @@ -96,6 +96,11 @@ export function CvmisLogo({ size, cssHeight, animated = false, className }: Cvmi ...(cssHeight ? { height: cssHeight, width: 'auto' } : {}), }} > + + + + + {/* Rise group — all pills rise together from below */} - {/* Blend overlays — multiply-blend copies of fanning pills for overlap darkening */} - - - + {/* Blend overlays — multiply-blend copies of fanning pills, clipped to center pill overlap */} + + + + + - - - + + + + + diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index b4e6b57..49ea0f3 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -241,7 +241,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { className="flex flex-col items-center" style={{ marginBottom: '28px' }} > -
+
` fields to `` 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) — ~18–20px. The subtitle can be ~13–14px, 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. diff --git a/tasks/prd-semantic-search.md b/tasks/prd-semantic-search.md new file mode 100644 index 0000000..25a22ac --- /dev/null +++ b/tasks/prd-semantic-search.md @@ -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")?