Compare commits

...

18 Commits

Author SHA1 Message Date
admin 0fbbf9e46f merge 2026-02-15 23:20:24 +00:00
admin 4580ca9c84 feat: US-014 - Update to Gemini 3 Flash Preview with model indicator 2026-02-15 21:02:52 +00:00
admin 667e5b249c feat: US-013 - Self-host ONNX embedding model
Download all-MiniLM-L6-v2 model files to public/models/ and configure
@xenova/transformers to load from local path instead of Hugging Face CDN.
Eliminates external dependency for semantic search embedding model.
2026-02-15 20:59:03 +00:00
admin 9e9dd1ae4b feat: US-012 - Welcome message with suggested question chips 2026-02-15 20:48:00 +00:00
admin ab5444ee94 feat: US-011 - Mobile full-screen chat panel 2026-02-15 20:43:48 +00:00
admin 657d2f299e Added .env, and ammended .gitignore 2026-02-15 20:32:46 +00:00
admin 5f3e0db712 feat: US-010 - Chat widget — clickable portfolio item cards in responses 2026-02-15 18:30:07 +00:00
admin 29e1728e11 feat: US-009 - Chat widget — Gemini Flash integration 2026-02-15 18:24:42 +00:00
admin 273c143d5e feat: US-008 - Chat widget — panel UI with message display 2026-02-15 18:18:51 +00:00
admin 7ee1a2d9de feat: US-007 - Chat widget — floating button component 2026-02-15 18:13:08 +00:00
admin 2fca61b43a feat: US-006 - Integrate semantic search into command palette 2026-02-15 18:08:25 +00:00
admin c4480d7c99 feat: US-005 - Implement cosine similarity search module 2026-02-15 18:01:51 +00:00
admin ae15ccf961 chore: mark US-004 complete, update progress log 2026-02-15 17:59:11 +00:00
admin 91f8dac261 feat: US-004 - Preload ONNX model during boot sequence 2026-02-15 17:58:41 +00:00
admin aa1774320a feat: US-003 - Generate and commit embeddings.json 2026-02-15 17:55:53 +00:00
admin 219a3f04be chore: mark US-002 complete, update progress log 2026-02-15 17:52:51 +00:00
admin 384e393963 feat: US-002 - Build rich text representations for each palette item 2026-02-15 17:52:07 +00:00
admin 489e306b0a feat: US-001 - Install @xenova/transformers and add generate-embeddings script skeleton 2026-02-15 17:49:25 +00:00
32 changed files with 51506 additions and 319 deletions
+2 -1
View File
@@ -31,7 +31,8 @@
"Bash(timeout /t 3 /nobreak)", "Bash(timeout /t 3 /nobreak)",
"Bash(jq:*)", "Bash(jq:*)",
"Bash(git stash:*)", "Bash(git stash:*)",
"Bash(npx tsc:*)" "Bash(npx tsc:*)",
"mcp__context7__resolve-library-id"
] ]
} }
} }
+1
View File
@@ -7,6 +7,7 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.env
node_modules node_modules
dist dist
dist-ssr dist-ssr
+46
View File
@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
- Core app code lives in `src/`:
- `src/components/` for UI components (`PascalCase.tsx`)
- `src/hooks/` for custom hooks (`useX.ts`)
- `src/lib/` for utilities and integrations (search, embeddings, Gemini)
- `src/contexts/`, `src/types/`, and `src/data/` for state, typing, and static data
- Static/public assets live in `public/` (including `public/models/`), while build output is generated in `dist/`.
- Utility scripts live in `scripts/` (for example, `scripts/generate-embeddings.ts`).
- Design references and experiments are in top-level folders such as `designs/`, `References/`, and `LogoAnimation/`.
## Build, Test, and Development Commands
- `npm run dev` starts the Vite development server.
- `npm run build` runs TypeScript project builds and creates a production bundle.
- `npm run preview` serves the production build locally.
- `npm run lint` runs ESLint across the repo.
- `npm run typecheck` runs TypeScript checks without emitting files.
- `npm run generate-embeddings` regenerates semantic-search embeddings.
## Coding Style & Naming Conventions
- Language stack: TypeScript + React 18 + Vite.
- Follow ESLint (`eslint.config.js`) and TypeScript strictness before opening PRs.
- Use 2-space indentation and trailing commas where existing files do.
- Naming conventions:
- Components: `PascalCase` (`DashboardLayout.tsx`)
- Hooks: `useCamelCase` (`useFocusTrap.ts`)
- Utilities/data files: lowercase or kebab-style by domain (`semantic-search.ts`, `consultations.ts`).
## Testing Guidelines
- There is currently no committed automated test framework (`*.test.*` / `*.spec.*` not present).
- Minimum validation for each change: `npm run lint`, `npm run typecheck`, and `npm run build`.
- For UI changes, include manual verification notes (route/flow tested, responsive behavior, accessibility impact).
## Commit & Pull Request Guidelines
- Follow the existing history style: Conventional Commit prefixes (`feat:`, `chore:`) plus optional story IDs (for example, `feat: US-014 - ...`).
- Keep commits focused and atomic; avoid mixing refactors with feature behavior.
- PRs should include:
- concise summary and motivation
- linked task/story ID when available
- screenshots/GIFs for visual changes
- confirmation that lint, typecheck, and build passed.
## Security & Configuration Tips
- Store secrets in `.env`; never hard-code API keys.
- Do not commit local env files or generated artifacts outside intended tracked data.
@@ -0,0 +1,185 @@
{
"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",
"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": "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": [
"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": "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": "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": [
"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": "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": "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": [
"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 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": "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": [
"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": "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": "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": [
"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": "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": "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": [
"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 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": "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": [
"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": "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": "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": [
"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": "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 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",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": true,
"notes": "Simple revert of US-001. Phase state is on line 47 of App.tsx."
}
]
}
@@ -0,0 +1,202 @@
# Progress Log — Login Logo & Blur Refinements
# Branch: ralph/login-logo-refinements
# 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-pressed: #085858
- --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
### Key Files for This Feature
- 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 (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 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-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:**
- 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-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:**
- 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-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:**
- 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-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:**
- 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-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:**
- `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-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 `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-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:**
- 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-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:**
- 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-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:**
- `#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-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:**
- 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
---
@@ -0,0 +1,276 @@
{
"project": "Portfolio — Semantic Search & AI Chat",
"branchName": "ralph/semantic-search",
"description": "Replace Fuse.js command palette search with client-side semantic vector search (ONNX model), then add a Gemini Flash-powered AI chat widget.",
"userStories": [
{
"id": "US-001",
"title": "Install @xenova/transformers and add generate-embeddings script skeleton",
"description": "As a developer, I need the Transformers.js dependency installed and a runnable script scaffold so subsequent stories can generate and use embeddings.",
"acceptanceCriteria": [
"npm install @xenova/transformers",
"Create scripts/generate-embeddings.ts with a main() function that imports the pipeline from @xenova/transformers",
"Script loads the all-MiniLM-L6-v2 model and embeds a single test string, logging the vector length to confirm it works",
"Add npm script: \"generate-embeddings\": \"npx tsx scripts/generate-embeddings.ts\"",
"Running npm run generate-embeddings prints the vector length (384) and exits cleanly",
"Typecheck passes"
],
"priority": 1,
"passes": true,
"notes": "Use @xenova/transformers (not @huggingface/transformers — the Xenova fork has better Node.js ONNX support). The model ID is 'Xenova/all-MiniLM-L6-v2'. Pipeline type is 'feature-extraction'. tsx is already available via npx for running TypeScript scripts."
},
{
"id": "US-002",
"title": "Build rich text representations for each palette item",
"description": "As a developer, I want each palette item to have a natural-language paragraph for embedding that captures deep context, not just the title.",
"acceptanceCriteria": [
"New function buildEmbeddingTexts() in src/lib/search.ts that returns Array<{ id: string, text: string }> for all palette items",
"Consultation items include: role, org, duration, history narrative, examination bullets, coded entry descriptions",
"Skill items include: name, category, frequency, proficiency percentage, years of experience",
"KPI items include: value, label, explanation, story context and outcomes",
"Investigation items include: name, methodology, tech stack list, results",
"Education items include: title, institution, type, research detail",
"Quick Action items include: title and subtitle (short text is fine)",
"Achievement items include: title, subtitle, and linked KPI story context if available",
"Each text is a readable natural-language paragraph, not a keyword dump",
"Typecheck passes"
],
"priority": 2,
"passes": true,
"notes": "This function will be used by both the build script (to generate embeddings) and potentially by the chat widget (for context). Import the raw data files (consultations, skills, kpis, investigations, documents) to access the full data beyond what buildPaletteData() surfaces. The id must match the PaletteItem id so embeddings can be correlated."
},
{
"id": "US-003",
"title": "Generate and commit embeddings.json",
"description": "As a developer, I want the generate-embeddings script to produce a complete embeddings.json file using the rich text representations.",
"acceptanceCriteria": [
"scripts/generate-embeddings.ts imports buildEmbeddingTexts() from src/lib/search.ts",
"Script embeds each item's text using the all-MiniLM-L6-v2 model via @xenova/transformers pipeline",
"Outputs src/data/embeddings.json as an array of { id: string, embedding: number[] }",
"Each embedding is a 384-dimensional float array",
"Running npm run generate-embeddings regenerates the file successfully",
"The JSON file is valid and parseable",
"Typecheck passes"
],
"priority": 3,
"passes": true,
"notes": "The pipeline returns a Tensor — use .tolist() or .data to extract the raw float array. Mean-pool across the token dimension (dim 1) to get a single 384-d vector per input. Process items sequentially to avoid OOM in Node. The output file will be ~200KB for ~40 items with 384 floats each."
},
{
"id": "US-004",
"title": "Preload ONNX model during boot sequence",
"description": "As a visitor, I want the semantic search model to download in the background during the boot/ECG/login phases so it's ready when I reach the dashboard.",
"acceptanceCriteria": [
"New src/lib/embedding-model.ts module that exports: initModel(), embedQuery(text: string), and isModelReady()",
"initModel() loads the all-MiniLM-L6-v2 pipeline from @xenova/transformers and stores it in a module-level variable",
"embedQuery() returns a Promise<number[]> (384-d vector) for a given text string",
"isModelReady() returns boolean indicating if the model has finished loading",
"initModel() is called in App.tsx useEffect on mount (during boot phase) — fire and forget, no await",
"If initModel() fails (network error, etc.), isModelReady() remains false — no error thrown or shown",
"Model is cached by @xenova/transformers in IndexedDB — subsequent page loads are near-instant",
"Boot/ECG/login animations are not affected by model loading",
"Typecheck passes"
],
"priority": 4,
"passes": true,
"notes": "Use pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2') which auto-downloads and caches the ONNX model. The module-level pattern (let pipelineInstance = null) avoids React re-render issues. embedQuery should mean-pool the tensor output the same way as the build script. Wrap initModel() in a try/catch that silently swallows errors."
},
{
"id": "US-005",
"title": "Implement cosine similarity search module",
"description": "As a developer, I need a semantic search function that compares a query embedding against pre-computed item embeddings and returns ranked results.",
"acceptanceCriteria": [
"New src/lib/semantic-search.ts module",
"Exports semanticSearch(queryEmbedding: number[], embeddings: Array<{ id: string, embedding: number[] }>, threshold?: number): Array<{ id: string, score: number }>",
"Uses cosine similarity: dot(a,b) / (magnitude(a) * magnitude(b))",
"Results sorted by score descending",
"Optional threshold parameter filters out low-relevance results (default 0.3)",
"Exports loadEmbeddings() that imports embeddings.json and returns the parsed array",
"Typecheck passes"
],
"priority": 5,
"passes": true,
"notes": "Keep the cosine similarity implementation simple — no libraries needed for 384-d vectors over ~40 items. The loadEmbeddings function can use a dynamic import or direct import of the JSON file (Vite handles JSON imports natively)."
},
{
"id": "US-006",
"title": "Integrate semantic search into command palette",
"description": "As a visitor, I want the command palette to use semantic search when available, falling back to Fuse.js otherwise.",
"acceptanceCriteria": [
"CommandPalette.tsx checks isModelReady() from embedding-model.ts",
"When model is ready and query is non-empty: call embedQuery(query), then semanticSearch() against loaded embeddings, then map result IDs back to PaletteItem objects",
"When model is NOT ready: use existing Fuse.js search (current behavior preserved exactly)",
"Search is debounced by ~200ms to avoid calling embedQuery on every keystroke",
"Results maintain existing groupBySection() grouping and section ordering",
"Existing keyboard navigation, action routing, and UI unchanged",
"Typecheck passes",
"Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title"
],
"priority": 6,
"passes": true,
"notes": "The debounce is important — embedQuery takes ~20-50ms per call. Use a useRef + setTimeout pattern or a simple debounce hook. The mapping from semantic search results (id + score) back to PaletteItems should use a Map for O(1) lookup. Keep the Fuse.js imports and buildSearchIndex — they're the fallback path."
},
{
"id": "US-007",
"title": "Chat widget — floating button component",
"description": "As a visitor, I see a floating chat button at the bottom-right of the dashboard that I can click to open a chat panel.",
"acceptanceCriteria": [
"New src/components/ChatWidget.tsx component",
"Renders a 48px circular button, fixed position, bottom: 24px, right: 24px",
"Uses teal accent background (var(--accent)), white MessageCircle icon from lucide-react",
"Shadow: var(--shadow-md). Hover: var(--shadow-lg) + scale(1.05) transition",
"Button has a subtle entrance animation: fade + translateY(8px) → translateY(0), delayed ~1s after mount",
"Respects prefers-reduced-motion (no animation, just visible)",
"z-index above dashboard content but below command palette overlay (z-index 90)",
"onClick toggles an isOpen state (panel rendering comes in next story)",
"Mounted in DashboardLayout.tsx",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 7,
"passes": true,
"notes": "Use framer-motion for the entrance animation to match the rest of the app's motion patterns. The button should use font-ui for any text. On mobile (<640px), button is 40px and positioned bottom: 16px, right: 16px. The VITE_GEMINI_API_KEY env var check can wait until the Gemini integration story — for now just render the button unconditionally."
},
{
"id": "US-008",
"title": "Chat widget — panel UI with message display",
"description": "As a visitor, I want a chat panel that opens above the floating button where I can type questions and see responses.",
"acceptanceCriteria": [
"Chat panel renders when isOpen is true, positioned above the floating button (bottom: 88px, right: 24px)",
"Panel dimensions: 380px wide, max-height 480px, with overflow-y auto for messages",
"Header: title text ('Ask about Andy'), close button (X icon)",
"Message area: user messages right-aligned in teal-tinted bubbles, assistant messages left-aligned in light gray bubbles",
"Input area at bottom: text field with placeholder 'Ask me anything...', send button (Send icon)",
"Enter key submits message, Shift+Enter for newline",
"Panel entrance animation: scale(0.95) + opacity(0) → scale(1) + opacity(1), 200ms ease-out",
"Panel exit animation: reverse of entrance",
"Respects prefers-reduced-motion",
"Responsive: on mobile (<640px), panel is full-width (left: 0, right: 0, bottom: 0) with rounded top corners only",
"Messages are stored in component state as Array<{ role: 'user' | 'assistant', content: string }>",
"Submitting a message adds it to state and shows it in the UI (no API call yet — assistant response is a placeholder)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 8,
"passes": true,
"notes": "Use the design system tokens: var(--surface) for panel bg, var(--border-light) for borders, var(--text-primary) for text, var(--accent) for user bubble bg at 10% opacity, font-ui for body text, font-geist for timestamps. The placeholder assistant response can be a static string like 'AI chat coming soon — this is a preview of the chat interface.' This lets us verify the full UI before wiring up Gemini."
},
{
"id": "US-009",
"title": "Chat widget — Gemini Flash integration",
"description": "As a visitor, I can ask natural language questions and get intelligent, streamed answers about Andy's experience.",
"acceptanceCriteria": [
"New src/lib/gemini.ts module that exports sendChatMessage(messages: ChatMessage[], cvContext: string): AsyncGenerator<string>",
"Calls Google Gemini Flash API (gemini-2.0-flash) using the REST API with fetch (no SDK needed)",
"API key sourced from import.meta.env.VITE_GEMINI_API_KEY",
"System prompt includes structured CV context built from buildEmbeddingTexts() output",
"System prompt instructs the model to answer questions about Andy's professional experience accurately and concisely",
"System prompt instructs the model to include relevant palette item IDs in its response as a JSON array at the end",
"Responses are streamed using the Gemini streaming endpoint",
"ChatWidget.tsx wires up real messages: on submit, calls sendChatMessage and streams tokens into the assistant message bubble",
"Loading state shown (typing indicator) while waiting for first token",
"If VITE_GEMINI_API_KEY is not set, chat button is still visible but panel shows 'Chat is currently unavailable' message",
"If API call fails, show error message in chat: 'Sorry, I couldn't process that. Please try again.'",
"Conversation history (last 10 messages) passed to API for multi-turn context",
"Typecheck passes"
],
"priority": 9,
"passes": true,
"notes": "Gemini REST streaming endpoint: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=API_KEY. The response is SSE (server-sent events) — parse each 'data:' line as JSON and extract candidates[0].content.parts[0].text. The system prompt with CV context will be ~2-3K tokens — well within Gemini Flash limits. For the palette item IDs, instruct the model to end its response with a line like [ITEMS: id1, id2, id3] which can be parsed client-side."
},
{
"id": "US-010",
"title": "Chat widget — clickable portfolio item cards in responses",
"description": "As a visitor, I want AI chat responses to include clickable portfolio items so I can drill into relevant sections.",
"acceptanceCriteria": [
"After parsing the assistant response, extract referenced palette item IDs from the [ITEMS: ...] suffix",
"Render matched items as compact clickable cards below the answer text in the assistant bubble",
"Cards reuse icon/color mapping from CommandPalette (iconByType, iconColorStyles)",
"Cards show item title and subtitle in a compact horizontal layout",
"Clicking a card triggers the same action routing as command palette via handlePaletteAction in DashboardLayout",
"If no items are referenced or IDs don't match, no cards are shown (just the text answer)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 10,
"passes": true,
"notes": "The action routing needs to flow from ChatWidget up to DashboardLayout. Add an onAction prop to ChatWidget (same pattern as CommandPalette). DashboardLayout passes handlePaletteAction to ChatWidget. Export iconByType and iconColorStyles from CommandPalette (or extract to a shared module) so ChatWidget can reuse them."
},
{
"id": "US-011",
"title": "Mobile full-screen chat panel",
"description": "As a mobile visitor, I want the chat panel to be a full-screen overlay so it's easy to use on small screens.",
"acceptanceCriteria": [
"Below md breakpoint (768px), chat panel renders as full-screen overlay using position: fixed; inset: 0 with 100dvh height",
"Full-screen mode has the existing header with close button (no visual change needed, just full-width)",
"Floating chat button is hidden (display: none or opacity: 0) while panel is open on mobile (<768px)",
"Above 768px, existing panel behavior is unchanged (380px wide, anchored bottom-right, max-height 480px)",
"Panel open/close animation still respects prefers-reduced-motion",
"Safe area insets applied via env(safe-area-inset-*) for notched devices",
"Input area stays pinned to bottom of screen on mobile",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 11,
"passes": true,
"notes": "The current ChatWidget already has some mobile handling (bottom-sheet style at <640px). This story changes the breakpoint to 768px (md) and makes it truly full-screen instead of 85vh. Use 100dvh (dynamic viewport height) to account for mobile browser chrome. The floating button visibility can be controlled by combining isOpen state with a CSS media query or a useMediaQuery hook. The <style> block with data-chat-panel attribute is the place to update responsive rules."
},
{
"id": "US-012",
"title": "Welcome message with suggested question chips",
"description": "As a visitor opening the chat, I see a friendly welcome message and clickable suggested questions so I know what to ask.",
"acceptanceCriteria": [
"When chat panel is open and conversation is empty, display welcome text: 'Hey! I'm here to help you learn more about Andy. What would you like to know?'",
"Welcome text is styled as an AI message bubble (left-aligned, light background, same styling as assistant messages)",
"Below the welcome bubble, show 2-3 clickable pill/chip buttons with suggested questions",
"Suggested questions: 'What's his NHS experience?', 'Tell me about his data skills', 'What projects has he built?'",
"Chips styled with: teal accent border, rounded-full, font-ui 12-13px, hover state (teal background tint)",
"Clicking a chip sends that question as a user message (same codepath as typing + Enter)",
"Welcome message and chips always visible when conversation is empty (persist across panel open/close)",
"Once any message is sent, the welcome/chips area is replaced by the conversation messages",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": true,
"notes": "Replace the current empty-state text ('Ask me anything about Andy's experience, skills, or projects.') with the new welcome bubble + chips. The chips should call handleSubmit (or equivalent) with the chip text pre-filled — simplest approach is setInputValue(chipText) then immediately trigger submit. Check that the welcome state reappears if the user hasn't sent a message (messages.length === 0). The suggested questions could live in a const array at the top of ChatWidget for easy future editing."
},
{
"id": "US-013",
"title": "Self-host ONNX embedding model",
"description": "As a developer, I want the ONNX model files served from the same host as the site to eliminate dependency on Hugging Face CDN.",
"acceptanceCriteria": [
"Model files for Xenova/all-MiniLM-L6-v2 downloaded and placed in public/models/all-MiniLM-L6-v2/onnx/ (matching HF repo structure)",
"Required files present: model_quantized.onnx, tokenizer.json, tokenizer_config.json, config.json, and any other files the pipeline expects",
"src/lib/embedding-model.ts updated: configure @xenova/transformers env to use local model path (e.g., env.localModelPath or custom model URL pointing to /models/)",
"scripts/generate-embeddings.ts also updated to use the same local model path for consistency",
"Model files are NOT in .gitignore — they are committed as static assets",
"No network requests to huggingface.co in the browser network tab when semantic search is used",
"Semantic search still works correctly in the command palette after the change",
"Typecheck passes"
],
"priority": 13,
"passes": true,
"notes": "Transformers.js uses env.localModelPath or env.remoteHost to control where models are fetched from. Setting env.localModelPath = '/models/' should make it look for files at /models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx etc. The Vite public/ directory serves files at the root — so public/models/ becomes /models/ at runtime. For the build script (Node.js), use a file:// path or the local filesystem path instead. Download model files from https://huggingface.co/Xenova/all-MiniLM-L6-v2/tree/main — the quantized ONNX model is ~23MB. Check what files the pipeline actually requests by watching network tab before making this change."
},
{
"id": "US-014",
"title": "Update to Gemini 3 Flash Preview with model indicator",
"description": "As a developer, I want to use the latest free Gemini model, and as a visitor, I want to see what model powers the chat.",
"acceptanceCriteria": [
"Extract model name to a single constant (e.g., GEMINI_MODEL = 'gemini-3-flash-preview') used for both the API URL and display",
"GEMINI_API_BASE URL updated to use the new model constant",
"Review and tighten the system prompt — ensure it's well-structured, concise, and clear for the new model",
"Review the [ITEMS: ...] suffix instruction — ensure new model follows the format reliably",
"Small model indicator in chat panel header: 'Gemini 3 Flash' in font-geist, 11px, var(--text-tertiary)",
"Model indicator positioned right-aligned in the header bar or as a subtle line below the header",
"Streaming SSE parsing still works correctly with the new model endpoint",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": true,
"notes": "The current API base is 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash'. Change the model segment to 'gemini-3-flash-preview'. The API path structure (v1beta/models/{model}:streamGenerateContent) should be the same. Verify that gemini-3-flash-preview is the correct model ID — check Google AI Studio or the API docs. For the display name, use a human-friendly string like 'Gemini 3 Flash' (not the full model ID). The constant should be defined at the top of gemini.ts and exported for use in ChatWidget."
}
]
}
@@ -0,0 +1,315 @@
# Progress Log — Semantic Search & AI Chat
# Branch: ralph/semantic-search
# Started: 2026-02-15
## Codebase Patterns
- `@xenova/transformers` pipeline with `pooling: 'mean'` and `normalize: true` returns a Tensor; use `Array.from(output.data as Float32Array)` to extract the 384-d vector
- Scripts live in `scripts/` and run via `npx tsx` (tsx is not a project dep, npx fetches it)
- tsconfig `include` only covers `src/` — scripts are type-checked by tsx at runtime, not by `tsc --noEmit`
- Project uses `"type": "module"` in package.json
- Palette item IDs: `exp-{consultation.id}`, `skill-{skill.id}`, `proj-{investigation.id}`, `ach-{0-3}`, `edu-{0-3}`, `action-{0-3}`
- `buildEmbeddingTexts()` in `src/lib/search.ts` returns `Array<{ id: string, text: string }>` with IDs matching PaletteItem IDs — use this for both embedding generation and chat context
- `src/data/embeddings.json` is an array of `{ id: string, embedding: number[] }` — 42 items, 384-d vectors, IDs match PaletteItem IDs. Vite imports JSON natively.
- `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()`
- `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases
- ONNX model files self-hosted in `public/models/Xenova/all-MiniLM-L6-v2/` — `env.localModelPath = '/models/'`, `env.allowRemoteModels = false`, `env.useBrowserCache = false` eliminates HF CDN dependency
- `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b))
- CommandPalette uses `semanticResults` state + debounced `useEffect` for async semantic search, falling back to Fuse.js when `isModelReady()` returns false or on any error
- `loadEmbeddings()` and `paletteMap` (Map<id, PaletteItem>) are precomputed via `useMemo` — no re-computation on each search
- ChatWidget is mounted in DashboardLayout alongside CommandPalette and DetailPanel — z-index 90 (below command palette z-1000)
- `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation
- ChatWidget stores messages as `Array<{ role: 'user' | 'assistant', content: string }>` — same shape as LLM message format, ready for Gemini integration
- ChatWidget `isOpen` state controls both panel visibility and button icon (MessageCircle ↔ X) — panel rendering handled by AnimatePresence
- `src/lib/gemini.ts` exports `sendChatMessage(messages)` (async generator), `isGeminiAvailable()`, `parseItemIds(text)`, `stripItemsSuffix(text)` — ChatMessage type is `{ role: 'user' | 'assistant', content: string }`
- Gemini API uses SSE streaming: POST to `:streamGenerateContent?alt=sse&key=KEY`, parse `data:` lines as JSON, extract `candidates[0].content.parts[0].text`
- System prompt built from `buildEmbeddingTexts()` — instructs model to end responses with `[ITEMS: id1, id2, id3]` for portfolio item linking
- `isGeminiAvailable()` checks `import.meta.env.VITE_GEMINI_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible
- Assistant messages store item IDs as `<!--ITEMS:id1,id2-->` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering
- Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API
- Icon/color mappings (`iconByType`, `iconColorStyles`) live in `src/lib/palette-icons.ts` — shared between CommandPalette and ChatWidget
- ChatWidget accepts optional `onAction?: (action: PaletteAction) => void` prop — same pattern as CommandPalette's `onAction`
- `DashboardLayout` passes `handlePaletteAction` to both CommandPalette and ChatWidget for unified action routing
- TopBar is `z-index: 100` (fixed), nav is `z-index: 99` (sticky) — mobile full-screen overlays need `z-index > 100` to appear above them
- Inline `style={{ display: 'flex' }}` overrides Tailwind's `hidden` class — use `!important` modifier (`max-md:!hidden`) or move display to Tailwind classes to allow responsive hiding
- ChatWidget mobile breakpoint is `md` (768px) — below this, panel is full-screen; above, it's 380px anchored bottom-right
- `handleSubmit(overrideText?)` accepts optional text param — use this when programmatically sending messages (e.g., suggested question chips) to avoid stale `inputValue` state
- `SUGGESTED_QUESTIONS` const array at top of ChatWidget — edit here to change welcome screen chip text
- `GEMINI_MODEL` and `GEMINI_DISPLAY_NAME` exported from `src/lib/gemini.ts` — single source of truth for model ID and display name; update both when changing models
---
## 2026-02-15 - US-001
- Installed `@xenova/transformers` (^2.17.2)
- Created `scripts/generate-embeddings.ts` with main() that loads `Xenova/all-MiniLM-L6-v2` and embeds a test string
- Added `"generate-embeddings"` npm script
- Verified: outputs vector length 384 and exits cleanly
- Typecheck passes
- Files changed: `package.json`, `package-lock.json`, `scripts/generate-embeddings.ts`
- **Learnings for future iterations:**
- `pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')` auto-downloads and caches the ONNX model (~23MB)
- First run takes a few seconds for model download; subsequent runs are near-instant from cache
- The pipeline's `pooling: 'mean'` and `normalize: true` options handle mean-pooling and L2 normalization in one step — no manual tensor manipulation needed
- `output.data` is a `Float32Array`; wrap in `Array.from()` for a plain number array
---
## 2026-02-15 - US-002
- Added `buildEmbeddingTexts()` function to `src/lib/search.ts`
- Imports all raw data files (consultations, skills, kpis, investigations, documents)
- Generates natural-language paragraphs for each palette item type:
- Consultations: role, org, duration, history narrative, examination bullets, coded entry descriptions
- Skills: name, category, frequency, proficiency %, years of experience
- Achievements: title, subtitle, full KPI explanation + story context + outcomes
- Investigations: name, methodology, tech stack, results
- Education: title, type, institution, duration, classification, research detail, notes (from documents.ts)
- Quick Actions: title + subtitle
- IDs match PaletteItem IDs (e.g. `exp-{id}`, `skill-{id}`, `ach-{i}`, `proj-{id}`, `edu-{i}`, `action-{i}`)
- Typecheck and lint pass
- Files changed: `src/lib/search.ts`
- **Learnings for future iterations:**
- Education items in `buildPaletteData()` are hardcoded arrays (not iterated from `documents`), with ids `edu-0` through `edu-3`. The mapping to `documents.ts` entries is: edu-0→doc-mary-seacole, edu-1→doc-mpharm, edu-2→doc-alevels, edu-3→doc-gphc
- Achievement items are similarly hardcoded with ids `ach-0` through `ach-3`, each linked to a KPI id
- Quick action items are `action-0` through `action-3`
- `documents.ts` is imported but wasn't previously used in `search.ts` — now used for education embedding text
---
## 2026-02-15 - US-003
- Updated `scripts/generate-embeddings.ts` to import `buildEmbeddingTexts()` and generate full embeddings
- Script embeds all 42 palette items sequentially using `Xenova/all-MiniLM-L6-v2`
- Outputs `src/data/embeddings.json` as `Array<{ id: string, embedding: number[] }>`
- Each embedding is a 384-dimensional float array
- File is ~453KB (42 items × 384 floats with pretty-printed JSON)
- `npm run generate-embeddings` regenerates the file successfully
- Typecheck and lint pass
- Files changed: `scripts/generate-embeddings.ts`, `src/data/embeddings.json`
- **Learnings for future iterations:**
- `import.meta.dirname` works in tsx/Node ESM scripts — use it instead of `__dirname` (which isn't available in ESM)
- `@/` path alias works in `npx tsx` scripts because tsx resolves tsconfig paths automatically
- The embeddings file is ~450KB with pretty-print; could be reduced with compact JSON but readability is preferred for now
- Processing 42 items takes ~10-15 seconds on first run (model cached after first download)
---
## 2026-02-15 - US-004
- Created `src/lib/embedding-model.ts` with three exports: `initModel()`, `embedQuery()`, `isModelReady()`
- Module-level `let extractor` pattern avoids React re-render issues
- `initModel()` uses `loading` guard to prevent duplicate pipeline loads
- `embedQuery()` uses same `pooling: 'mean'` and `normalize: true` as the build script
- `initModel()` called fire-and-forget in `App.tsx` `useEffect([], [])` — runs during boot phase
- Silent failure: try/catch swallows errors, `isModelReady()` stays false
- Typecheck, lint, and build all pass
- Files changed: `src/lib/embedding-model.ts` (new), `src/App.tsx`
- **Learnings for future iterations:**
- `FeatureExtractionPipeline` type is exported from `@xenova/transformers` and can be used for the module-level variable
- The `loading` boolean guard prevents race conditions if `initModel()` is called multiple times (e.g., React strict mode double-mount)
- `initModel()` is intentionally not awaited — it's fire-and-forget so it doesn't block the boot animation
- Consumers should check `isModelReady()` before calling `embedQuery()` — it throws if model isn't loaded
---
## 2026-02-15 - US-005
- Created `src/lib/semantic-search.ts` with cosine similarity search and embeddings loader
- `semanticSearch()` computes cosine similarity, filters by threshold (default 0.3), returns sorted by score descending
- `loadEmbeddings()` imports `embeddings.json` via Vite's native JSON import and returns typed array
- Typecheck and lint pass (0 new warnings)
- Files changed: `src/lib/semantic-search.ts` (new)
- **Learnings for future iterations:**
- Vite handles JSON imports natively — `import data from '@/data/embeddings.json'` just works, no dynamic import needed
- Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used
- With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries
---
## 2026-02-15 - US-006
- Integrated semantic search into CommandPalette with Fuse.js fallback
- When `isModelReady()` is true: debounces query by 200ms, calls `embedQuery()`, runs `semanticSearch()` against preloaded embeddings, maps result IDs back to PaletteItems via O(1) Map lookup
- When model is NOT ready: uses existing Fuse.js search (behavior preserved exactly)
- Results maintain `groupBySection()` grouping and section ordering
- Existing keyboard navigation, action routing, and UI unchanged
- Semantic results state is cleared when palette opens/closes and when query is empty
- Error handling: any failure in embedQuery/semanticSearch silently falls back to Fuse.js
- Typecheck, lint, and build all pass
- Browser verified: Fuse.js fallback works correctly; ONNX model loads asynchronously during boot and activates semantic search when ready
- Files changed: `src/components/CommandPalette.tsx`
- **Learnings for future iterations:**
- Semantic search is async so it can't live in a `useMemo` — use `useState` + debounced `useEffect` pattern instead
- The `useRef + setTimeout` debounce pattern works well here: set `debounceRef.current = setTimeout(...)`, clear it in the cleanup function, and in early-return paths
- `isModelReady()` is a synchronous check — call it before setting up the debounce timeout to avoid unnecessary delays when model isn't loaded
- The ONNX model takes several seconds to load in the browser (downloads ~23MB first time, then cached in IndexedDB), so initial searches will always use Fuse.js fallback
- `loadEmbeddings()` is cheap (just returns the already-imported JSON) — safe to call in `useMemo` without performance concern
---
## 2026-02-15 - US-007
- Created `src/components/ChatWidget.tsx` — floating chat button with toggle state
- 48px circular button (40px on mobile <640px), fixed bottom-right, teal accent background, white MessageCircle icon
- Entrance animation: fade + translateY(8px→0), 1s delay after mount, via framer-motion variants
- Respects `prefers-reduced-motion` — skips animation, shows immediately
- Hover: shadow-md → shadow-lg + scale(1.05), 150ms transition
- z-index 90 (below command palette z-1000)
- onClick toggles `isOpen` state, swaps icon between MessageCircle and X
- Mounted in `DashboardLayout.tsx` alongside CommandPalette and DetailPanel
- Typecheck, lint (0 errors), and build all pass
- Browser verified: button visible at bottom-right, toggle works (Open chat ↔ Close chat)
- Files changed: `src/components/ChatWidget.tsx` (new), `src/components/DashboardLayout.tsx`
- **Learnings for future iterations:**
- Responsive sizing via Tailwind classes (`h-10 w-10 sm:h-12 sm:w-12`) works well with inline style for non-Tailwind properties (boxShadow, border-radius)
- `AnimatePresence` is already imported and ready for the panel animation in US-008
- The `isOpen` state lives in ChatWidget — US-008 will add the panel UI inside the same component
- Hover effects use `onMouseEnter/Leave` with direct style mutation (same pattern as other dashboard components)
---
## 2026-02-15 - US-008
- Built chat panel UI inside `ChatWidget.tsx` with header, message area, and input
- Panel opens above the floating button with scale+opacity entrance/exit animation via framer-motion `AnimatePresence`
- Messages stored as `Array<{ role: 'user' | 'assistant', content: string }>` in component state
- User messages right-aligned in teal-tinted bubbles (`var(--accent-light)` bg, `var(--accent-border)` border)
- Assistant messages left-aligned in light gray bubbles (`var(--bg-dashboard)` bg, `var(--border-light)` border)
- Message corner radii differ: user bubbles have small bottom-right radius, assistant bubbles small bottom-left (conversational feel)
- Input area: textarea with Enter to submit, Shift+Enter for newline. Send button enabled/disabled based on input content
- Empty state shows placeholder text when no messages yet
- Auto-scrolls to latest message via `useRef` + `scrollIntoView`
- Auto-focuses input when panel opens (200ms delay for animation)
- Responsive: on mobile (<640px), panel is full-width bottom sheet with rounded top corners; on desktop, 380px wide positioned above the button
- Panel entrance: scale(0.95)+opacity(0) → scale(1)+opacity(1), 200ms. Exit: reverse, 150ms
- Respects `prefers-reduced-motion` — skips all animation
- Close button in header triggers `setIsOpen(false)` (same as floating button toggle)
- Submitting appends both user message and placeholder assistant response to state
- Typecheck, lint (0 errors), and build all pass
- Browser verified: panel opens/closes correctly, messages display, input works, Enter submits, close button works
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- `AnimatePresence` with `key` prop on the panel div is needed for exit animations to work
- Panel uses `transformOrigin: 'bottom right'` for natural scale animation from the button corner
- CSS-in-JS `<style>` tag with `data-chat-panel` attribute handles responsive width/height (Tailwind can't express max-height conditionally based on viewport width easily)
- `textarea` with `rows={1}` and `maxHeight: 80px` gives auto-growing feel; `resize: none` prevents manual resize
- The `ChatMessage` interface (`{ role, content }`) is ready to be extended for US-009 Gemini integration — same shape as typical LLM message format
- `onFocus/onBlur` border color transitions on the textarea give a polished input interaction
---
## 2026-02-15 - US-009
- Created `src/lib/gemini.ts` — Gemini Flash streaming integration module
- `sendChatMessage(messages)` async generator that streams SSE tokens from Gemini 2.0 Flash
- `isGeminiAvailable()` checks for `VITE_GEMINI_API_KEY` env var
- `parseItemIds(text)` extracts `[ITEMS: id1, id2]` from response text
- `stripItemsSuffix(text)` removes the `[ITEMS: ...]` line for clean display
- System prompt built from `buildEmbeddingTexts()` output — full CV context (~42 items)
- Model instructed to answer concisely and append relevant palette item IDs
- Rewired `ChatWidget.tsx` to use real Gemini API instead of placeholder responses
- Streaming: tokens progressively appear in assistant message bubble
- Typing indicator (Loader2 spinner + "Thinking...") shown while waiting for first token
- Input disabled during streaming, send button grayed out
- Error handling: API failures show "Sorry, I couldn't process that. Please try again."
- Missing API key: panel shows "Chat is currently unavailable", input area hidden
- Conversation history capped at 10 messages before sending to API
- Assistant messages store parsed item IDs as `<!--ITEMS:id1,id2-->` HTML comment (for US-010)
- Messages sent to API have metadata stripped to keep context clean
- Typecheck, lint (0 errors), and build all pass
- Files changed: `src/lib/gemini.ts` (new), `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- Gemini SSE format: `data:` prefix per line, JSON body with `candidates[0].content.parts[0].text`
- `system_instruction` field in Gemini request body sets the system prompt (not a message in `contents`)
- Gemini role mapping: `'assistant'` → `'model'` in the API's `contents` array
- Buffer-based SSE parsing handles chunk boundaries: split on `\n`, keep last incomplete line in buffer
- `buildEmbeddingTexts()` is a great source for structured CV context — natural language paragraphs per item
- The `<!--ITEMS:-->` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display
- `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three
---
## 2026-02-15 - US-010
- Extracted `iconByType` and `iconColorStyles` from `CommandPalette.tsx` into shared `src/lib/palette-icons.ts`
- Updated `CommandPalette.tsx` to import from the shared module (no behavioral change)
- Added `onAction?: (action: PaletteAction) => void` prop to `ChatWidget` — same pattern as `CommandPalette`
- `DashboardLayout.tsx` passes `handlePaletteAction` to `ChatWidget` (same handler used by CommandPalette)
- ChatWidget builds a `paletteMap` (Map<id, PaletteItem>) via `useMemo` for O(1) item lookups
- Added `getMessageItemIds()` to parse `<!--ITEMS:id1,id2-->` HTML comments from message content
- Added `getMessageItems()` to resolve parsed IDs to PaletteItem objects via the map
- Assistant message bubbles now render compact clickable item cards below text when items are referenced:
- Cards use same icon/color scheme from CommandPalette (22px icon + title + subtitle)
- Cards have hover highlight (`var(--accent-light)`) and trigger `onAction(item.action)` on click
- Cards only appear after streaming completes (when `<!--ITEMS:-->` metadata is in final content)
- If no items referenced or IDs don't match, no cards shown — just text
- Typecheck, lint (0 errors), and build all pass
- Files changed: `src/lib/palette-icons.ts` (new), `src/components/ChatWidget.tsx`, `src/components/CommandPalette.tsx`, `src/components/DashboardLayout.tsx`
- **Learnings for future iterations:**
- Extracting shared constants to `src/lib/` is the right pattern — both `CommandPalette` and `ChatWidget` now use the same icon mappings without duplication
- `buildPaletteData()` is pure (no side effects) and idempotent — safe to call in `useMemo` with empty deps
- The `<!--ITEMS:-->` HTML comment regex `<!--ITEMS:([^>]*)-->` works reliably; `[^>]*` captures everything between the colons and closing
- Item card buttons use `fontFamily: 'inherit'` to pick up the panel's `font-ui` — without this, browser defaults apply
- The `overflow: 'hidden'` on the message bubble container is needed so the item cards section (with its own border-top) stays visually contained within the bubble's border-radius
---
## 2026-02-15 - US-011
- Updated ChatWidget mobile breakpoint from `sm` (640px) to `md` (768px)
- Changed mobile panel from 85vh bottom-sheet to full-screen overlay using `position: fixed; inset: 0` with `100dvh` height
- Panel z-index on mobile bumped to 101 (`max-md:z-[101]`) to render above TopBar (z-100) and nav (z-99)
- Floating chat button hidden on mobile when panel is open via `max-md:!hidden` Tailwind class
- Fixed specificity issue: inline `style={{ display: 'flex' }}` was overriding Tailwind's `hidden` — moved flex/centering to Tailwind classes (`flex items-center justify-center`)
- Safe area insets applied via `env(safe-area-inset-*)` CSS on the `[data-chat-panel]` element for notched devices
- Input area stays pinned to bottom via existing flex layout (flex-col container + flex-1 message area + flex-shrink-0 input)
- Desktop behavior unchanged: 380px wide, anchored bottom-right, max-height 480px, floating button visible
- Panel open/close animations still respect `prefers-reduced-motion`
- Typecheck, lint (0 errors), and build all pass
- Browser verified at 375×812 (mobile) and 1280×800 (desktop): full-screen overlay works, button hides/shows correctly, close button works
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- Inline `style` properties always override CSS classes — to allow Tailwind responsive utilities (like `max-md:hidden`) to work, move conflicting properties (especially `display`) to Tailwind classes instead
- Use `!important` modifier (`max-md:!hidden`) when competing with framer-motion's inline styles that can't be easily removed
- TopBar (`z-100`) and nav (`z-99`) sit above the chat panel's default `z-90` — mobile full-screen panels need `z-101+` to overlay properly
- `100dvh` (dynamic viewport height) is essential for mobile full-screen panels — it accounts for browser chrome (address bar, toolbar) unlike `100vh`
- The `[data-chat-panel]` CSS selector in the `<style>` block is the right place for responsive size rules since Tailwind can't conditionally set max-height based on viewport width
---
## 2026-02-15 - US-012
- Replaced empty-state centered text with welcome bubble + suggested question chips
- Welcome bubble styled as assistant message (left-aligned, `var(--bg-dashboard)` bg, `var(--border-light)` border)
- Added `SUGGESTED_QUESTIONS` const array at module top for easy future editing
- Three chips: "What's his NHS experience?", "Tell me about his data skills", "What projects has he built?"
- Chips styled: rounded-full, teal accent border, teal hover tint, `font-ui` 12.5px
- Clicking a chip calls `handleSubmit(questionText)` — same codepath as typing + Enter
- Refactored `handleSubmit` to accept optional `overrideText` parameter (avoids stale state issue with `setInputValue` + immediate submit)
- Wrapped send button `onClick` in arrow function to prevent passing MouseEvent as text argument
- Welcome/chips visible when `messages.length === 0`, replaced by conversation once any message is sent
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
- Browser verified: welcome bubble displays correctly, chips render, clicking chip sends message and replaces welcome state
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- When refactoring a callback to accept optional parameters, wrap `onClick={handler}` as `onClick={() => handler()}` to prevent React from passing the SyntheticEvent as the first argument
- `SUGGESTED_QUESTIONS` as a module-level const is the simplest approach — easily editable, no data file needed for 3 items
- The `handleSubmit(overrideText?)` pattern avoids the stale-state problem: `setInputValue(text)` followed by immediate `handleSubmit()` would read the old `inputValue` since React batches state updates
---
## 2026-02-15 - US-013
- Downloaded all-MiniLM-L6-v2 model files to `public/models/Xenova/all-MiniLM-L6-v2/`:
- `config.json`, `tokenizer.json`, `tokenizer_config.json`, `onnx/model_quantized.onnx` (~22MB)
- Updated `src/lib/embedding-model.ts`:
- `env.localModelPath = '/models/'` — Vite serves `public/` at root
- `env.allowRemoteModels = false` — prevents any HF CDN fallback
- `env.useBrowserCache = false` — prevents stale Cache API entries from interfering
- Updated `scripts/generate-embeddings.ts`:
- `env.localModelPath = resolve(import.meta.dirname, '..', 'public', 'models')` — absolute path for Node.js
- `env.allowRemoteModels = false`
- Model files committed as static assets (not in .gitignore)
- Browser verified: all 4 model files fetched from `localhost:5173/models/` with 200 OK, zero `huggingface.co` requests
- Semantic search verified working: "data analysis" returns multi-category results (Core Skills, Active Projects, Achievements)
- Build script (`npm run generate-embeddings`) still works with local model files
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
- Files changed: `src/lib/embedding-model.ts`, `scripts/generate-embeddings.ts`, `public/models/Xenova/all-MiniLM-L6-v2/` (new directory with 4 files)
- **Learnings for future iterations:**
- `@xenova/transformers` env configuration: `env.localModelPath` sets the base path, `env.allowRemoteModels = false` prevents CDN fallback, `env.useBrowserCache = false` bypasses Browser Cache API
- The library constructs paths as `{localModelPath}/{modelId}/{filename}` — so `/models/` + `Xenova/all-MiniLM-L6-v2` + `/onnx/model_quantized.onnx`
- Browser Cache API can retain stale entries from previous HF CDN loads — setting `useBrowserCache = false` forces fresh fetches from the configured local path
- For Node.js scripts, use an absolute filesystem path for `localModelPath` (not a URL)
- The quantized ONNX model (`model_quantized.onnx`) is ~22MB — acceptable for a static asset since it's cached after first load
---
## 2026-02-15 - US-014
- Extracted `GEMINI_MODEL` and `GEMINI_DISPLAY_NAME` constants in `src/lib/gemini.ts`
- Updated `GEMINI_API_BASE` to use template literal with `GEMINI_MODEL` constant (`gemini-3-flash-preview`)
- Tightened system prompt: restructured with markdown headers, more concise instructions, clearer `[ITEMS: ...]` format specification
- Added model indicator to ChatWidget header: "Gemini 3 Flash" in `font-geist`, 11px, `var(--text-tertiary)`, right-aligned next to title
- Imported `GEMINI_DISPLAY_NAME` in ChatWidget for the indicator text
- Typecheck passes (0 errors), lint passes (0 new errors/warnings), build succeeds
- Files changed: `src/lib/gemini.ts`, `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- `gemini-3-flash-preview` is the correct model ID for Gemini 3 Flash (confirmed via Google AI docs); Gemini 2.0 Flash deprecated, shutdown scheduled for March 31 2026
- The API path structure (`v1beta/models/{model}:streamGenerateContent?alt=sse&key=KEY`) is unchanged between Gemini 2 and 3
- Extracting both `GEMINI_MODEL` (for API URL) and `GEMINI_DISPLAY_NAME` (for UI) as separate constants keeps the API ID decoupled from the human-readable name
- System prompt with markdown headers (##) gives the model clearer section boundaries — improves instruction following for structured output like `[ITEMS: ...]`
- Pre-existing uncommitted change in `src/App.tsx` (boot→login phase skip) was excluded from the commit — always check `git diff --stat` and stage specific files
---
+185 -94
View File
@@ -1,185 +1,276 @@
{ {
"project": "Portfolio — Login Logo & Blur Refinements", "project": "Portfolio — Semantic Search & AI Chat",
"branchName": "ralph/login-logo-refinements", "branchName": "ralph/semantic-search",
"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.", "description": "Replace Fuse.js command palette search with client-side semantic vector search (ONNX model), then add a Gemini Flash-powered AI chat widget.",
"userStories": [ "userStories": [
{ {
"id": "US-001", "id": "US-001",
"title": "Skip to login phase for dev iteration", "title": "Install @xenova/transformers and add generate-embeddings script skeleton",
"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.", "description": "As a developer, I need the Transformers.js dependency installed and a runnable script scaffold so subsequent stories can generate and use embeddings.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"In src/App.tsx, change the initial Phase state from 'boot' to 'login'", "npm install @xenova/transformers",
"The boot, ECG, and login phases remain in code — only the initial state changes", "Create scripts/generate-embeddings.ts with a main() function that imports the pipeline from @xenova/transformers",
"App loads directly to the login screen on refresh", "Script loads the all-MiniLM-L6-v2 model and embeds a single test string, logging the vector length to confirm it works",
"Add npm script: \"generate-embeddings\": \"npx tsx scripts/generate-embeddings.ts\"",
"Running npm run generate-embeddings prints the vector length (384) and exits cleanly",
"Typecheck passes" "Typecheck passes"
], ],
"priority": 1, "priority": 1,
"passes": true, "passes": true,
"notes": "Temporary — final story reverts this. Phase state is on line 47 of App.tsx." "notes": "Use @xenova/transformers (not @huggingface/transformers — the Xenova fork has better Node.js ONNX support). The model ID is 'Xenova/all-MiniLM-L6-v2'. Pipeline type is 'feature-extraction'. tsx is already available via npx for running TypeScript scripts."
}, },
{ {
"id": "US-002", "id": "US-002",
"title": "Extract animation timing into named constants", "title": "Build rich text representations for each palette item",
"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.", "description": "As a developer, I want each palette item to have a natural-language paragraph for embedding that captures deep context, not just the title.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"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)", "New function buildEmbeddingTexts() in src/lib/search.ts that returns Array<{ id: string, text: string }> for all palette items",
"Additional named constants for overlap blend: OVERLAY_BLEND_START_PROGRESS (target 0.5), OVERLAP_BLEND_MAX_OPACITY (target 0.2), OVERLAP_BLEND_TRANSITION_DURATION", "Consultation items include: role, org, duration, history narrative, examination bullets, coded entry descriptions",
"Component behaviour unchanged when constants retain current values", "Skill items include: name, category, frequency, proficiency percentage, years of experience",
"Constants are clearly named and grouped with a brief comment block", "KPI items include: value, label, explanation, story context and outcomes",
"Investigation items include: name, methodology, tech stack list, results",
"Education items include: title, institution, type, research detail",
"Quick Action items include: title and subtitle (short text is fine)",
"Achievement items include: title, subtitle, and linked KPI story context if available",
"Each text is a readable natural-language paragraph, not a keyword dump",
"Typecheck passes" "Typecheck passes"
], ],
"priority": 2, "priority": 2,
"passes": true, "passes": true,
"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." "notes": "This function will be used by both the build script (to generate embeddings) and potentially by the chat widget (for context). Import the raw data files (consultations, skills, kpis, investigations, documents) to access the full data beyond what buildPaletteData() surfaces. The id must match the PaletteItem id so embeddings can be correlated."
}, },
{ {
"id": "US-003", "id": "US-003",
"title": "Scale logo and branding block to ~50% of login card height", "title": "Generate and commit embeddings.json",
"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.", "description": "As a developer, I want the generate-embeddings script to produce a complete embeddings.json file using the rich text representations.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Logo cssHeight scaled up from current clamp(48px, 4vw, 64px) — target approximately clamp(160px, 18vw, 280px), tune visually for balance", "scripts/generate-embeddings.ts imports buildEmbeddingTexts() from src/lib/search.ts",
"Width scales proportionally (SVG viewBox preserves aspect ratio)", "Script embeds each item's text using the all-MiniLM-L6-v2 model via @xenova/transformers pipeline",
"The branding block (logo + CVMIS title + subtitle + spacing) occupies approximately 50% of the total login card height", "Outputs src/data/embeddings.json as an array of { id: string, embedding: number[] }",
"Logo does not overflow or clip on mobile viewports (>=375px wide)", "Each embedding is a 384-dimensional float array",
"Typecheck passes", "Running npm run generate-embeddings regenerates the file successfully",
"Verify in browser using dev-browser skill" "The JSON file is valid and parseable",
"Typecheck passes"
], ],
"priority": 3, "priority": 3,
"passes": true, "passes": true,
"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." "notes": "The pipeline returns a Tensor — use .tolist() or .data to extract the raw float array. Mean-pool across the token dimension (dim 1) to get a single 384-d vector per input. Process items sequentially to avoid OOM in Node. The output file will be ~200KB for ~40 items with 384 floats each."
}, },
{ {
"id": "US-004", "id": "US-004",
"title": "Increase branding text to match dashboard typography scale", "title": "Preload ONNX model during boot sequence",
"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.", "description": "As a visitor, I want the semantic search model to download in the background during the boot/ECG/login phases so it's ready when I reach the dashboard.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"CVMIS title font size increased from 13px — target approximately 18-20px to match dashboard heading scale", "New src/lib/embedding-model.ts module that exports: initModel(), embedQuery(text: string), and isModelReady()",
"CV Management Information System subtitle font size increased from 11px — target approximately 13-14px", "initModel() loads the all-MiniLM-L6-v2 pipeline from @xenova/transformers and stores it in a module-level variable",
"Both remain in font-ui (Elvaro Grotesque) with appropriate weight hierarchy", "embedQuery() returns a Promise<number[]> (384-d vector) for a given text string",
"Text remains visually balanced with the larger logo above and the login form below", "isModelReady() returns boolean indicating if the model has finished loading",
"Typecheck passes", "initModel() is called in App.tsx useEffect on mount (during boot phase) — fire and forget, no await",
"Verify in browser using dev-browser skill" "If initModel() fails (network error, etc.), isModelReady() remains false — no error thrown or shown",
"Model is cached by @xenova/transformers in IndexedDB — subsequent page loads are near-instant",
"Boot/ECG/login animations are not affected by model loading",
"Typecheck passes"
], ],
"priority": 4, "priority": 4,
"passes": true, "passes": true,
"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." "notes": "Use pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2') which auto-downloads and caches the ONNX model. The module-level pattern (let pipelineInstance = null) avoids React re-render issues. embedQuery should mean-pool the tensor output the same way as the build script. Wrap initModel() in a try/catch that silently swallows errors."
}, },
{ {
"id": "US-005", "id": "US-005",
"title": "Add overlap blend effect on fanning capsules", "title": "Implement cosine similarity search module",
"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.", "description": "As a developer, I need a semantic search function that compares a query embedding against pre-computed item embeddings and returns ranked results.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"CSS mix-blend-mode: multiply applied to the fanning pill elements in CvmisLogo.tsx", "New src/lib/semantic-search.ts module",
"Blend effect is not visible at the start of the fan animation", "Exports semanticSearch(queryEmbedding: number[], embeddings: Array<{ id: string, embedding: number[] }>, threshold?: number): Array<{ id: string, score: number }>",
"Blend fades in starting at ~50% of fan animation progress (using OVERLAY_BLEND_START_PROGRESS constant from US-002)", "Uses cosine similarity: dot(a,b) / (magnitude(a) * magnitude(b))",
"Blend reaches max intensity by end of fan (using OVERLAP_BLEND_MAX_OPACITY constant from US-002)", "Results sorted by score descending",
"Max blend opacity approximately 0.2 (20%)", "Optional threshold parameter filters out low-relevance results (default 0.3)",
"Blend is only perceptible where capsules actually overlap on light backgrounds", "Exports loadEmbeddings() that imports embeddings.json and returns the parsed array",
"Blend transition feels smooth, not abrupt", "Typecheck passes"
"Respects prefers-reduced-motion (no animation, show final state)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
], ],
"priority": 5, "priority": 5,
"passes": true, "passes": true,
"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." "notes": "Keep the cosine similarity implementation simple — no libraries needed for 384-d vectors over ~40 items. The loadEmbeddings function can use a dynamic import or direct import of the JSON file (Vite handles JSON imports natively)."
}, },
{ {
"id": "US-006", "id": "US-006",
"title": "Extend backdrop blur to cover full dashboard including TopBar", "title": "Integrate semantic search into command palette",
"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.", "description": "As a visitor, I want the command palette to use semantic search when available, falling back to Fuse.js otherwise.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"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", "CommandPalette.tsx checks isModelReady() from embedding-model.ts",
"TopBar, Sidebar, and all dashboard content are uniformly blurred behind the overlay", "When model is ready and query is non-empty: call embedQuery(query), then semanticSearch() against loaded embeddings, then map result IDs back to PaletteItem objects",
"Login card itself remains crisp and unblurred (card z-index above overlay)", "When model is NOT ready: use existing Fuse.js search (current behavior preserved exactly)",
"Blur still fades out during the dissolve/exit transition", "Search is debounced by ~200ms to avoid calling embedQuery on every keystroke",
"Results maintain existing groupBySection() grouping and section ordering",
"Existing keyboard navigation, action routing, and UI unchanged",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title"
], ],
"priority": 6, "priority": 6,
"passes": true, "passes": true,
"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." "notes": "The debounce is important — embedQuery takes ~20-50ms per call. Use a useRef + setTimeout pattern or a simple debounce hook. The mapping from semantic search results (id + score) back to PaletteItems should use a Map for O(1) lookup. Keep the Fuse.js imports and buildSearchIndex — they're the fallback path."
}, },
{ {
"id": "US-007", "id": "US-007",
"title": "Reduce backdrop blur intensity by ~50%", "title": "Chat widget — floating button component",
"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.", "description": "As a visitor, I see a floating chat button at the bottom-right of the dashboard that I can click to open a chat panel.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Blur value reduced from blur(20px) to approximately blur(10px)", "New src/components/ChatWidget.tsx component",
"The blur value is a named constant co-located with other LoginScreen timing constants for easy adjustment", "Renders a 48px circular button, fixed position, bottom: 24px, right: 24px",
"Login card remains clearly readable against the softened backdrop", "Uses teal accent background (var(--accent)), white MessageCircle icon from lucide-react",
"The dissolve exit animation still animates blur from 10px to 0px", "Shadow: var(--shadow-md). Hover: var(--shadow-lg) + scale(1.05) transition",
"Button has a subtle entrance animation: fade + translateY(8px) → translateY(0), delayed ~1s after mount",
"Respects prefers-reduced-motion (no animation, just visible)",
"z-index above dashboard content but below command palette overlay (z-index 90)",
"onClick toggles an isOpen state (panel rendering comes in next story)",
"Mounted in DashboardLayout.tsx",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 7, "priority": 7,
"passes": true, "passes": true,
"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." "notes": "Use framer-motion for the entrance animation to match the rest of the app's motion patterns. The button should use font-ui for any text. On mobile (<640px), button is 40px and positioned bottom: 16px, right: 16px. The VITE_GEMINI_API_KEY env var check can wait until the Gemini integration story — for now just render the button unconditionally."
}, },
{ {
"id": "US-008", "id": "US-008",
"title": "Align login card border radius and shadow with dashboard design system", "title": "Chat widget — panel UI with message display",
"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.", "description": "As a visitor, I want a chat panel that opens above the floating button where I can type questions and see responses.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Login card border radius changed from 12px to 8px (matching var(--radius-card) / dashboard cards)", "Chat panel renders when isOpen is true, positioned above the floating button (bottom: 88px, right: 24px)",
"Login input fields and button border radius changed from 4px to 6px (matching var(--radius-sm) / dashboard inner elements)", "Panel dimensions: 380px wide, max-height 480px, with overflow-y auto for messages",
"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", "Header: title text ('Ask about Andy'), close button (X icon)",
"Use CSS custom property references (var(--radius-card), var(--radius-sm)) where available rather than hardcoded values", "Message area: user messages right-aligned in teal-tinted bubbles, assistant messages left-aligned in light gray bubbles",
"Input area at bottom: text field with placeholder 'Ask me anything...', send button (Send icon)",
"Enter key submits message, Shift+Enter for newline",
"Panel entrance animation: scale(0.95) + opacity(0) → scale(1) + opacity(1), 200ms ease-out",
"Panel exit animation: reverse of entrance",
"Respects prefers-reduced-motion",
"Responsive: on mobile (<640px), panel is full-width (left: 0, right: 0, bottom: 0) with rounded top corners only",
"Messages are stored in component state as Array<{ role: 'user' | 'assistant', content: string }>",
"Submitting a message adds it to state and shows it in the UI (no API call yet — assistant response is a placeholder)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 8, "priority": 8,
"passes": true, "passes": true,
"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." "notes": "Use the design system tokens: var(--surface) for panel bg, var(--border-light) for borders, var(--text-primary) for text, var(--accent) for user bubble bg at 10% opacity, font-ui for body text, font-geist for timestamps. The placeholder assistant response can be a static string like 'AI chat coming soon — this is a preview of the chat interface.' This lets us verify the full UI before wiring up Gemini."
}, },
{ {
"id": "US-009", "id": "US-009",
"title": "Replace hardcoded colors with design tokens", "title": "Chat widget — Gemini Flash integration",
"description": "As a developer, I want the login screen to reference the same CSS custom properties as the dashboard so palette changes propagate consistently.", "description": "As a visitor, I can ask natural language questions and get intelligent, streamed answers about Andy's experience.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Input text color changed from hardcoded #111827 to var(--text-primary, #1A2B2A)", "New src/lib/gemini.ts module that exports sendChatMessage(messages: ChatMessage[], cvContext: string): AsyncGenerator<string>",
"Cursor/caret color changed from hardcoded #0D6E6E to var(--accent, #0D6E6E)", "Calls Google Gemini Flash API (gemini-2.0-flash) using the REST API with fetch (no SDK needed)",
"Button background colors changed from hardcoded #0D6E6E / #0A8080 / #085858 to var(--accent) / var(--accent-hover) / appropriate pressed variant using token references", "API key sourced from import.meta.env.VITE_GEMINI_API_KEY",
"Any other hardcoded color values in LoginScreen.tsx that have corresponding CSS custom properties use the token instead", "System prompt includes structured CV context built from buildEmbeddingTexts() output",
"No visual change (token values resolve to same colors currently)", "System prompt instructs the model to answer questions about Andy's professional experience accurately and concisely",
"System prompt instructs the model to include relevant palette item IDs in its response as a JSON array at the end",
"Responses are streamed using the Gemini streaming endpoint",
"ChatWidget.tsx wires up real messages: on submit, calls sendChatMessage and streams tokens into the assistant message bubble",
"Loading state shown (typing indicator) while waiting for first token",
"If VITE_GEMINI_API_KEY is not set, chat button is still visible but panel shows 'Chat is currently unavailable' message",
"If API call fails, show error message in chat: 'Sorry, I couldn't process that. Please try again.'",
"Conversation history (last 10 messages) passed to API for multi-turn context",
"Typecheck passes" "Typecheck passes"
], ],
"priority": 9, "priority": 9,
"passes": true, "passes": true,
"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." "notes": "Gemini REST streaming endpoint: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=API_KEY. The response is SSE (server-sent events) — parse each 'data:' line as JSON and extract candidates[0].content.parts[0].text. The system prompt with CV context will be ~2-3K tokens — well within Gemini Flash limits. For the palette item IDs, instruct the model to end its response with a line like [ITEMS: id1, id2, id3] which can be parsed client-side."
}, },
{ {
"id": "US-010", "id": "US-010",
"title": "Fix minor typography inconsistencies", "title": "Chat widget — clickable portfolio item cards in responses",
"description": "As a visitor, I want the login screen's typography weight and sizing to feel consistent with the dashboard's conventions.", "description": "As a visitor, I want AI chat responses to include clickable portfolio items so I can drill into relevant sections.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Form label font weight increased from 500 to 600 (matching dashboard card header weight convention)", "After parsing the assistant response, extract referenced palette item IDs from the [ITEMS: ...] suffix",
"Input text mid-value aligned to ~14px to match dashboard body text", "Render matched items as compact clickable cards below the answer text in the assistant bubble",
"Button text mid-value aligned to ~15px", "Cards reuse icon/color mapping from CommandPalette (iconByType, iconColorStyles)",
"Connection status indicator gap increased from 6px to 8px (matching dashboard CardHeader gap)", "Cards show item title and subtitle in a compact horizontal layout",
"No dramatic visual change — these are subtle alignment fixes", "Clicking a card triggers the same action routing as command palette via handlePaletteAction in DashboardLayout",
"Typecheck passes" "If no items are referenced or IDs don't match, no cards are shown (just the text answer)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
], ],
"priority": 10, "priority": 10,
"passes": true, "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." "notes": "The action routing needs to flow from ChatWidget up to DashboardLayout. Add an onAction prop to ChatWidget (same pattern as CommandPalette). DashboardLayout passes handlePaletteAction to ChatWidget. Export iconByType and iconColorStyles from CommandPalette (or extract to a shared module) so ChatWidget can reuse them."
}, },
{ {
"id": "US-011", "id": "US-011",
"title": "Re-enable boot sequence", "title": "Mobile full-screen chat panel",
"description": "As a user, I want the full boot → ECG → login → dashboard experience restored.", "description": "As a mobile visitor, I want the chat panel to be a full-screen overlay so it's easy to use on small screens.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"In src/App.tsx, change the initial Phase state back from 'login' to 'boot'", "Below md breakpoint (768px), chat panel renders as full-screen overlay using position: fixed; inset: 0 with 100dvh height",
"Boot → ECG → Login → Dashboard sequence works end to end", "Full-screen mode has the existing header with close button (no visual change needed, just full-width)",
"Login screen shows blurred dashboard behind it with reduced blur and full TopBar coverage", "Floating chat button is hidden (display: none or opacity: 0) while panel is open on mobile (<768px)",
"Logo animation plays with blend effect, typing animation follows, connection indicator transitions, button pulses", "Above 768px, existing panel behavior is unchanged (380px wide, anchored bottom-right, max-height 480px)",
"Clicking login dissolves the overlay to reveal the dashboard", "Panel open/close animation still respects prefers-reduced-motion",
"Safe area insets applied via env(safe-area-inset-*) for notched devices",
"Input area stays pinned to bottom of screen on mobile",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 11, "priority": 11,
"passes": true, "passes": true,
"notes": "Simple revert of US-001. Phase state is on line 47 of App.tsx." "notes": "The current ChatWidget already has some mobile handling (bottom-sheet style at <640px). This story changes the breakpoint to 768px (md) and makes it truly full-screen instead of 85vh. Use 100dvh (dynamic viewport height) to account for mobile browser chrome. The floating button visibility can be controlled by combining isOpen state with a CSS media query or a useMediaQuery hook. The <style> block with data-chat-panel attribute is the place to update responsive rules."
},
{
"id": "US-012",
"title": "Welcome message with suggested question chips",
"description": "As a visitor opening the chat, I see a friendly welcome message and clickable suggested questions so I know what to ask.",
"acceptanceCriteria": [
"When chat panel is open and conversation is empty, display welcome text: 'Hey! I'm here to help you learn more about Andy. What would you like to know?'",
"Welcome text is styled as an AI message bubble (left-aligned, light background, same styling as assistant messages)",
"Below the welcome bubble, show 2-3 clickable pill/chip buttons with suggested questions",
"Suggested questions: 'What's his NHS experience?', 'Tell me about his data skills', 'What projects has he built?'",
"Chips styled with: teal accent border, rounded-full, font-ui 12-13px, hover state (teal background tint)",
"Clicking a chip sends that question as a user message (same codepath as typing + Enter)",
"Welcome message and chips always visible when conversation is empty (persist across panel open/close)",
"Once any message is sent, the welcome/chips area is replaced by the conversation messages",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 12,
"passes": true,
"notes": "Replace the current empty-state text ('Ask me anything about Andy's experience, skills, or projects.') with the new welcome bubble + chips. The chips should call handleSubmit (or equivalent) with the chip text pre-filled — simplest approach is setInputValue(chipText) then immediately trigger submit. Check that the welcome state reappears if the user hasn't sent a message (messages.length === 0). The suggested questions could live in a const array at the top of ChatWidget for easy future editing."
},
{
"id": "US-013",
"title": "Self-host ONNX embedding model",
"description": "As a developer, I want the ONNX model files served from the same host as the site to eliminate dependency on Hugging Face CDN.",
"acceptanceCriteria": [
"Model files for Xenova/all-MiniLM-L6-v2 downloaded and placed in public/models/all-MiniLM-L6-v2/onnx/ (matching HF repo structure)",
"Required files present: model_quantized.onnx, tokenizer.json, tokenizer_config.json, config.json, and any other files the pipeline expects",
"src/lib/embedding-model.ts updated: configure @xenova/transformers env to use local model path (e.g., env.localModelPath or custom model URL pointing to /models/)",
"scripts/generate-embeddings.ts also updated to use the same local model path for consistency",
"Model files are NOT in .gitignore — they are committed as static assets",
"No network requests to huggingface.co in the browser network tab when semantic search is used",
"Semantic search still works correctly in the command palette after the change",
"Typecheck passes"
],
"priority": 13,
"passes": true,
"notes": "Transformers.js uses env.localModelPath or env.remoteHost to control where models are fetched from. Setting env.localModelPath = '/models/' should make it look for files at /models/Xenova/all-MiniLM-L6-v2/onnx/model_quantized.onnx etc. The Vite public/ directory serves files at the root — so public/models/ becomes /models/ at runtime. For the build script (Node.js), use a file:// path or the local filesystem path instead. Download model files from https://huggingface.co/Xenova/all-MiniLM-L6-v2/tree/main — the quantized ONNX model is ~23MB. Check what files the pipeline actually requests by watching network tab before making this change."
},
{
"id": "US-014",
"title": "Update to Gemini 3 Flash Preview with model indicator",
"description": "As a developer, I want to use the latest free Gemini model, and as a visitor, I want to see what model powers the chat.",
"acceptanceCriteria": [
"Extract model name to a single constant (e.g., GEMINI_MODEL = 'gemini-3-flash-preview') used for both the API URL and display",
"GEMINI_API_BASE URL updated to use the new model constant",
"Review and tighten the system prompt — ensure it's well-structured, concise, and clear for the new model",
"Review the [ITEMS: ...] suffix instruction — ensure new model follows the format reliably",
"Small model indicator in chat panel header: 'Gemini 3 Flash' in font-geist, 11px, var(--text-tertiary)",
"Model indicator positioned right-aligned in the header bar or as a subtle line below the header",
"Streaming SSE parsing still works correctly with the new model endpoint",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 14,
"passes": false,
"notes": "The current API base is 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash'. Change the model segment to 'gemini-3-flash-preview'. The API path structure (v1beta/models/{model}:streamGenerateContent) should be the same. Verify that gemini-3-flash-preview is the correct model ID — check Google AI Studio or the API docs. For the display name, use a human-friendly string like 'Gemini 3 Flash' (not the full model ID). The constant should be defined at the top of gemini.ts and exported for use in ChatWidget."
} }
] ]
} }
+260 -164
View File
@@ -1,202 +1,298 @@
# Progress Log — Login Logo & Blur Refinements # Progress Log — Semantic Search & AI Chat
# Branch: ralph/login-logo-refinements # Branch: ralph/semantic-search
# Started: 2026-02-15 # Started: 2026-02-15
## Codebase Patterns ## Codebase Patterns
- `@xenova/transformers` pipeline with `pooling: 'mean'` and `normalize: true` returns a Tensor; use `Array.from(output.data as Float32Array)` to extract the 384-d vector
### Project Structure - Scripts live in `scripts/` and run via `npx tsx` (tsx is not a project dep, npx fetches it)
- Components in `src/components/`, tiles in `src/components/tiles/` - tsconfig `include` only covers `src/` — scripts are type-checked by tsx at runtime, not by `tsc --noEmit`
- Data files in `src/data/` - Project uses `"type": "module"` in package.json
- Types in `src/types/pmr.ts` and `src/types/index.ts` - Palette item IDs: `exp-{consultation.id}`, `skill-{skill.id}`, `proj-{investigation.id}`, `ach-{0-3}`, `edu-{0-3}`, `action-{0-3}`
- Hooks in `src/hooks/`, Contexts in `src/contexts/`, Lib in `src/lib/` - `buildEmbeddingTexts()` in `src/lib/search.ts` returns `Array<{ id: string, text: string }>` with IDs matching PaletteItem IDs — use this for both embedding generation and chat context
- Path alias: `@/` maps to `./src/` - `src/data/embeddings.json` is an array of `{ id: string, embedding: number[] }` — 42 items, 384-d vectors, IDs match PaletteItem IDs. Vite imports JSON natively.
- `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()`
### Phase Management - `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases
- App.tsx controls phase: 'boot' -> 'ecg' -> 'login' -> 'pmr' - ONNX model files self-hosted in `public/models/Xenova/all-MiniLM-L6-v2/` — `env.localModelPath = '/models/'`, `env.allowRemoteModels = false`, `env.useBrowserCache = false` eliminates HF CDN dependency
- BootSequence.tsx, ECGAnimation.tsx — LOCKED, do not modify - `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b))
- LoginScreen.tsx bridges to dashboard - CommandPalette uses `semanticResults` state + debounced `useEffect` for async semantic search, falling back to Fuse.js when `isModelReady()` returns false or on any error
- `loadEmbeddings()` and `paletteMap` (Map<id, PaletteItem>) are precomputed via `useMemo` — no re-computation on each search
### Typography - ChatWidget is mounted in DashboardLayout alongside CommandPalette and DetailPanel — z-index 90 (below command palette z-1000)
- Elvaro Grotesque (`font-ui`, `var(--font-ui)`) — primary UI font - `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation
- Blumir (`font-ui-alt`) — alternative variable font - ChatWidget stores messages as `Array<{ role: 'user' | 'assistant', content: string }>` — same shape as LLM message format, ready for Gemini integration
- Geist Mono (`font-geist`, `var(--font-geist-mono)`) — timestamps, data values - ChatWidget `isOpen` state controls both panel visibility and button icon (MessageCircle ↔ X) — panel rendering handled by AnimatePresence
- Fira Code (`font-mono`) — boot/ECG terminal only - `src/lib/gemini.ts` exports `sendChatMessage(messages)` (async generator), `isGeminiAvailable()`, `parseItemIds(text)`, `stripItemsSuffix(text)` — ChatMessage type is `{ role: 'user' | 'assistant', content: string }`
- Do NOT use Inter, Roboto, DM Sans, or system defaults - Gemini API uses SSE streaming: POST to `:streamGenerateContent?alt=sse&key=KEY`, parse `data:` lines as JSON, extract `candidates[0].content.parts[0].text`
- System prompt built from `buildEmbeddingTexts()` — instructs model to end responses with `[ITEMS: id1, id2, id3]` for portfolio item linking
### Design Tokens (index.css CSS variables) - `isGeminiAvailable()` checks `import.meta.env.VITE_GEMINI_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible
- --surface: #FFFFFF (card/topbar background) - Assistant messages store item IDs as `<!--ITEMS:id1,id2-->` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering
- --bg-dashboard: #F0F5F4 (warm sage content background) - Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API
- --accent: #0D6E6E (teal primary) - Icon/color mappings (`iconByType`, `iconColorStyles`) live in `src/lib/palette-icons.ts` — shared between CommandPalette and ChatWidget
- --accent-hover: #0A8080 - ChatWidget accepts optional `onAction?: (action: PaletteAction) => void` prop — same pattern as CommandPalette's `onAction`
- --accent-pressed: #085858 - `DashboardLayout` passes `handlePaletteAction` to both CommandPalette and ChatWidget for unified action routing
- --accent-light: rgba(10,128,128,0.08) - TopBar is `z-index: 100` (fixed), nav is `z-index: 99` (sticky) — mobile full-screen overlays need `z-index > 100` to appear above them
- --border: #D4E0DE (structural borders) - Inline `style={{ display: 'flex' }}` overrides Tailwind's `hidden` class — use `!important` modifier (`max-md:!hidden`) or move display to Tailwind classes to allow responsive hiding
- --border-card: #E4EDEB (card/inner borders) - ChatWidget mobile breakpoint is `md` (768px) — below this, panel is full-screen; above, it's 380px anchored bottom-right
- --text-primary: #1A2B2A - `handleSubmit(overrideText?)` accepts optional text param — use this when programmatically sending messages (e.g., suggested question chips) to avoid stale `inputValue` state
- --text-secondary: #5B7A78 - `SUGGESTED_QUESTIONS` const array at top of ChatWidget — edit here to change welcome screen chip text
- --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
### Key Files for This Feature
- 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 (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 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-001: Skip to login phase for dev iteration ## 2026-02-15 - US-001
- Changed initial Phase state from `'boot'` to `'login'` in `src/App.tsx` line 47 - Installed `@xenova/transformers` (^2.17.2)
- Files changed: `src/App.tsx` - Created `scripts/generate-embeddings.ts` with main() that loads `Xenova/all-MiniLM-L6-v2` and embeds a test string
- Added `"generate-embeddings"` npm script
- Verified: outputs vector length 384 and exits cleanly
- Typecheck passes
- Files changed: `package.json`, `package-lock.json`, `scripts/generate-embeddings.ts`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- Phase state is a simple `useState<Phase>` on line 47 of App.tsx - `pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')` auto-downloads and caches the ONNX model (~23MB)
- All phase rendering logic (`boot`, `ecg`, `login`, `pmr`) remains intact — only initial value changes - First run takes a few seconds for model download; subsequent runs are near-instant from cache
- US-011 will revert this exact change back to `'boot'` - The pipeline's `pooling: 'mean'` and `normalize: true` options handle mean-pooling and L2 normalization in one step — no manual tensor manipulation needed
- `output.data` is a `Float32Array`; wrap in `Array.from()` for a plain number array
--- ---
## 2026-02-15 - US-002: Extract animation timing into named constants ## 2026-02-15 - US-002
- Extracted all inline timing values in CvmisLogo.tsx to named constants at top of file - Added `buildEmbeddingTexts()` function to `src/lib/search.ts`
- 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 - Imports all raw data files (consultations, skills, kpis, investigations, documents)
- Added overlap blend constants for US-005: OVERLAY_BLEND_START_PROGRESS, OVERLAP_BLEND_MAX_OPACITY, OVERLAP_BLEND_TRANSITION_DURATION_S (exported) - Generates natural-language paragraphs for each palette item type:
- Files changed: `src/components/CvmisLogo.tsx` - Consultations: role, org, duration, history narrative, examination bullets, coded entry descriptions
- Skills: name, category, frequency, proficiency %, years of experience
- Achievements: title, subtitle, full KPI explanation + story context + outcomes
- Investigations: name, methodology, tech stack, results
- Education: title, type, institution, duration, classification, research detail, notes (from documents.ts)
- Quick Actions: title + subtitle
- IDs match PaletteItem IDs (e.g. `exp-{id}`, `skill-{id}`, `ach-{i}`, `proj-{id}`, `edu-{i}`, `action-{i}`)
- Typecheck and lint pass
- Files changed: `src/lib/search.ts`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- 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 - Education items in `buildPaletteData()` are hardcoded arrays (not iterated from `documents`), with ids `edu-0` through `edu-3`. The mapping to `documents.ts` entries is: edu-0→doc-mary-seacole, edu-1→doc-mpharm, edu-2→doc-alevels, edu-3→doc-gphc
- 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 - Achievement items are similarly hardcoded with ids `ach-0` through `ach-3`, each linked to a KPI id
- FAN_EASING was already a named constant before this story; it was left in place and grouped with the new fan constants - Quick action items are `action-0` through `action-3`
- `documents.ts` is imported but wasn't previously used in `search.ts` — now used for education embedding text
--- ---
## 2026-02-15 - US-003: Scale logo and branding block to ~50% of login card height ## 2026-02-15 - US-003
- Scaled CvmisLogo `cssHeight` prop from `clamp(80px, 8vw, 120px)` to `clamp(160px, 18vw, 280px)` - Updated `scripts/generate-embeddings.ts` to import `buildEmbeddingTexts()` and generate full embeddings
- Adjusted logo wrapper marginBottom from 10px to 12px for spacing balance - Script embeds all 42 palette items sequentially using `Xenova/all-MiniLM-L6-v2`
- Browser-verified: desktop ratio 51.3% (target 50% ±10%), mobile (375px) ratio 41.1% — both within tolerance - Outputs `src/data/embeddings.json` as `Array<{ id: string, embedding: number[] }>`
- No overflow or clipping on mobile viewport - Each embedding is a 384-dimensional float array
- Files changed: `src/components/LoginScreen.tsx` - File is ~453KB (42 items × 384 floats with pretty-printed JSON)
- `npm run generate-embeddings` regenerates the file successfully
- Typecheck and lint pass
- Files changed: `scripts/generate-embeddings.ts`, `src/data/embeddings.json`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- The CvmisLogo `cssHeight` prop maps directly to a CSS `height` style on the SVG — `clamp()` values work well for responsive scaling - `import.meta.dirname` works in tsx/Node ESM scripts — use it instead of `__dirname` (which isn't available in ESM)
- At 375px viewport, `18vw = 67.5px` which triggers the clamp minimum of 160px — the logo remains a comfortable size on small screens - `@/` path alias works in `npx tsx` scripts because tsx resolves tsconfig paths automatically
- The branding block ratio can be measured by comparing `brandingBlock.getBoundingClientRect().height + marginBottom` against `card.innerHeight - padding` - The embeddings file is ~450KB with pretty-print; could be reduced with compact JSON but readability is preferred for now
- The branding block container has class `flex flex-col items-center` — use this selector for programmatic measurement - Processing 42 items takes ~10-15 seconds on first run (model cached after first download)
--- ---
## 2026-02-15 - US-004: Increase branding text to match dashboard typography scale ## 2026-02-15 - US-004
- Increased CVMIS title fontSize from `13px` to `clamp(16px, 1.4vw, 20px)` — renders 20px on desktop - Created `src/lib/embedding-model.ts` with three exports: `initModel()`, `embedQuery()`, `isModelReady()`
- Increased subtitle fontSize from `11px` to `clamp(12px, 1vw, 14px)` — renders 14px on desktop - Module-level `let extractor` pattern avoids React re-render issues
- Increased subtitle marginTop from `2px` to `3px` for better spacing with larger text - `initModel()` uses `loading` guard to prevent duplicate pipeline loads
- Both remain in font-ui (Elvaro Grotesque) with weight 600 (title) and 400 (subtitle) - `embedQuery()` uses same `pooling: 'mean'` and `normalize: true` as the build script
- Browser-verified: text is visually balanced with the larger logo and login form - `initModel()` called fire-and-forget in `App.tsx` `useEffect([], [])` — runs during boot phase
- Files changed: `src/components/LoginScreen.tsx` - Silent failure: try/catch swallows errors, `isModelReady()` stays false
- Typecheck, lint, and build all pass
- Files changed: `src/lib/embedding-model.ts` (new), `src/App.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- The branding text clamp values use the same responsive pattern as the logo `cssHeight` — mid-values around 1-1.5vw work well for text - `FeatureExtractionPipeline` type is exported from `@xenova/transformers` and can be used for the module-level variable
- Title and subtitle are `<span>` elements inside the `.flex.flex-col.items-center` branding container - The `loading` boolean guard prevents race conditions if `initModel()` is called multiple times (e.g., React strict mode double-mount)
- Weight hierarchy (600 title, 400 subtitle) provides sufficient visual differentiation without needing size contrast as large - `initModel()` is intentionally not awaited — it's fire-and-forget so it doesn't block the boot animation
- Consumers should check `isModelReady()` before calling `embedQuery()` — it throws if model isn't loaded
--- ---
## 2026-02-15 - US-005: Add overlap blend effect on fanning capsules ## 2026-02-15 - US-005
- Added `blendActive` state to CvmisLogo, triggered by timer at `blendStartMs` (50% through fan animation) - Created `src/lib/semantic-search.ts` with cosine similarity search and embeddings loader
- 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 - `semanticSearch()` computes cosine similarity, filters by threshold (default 0.3), returns sorted by score descending
- Blend overlays share the same `transform` and `transition` as their corresponding original pills, plus an opacity transition using `OVERLAP_BLEND_TRANSITION_DURATION_S` - `loadEmbeddings()` imports `embeddings.json` via Vite's native JSON import and returns typed array
- Reduced motion: `blendActive` starts `true`, `transition: 'none'` — final blend state shown immediately - Typecheck and lint pass (0 new warnings)
- Browser-verified: blend darkening visible at pill overlap areas, opacity confirmed at 0.2 - Files changed: `src/lib/semantic-search.ts` (new)
- Files changed: `src/components/CvmisLogo.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- `mix-blend-mode` is not CSS-animatable — use overlay elements with animated opacity instead of trying to transition the blend mode - Vite handles JSON imports natively — `import data from '@/data/embeddings.json'` just works, no dynamic import needed
- Blend overlay approach: duplicate the pill shapes (rect only, no icons) as separate `<g>` elements with `mixBlendMode: 'multiply'` and low opacity - Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used
- The `useMemo` for `blendStartMs` avoids recalculation — all timing constants are module-level so this is stable - With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries
- Combined CSS transition strings work in SVG `<g>` style: `transform 0.6s cubic-bezier(...), opacity 0.3s ease-out`
--- ---
## 2026-02-15 - US-006: Extend backdrop blur to cover full dashboard including TopBar ## 2026-02-15 - US-006
- Changed overlay from Tailwind `z-50` class to inline `zIndex: 110` to sit above TopBar (`zIndex: 100`) - Integrated semantic search into CommandPalette with Fuse.js fallback
- Browser-verified: TopBar, sidebar, and all content uniformly blurred; login card remains crisp - When `isModelReady()` is true: debounces query by 200ms, calls `embedQuery()`, runs `semanticSearch()` against preloaded embeddings, maps result IDs back to PaletteItems via O(1) Map lookup
- Files changed: `src/components/LoginScreen.tsx` - When model is NOT ready: uses existing Fuse.js search (behavior preserved exactly)
- Results maintain `groupBySection()` grouping and section ordering
- Existing keyboard navigation, action routing, and UI unchanged
- Semantic results state is cleared when palette opens/closes and when query is empty
- Error handling: any failure in embedQuery/semanticSearch silently falls back to Fuse.js
- Typecheck, lint, and build all pass
- Browser verified: Fuse.js fallback works correctly; ONNX model loads asynchronously during boot and activates semantic search when ready
- Files changed: `src/components/CommandPalette.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- TopBar uses inline `zIndex: 100` (not a Tailwind class), so overlay needs inline zIndex > 100 - Semantic search is async so it can't live in a `useMemo` — use `useState` + debounced `useEffect` pattern instead
- Tailwind's `z-50` = z-index 50, which was below the TopBar — switched to inline style for precise control - The `useRef + setTimeout` debounce pattern works well here: set `debounceRef.current = setTimeout(...)`, clear it in the cleanup function, and in early-return paths
- The login card doesn't need its own z-index since it's a child of the overlay and inherits stacking context - `isModelReady()` is a synchronous check — call it before setting up the debounce timeout to avoid unnecessary delays when model isn't loaded
- The ONNX model takes several seconds to load in the browser (downloads ~23MB first time, then cached in IndexedDB), so initial searches will always use Fuse.js fallback
- `loadEmbeddings()` is cheap (just returns the already-imported JSON) — safe to call in `useMemo` without performance concern
--- ---
## 2026-02-15 - US-007: Reduce backdrop blur intensity by ~50% ## 2026-02-15 - US-007
- Added `BACKDROP_BLUR_PX = 10` constant at top of LoginScreen.tsx - Created `src/components/ChatWidget.tsx` — floating chat button with toggle state
- Replaced hardcoded `blur(20px)` in initial style with template literal using constant - 48px circular button (40px on mobile <640px), fixed bottom-right, teal accent background, white MessageCircle icon
- Exit animation still targets `blur(0px)` — Framer Motion interpolates from current 10px to 0px - Entrance animation: fade + translateY(8px→0), 1s delay after mount, via framer-motion variants
- Files changed: `src/components/LoginScreen.tsx` - Respects `prefers-reduced-motion` — skips animation, shows immediately
- Hover: shadow-md → shadow-lg + scale(1.05), 150ms transition
- z-index 90 (below command palette z-1000)
- onClick toggles `isOpen` state, swaps icon between MessageCircle and X
- Mounted in `DashboardLayout.tsx` alongside CommandPalette and DetailPanel
- Typecheck, lint (0 errors), and build all pass
- Browser verified: button visible at bottom-right, toggle works (Open chat ↔ Close chat)
- Files changed: `src/components/ChatWidget.tsx` (new), `src/components/DashboardLayout.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- The `BACKDROP_BLUR_PX` constant is in the "Login screen timing & visual constants" block at top of LoginScreen.tsx - Responsive sizing via Tailwind classes (`h-10 w-10 sm:h-12 sm:w-12`) works well with inline style for non-Tailwind properties (boxShadow, border-radius)
- 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 - `AnimatePresence` is already imported and ready for the panel animation in US-008
- Only the initial style needs the constant; the exit target (`blur(0px)`) is always 0 - The `isOpen` state lives in ChatWidget — US-008 will add the panel UI inside the same component
- Hover effects use `onMouseEnter/Leave` with direct style mutation (same pattern as other dashboard components)
--- ---
## 2026-02-15 - US-008: Align login card border radius and shadow with dashboard design system ## 2026-02-15 - US-008
- Changed card borderRadius from `12px` to `var(--radius-card, 8px)` - Built chat panel UI inside `ChatWidget.tsx` with header, message area, and input
- 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))` - Panel opens above the floating button with scale+opacity entrance/exit animation via framer-motion `AnimatePresence`
- Changed username input, password input, and button borderRadius from `4px` to `var(--radius-sm, 6px)` - Messages stored as `Array<{ role: 'user' | 'assistant', content: string }>` in component state
- All values use CSS custom property references with fallbacks - User messages right-aligned in teal-tinted bubbles (`var(--accent-light)` bg, `var(--accent-border)` border)
- Files changed: `src/components/LoginScreen.tsx` - Assistant messages left-aligned in light gray bubbles (`var(--bg-dashboard)` bg, `var(--border-light)` border)
- Message corner radii differ: user bubbles have small bottom-right radius, assistant bubbles small bottom-left (conversational feel)
- Input area: textarea with Enter to submit, Shift+Enter for newline. Send button enabled/disabled based on input content
- Empty state shows placeholder text when no messages yet
- Auto-scrolls to latest message via `useRef` + `scrollIntoView`
- Auto-focuses input when panel opens (200ms delay for animation)
- Responsive: on mobile (<640px), panel is full-width bottom sheet with rounded top corners; on desktop, 380px wide positioned above the button
- Panel entrance: scale(0.95)+opacity(0) → scale(1)+opacity(1), 200ms. Exit: reverse, 150ms
- Respects `prefers-reduced-motion` — skips all animation
- Close button in header triggers `setIsOpen(false)` (same as floating button toggle)
- Submitting appends both user message and placeholder assistant response to state
- Typecheck, lint (0 errors), and build all pass
- Browser verified: panel opens/closes correctly, messages display, input works, Enter submits, close button works
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- 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 - `AnimatePresence` with `key` prop on the panel div is needed for exit animations to work
- The button has 18-space indentation vs 20-space for inputs — `replace_all` may not catch all instances if matching on indentation - Panel uses `transformOrigin: 'bottom right'` for natural scale animation from the button corner
- The spinner (`borderRadius: '50%'`) and status indicator dot should NOT be changed — they're circles, not card elements - CSS-in-JS `<style>` tag with `data-chat-panel` attribute handles responsive width/height (Tailwind can't express max-height conditionally based on viewport width easily)
- `textarea` with `rows={1}` and `maxHeight: 80px` gives auto-growing feel; `resize: none` prevents manual resize
- The `ChatMessage` interface (`{ role, content }`) is ready to be extended for US-009 Gemini integration — same shape as typical LLM message format
- `onFocus/onBlur` border color transitions on the textarea give a polished input interaction
--- ---
## 2026-02-15 - US-009: Replace hardcoded colors with design tokens ## 2026-02-15 - US-009
- Replaced all hardcoded hex colors in LoginScreen.tsx with CSS custom property references (`var()` with fallbacks) - Created `src/lib/gemini.ts` — Gemini Flash streaming integration module
- Input text color: `#111827` → `var(--text-primary, #1A2B2A)` - `sendChatMessage(messages)` async generator that streams SSE tokens from Gemini 2.0 Flash
- Cursor/caret color: `#0D6E6E` → `var(--accent, #0D6E6E)` (2 instances) - `isGeminiAvailable()` checks for `VITE_GEMINI_API_KEY` env var
- Button backgrounds: `#0D6E6E` → `var(--accent)`, `#0A8080` → `var(--accent-hover)`, `#085858` → `var(--accent-pressed)` - `parseItemIds(text)` extracts `[ITEMS: id1, id2]` from response text
- Spinner border/top: `#E4EDEB` → `var(--border-light)`, `#0D6E6E` → `var(--accent)` - `stripItemsSuffix(text)` removes the `[ITEMS: ...]` line for clean display
- Input inactive borders: `#E4EDEB` → `var(--border-light)` (username + password fields) - System prompt built from `buildEmbeddingTexts()` output — full CV context (~42 items)
- Card border: `#E4EDEB` → `var(--border-light)` - Model instructed to answer concisely and append relevant palette item IDs
- Footer border: `#E4EDEB` → `var(--border-light)` - Rewired `ChatWidget.tsx` to use real Gemini API instead of placeholder responses
- Connection status colors: `#059669` → `var(--success)`, `#DC2626` → `var(--alert)` (dot bg + text) - Streaming: tokens progressively appear in assistant message bubble
- Focus ring: `ring-[#0D6E6E]/40` → `ring-accent/40` (Tailwind token) - Typing indicator (Loader2 spinner + "Thinking...") shown while waiting for first token
- Added `--accent-pressed: #085858` token to `index.css` `:root` (completes the accent state trio) - Input disabled during streaming, send button grayed out
- Files changed: `src/components/LoginScreen.tsx`, `src/index.css` - Error handling: API failures show "Sorry, I couldn't process that. Please try again."
- Missing API key: panel shows "Chat is currently unavailable", input area hidden
- Conversation history capped at 10 messages before sending to API
- Assistant messages store parsed item IDs as `<!--ITEMS:id1,id2-->` HTML comment (for US-010)
- Messages sent to API have metadata stripped to keep context clean
- Typecheck, lint (0 errors), and build all pass
- Files changed: `src/lib/gemini.ts` (new), `src/components/ChatWidget.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- `#FFFFFF` on button text is intentional for contrast — no `--text-on-accent` token exists; leave as hardcoded white - Gemini SSE format: `data:` prefix per line, JSON body with `candidates[0].content.parts[0].text`
- `rgba(240, 245, 244, 0.7)` overlay bg is `--bg-dashboard` at 70% opacity — no token for this; leave as rgba - `system_instruction` field in Gemini request body sets the system prompt (not a message in `contents`)
- Status dot glow `boxShadow` uses rgba variants of success/alert colors at 40% opacity — no token for these glow effects - Gemini role mapping: `'assistant'` → `'model'` in the API's `contents` array
- Tailwind config has `accent` and `accent-hover` color tokens, so `ring-accent/40` works in class names - Buffer-based SSE parsing handles chunk boundaries: split on `\n`, keep last incomplete line in buffer
- Always add fallback values in `var()` references (e.g., `var(--accent, #0D6E6E)`) for resilience - `buildEmbeddingTexts()` is a great source for structured CV context — natural language paragraphs per item
- The `<!--ITEMS:-->` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display
- `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three
--- ---
## 2026-02-15 - US-010: Fix minor typography inconsistencies ## 2026-02-15 - US-010
- Changed form label fontWeight from 500 to 600 (both Username and Password labels) to match dashboard card header weight convention - Extracted `iconByType` and `iconColorStyles` from `CommandPalette.tsx` into shared `src/lib/palette-icons.ts`
- Adjusted input text fontSize mid-value from `clamp(13px, 1.1vw, 15px)` to `clamp(13px, 1.2vw, 15px)` — renders ~14-15px on standard viewports - Updated `CommandPalette.tsx` to import from the shared module (no behavioral change)
- Adjusted button text fontSize mid-value from `clamp(14px, 1.1vw, 16px)` to `clamp(14px, 1.2vw, 16px)` — renders ~15px on standard viewports - Added `onAction?: (action: PaletteAction) => void` prop to `ChatWidget` — same pattern as `CommandPalette`
- Changed connection status indicator gap from 6px to 8px (matching dashboard CardHeader gap) - `DashboardLayout.tsx` passes `handlePaletteAction` to `ChatWidget` (same handler used by CommandPalette)
- Files changed: `src/components/LoginScreen.tsx` - ChatWidget builds a `paletteMap` (Map<id, PaletteItem>) via `useMemo` for O(1) item lookups
- Added `getMessageItemIds()` to parse `<!--ITEMS:id1,id2-->` HTML comments from message content
- Added `getMessageItems()` to resolve parsed IDs to PaletteItem objects via the map
- Assistant message bubbles now render compact clickable item cards below text when items are referenced:
- Cards use same icon/color scheme from CommandPalette (22px icon + title + subtitle)
- Cards have hover highlight (`var(--accent-light)`) and trigger `onAction(item.action)` on click
- Cards only appear after streaming completes (when `<!--ITEMS:-->` metadata is in final content)
- If no items referenced or IDs don't match, no cards shown — just text
- Typecheck, lint (0 errors), and build all pass
- Files changed: `src/lib/palette-icons.ts` (new), `src/components/ChatWidget.tsx`, `src/components/CommandPalette.tsx`, `src/components/DashboardLayout.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- These are all subtle alignment tweaks — the clamp mid-value change from 1.1vw to 1.2vw shifts rendering by ~1px on 1280px viewports - Extracting shared constants to `src/lib/` is the right pattern — both `CommandPalette` and `ChatWidget` now use the same icon mappings without duplication
- The label fontWeight 600 matches the dashboard's `CardHeader` convention (seen in `Card.tsx`) - `buildPaletteData()` is pure (no side effects) and idempotent — safe to call in `useMemo` with empty deps
- The connection indicator gap of 8px matches the standard icon-text gap used in dashboard card headers - The `<!--ITEMS:-->` HTML comment regex `<!--ITEMS:([^>]*)-->` works reliably; `[^>]*` captures everything between the colons and closing
- Item card buttons use `fontFamily: 'inherit'` to pick up the panel's `font-ui` — without this, browser defaults apply
- The `overflow: 'hidden'` on the message bubble container is needed so the item cards section (with its own border-top) stays visually contained within the bubble's border-radius
--- ---
## 2026-02-15 - US-011: Re-enable boot sequence ## 2026-02-15 - US-011
- Changed initial Phase state from `'login'` back to `'boot'` in `src/App.tsx` line 47 - Updated ChatWidget mobile breakpoint from `sm` (640px) to `md` (768px)
- Reverts the dev shortcut from US-001, restoring the full boot → ECG → login → dashboard flow - Changed mobile panel from 85vh bottom-sheet to full-screen overlay using `position: fixed; inset: 0` with `100dvh` height
- Typecheck and lint pass cleanly - Panel z-index on mobile bumped to 101 (`max-md:z-[101]`) to render above TopBar (z-100) and nav (z-99)
- Files changed: `src/App.tsx` - Floating chat button hidden on mobile when panel is open via `max-md:!hidden` Tailwind class
- Fixed specificity issue: inline `style={{ display: 'flex' }}` was overriding Tailwind's `hidden` — moved flex/centering to Tailwind classes (`flex items-center justify-center`)
- Safe area insets applied via `env(safe-area-inset-*)` CSS on the `[data-chat-panel]` element for notched devices
- Input area stays pinned to bottom via existing flex layout (flex-col container + flex-1 message area + flex-shrink-0 input)
- Desktop behavior unchanged: 380px wide, anchored bottom-right, max-height 480px, floating button visible
- Panel open/close animations still respect `prefers-reduced-motion`
- Typecheck, lint (0 errors), and build all pass
- Browser verified at 375×812 (mobile) and 1280×800 (desktop): full-screen overlay works, button hides/shows correctly, close button works
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:** - **Learnings for future iterations:**
- This is a simple one-line revert — the phase state controls the entire UI flow - Inline `style` properties always override CSS classes — to allow Tailwind responsive utilities (like `max-md:hidden`) to work, move conflicting properties (especially `display`) to Tailwind classes instead
- All 11 stories in this PRD are now complete - Use `!important` modifier (`max-md:!hidden`) when competing with framer-motion's inline styles that can't be easily removed
- TopBar (`z-100`) and nav (`z-99`) sit above the chat panel's default `z-90` — mobile full-screen panels need `z-101+` to overlay properly
- `100dvh` (dynamic viewport height) is essential for mobile full-screen panels — it accounts for browser chrome (address bar, toolbar) unlike `100vh`
- The `[data-chat-panel]` CSS selector in the `<style>` block is the right place for responsive size rules since Tailwind can't conditionally set max-height based on viewport width
---
## 2026-02-15 - US-012
- Replaced empty-state centered text with welcome bubble + suggested question chips
- Welcome bubble styled as assistant message (left-aligned, `var(--bg-dashboard)` bg, `var(--border-light)` border)
- Added `SUGGESTED_QUESTIONS` const array at module top for easy future editing
- Three chips: "What's his NHS experience?", "Tell me about his data skills", "What projects has he built?"
- Chips styled: rounded-full, teal accent border, teal hover tint, `font-ui` 12.5px
- Clicking a chip calls `handleSubmit(questionText)` — same codepath as typing + Enter
- Refactored `handleSubmit` to accept optional `overrideText` parameter (avoids stale state issue with `setInputValue` + immediate submit)
- Wrapped send button `onClick` in arrow function to prevent passing MouseEvent as text argument
- Welcome/chips visible when `messages.length === 0`, replaced by conversation once any message is sent
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
- Browser verified: welcome bubble displays correctly, chips render, clicking chip sends message and replaces welcome state
- Files changed: `src/components/ChatWidget.tsx`
- **Learnings for future iterations:**
- When refactoring a callback to accept optional parameters, wrap `onClick={handler}` as `onClick={() => handler()}` to prevent React from passing the SyntheticEvent as the first argument
- `SUGGESTED_QUESTIONS` as a module-level const is the simplest approach — easily editable, no data file needed for 3 items
- The `handleSubmit(overrideText?)` pattern avoids the stale-state problem: `setInputValue(text)` followed by immediate `handleSubmit()` would read the old `inputValue` since React batches state updates
---
## 2026-02-15 - US-013
- Downloaded all-MiniLM-L6-v2 model files to `public/models/Xenova/all-MiniLM-L6-v2/`:
- `config.json`, `tokenizer.json`, `tokenizer_config.json`, `onnx/model_quantized.onnx` (~22MB)
- Updated `src/lib/embedding-model.ts`:
- `env.localModelPath = '/models/'` — Vite serves `public/` at root
- `env.allowRemoteModels = false` — prevents any HF CDN fallback
- `env.useBrowserCache = false` — prevents stale Cache API entries from interfering
- Updated `scripts/generate-embeddings.ts`:
- `env.localModelPath = resolve(import.meta.dirname, '..', 'public', 'models')` — absolute path for Node.js
- `env.allowRemoteModels = false`
- Model files committed as static assets (not in .gitignore)
- Browser verified: all 4 model files fetched from `localhost:5173/models/` with 200 OK, zero `huggingface.co` requests
- Semantic search verified working: "data analysis" returns multi-category results (Core Skills, Active Projects, Achievements)
- Build script (`npm run generate-embeddings`) still works with local model files
- Typecheck passes (0 errors), lint passes (0 new errors/warnings)
- Files changed: `src/lib/embedding-model.ts`, `scripts/generate-embeddings.ts`, `public/models/Xenova/all-MiniLM-L6-v2/` (new directory with 4 files)
- **Learnings for future iterations:**
- `@xenova/transformers` env configuration: `env.localModelPath` sets the base path, `env.allowRemoteModels = false` prevents CDN fallback, `env.useBrowserCache = false` bypasses Browser Cache API
- The library constructs paths as `{localModelPath}/{modelId}/{filename}` — so `/models/` + `Xenova/all-MiniLM-L6-v2` + `/onnx/model_quantized.onnx`
- Browser Cache API can retain stale entries from previous HF CDN loads — setting `useBrowserCache = false` forces fresh fetches from the configured local path
- For Node.js scripts, use an absolute filesystem path for `localModelPath` (not a URL)
- The quantized ONNX model (`model_quantized.onnx`) is ~22MB — acceptable for a static asset since it's cached after first load
--- ---
+111
View File
@@ -0,0 +1,111 @@
# Landing Page Polish Plan
## KPI Cards (Make Evidence Drawer Obvious)
### Core copy change
- Update subsection header to: `Latest Results (click to view full reference range)`.
### Recommended interaction and affordance updates
1. Add explicit CTA text on every KPI card:
- `Click to view evidence` or `Open case summary`.
- Keep this always visible (do not hide behind hover).
2. Add a visible action affordance icon:
- Use a chevron, plus, or document icon in the card corner.
- Keep icon visible at all times to signal clickability.
3. Strengthen hover and focus states:
- On hover/focus: slightly lift card, increase border contrast, subtle shadow/glow.
- Ensure clear keyboard focus ring for accessibility.
- Keep `cursor: pointer` on full card.
4. Add a one-time coachmark:
- Pulse a single KPI card on first visit.
- Message: `Open any metric to see evidence`.
- Dismiss permanently after first KPI click.
5. Add a section-level helper hint above KPI grid:
- `Select a metric to inspect methodology, impact, and outcomes`.
6. Keep interaction labels persistent for mobile:
- Do not rely on hover-only affordances.
- Ensure all cues are visible on touch devices.
7. Add click/tap micro-feedback:
- Subtle pressed-state animation on card tap.
- Immediate drawer motion to confirm action.
### Priority (low effort -> high gain)
1. Header copy update
2. Persistent CTA text + action icon
3. Strong hover/focus states
4. One-time coachmark
5. Micro-animation polish
---
## Network Graph (Career Constellation) Improvements
## Key issues identified in current implementation
1. Keyboard accessibility overlay is incorrect:
- Hidden focus buttons are all centered rather than mapped to real node coordinates.
2. Simulation starts from poor initial state:
- Nodes initialize from `(0,0)`, causing visual jumpiness and unstable first impression.
3. Label readability and collision handling are weak:
- Dense regions become hard to scan quickly.
4. Interaction is hover-first:
- Mobile/touch and keyboard parity is limited.
5. Timeline logic is invisible:
- Layout uses years but lacks visual timeline scaffolding (ticks/axis/era cues).
## Direction agreed
- Desktop: pivot to a two-column workspace.
- Left column: graph (sticky).
- Right column: chronological clinical record stream (work + education).
- Mobile/tablet: keep stacked layout (graph above timeline).
## Important implementation note
- Do **not** visually rotate the SVG with CSS transforms.
- Instead, remap the graph layout so time runs vertically:
- Roles aligned by year from top (oldest) to bottom (newest).
- Skills positioned around their linked roles.
## Recommended graph changes
1. Add timeline guides:
- Year ticks/markers and subtle era separators.
- Small legend for node/link semantics.
2. Seed deterministic initial positions:
- Pre-place role nodes on year track.
- Pre-place skill nodes near connected role clusters.
- Then run constrained simulation for gentle settling, not dramatic motion.
3. Fix keyboard/touch interaction model:
- Map focusable hit targets to actual node positions.
- Add tap-to-pin highlight mode for mobile.
- Keep Enter/Space behavior equivalent for keyboard users.
4. Improve label system:
- Smarter truncation, optional reveal-on-hover/focus, and collision avoidance.
- Increase contrast and spacing where clusters are dense.
5. Preserve and enhance relationship highlighting:
- Keep connected-node/link emphasis behavior.
- Improve selected state persistence (not just hover transient state).
## Priority (low effort -> high gain)
1. Timeline guides + legend
2. Deterministic initial positions
3. Correct keyboard hit-target mapping
4. Tap-to-pin for mobile
5. Label collision/declutter strategy
---
## Layout Note for Chronology Column
- Use a single chronological stream with type badges (`Role`, `Education`).
- This preserves the same current visual order while staying future-proof if entries interleave later.
+877 -1
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -8,10 +8,13 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview",
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
"benchmark": "npx tsx scripts/benchmark.ts"
}, },
"dependencies": { "dependencies": {
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@xenova/transformers": "^2.17.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
@@ -0,0 +1,25 @@
{
"_name_or_path": "sentence-transformers/all-MiniLM-L6-v2",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"classifier_dropout": null,
"gradient_checkpointing": false,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 384,
"initializer_range": 0.02,
"intermediate_size": 1536,
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 6,
"pad_token_id": 0,
"position_embedding_type": "absolute",
"transformers_version": "4.29.2",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 30522
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"model_max_length": 512,
"never_split": null,
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"unk_token": "[UNK]"
}
+120
View File
@@ -0,0 +1,120 @@
{
"passThreshold": 18,
"maxScore": 20,
"questions": [
{
"id": "Q01",
"question": "How many years has Andy been employed by the NHS?",
"expectedAnswer": "Approximately 3-4 years. Andy's NHS employment started in May 2022 when he joined NHS Norfolk and Waveney ICB. His previous role at Tesco PLC was in the private sector, not the NHS.",
"keyFacts": [
"NHS employment started May 2022",
"Tesco was private employer",
"approximately 3-4 years NHS employment"
]
},
{
"id": "Q02",
"question": "What was Andy's involvement with tirzepatide?",
"expectedAnswer": "Andy supported commissioning of NICE TA1026 (tirzepatide). He authored the initial executive paper advocating a primary care delivery model over specialist provider, which drove a system shift to GP-led model.",
"keyFacts": [
"NICE TA1026",
"authored executive paper",
"primary care model",
"GP-led delivery"
]
},
{
"id": "Q03",
"question": "What specific tools and software has Andy built?",
"expectedAnswer": "Andy has built 5 notable projects: a patient switching algorithm (Python, 14000 patients, £2.6M savings), a Blueteq generator for high-cost drug forms, a controlled drugs monitoring system, a Sankey chart tool for visualising patient flows, and PharMetrics — a Power BI analytics dashboard.",
"keyFacts": [
"patient switching algorithm",
"Blueteq generator",
"CD monitoring system",
"Sankey chart tool",
"PharMetrics dashboard"
]
},
{
"id": "Q04",
"question": "What were Andy's A-level subjects and grades?",
"expectedAnswer": "Andy achieved Mathematics A*, Chemistry B, and Politics C at Highworth Grammar School between 2009-2011.",
"keyFacts": [
"Mathematics A*",
"Chemistry B",
"Politics C",
"Highworth Grammar School"
]
},
{
"id": "Q05",
"question": "Was Andy's Tesco role part of the NHS?",
"expectedAnswer": "No. Andy's role at Tesco PLC was in the private sector as a community pharmacist. Tesco PLC is a private employer. He was an LPC representative during this time.",
"keyFacts": [
"Tesco PLC is private/not NHS",
"community pharmacy",
"LPC representative"
]
},
{
"id": "Q06",
"question": "How did the patient switching algorithm work?",
"expectedAnswer": "It was Python-based and used real-world GP prescribing data to auto-identify patients eligible for cost-effective medication alternatives. It compressed months of manual work into 3 days, covered 14,000 patients, and identified £2.6M in savings.",
"keyFacts": [
"Python",
"GP prescribing data",
"14000 patients",
"£2.6M savings",
"compressed months to 3 days"
]
},
{
"id": "Q07",
"question": "What clinical specialties has Andy worked across?",
"expectedAnswer": "Andy has worked across rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine through his high-cost drugs role.",
"keyFacts": [
"rheumatology",
"ophthalmology",
"dermatology",
"gastroenterology",
"neurology",
"migraine"
]
},
{
"id": "Q08",
"question": "What is Andy's experience with the dm+d?",
"expectedAnswer": "Andy created a comprehensive medicines data table integrating all dm+d products with standardised strengths, morphine equivalents, and Anticholinergic Burden scoring, serving as a single source of truth.",
"keyFacts": [
"dm+d integration",
"standardised strengths",
"morphine equivalents",
"Anticholinergic Burden",
"single source of truth"
]
},
{
"id": "Q09",
"question": "What budget does Andy manage and how?",
"expectedAnswer": "Andy manages a £220M prescribing budget using forecasting models, variance analysis, and financial reporting to the executive team, enabling proactive financial planning.",
"keyFacts": [
"£220M",
"forecasting models",
"variance analysis",
"proactive financial planning"
]
},
{
"id": "Q10",
"question": "What leadership training does Andy have?",
"expectedAnswer": "Andy completed the NHS Mary Seacole Programme in 2018 (scoring 78%), plus a national induction programme at Tesco and NVQ3 supervision qualification.",
"keyFacts": [
"Mary Seacole Programme",
"2018",
"78%",
"national induction training at Tesco",
"NVQ3 supervision"
]
}
]
}
+382
View File
@@ -0,0 +1,382 @@
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildEmbeddingTexts } from '@/lib/search'
// Load .env file manually (avoid adding dotenv dependency)
function loadEnvFile(): void {
const envPath = resolve(import.meta.dirname, '..', '.env')
if (!existsSync(envPath)) return
const content = readFileSync(envPath, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex)
const value = trimmed.slice(eqIndex + 1)
if (!process.env[key]) {
process.env[key] = value
}
}
}
loadEnvFile()
// --- Types ---
interface BenchmarkQuestion {
id: string
question: string
expectedAnswer: string
keyFacts: string[]
}
interface BenchmarkConfig {
passThreshold: number
maxScore: number
questions: BenchmarkQuestion[]
}
interface ScoringResult {
score: 0 | 1 | 2
justification: string
}
interface QuestionResult {
id: string
question: string
expectedAnswer: string
actualAnswer: string
score: number
justification: string
}
interface BenchmarkResults {
iteration: number
timestamp: string
model: string
totalScore: number
maxPossibleScore: number
passThreshold: number
passed: boolean
hasZeros: boolean
results: QuestionResult[]
}
// --- Gemini API ---
const GEMINI_MODEL = 'gemini-3-flash-preview'
const GEMINI_API_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}`
function getApiKey(): string {
const key = process.env.VITE_GEMINI_API_KEY
if (!key) {
throw new Error('VITE_GEMINI_API_KEY not set. Ensure .env file exists with this key.')
}
return key
}
function buildSystemPrompt(): string {
const texts = buildEmbeddingTexts()
const cvContent = texts.map((t) => `- ${t.text}`).join('\n')
return `You are an AI assistant on Andy Charlwood's portfolio website. Answer questions about his experience, skills, projects, and qualifications.
## Andy's Professional Profile
${cvContent}
## Rules
1. Use ONLY the profile above. Never invent roles, dates, or achievements.
2. Be concise (2-4 sentences). Be professional but friendly.
3. If the information isn't in the profile, say so.
## Item References
After your answer, on a NEW line, list relevant portfolio item IDs:
[ITEMS: id1, id2, id3]
- IDs match the profile entries above (exp-*, skill-*, proj-*, ach-*, edu-*, action-*).
- Only include IDs directly relevant to your answer.
- If no items are relevant, omit the [ITEMS: ...] line entirely.`
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callGemini(
systemPrompt: string,
userMessage: string,
temperature = 0.7,
maxOutputTokens = 512,
): Promise<string> {
const apiKey = getApiKey()
const maxRetries = 5
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(
`${GEMINI_API_BASE}:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: {
parts: [{ text: systemPrompt }],
},
contents: [
{ role: 'user', parts: [{ text: userMessage }] },
],
generationConfig: {
temperature,
maxOutputTokens,
},
}),
},
)
if (response.status === 429 || response.status === 503) {
const errorBody = await response.text()
const retryMatch = errorBody.match(/retry in ([\d.]+)s/)
const waitSeconds = retryMatch ? Math.ceil(parseFloat(retryMatch[1])) + 2 : (attempt + 1) * 15
const reason = response.status === 429 ? 'Rate limited' : 'Service unavailable'
console.log(` ${reason}. Waiting ${waitSeconds}s (attempt ${attempt + 1}/${maxRetries})...`)
await sleep(waitSeconds * 1000)
continue
}
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`Gemini API error ${response.status}: ${errorBody}`)
}
const data = await response.json()
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text
if (!text) {
throw new Error(`No text in Gemini response: ${JSON.stringify(data)}`)
}
return text
}
throw new Error('Max retries exceeded for rate limiting')
}
// --- Scoring ---
function extractJson(text: string): string | null {
// Try parsing directly first
try {
JSON.parse(text)
return text
} catch { /* not direct JSON, continue extraction */ }
// Strip markdown code fences
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/)
if (fenceMatch) {
return fenceMatch[1].trim()
}
// Find first { ... } block
const braceStart = text.indexOf('{')
if (braceStart === -1) return null
// Find matching closing brace
let depth = 0
let inString = false
let escaped = false
for (let i = braceStart; i < text.length; i++) {
const ch = text[i]
if (escaped) { escaped = false; continue }
if (ch === '\\') { escaped = true; continue }
if (ch === '"') { inString = !inString; continue }
if (inString) continue
if (ch === '{') depth++
if (ch === '}') { depth--; if (depth === 0) return text.slice(braceStart, i + 1) }
}
return null
}
async function scoreAnswer(
question: string,
expectedAnswer: string,
keyFacts: string[],
actualAnswer: string,
): Promise<ScoringResult> {
const scoringPrompt = `You are a strict evaluator. Compare an ACTUAL answer to an EXPECTED answer about a person's CV.
Rubric:
- 2 = ACCURATE: Covers key facts correctly. Minor omissions OK if no errors.
- 1 = PARTIAL: Some key facts right but misses important details or is vague.
- 0 = INCORRECT: Contains factual errors, contradicts expected answer, or misses the point.
Key facts for score 2:
${keyFacts.map((f) => `- ${f}`).join('\n')}
IMPORTANT: Respond with ONLY a single-line JSON object. No markdown, no code fences, no extra text.
Example: {"score":2,"justification":"Covers all key facts accurately"}
Keep justification under 30 words.`
const userMessage = `QUESTION: ${question}
EXPECTED ANSWER: ${expectedAnswer}
ACTUAL ANSWER: ${actualAnswer}`
const rawResponse = await callGemini(scoringPrompt, userMessage, 0, 512)
// Extract JSON — handle code fences, preamble text, multiline responses
const extracted = extractJson(rawResponse)
if (!extracted) {
console.warn(` Warning: Could not extract JSON from scoring response: ${rawResponse.slice(0, 200)}`)
return { score: 0, justification: `Failed to parse scoring response` }
}
try {
const parsed = JSON.parse(extracted) as ScoringResult
if (![0, 1, 2].includes(parsed.score)) {
console.warn(` Warning: Invalid score value: ${parsed.score}`)
return { score: 0, justification: `Invalid score value: ${parsed.score}` }
}
return parsed
} catch {
console.warn(` Warning: Invalid JSON: ${extracted.slice(0, 150)}`)
return { score: 0, justification: `Invalid JSON in response` }
}
}
// --- Iteration Management ---
function getNextIteration(resultsDir: string): number {
if (!existsSync(resultsDir)) return 0
const files = readdirSync(resultsDir).filter((f) => f.startsWith('iteration-') && f.endsWith('.json'))
if (files.length === 0) return 0
const iterations = files.map((f) => {
const match = f.match(/iteration-(\d+)\.json/)
return match ? parseInt(match[1], 10) : -1
})
return Math.max(...iterations) + 1
}
// --- Console Output ---
function printSummary(results: BenchmarkResults): void {
console.log('\n' + '='.repeat(80))
console.log(`BENCHMARK RESULTS — Iteration ${results.iteration}`)
console.log(`Model: ${results.model} | ${results.timestamp}`)
console.log('='.repeat(80))
// Table header
console.log(
'ID'.padEnd(6) +
'Score'.padEnd(8) +
'Question'.padEnd(50) +
'Justification'
)
console.log('-'.repeat(80))
for (const r of results.results) {
const scoreLabel = r.score === 2 ? '2 ✓' : r.score === 1 ? '1 ~' : '0 ✗'
const questionTruncated = r.question.length > 47 ? r.question.slice(0, 44) + '...' : r.question
const justTruncated = r.justification.length > 60 ? r.justification.slice(0, 57) + '...' : r.justification
console.log(
r.id.padEnd(6) +
scoreLabel.padEnd(8) +
questionTruncated.padEnd(50) +
justTruncated
)
}
console.log('-'.repeat(80))
console.log(
`TOTAL: ${results.totalScore}/${results.maxPossibleScore}` +
` | Threshold: ${results.passThreshold}/${results.maxPossibleScore}` +
` | Has zeros: ${results.hasZeros ? 'YES' : 'No'}` +
` | ${results.passed ? 'PASSED ✓' : 'FAILED ✗'}`
)
console.log('='.repeat(80))
}
// --- Main ---
async function main() {
const scriptDir = import.meta.dirname
const configPath = resolve(scriptDir, 'benchmark-config.json')
const resultsDir = resolve(scriptDir, 'benchmark-results')
// Load config
const config: BenchmarkConfig = JSON.parse(readFileSync(configPath, 'utf-8'))
console.log(`Loaded ${config.questions.length} benchmark questions.`)
// Determine iteration number
const iteration = getNextIteration(resultsDir)
console.log(`Running iteration ${iteration}...`)
// Build system prompt (same as production)
const systemPrompt = buildSystemPrompt()
console.log(`System prompt built (${systemPrompt.length} chars).`)
// Run each question
const questionResults: QuestionResult[] = []
for (const q of config.questions) {
console.log(`\n[${q.id}] ${q.question}`)
// Get answer from Gemini
console.log(' Getting answer...')
const actualAnswer = await callGemini(systemPrompt, q.question)
console.log(` Answer: ${actualAnswer.slice(0, 100)}...`)
// Score the answer
console.log(' Scoring...')
const { score, justification } = await scoreAnswer(
q.question,
q.expectedAnswer,
q.keyFacts,
actualAnswer,
)
console.log(` Score: ${score}/2 — ${justification}`)
questionResults.push({
id: q.id,
question: q.question,
expectedAnswer: q.expectedAnswer,
actualAnswer,
score,
justification,
})
}
// Calculate totals
const totalScore = questionResults.reduce((sum, r) => sum + r.score, 0)
const hasZeros = questionResults.some((r) => r.score === 0)
const passed = totalScore >= config.passThreshold && !hasZeros
const results: BenchmarkResults = {
iteration,
timestamp: new Date().toISOString(),
model: GEMINI_MODEL,
totalScore,
maxPossibleScore: config.maxScore,
passThreshold: config.passThreshold,
passed,
hasZeros,
results: questionResults,
}
// Save results
mkdirSync(resultsDir, { recursive: true })
const resultsPath = resolve(resultsDir, `iteration-${iteration}.json`)
writeFileSync(resultsPath, JSON.stringify(results, null, 2))
console.log(`\nResults saved to ${resultsPath}`)
// Print summary table
printSummary(results)
// Exit with appropriate code
process.exit(passed ? 0 : 1)
}
main().catch((err) => {
console.error('Benchmark failed:', err)
process.exit(2)
})
+34
View File
@@ -0,0 +1,34 @@
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { env, pipeline } from '@xenova/transformers'
import { buildEmbeddingTexts } from '@/lib/search'
// Use local model files from public/models/ (same files the browser uses)
env.localModelPath = resolve(import.meta.dirname, '..', 'public', 'models')
env.allowRemoteModels = false
async function main() {
const items = buildEmbeddingTexts()
console.log(`Found ${items.length} items to embed.`)
console.log('Loading all-MiniLM-L6-v2 model...')
const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2')
const embeddings: Array<{ id: string; embedding: number[] }> = []
for (const item of items) {
const output = await extractor(item.text, { pooling: 'mean', normalize: true })
const vector = Array.from(output.data as Float32Array)
embeddings.push({ id: item.id, embedding: vector })
console.log(` [${embeddings.length}/${items.length}] ${item.id} (${vector.length}d)`)
}
const outPath = resolve(import.meta.dirname, '..', 'src', 'data', 'embeddings.json')
writeFileSync(outPath, JSON.stringify(embeddings, null, 2))
console.log(`\nWrote ${embeddings.length} embeddings to ${outPath}`)
}
main().catch((err) => {
console.error('Failed:', err)
process.exit(1)
})
+5
View File
@@ -6,6 +6,7 @@ import { LoginScreen } from './components/LoginScreen'
import { DashboardLayout } from './components/DashboardLayout' import { DashboardLayout } from './components/DashboardLayout'
import { AccessibilityProvider } from './contexts/AccessibilityContext' import { AccessibilityProvider } from './contexts/AccessibilityContext'
import { DetailPanelProvider } from './contexts/DetailPanelContext' import { DetailPanelProvider } from './contexts/DetailPanelContext'
import { initModel } from './lib/embedding-model'
function SkipButton({ onSkip }: { onSkip: () => void }) { function SkipButton({ onSkip }: { onSkip: () => void }) {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
@@ -47,6 +48,10 @@ function App() {
const [phase, setPhase] = useState<Phase>('login') const [phase, setPhase] = useState<Phase>('login')
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
useEffect(() => {
initModel()
}, [])
const skipToLogin = () => setPhase('login') const skipToLogin = () => setPhase('login')
return ( return (
+648
View File
@@ -0,0 +1,648 @@
import { useState, useRef, useEffect, useCallback, useMemo, type KeyboardEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
import {
sendChatMessage,
isGeminiAvailable,
parseItemIds,
stripItemsSuffix,
GEMINI_DISPLAY_NAME,
type ChatMessage,
} from '@/lib/gemini'
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const MAX_HISTORY = 10
const SUGGESTED_QUESTIONS = [
"What's his NHS experience?",
'Tell me about his data skills',
'What projects has he built?',
]
const buttonVariants = {
hidden: prefersReducedMotion
? { opacity: 1, y: 0 }
: { opacity: 0, y: 8 },
visible: {
opacity: 1,
y: 0,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, ease: 'easeOut', delay: 1 },
},
}
const panelVariants = {
hidden: prefersReducedMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' },
},
exit: prefersReducedMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } },
}
interface ChatWidgetProps {
onAction?: (action: PaletteAction) => void
}
export function ChatWidget({ onAction }: ChatWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const geminiAvailable = isGeminiAvailable()
// Build palette map for looking up items by ID
const paletteMap = useMemo(() => {
const items = buildPaletteData()
const map = new Map<string, PaletteItem>()
for (const item of items) map.set(item.id, item)
return map
}, [])
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when panel opens
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
const handleSubmit = useCallback(async (overrideText?: string) => {
const trimmed = (overrideText ?? inputValue).trim()
if (!trimmed || isStreaming) return
const userMessage: ChatMessage = { role: 'user', content: trimmed }
const updatedMessages = [...messages, userMessage]
// Cap history to last MAX_HISTORY messages, strip internal metadata
const historyForApi = updatedMessages.slice(-MAX_HISTORY).map((msg) => ({
...msg,
content: msg.content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim(),
}))
setMessages(updatedMessages)
setInputValue('')
setIsStreaming(true)
// Add empty assistant message that will be streamed into
const assistantMessage: ChatMessage = { role: 'assistant', content: '' }
setMessages((prev) => [...prev, assistantMessage])
try {
const stream = sendChatMessage(historyForApi)
let accumulated = ''
for await (const chunk of stream) {
accumulated += chunk
// Update the last (assistant) message with accumulated text
setMessages((prev) => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: accumulated }
return updated
})
}
// Final cleanup: strip [ITEMS: ...] suffix from display text (keep raw for parsing)
// We store the clean display text but parse items from the raw accumulated text
const cleanText = stripItemsSuffix(accumulated)
const itemIds = parseItemIds(accumulated)
const finalContent = itemIds.length > 0
? `${cleanText}\n<!--ITEMS:${itemIds.join(',')}-->`
: cleanText
setMessages((prev) => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: finalContent }
return updated
})
} catch {
setMessages((prev) => {
const updated = [...prev]
updated[updated.length - 1] = {
role: 'assistant',
content: "Sorry, I couldn't process that. Please try again.",
}
return updated
})
} finally {
setIsStreaming(false)
}
}, [inputValue, isStreaming, messages])
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}
// Extract display text from message content (strip hidden item metadata)
const getDisplayText = (content: string) => {
return content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim()
}
// Extract item IDs from the <!--ITEMS:...--> HTML comment in message content
const getMessageItemIds = (content: string): string[] => {
const match = content.match(/<!--ITEMS:([^>]*)-->/)
if (!match) return []
return match[1].split(',').map((id) => id.trim()).filter(Boolean)
}
// Resolve item IDs to PaletteItems
const getMessageItems = (content: string): PaletteItem[] => {
return getMessageItemIds(content)
.map((id) => paletteMap.get(id))
.filter((item): item is PaletteItem => item !== undefined)
}
// Handle clicking an item card — route through onAction
const handleItemClick = useCallback((item: PaletteItem) => {
if (onAction) {
onAction(item.action)
} else {
if (item.action.type === 'link') {
window.open(item.action.url, '_blank', 'noopener,noreferrer')
}
}
}, [onAction])
return (
<>
{/* Chat panel */}
<AnimatePresence>
{isOpen && (
<motion.div
key="chat-panel"
initial="hidden"
animate="visible"
exit="exit"
variants={panelVariants}
role="dialog"
aria-label="Chat with AI about Andy"
className="fixed z-[90] font-ui
inset-0 rounded-none max-md:z-[101]
md:inset-auto md:bottom-[88px] md:right-6 md:rounded-xl"
style={{
width: undefined,
background: 'var(--surface)',
border: '1px solid var(--border-light)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
transformOrigin: 'bottom right',
}}
>
<style>{`
@media (min-width: 768px) {
[data-chat-panel] { width: 380px; max-height: 480px; }
}
@media (max-width: 767px) {
[data-chat-panel] {
height: 100dvh;
max-height: 100dvh;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
}
`}</style>
<div
data-chat-panel
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '1px solid var(--border-light)',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
<span
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
Ask about Andy
</span>
<span
className="font-geist"
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
}}
>
{GEMINI_DISPLAY_NAME}
</span>
</div>
<button
onClick={() => setIsOpen(false)}
aria-label="Close chat"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
borderRadius: '6px',
border: 'none',
background: 'transparent',
color: 'var(--text-secondary)',
cursor: 'pointer',
transition: 'background-color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<X size={16} strokeWidth={2} />
</button>
</div>
{/* Messages area */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
className="pmr-scrollbar"
>
{!geminiAvailable && (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: 1.5,
}}
>
Chat is currently unavailable.
</div>
)}
{geminiAvailable && messages.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Welcome bubble — styled as assistant message */}
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px 12px 12px 4px',
fontSize: '13px',
lineHeight: 1.5,
background: 'var(--bg-dashboard)',
color: 'var(--text-primary)',
border: '1px solid var(--border-light)',
whiteSpace: 'pre-wrap',
}}
>
Hey! I'm here to help you learn more about Andy. What would you like to know?
</div>
</div>
{/* Suggested question chips */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
paddingLeft: '4px',
}}
>
{SUGGESTED_QUESTIONS.map((question) => (
<button
key={question}
onClick={() => handleSubmit(question)}
style={{
padding: '6px 14px',
borderRadius: '9999px',
border: '1px solid var(--accent-border)',
background: 'transparent',
color: 'var(--text-secondary)',
fontSize: '12.5px',
fontFamily: 'inherit',
cursor: 'pointer',
transition: 'background-color 150ms ease-out, color 150ms ease-out',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = 'var(--text-secondary)'
}}
>
{question}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => {
const referencedItems = msg.role === 'assistant' ? getMessageItems(msg.content) : []
return (
<div
key={i}
style={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '85%',
borderRadius: msg.role === 'user'
? '12px 12px 4px 12px'
: '12px 12px 12px 4px',
fontSize: '13px',
lineHeight: 1.5,
background: msg.role === 'user'
? 'var(--accent-light)'
: 'var(--bg-dashboard)',
color: 'var(--text-primary)',
border: msg.role === 'user'
? '1px solid var(--accent-border)'
: '1px solid var(--border-light)',
overflow: 'hidden',
}}
>
<div style={{ padding: '10px 14px', whiteSpace: 'pre-wrap' }}>
{getDisplayText(msg.content)}
</div>
{referencedItems.length > 0 && (
<div
style={{
borderTop: '1px solid var(--border-light)',
padding: '6px 8px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
{referencedItems.map((item) => {
const IconComponent = iconByType[item.iconType]
const colorStyle = iconColorStyles[item.iconVariant]
return (
<button
key={item.id}
onClick={() => handleItemClick(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
borderRadius: '6px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
width: '100%',
textAlign: 'left',
transition: 'background-color 100ms ease-out',
fontSize: '12px',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<div
style={{
width: '22px',
height: '22px',
borderRadius: '5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: colorStyle.background,
color: colorStyle.color,
}}
>
{IconComponent && <IconComponent size={12} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 500,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.title}
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginTop: '-1px',
}}
>
{item.subtitle}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
</div>
)
})}
{/* Typing indicator */}
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div
style={{
padding: '10px 14px',
borderRadius: '12px 12px 12px 4px',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border-light)',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
<Loader2
size={14}
strokeWidth={2}
style={{
animation: 'spin 1s linear infinite',
}}
/>
<span>Thinking...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
{geminiAvailable && (
<div
style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-light)',
display: 'flex',
alignItems: 'flex-end',
gap: '8px',
flexShrink: 0,
}}
>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything..."
rows={1}
disabled={isStreaming}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-light)',
borderRadius: '8px',
padding: '10px 12px',
fontSize: '13px',
lineHeight: 1.5,
color: 'var(--text-primary)',
background: 'var(--surface)',
outline: 'none',
fontFamily: 'inherit',
maxHeight: '80px',
overflowY: 'auto',
transition: 'border-color 150ms ease-out',
opacity: isStreaming ? 0.6 : 1,
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
}}
/>
<button
onClick={() => handleSubmit()}
disabled={!inputValue.trim() || isStreaming}
aria-label="Send message"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '36px',
height: '36px',
borderRadius: '8px',
border: 'none',
background: inputValue.trim() && !isStreaming ? 'var(--accent)' : 'var(--border-light)',
color: inputValue.trim() && !isStreaming ? '#FFFFFF' : 'var(--text-tertiary)',
cursor: inputValue.trim() && !isStreaming ? 'pointer' : 'default',
flexShrink: 0,
transition: 'background-color 150ms ease-out, color 150ms ease-out',
}}
>
<Send size={16} strokeWidth={2} />
</button>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Floating chat button — hidden on mobile when panel is open */}
<motion.button
initial="hidden"
animate="visible"
variants={buttonVariants}
onClick={() => setIsOpen((prev) => !prev)}
aria-label={isOpen ? 'Close chat' : 'Open chat'}
className={`fixed z-[90] cursor-pointer flex items-center justify-center bottom-4 right-4 h-10 w-10 md:bottom-6 md:right-6 md:h-12 md:w-12${isOpen ? ' max-md:!hidden' : ''}`}
style={{
borderRadius: '50%',
border: 'none',
background: 'var(--accent)',
color: '#FFFFFF',
boxShadow: 'var(--shadow-md)',
transition: 'box-shadow 150ms ease-out, transform 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = 'var(--shadow-lg)'
e.currentTarget.style.transform = 'scale(1.05)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
e.currentTarget.style.transform = 'scale(1)'
}}
>
{isOpen ? <X size={20} strokeWidth={2} /> : <MessageCircle size={20} strokeWidth={2} />}
</motion.button>
{/* Spinner keyframes */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</>
)
}
+57 -31
View File
@@ -1,20 +1,14 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { import { Search } from 'lucide-react'
Search,
User,
Activity,
Monitor,
Award,
GraduationCap,
Zap,
type LucideIcon,
} from 'lucide-react'
import { import {
buildPaletteData, buildPaletteData,
buildSearchIndex, buildSearchIndex,
groupBySection, groupBySection,
} from '@/lib/search' } from '@/lib/search'
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search' import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
import { isModelReady, embedQuery } from '@/lib/embedding-model'
import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -24,24 +18,6 @@ interface CommandPaletteProps {
onAction?: (action: PaletteAction) => void onAction?: (action: PaletteAction) => void
} }
// Icon mapping by type
const iconByType: Record<string, LucideIcon> = {
role: User,
skill: Activity,
project: Monitor,
achievement: Award,
edu: GraduationCap,
action: Zap,
}
// Color variant → CSS variable mapping for icon containers
const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
green: { background: 'var(--success-light)', color: 'var(--success)' },
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
}
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) { export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1) const [selectedIndex, setSelectedIndex] = useState(-1)
@@ -53,13 +29,62 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
const paletteData = useMemo(() => buildPaletteData(), []) const paletteData = useMemo(() => buildPaletteData(), [])
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData]) const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
// Compute visible items based on query // Preload embeddings and build lookup map
const embeddings = useMemo(() => loadEmbeddings(), [])
const paletteMap = useMemo(() => {
const map = new Map<string, PaletteItem>()
for (const item of paletteData) map.set(item.id, item)
return map
}, [paletteData])
// Semantic search results (async, debounced)
const [semanticResults, setSemanticResults] = useState<PaletteItem[] | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
const trimmed = query.trim()
// Clear semantic results when query is empty
if (!trimmed) {
setSemanticResults(null)
return
}
// Only use semantic search when model is ready
if (!isModelReady()) {
setSemanticResults(null)
return
}
// Debounce ~200ms
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
try {
const queryVec = await embedQuery(trimmed)
const results = semanticSearch(queryVec, embeddings)
const items = results
.map(r => paletteMap.get(r.id))
.filter((item): item is PaletteItem => item !== undefined)
setSemanticResults(items)
} catch {
// Fall back to Fuse.js on any error
setSemanticResults(null)
}
}, 200)
return () => clearTimeout(debounceRef.current)
}, [query, embeddings, paletteMap])
// Compute visible items: semantic search when available, Fuse.js fallback
const visibleItems = useMemo(() => { const visibleItems = useMemo(() => {
if (!query.trim()) { if (!query.trim()) {
return paletteData return paletteData
} }
if (semanticResults !== null) {
return semanticResults
}
return searchIndex.search(query).map(result => result.item) return searchIndex.search(query).map(result => result.item)
}, [query, paletteData, searchIndex]) }, [query, paletteData, searchIndex, semanticResults])
// Group visible items by section // Group visible items by section
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems]) const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
@@ -80,6 +105,7 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp
if (isOpen) { if (isOpen) {
setQuery('') setQuery('')
setSelectedIndex(-1) setSelectedIndex(-1)
setSemanticResults(null)
// Focus input on next frame // Focus input on next frame
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus() inputRef.current?.focus()
+4
View File
@@ -14,6 +14,7 @@ import { ParentSection } from './ParentSection'
import CareerConstellation from './CareerConstellation' import CareerConstellation from './CareerConstellation'
import { WorkExperienceSubsection } from './WorkExperienceSubsection' import { WorkExperienceSubsection } from './WorkExperienceSubsection'
import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection' import { RepeatMedicationsSubsection } from './RepeatMedicationsSubsection'
import { ChatWidget } from './ChatWidget'
import { useActiveSection } from '@/hooks/useActiveSection' import { useActiveSection } from '@/hooks/useActiveSection'
import { useDetailPanel } from '@/contexts/DetailPanelContext' import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { consultations } from '@/data/consultations' import { consultations } from '@/data/consultations'
@@ -418,6 +419,9 @@ export function DashboardLayout() {
{/* Detail panel */} {/* Detail panel */}
<DetailPanel /> <DetailPanel />
{/* Floating chat widget */}
<ChatWidget onAction={handlePaletteAction} />
</div> </div>
) )
} }
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { env, pipeline, type FeatureExtractionPipeline } from '@xenova/transformers'
// Serve model files from /models/ (Vite serves public/ at root)
env.localModelPath = '/models/'
env.allowRemoteModels = false
env.useBrowserCache = false
let extractor: FeatureExtractionPipeline | null = null
let loading = false
export async function initModel(): Promise<void> {
if (extractor || loading) return
loading = true
try {
extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2') as FeatureExtractionPipeline
} catch {
// Silently swallow — model unavailable, semantic search won't activate
} finally {
loading = false
}
}
export async function embedQuery(text: string): Promise<number[]> {
if (!extractor) throw new Error('Model not loaded')
const output = await extractor(text, { pooling: 'mean', normalize: true })
return Array.from(output.data as Float32Array)
}
export function isModelReady(): boolean {
return extractor !== null
}
+158
View File
@@ -0,0 +1,158 @@
import { buildEmbeddingTexts } from '@/lib/search'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export const GEMINI_MODEL = 'gemini-3-flash-preview'
export const GEMINI_DISPLAY_NAME = 'Gemini 3 Flash'
const GEMINI_API_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}`
function getApiKey(): string | undefined {
return import.meta.env.VITE_GEMINI_API_KEY as string | undefined
}
export function isGeminiAvailable(): boolean {
return !!getApiKey()
}
function buildSystemPrompt(): string {
const texts = buildEmbeddingTexts()
const cvContent = texts.map((t) => `- ${t.text}`).join('\n')
return `You are an AI assistant on Andy Charlwood's portfolio website. Answer questions about his experience, skills, projects, and qualifications.
## Andy's Professional Profile
${cvContent}
## Rules
1. Use ONLY the profile above. Never invent roles, dates, or achievements.
2. Be concise (2-4 sentences). Be professional but friendly.
3. If the information isn't in the profile, say so.
## Item References
After your answer, on a NEW line, list relevant portfolio item IDs:
[ITEMS: id1, id2, id3]
- IDs match the profile entries above (exp-*, skill-*, proj-*, ach-*, edu-*, action-*).
- Only include IDs directly relevant to your answer.
- If no items are relevant, omit the [ITEMS: ...] line entirely.`
}
function buildRequestBody(
messages: ChatMessage[],
systemPrompt: string,
): object {
const contents = messages.map((msg) => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }],
}))
return {
system_instruction: {
parts: [{ text: systemPrompt }],
},
contents,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 512,
},
}
}
export async function* sendChatMessage(
messages: ChatMessage[],
): AsyncGenerator<string> {
const apiKey = getApiKey()
if (!apiKey) {
throw new Error('Gemini API key not configured')
}
const systemPrompt = buildSystemPrompt()
const body = buildRequestBody(messages, systemPrompt)
const response = await fetch(
`${GEMINI_API_BASE}:streamGenerateContent?alt=sse&key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
)
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
const jsonStr = trimmed.slice(5).trim()
if (!jsonStr || jsonStr === '[DONE]') continue
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
yield text
}
} catch {
// Skip malformed JSON chunks
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith('data:')) {
const jsonStr = buffer.trim().slice(5).trim()
if (jsonStr && jsonStr !== '[DONE]') {
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
if (text) {
yield text
}
} catch {
// Skip malformed final chunk
}
}
}
} finally {
reader.releaseLock()
}
}
export function parseItemIds(text: string): string[] {
const match = text.match(/\[ITEMS:\s*([^\]]+)\]/)
if (!match) return []
return match[1]
.split(',')
.map((id) => id.trim())
.filter(Boolean)
}
export function stripItemsSuffix(text: string): string {
return text.replace(/\n?\[ITEMS:[^\]]*\]\s*$/, '').trim()
}
+26
View File
@@ -0,0 +1,26 @@
import {
User,
Activity,
Monitor,
Award,
GraduationCap,
Zap,
type LucideIcon,
} from 'lucide-react'
import type { IconColorVariant } from '@/lib/search'
export const iconByType: Record<string, LucideIcon> = {
role: User,
skill: Activity,
project: Monitor,
achievement: Award,
edu: GraduationCap,
action: Zap,
}
export const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
green: { background: 'var(--success-light)', color: 'var(--success)' },
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
}
+96
View File
@@ -1,6 +1,7 @@
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import { consultations } from '@/data/consultations' import { consultations } from '@/data/consultations'
import { documents } from '@/data/documents'
import { investigations } from '@/data/investigations' import { investigations } from '@/data/investigations'
import { skills } from '@/data/skills' import { skills } from '@/data/skills'
import { kpis } from '@/data/kpis' import { kpis } from '@/data/kpis'
@@ -241,3 +242,98 @@ export function groupBySection(items: PaletteItem[]): Array<{ section: PaletteSe
.map(section => ({ section, items: groups.get(section)! })) .map(section => ({ section, items: groups.get(section)! }))
} }
// Build rich natural-language text representations for semantic embedding.
// IDs match PaletteItem IDs so embeddings can be correlated back to palette entries.
export function buildEmbeddingTexts(): Array<{ id: string; text: string }> {
const texts: Array<{ id: string; text: string }> = []
// Consultations (Experience)
consultations.forEach((c) => {
const examBullets = c.examination.join('. ')
const codedDescriptions = c.codedEntries.map(e => e.description).join('. ')
texts.push({
id: `exp-${c.id}`,
text: `${c.role} at ${c.organization}, ${c.duration}. ${c.history} Key achievements: ${examBullets}. ${codedDescriptions}.`,
})
})
// Skills
skills.forEach((skill) => {
texts.push({
id: `skill-${skill.id}`,
text: `${skill.name} is a ${skill.category.toLowerCase()} skill used ${skill.frequency.toLowerCase()}, with ${skill.proficiency}% proficiency and ${skill.yearsOfExperience} years of experience since ${skill.startYear}.`,
})
})
// KPI-backed Achievements
const achievementMap: Array<{ id: string; title: string; subtitle: string; kpiId: string }> = [
{ id: 'ach-0', title: '£14.6M Efficiency Savings Identified', subtitle: 'Data-driven prescribing interventions', kpiId: 'savings' },
{ id: 'ach-1', title: '£220M Budget Oversight', subtitle: 'Full analytical accountability to ICB board', kpiId: 'budget' },
{ id: 'ach-2', title: 'Power BI Dashboards for 200+ Users', subtitle: 'Clinicians & commissioners across ICB', kpiId: 'years' },
{ id: 'ach-3', title: '1.2M Population Served', subtitle: 'Norfolk & Waveney Integrated Care System', kpiId: 'population' },
]
achievementMap.forEach((entry) => {
const kpi = kpis.find(k => k.id === entry.kpiId)
const storyContext = kpi?.story
? ` ${kpi.story.context} ${kpi.story.role} Outcomes: ${kpi.story.outcomes.join('. ')}.`
: ''
texts.push({
id: entry.id,
text: `Achievement: ${entry.title}. ${entry.subtitle}. ${kpi?.explanation ?? ''}${storyContext}`,
})
})
// Investigations (Active Projects)
investigations.forEach((inv) => {
const techList = inv.techStack.join(', ')
const resultList = inv.results.join('. ')
texts.push({
id: `proj-${inv.id}`,
text: `Project: ${inv.name}. ${inv.methodology} Tech stack: ${techList}. Results: ${resultList}.`,
})
})
// Education
const educationItems: Array<{ id: string; docId: string; fallbackTitle: string; fallbackSub: string }> = [
{ id: 'edu-0', docId: 'doc-mary-seacole', fallbackTitle: 'NHS Leadership Academy — Mary Seacole Programme', fallbackSub: 'NHS Leadership Academy · 2018' },
{ id: 'edu-1', docId: 'doc-mpharm', fallbackTitle: 'MPharm (Hons) — 2:1', fallbackSub: 'University of East Anglia · 20112015' },
{ id: 'edu-2', docId: 'doc-alevels', fallbackTitle: 'A-Levels', fallbackSub: 'Highworth Grammar School · 20092011' },
{ id: 'edu-3', docId: 'doc-gphc', fallbackTitle: 'GPhC Registration', fallbackSub: 'General Pharmaceutical Council · August 2016' },
]
educationItems.forEach((entry) => {
const doc = documents.find(d => d.id === entry.docId)
if (doc) {
const research = doc.researchDetail ? ` Research: ${doc.researchDetail}.` : ''
const classification = doc.classification ? ` Classification: ${doc.classification}.` : ''
texts.push({
id: entry.id,
text: `Education: ${doc.title}. ${doc.type} from ${doc.institution ?? doc.source}, ${doc.duration ?? doc.date}.${classification}${research} ${doc.notes ?? ''}`,
})
} else {
texts.push({
id: entry.id,
text: `Education: ${entry.fallbackTitle}. ${entry.fallbackSub}.`,
})
}
})
// Quick Actions
const quickActionTexts: Array<{ id: string; title: string; subtitle: string }> = [
{ id: 'action-0', title: 'Download CV', subtitle: 'Export as PDF' },
{ id: 'action-1', title: 'Send Email', subtitle: 'andy@charlwood.xyz' },
{ id: 'action-2', title: 'View LinkedIn', subtitle: 'Professional profile' },
{ id: 'action-3', title: 'View Projects', subtitle: 'GitHub & portfolio' },
]
quickActionTexts.forEach((entry) => {
texts.push({
id: entry.id,
text: `${entry.title}. ${entry.subtitle}.`,
})
})
return texts
}
+42
View File
@@ -0,0 +1,42 @@
import embeddingsData from '@/data/embeddings.json'
interface EmbeddingEntry {
id: string
embedding: number[]
}
interface SearchResult {
id: string
score: number
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0
let magA = 0
let magB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
magA += a[i] * a[i]
magB += b[i] * b[i]
}
const denom = Math.sqrt(magA) * Math.sqrt(magB)
return denom === 0 ? 0 : dot / denom
}
export function semanticSearch(
queryEmbedding: number[],
embeddings: EmbeddingEntry[],
threshold = 0.3
): SearchResult[] {
return embeddings
.map(entry => ({
id: entry.id,
score: cosineSimilarity(queryEmbedding, entry.embedding),
}))
.filter(r => r.score >= threshold)
.sort((a, b) => b.score - a.score)
}
export function loadEmbeddings(): EmbeddingEntry[] {
return embeddingsData as EmbeddingEntry[]
}
+125
View File
@@ -0,0 +1,125 @@
# PRD: Chat Widget Polish & Model Updates
## Introduction
The semantic search and AI chat features are functionally complete (US-001 through US-010). This PRD covers four polish items: mobile full-screen chat experience, a welcome message with suggested questions, self-hosting the ONNX embedding model, and updating from Gemini 2.0 Flash to Gemini 3 Flash Preview.
## Goals
- Full-screen chat on mobile (<768px) for a better small-screen experience
- Welcome message with suggested question chips to reduce blank-state friction
- Self-host the ONNX model (`all-MiniLM-L6-v2`) to eliminate dependency on Hugging Face CDN
- Update Gemini model to `gemini-3-flash-preview` and show which model powers the chat
- Refresh system prompt while updating the model
## User Stories
### US-011: Mobile full-screen chat panel
**Description:** As a mobile visitor, I want the chat panel to be a full-screen overlay so it's easy to use on small screens.
**Acceptance Criteria:**
- [ ] Below `md` breakpoint (768px), chat panel renders as full-screen overlay (100vw x 100vh, or using `dvh` for mobile browser chrome)
- [ ] Full-screen mode has a visible header with close button
- [ ] Floating chat button is hidden while panel is open on mobile
- [ ] Above 768px, existing panel behavior unchanged (380px wide, anchored bottom-right)
- [ ] Smooth transition between open/closed states respects `prefers-reduced-motion`
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
### US-012: Welcome message with suggested questions
**Description:** As a visitor opening the chat for the first time, I see a friendly welcome and clickable suggested questions so I know what to ask.
**Acceptance Criteria:**
- [ ] When chat panel opens and conversation is empty, display welcome message: "Hey! I'm here to help you learn more about Andy. What would you like to know?"
- [ ] Below the welcome message, show 2-3 clickable pill/chip buttons with suggested questions (e.g., "What's his NHS experience?", "Tell me about his data skills", "What projects has he built?")
- [ ] Clicking a suggested question sends it as a user message (same as typing and pressing Enter)
- [ ] Welcome message and chips are always visible when conversation is empty (persist across open/close if no messages sent)
- [ ] Once a message is sent, the welcome/chips area is replaced by the conversation
- [ ] Chips use design system tokens (teal accent border, hover state)
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
### US-013: Self-host ONNX embedding model
**Description:** As a developer, I want the ONNX model files served from the same host as the site, so there's no runtime dependency on Hugging Face CDN.
**Acceptance Criteria:**
- [ ] Model files for `all-MiniLM-L6-v2` downloaded and placed in `public/models/all-MiniLM-L6-v2/` (or `public/models/onnx/` — whichever is cleaner)
- [ ] Files include at minimum: `onnx/model_quantized.onnx`, `tokenizer.json`, `tokenizer_config.json`, `config.json`
- [ ] `src/lib/embedding-model.ts` updated to load from local path instead of Hugging Face CDN
- [ ] Build-time embedding script (`scripts/generate-embeddings.ts`) also uses local model path
- [ ] `.gitignore` does NOT ignore the model files — they are committed as static assets
- [ ] Verify model loads correctly in browser (semantic search still works in command palette)
- [ ] Typecheck passes
### US-014: Update to Gemini 3 Flash Preview + model indicator
**Description:** As a developer, I want to use the latest free Gemini model, and as a visitor, I want to see what model powers the chat.
**Acceptance Criteria:**
- [ ] `GEMINI_API_BASE` in `src/lib/gemini.ts` updated from `gemini-2.0-flash` to `gemini-3-flash-preview`
- [ ] Review and update the system prompt for clarity (ensure it's well-structured for the new model)
- [ ] Review and update the response format instructions (the `[ITEMS: ...]` suffix pattern)
- [ ] Small text indicator in chat panel header or footer showing the model name (e.g., "Gemini 3 Flash" in `font-geist`, 11px, tertiary color)
- [ ] If the model string needs to change in future, it should be a single constant — not hardcoded in multiple places
- [ ] Typecheck passes
- [ ] Verify in browser using dev-browser skill
## Functional Requirements
- FR-1: Chat panel below 768px uses full-screen overlay layout (`position: fixed; inset: 0`)
- FR-2: Chat button hidden when full-screen panel is open on mobile
- FR-3: Welcome message and suggested question chips shown when conversation is empty
- FR-4: Clicking a suggested question chip triggers the same flow as manually typing and sending
- FR-5: ONNX model files served from `public/models/` as static assets
- FR-6: `embedding-model.ts` configures Transformers.js to use local model path
- FR-7: Gemini API calls use `gemini-3-flash-preview` model
- FR-8: Chat UI displays model name indicator
## Non-Goals
- No changes to the command palette UI or semantic search ranking logic
- No persistent chat history across page loads
- No rate limiting or abuse prevention
- No changes to the boot/ECG/login flow
- No model fine-tuning or custom training
## Design Considerations
### Mobile Full-Screen Chat
- Full viewport with safe area insets (`env(safe-area-inset-*)`) for notched devices
- Header matches existing panel header style but full-width
- Input pinned to bottom, messages scroll above
### Welcome Message & Chips
- Welcome text styled as an AI message bubble (left-aligned, light background)
- Chips: small rounded pills with teal border, teal text on hover, `font-ui` 12-13px
- 2-3 chips arranged in a flex-wrap row below the welcome bubble
- Example questions: "What's his NHS experience?", "Tell me about his data skills", "What projects has he built?"
### Model Indicator
- Placed in the chat panel header, right-aligned or below the "Ask about Andy" title
- `font-geist`, 11px, `var(--text-tertiary)` color
- Format: "Powered by Gemini 3 Flash" or just "Gemini 3 Flash"
## Technical Considerations
### Self-Hosting ONNX Model
- Transformers.js supports a `localURL` or custom `env.localModelPath` configuration to redirect model loading from HF CDN to a local path
- The quantized model (`model_quantized.onnx`) is ~23MB — acceptable for a static deploy
- Files must be served with correct MIME types (`.onnx` as `application/octet-stream`)
- The build-time script and browser runtime must both point to the same model files
### Gemini Model Update
- `gemini-3-flash-preview` may have a different API path structure — verify against the Generative Language API docs
- The streaming SSE format should be identical across Flash models, but verify the response shape
## Success Metrics
- Mobile chat is comfortable to use on a phone-sized viewport (no overflow, no cropping)
- Suggested questions reduce "blank screen" hesitation — visitors engage faster
- ONNX model loads successfully from local path (no HF CDN requests in network tab)
- Chat responses come through on the new Gemini model with correct item references
## Open Questions
- Should the suggested question chips be configurable from a data file, or hardcoded in the component?
- Does `gemini-3-flash-preview` require a different API version path (`v1beta` vs `v1`)?
+197
View File
@@ -0,0 +1,197 @@
# PRD: Improve LLM CV Knowledge Accuracy
## Introduction
The portfolio's AI chat gives inaccurate or shallow answers about Andy's work history. The root cause: the system prompt feeds `buildEmbeddingTexts()` summaries rather than the full CV detail. Questions about specific achievements, methodology, clinical specialties, or cross-role context produce vague or incorrect responses. This PRD defines an iterative improvement process: enrich the LLM's context, measure accuracy against 10 verifiable benchmark questions, and repeat until all pass — while ensuring changes are structural (not question-specific hacks).
Additionally, the LLM provider is changing from Gemini to **OpenRouter** using the **z-ai/glm-5** model. This requires migrating the API integration, renaming the module, and updating the benchmark harness to use the new provider.
## Goals
- Achieve 10/10 accuracy on benchmark questions with factually correct, detailed, citation-worthy answers
- Ensure improvements are structural — benefiting all possible queries, not just the 10 benchmarks
- Maintain the existing architecture (no new APIs beyond OpenRouter, no RAG infrastructure, no backend)
- Migrate from Gemini to OpenRouter (z-ai/glm-5) for both production chat and benchmark scoring
- Regenerate embeddings when embedding texts change, keeping search and LLM context in sync
## Benchmark Questions
These 10 questions have verifiable answers from CV_v4.md and the structured data files. Each tests a different knowledge gap.
| # | Question | Expected Answer (summary) | Tests |
|---|----------|--------------------------|-------|
| Q1 | "How many years has Andy been employed by the NHS?" | ~3.5 years (May 2022present). Tesco was private sector. | NHS vs non-NHS employer distinction |
| Q2 | "What was Andy's involvement with tirzepatide?" | Supported NICE TA1026 commissioning, authored executive paper advocating primary care model, drove GP-led delivery. | Deep role-specific detail |
| Q3 | "What specific tools and software has Andy built?" | 5 projects: switching algorithm, Blueteq generator, CD monitoring, Sankey tool, PharMetrics. Each with outcomes. | Cross-role aggregation |
| Q4 | "What were Andy's A-level subjects and grades?" | Maths A*, Chemistry B, Politics C. Highworth Grammar School, 20092011. | Specific education detail |
| Q5 | "Was Andy's Tesco role part of the NHS?" | No. Tesco PLC is private. Community pharmacy, not NHS employment. LPC representative for Norfolk. | Employer classification |
| Q6 | "How did the patient switching algorithm work?" | Python, real-world GP data, auto-identified patients for alternatives, 3 days vs months manual, 14,000 patients, £2.6M, novel GP payment system. | Methodology depth |
| Q7 | "What clinical specialties has Andy worked across?" | Rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, migraine — from high-cost drugs role. | Narrative detail not in bullet summaries |
| Q8 | "What is Andy's experience with the dm+d?" | Created comprehensive medicines data table integrating all dm+d products with standardised strengths, morphine equivalents, Anticholinergic Burden scoring — single source of truth. | Technical achievement context |
| Q9 | "What budget does Andy manage and how?" | £220M prescribing budget. Forecasting models, variance analysis, financial reporting to executive team, interactive expenditure dashboard. | Figure + methodology |
| Q10 | "What leadership training does Andy have?" | Mary Seacole Programme (2018, 78%). Also national induction programme at Tesco, NVQ3 supervision. | Cross-role synthesis |
### Scoring Criteria
Each question scored 02:
- **0 — Incorrect**: Wrong facts, invented detail, or contradicts CV
- **1 — Partial**: Correct but missing key detail, or vague where specifics are available
- **2 — Accurate**: Factually correct, appropriately detailed, cites specific achievements/metrics
**Pass threshold**: 18/20 (90%), with no question scoring 0.
### Anti-Benchmaxing Rules
- No hardcoded answers or question-specific prompt clauses
- Every change must be a structural improvement (richer context, better prompt patterns, enriched embeddings)
- After each iteration, mentally evaluate: "Would this help a question NOT in the benchmark?" — if no, reject the change
- The system prompt must not reference benchmark questions or their specific phrasings
## User Stories
### US-001: Migrate production chat from Gemini to OpenRouter
**Description:** As a developer, I need to replace the Gemini API integration with OpenRouter so the chat uses z-ai/glm-5.
**Acceptance Criteria:**
- [ ] Rename `src/lib/gemini.ts``src/lib/llm.ts`
- [ ] Update all imports across the codebase (`ChatWidget.tsx`, `search.ts`, etc.)
- [ ] Replace Gemini API calls with OpenRouter's OpenAI-compatible API (`https://openrouter.ai/api/v1/chat/completions`)
- [ ] Model set to `z-ai/glm-5`
- [ ] API key read from `VITE_OPEN_ROUTER_API_KEY` env var
- [ ] SSE streaming still works (OpenRouter supports `stream: true`)
- [ ] System prompt and message format adapted to OpenAI chat completions format (`messages` array with `role`/`content`)
- [ ] Export updated display name constant (e.g., `LLM_DISPLAY_NAME = 'GLM-5'`) and update model indicator in chat UI
- [ ] Rename `isGeminiAvailable()``isLLMAvailable()` (or similar)
- [ ] Typecheck passes
- [ ] **Verify in browser**: chat opens, sends a message, streams a response
### US-002: Migrate benchmark script to OpenRouter
**Description:** As a developer, I need the benchmark harness to use OpenRouter so it tests the same model and prompt path as production.
**Acceptance Criteria:**
- [ ] `scripts/benchmark.ts` uses OpenRouter API instead of Gemini
- [ ] API key read from `VITE_OPEN_ROUTER_API_KEY` (loaded from `.env`)
- [ ] Request format uses OpenAI chat completions structure
- [ ] Model identifier set to `z-ai/glm-5`
- [ ] Rate limit/retry logic updated for OpenRouter's error responses
- [ ] Scoring calls also use OpenRouter (same provider for all LLM calls)
- [ ] `npm run benchmark` still works end-to-end
- [ ] Typecheck passes
### US-003: Enrich system prompt with full CV context
**Description:** As a portfolio visitor, I want the AI to have comprehensive knowledge of Andy's background so it can answer detailed questions accurately.
**Acceptance Criteria:**
- [ ] System prompt includes full professional profile narrative (from CV_v4.md profile section)
- [ ] Each role includes full achievement bullets, not just summaries
- [ ] Clear distinction between NHS employment (May 2022+) and private sector (Tesco)
- [ ] Clinical specialties, methodology details, and specific outcomes included
- [ ] Education includes specific grades, subjects, research topics
- [ ] Prompt is well-structured with clear sections for easy LLM parsing
- [ ] No invented or extrapolated content — everything sourced from CV_v4.md and data files
- [ ] Typecheck passes
### US-004: Improve system prompt instructions
**Description:** As a portfolio visitor, I want the AI to use its knowledge effectively — citing specifics, distinguishing between employers, and aggregating across roles when asked.
**Acceptance Criteria:**
- [ ] Prompt instructs LLM to distinguish NHS employment from private sector roles
- [ ] Prompt instructs LLM to aggregate across roles when asked broad questions (e.g., "what tools has Andy built?")
- [ ] Prompt instructs LLM to cite specific metrics, dates, and outcomes when available
- [ ] Temperature and token limits are appropriate for detailed answers (review current 0.7 temp, 512 max tokens)
- [ ] Typecheck passes
### US-005: Enrich embedding texts for semantic search
**Description:** As a portfolio visitor, I want semantic search to surface relevant results even for nuanced queries so the chat and command palette find the right content.
**Acceptance Criteria:**
- [ ] `buildEmbeddingTexts()` generates richer text per item — full achievement narratives, methodology detail, clinical specialties
- [ ] Role `history` narratives are included (currently only `examination` bullets and `codedEntries`)
- [ ] Cross-references included where items relate (e.g., CD monitoring links to controlled drugs skill)
- [ ] Embedding texts remain well-formed natural language (not keyword soup)
- [ ] Typecheck passes
### US-006: Regenerate embeddings
**Description:** As a developer, I need embeddings regenerated whenever embedding texts change so semantic search results match the enriched content.
**Acceptance Criteria:**
- [ ] Embeddings regenerated using the same model (all-MiniLM-L6-v2)
- [ ] Output written to `src/data/embeddings.json`
- [ ] Number of embeddings matches number of palette items
- [ ] Regeneration can be triggered via script (`npm run generate-embeddings` or similar)
- [ ] Typecheck passes
### US-007: Iterative benchmark loop
**Description:** As a developer, I want to run the benchmark, review scores, make improvements, and repeat until the pass threshold is met.
**Acceptance Criteria:**
- [ ] Run benchmark → review scores → identify failing questions → make structural improvements → repeat
- [ ] Each iteration logged with: changes made, scores before/after, rationale
- [ ] Minimum 2 iterations, maximum 10
- [ ] Stop when 18/20 achieved with no question scoring 0
- [ ] Final iteration results saved as evidence
- [ ] All changes pass typecheck before benchmarking
### US-008: Validate no regression on general queries
**Description:** As a portfolio visitor, I want the AI to still handle general questions well after the benchmark-focused improvements.
**Acceptance Criteria:**
- [ ] Test 5 general questions not in the benchmark (e.g., "Tell me about Andy", "What does Andy do?", "How can I contact Andy?", "What is this website?", "What are Andy's strongest skills?")
- [ ] All general questions produce sensible, accurate responses
- [ ] No degradation in response quality for broad queries
- [ ] System prompt size hasn't grown to a point that degrades response speed noticeably
## Functional Requirements
- FR-1: Production chat must use OpenRouter API with model `z-ai/glm-5`
- FR-2: API key sourced from `VITE_OPEN_ROUTER_API_KEY` environment variable
- FR-3: LLM module renamed from `gemini.ts` to `llm.ts` with updated exports
- FR-4: Chat UI displays "GLM-5" as the model indicator (replacing "Gemini 3 Flash")
- FR-5: Benchmark harness must use the identical system prompt construction path as production (`buildSystemPrompt()` from `llm.ts`)
- FR-6: System prompt changes must be made in `llm.ts` and/or `search.ts` — the same files that serve production
- FR-7: Embedding text changes must be in `buildEmbeddingTexts()` in `search.ts`
- FR-8: Scoring must be automated via LLM (OpenRouter), not manual review
- FR-9: All benchmark artifacts (questions, expected answers, results) stored in `scripts/`
- FR-10: Embedding regeneration must produce deterministic output for the same input texts
- FR-11: System prompt must remain a single self-contained context block (no external retrieval at runtime)
## Non-Goals
- No RAG infrastructure or vector database
- No additional API integrations beyond OpenRouter
- No changes to the chat UI layout, streaming UX, or item linking (beyond model name display)
- No changes to the command palette search UX
- No changes to boot sequence, ECG, or login phases
- No new backend or server-side components
- Not optimising for adversarial/trick questions — focus is on legitimate CV queries
- No keeping Gemini as a fallback — this is a full replacement
## Technical Considerations
- **OpenRouter API format**: Uses OpenAI-compatible chat completions endpoint (`POST https://openrouter.ai/api/v1/chat/completions`). Messages use `{ role: 'system' | 'user' | 'assistant', content: string }` format. Streaming uses `stream: true` with SSE `data:` lines containing `choices[0].delta.content`.
- **Authentication**: `Authorization: Bearer <VITE_OPEN_ROUTER_API_KEY>` header. Include `HTTP-Referer` and `X-Title` headers as recommended by OpenRouter.
- **Rate limits**: OpenRouter has per-model rate limits. Add retry logic for 429 responses. The benchmark script should include delays between calls.
- **Embedding regeneration**: Needs Node.js script that loads the ONNX model and processes all texts. Existing `scripts/generate-embeddings` script should be reused.
- **Temperature**: Current 0.7 may introduce variability in answers. Consider lowering to 0.30.5 for more consistent factual responses. Benchmark both.
- **Max tokens**: Current 512 may truncate detailed answers. Consider increasing to 768 or 1024 for benchmark testing.
- **Prompt structure**: Well-structured prompts with clear headings/sections parse better for LLMs than flat text. Consider markdown structure in system prompt.
- **CORS**: OpenRouter supports browser-side calls. The existing client-side fetch pattern should work without changes.
## Success Metrics
- 18/20 or higher on benchmark (90%+ accuracy)
- No question scores 0 (no factual errors)
- 5/5 general validation questions pass
- System prompt remains under 8KB
- No typecheck or lint regressions
- Embedding regeneration completes without errors
- Chat streaming works in-browser with OpenRouter
## Resolved Questions
- **Model provider**: OpenRouter with z-ai/glm-5 (replaces Gemini 3 Flash).
- **File naming**: `gemini.ts` renamed to `llm.ts` for provider-agnostic naming.
- **Benchmark provider**: OpenRouter used for both chat answers and scoring (single provider).
- **Benchmark results are git-tracked.** Each iteration's scores are committed so improvement over time is visible and auditable.
- **Existing `scripts/generate-embeddings` script exists.** Review and adapt as needed rather than building from scratch.
- **Benchmark harness is permanent.** Kept as an ongoing regression test (`npm run benchmark`) for validating LLM accuracy after any data or prompt changes. Question set can be expanded over time.
+51 -27
View File
@@ -25,24 +25,36 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
**Acceptance Criteria:** **Acceptance Criteria:**
- [ ] Node script `scripts/generate-embeddings.ts` reads all palette data from `src/lib/search.ts` - [ ] 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) - [ ] Uses the same ONNX model (`all-MiniLM-L6-v2` via `@xenova/transformers`) as the browser runtime to generate embeddings
- [ ] Builds a rich text representation of each item (title + subtitle + keywords + extended context from data files)
- [ ] Outputs `src/data/embeddings.json` — array of `{ id: string, embedding: number[] }` - [ ] Outputs `src/data/embeddings.json` — array of `{ id: string, embedding: number[] }`
- [ ] Script is runnable via `npm run generate-embeddings` - [ ] Script is runnable via `npm run generate-embeddings`
- [ ] Script requires `OPENAI_API_KEY` env var; fails gracefully with clear error if missing - [ ] No external API key required — model runs locally via Node.js
- [ ] Embeddings file is committed to repo (static asset, not generated per-build) - [ ] Embeddings file is committed to repo (static asset, not generated per-build)
- [ ] Typecheck passes - [ ] Typecheck passes
#### US-002: Client-side cosine similarity search #### US-002: Preload ONNX model during boot sequence
**Description:** As a visitor, I want the semantic search model to be ready by the time I reach the dashboard, without slowing down the initial experience.
**Acceptance Criteria:**
- [ ] Model download (`all-MiniLM-L6-v2` via `@xenova/transformers`) begins when `App.tsx` mounts (during `'boot'` phase)
- [ ] Download runs in background — does not block or affect boot/ECG/login animations
- [ ] Model is cached in browser (IndexedDB) — second visit loads from cache instantly
- [ ] A global ready state (React context or module-level promise) signals when model is available
- [ ] If model fails to load (network error, etc.), the app continues normally — no error shown to user
- [ ] Typecheck passes
#### US-003: Client-side cosine similarity search
**Description:** As a visitor, I want the command palette to understand what I mean, not just match strings. **Description:** As a visitor, I want the command palette to understand what I mean, not just match strings.
**Acceptance Criteria:** **Acceptance Criteria:**
- [ ] New `src/lib/semantic-search.ts` module with cosine similarity function - [ ] New `src/lib/semantic-search.ts` module with cosine similarity function
- [ ] Loads `embeddings.json` and provides a `semanticSearch(query: string, items: PaletteItem[])` 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) - [ ] Query embedding computed in-browser using `all-MiniLM-L6-v2` ONNX model via `@xenova/transformers`
- [ ] Returns ranked `PaletteItem[]` with similarity scores - [ ] Returns ranked `PaletteItem[]` with similarity scores
- [ ] Typecheck passes - [ ] Typecheck passes
#### US-003: Integrate semantic search into command palette #### US-004: 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. **Description:** As a visitor, I want the command palette to use semantic search with Fuse.js as a fallback.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -53,7 +65,7 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
- [ ] Typecheck passes - [ ] Typecheck passes
- [ ] Verify in browser: search "data analysis" surfaces analytics-related roles/skills, not just items with "data" in the title - [ ] 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 #### US-005: 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. **Description:** As a developer, I want embeddings to capture rich context beyond just titles, so semantic search is truly useful.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -69,7 +81,7 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
### Phase 2: AI Chat Widget ### Phase 2: AI Chat Widget
#### US-005: Chat widget UI — floating button #### US-006: 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. **Description:** As a visitor, I see a floating chat button at the bottom-right of the dashboard that opens a chat panel.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -82,7 +94,7 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
- [ ] Typecheck passes - [ ] Typecheck passes
- [ ] Verify in browser using dev server - [ ] Verify in browser using dev server
#### US-006: Chat panel UI #### US-007: Chat panel UI
**Description:** As a visitor, I want a chat panel that feels like a support chat — compact, positioned above the floating button. **Description:** As a visitor, I want a chat panel that feels like a support chat — compact, positioned above the floating button.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -99,7 +111,7 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
- [ ] Typecheck passes - [ ] Typecheck passes
- [ ] Verify in browser using dev server - [ ] Verify in browser using dev server
#### US-007: Gemini Flash integration #### US-008: Gemini Flash integration
**Description:** As a visitor, I can ask natural language questions and get intelligent answers about Andy's experience. **Description:** As a visitor, I can ask natural language questions and get intelligent answers about Andy's experience.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -112,7 +124,7 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
- [ ] Loading state shown while waiting for response - [ ] Loading state shown while waiting for response
- [ ] Typecheck passes - [ ] Typecheck passes
#### US-008: Chat context and conversation history #### US-009: Chat context and conversation history
**Description:** As a visitor, I want multi-turn conversation so I can ask follow-up questions. **Description:** As a visitor, I want multi-turn conversation so I can ask follow-up questions.
**Acceptance Criteria:** **Acceptance Criteria:**
@@ -125,20 +137,21 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
## Functional Requirements ## Functional Requirements
### Phase 1 ### Phase 1
- FR-1: Build script generates OpenAI `text-embedding-3-small` embeddings for all palette items - FR-1: Build script generates embeddings using `all-MiniLM-L6-v2` ONNX model (same model used at runtime)
- FR-2: Embeddings stored as committed static JSON (`src/data/embeddings.json`) - FR-2: Embeddings stored as committed static JSON (`src/data/embeddings.json`)
- FR-3: Client-side cosine similarity ranks items by semantic relevance - 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-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) - FR-5: ONNX model preloaded during boot sequence (before dashboard renders)
- FR-6: Query embedding computed in-browser — no runtime API calls
### Phase 2 ### Phase 2
- FR-6: Floating chat button rendered in DashboardLayout, bottom-right, above content - FR-7: Floating chat button rendered in DashboardLayout, bottom-right, above content
- FR-7: Chat panel opens/closes on button click with animation - FR-8: 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: 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: 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: Relevant items rendered as clickable cards using existing palette item styling and action routing
- FR-11: Streaming responses displayed progressively - FR-12: Streaming responses displayed progressively
- FR-12: Conversation state managed per-session (cleared on page reload) - FR-13: Conversation state managed per-session (cleared on page reload)
## Non-Goals ## Non-Goals
@@ -175,13 +188,25 @@ The portfolio's command palette currently uses Fuse.js for fuzzy string matching
## Technical Considerations ## Technical Considerations
### Phase 1: Query Embedding Challenge ### Phase 1: ONNX Model Strategy
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. **Decision: `all-MiniLM-L6-v2` via `@xenova/transformers` for both build-time and runtime embedding.**
- Same model used everywhere — embeddings live in the same vector space, so cosine similarity works correctly
- No external API keys required for embedding generation or search
- Build script runs the model in Node.js to pre-compute item embeddings
- Browser loads the same model at runtime for query embedding
**Preloading strategy:**
- Model download (~23MB, ONNX format) begins during the boot sequence phase (`'boot'` in App.tsx)
- The boot → ECG → login flow takes 8-10s, giving ample time for download + cache
- Model is cached by the browser (IndexedDB via Transformers.js) — subsequent visits load instantly
- If model hasn't finished loading when user opens command palette, fall back to Fuse.js silently
**Embedding content:**
- Each palette item gets a natural-language paragraph for embedding, not just keywords
- E.g., a consultation becomes: "Senior Data Analyst at Norfolk and Waveney ICB, 2021 to present. Led medicines optimisation analytics, built Power BI dashboards for 200+ clinicians..."
- Richer text = better semantic matching
### Phase 2: Gemini Flash ### Phase 2: Gemini Flash
- Use `gemini-2.0-flash` (or latest) — fast, cheap, good for short-form Q&A - Use `gemini-2.0-flash` (or latest) — fast, cheap, good for short-form Q&A
@@ -205,7 +230,6 @@ The main challenge is computing a query embedding client-side without an API cal
## Open Questions ## 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? - **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")? - **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")?
- **Model CDN**: Transformers.js downloads models from Hugging Face by default. Should we self-host the ONNX model files for reliability, or trust HF's CDN?