Compare commits

..

378 Commits

Author SHA1 Message Date
admin d7888071b8 Ammended email template 2026-02-20 11:46:24 +00:00
admin 5040b9a9fd Fixed KPI card text 2026-02-20 11:16:22 +00:00
admin c778d79aec MIT licence 2026-02-20 10:59:01 +00:00
admin 28d2ae61ff Fixed incorrect licence desc. in ReadMe 2026-02-20 10:54:47 +00:00
admin c651f0ed44 Cleaned up repo with old files, and ammended logo size in sidebar/mobile overview 2026-02-20 10:53:40 +00:00
admin d478276c3b Fixed minor UI issue with the nav bar on mobile 2026-02-20 01:24:01 +00:00
admin 46c049def0 Added og:image & description tags 2026-02-20 01:01:03 +00:00
admin 98442c0f9f Changed inactive line opacity 2026-02-19 23:51:14 +00:00
admin 82fcd6bc94 Disable highlight effect on mobile 2026-02-19 23:45:36 +00:00
admin 9d153e95d1 Preconnect hints for page load 2026-02-19 21:46:27 +00:00
admin e452b66a7f Fixed initial load being slow 2026-02-19 21:38:39 +00:00
admin edc1327987 Added Mary Seacole back in 2026-02-19 20:27:06 +00:00
admin 72d159484f Fixed a few skills 2026-02-19 19:54:18 +00:00
admin cb1c958f68 Skill updates 2026-02-19 18:32:36 +00:00
admin 6bf5a6b6b2 update login transition 2026-02-19 16:45:58 +00:00
admin 3ddd4ecdbd Changes carousel auto scroll to pause when interaction occurs 2026-02-19 16:32:11 +00:00
admin d403e96d34 Minor change to summary and tab name 2026-02-19 16:23:12 +00:00
admin 3773268706 Rewrote KPI and Project sections! 2026-02-19 16:15:33 +00:00
admin b13252be71 Fixed LLM chat function 2026-02-19 14:47:17 +00:00
admin 3ae4abeb9f Fixed backend 2026-02-19 14:22:17 +00:00
admin 1fc2ba2385 Removed unused fonts 2026-02-19 14:14:45 +00:00
admin 30511cac81 Whoopsied the server 2026-02-19 14:14:08 +00:00
admin 95ea088a00 Added small backend server to manage LLM chat & contact me form 2026-02-19 14:07:40 +00:00
admin a1f7088b48 Changed "Your role" to "My Role" 2026-02-19 14:00:20 +00:00
admin 012c905c90 Ammended tab name 2026-02-19 13:31:57 +00:00
admin 5806f7a134 Updated links 2026-02-19 13:30:54 +00:00
admin 9f2be70fd6 Added analytics tracking 2026-02-19 13:10:34 +00:00
admin 9186be7e3e Added aria attributes/keyboard nav to carousel 2026-02-18 13:52:40 +00:00
admin 9baa6e605b Mobile overview changes 2026-02-18 12:25:53 +00:00
admin 8b79f7b273 mobile banner v1 2026-02-18 02:55:49 +00:00
admin 134e41f4f9 chore: auto-commit before merge (loop primary) 2026-02-18 00:42:07 +00:00
admin 62c0d2ea19 Pre UX polish 2026-02-18 00:23:35 +00:00
admin 836305e2a3 Fix mobile 2026-02-17 23:14:05 +00:00
admin d51efb535d chore: auto-commit before merge (loop primary) 2026-02-17 21:19:40 +00:00
admin 025f860815 Loop prep 2026-02-17 20:58:56 +00:00
admin 06ca2a2b46 test webhook autodeploy... v2 2026-02-17 15:19:54 +00:00
admin 851d62fcbb test webhook autodeploy 2026-02-17 15:18:54 +00:00
admin 0a337b41c2 Fix hover effect on chart causing transition/animation to break 2026-02-17 15:14:10 +00:00
admin 47b52b5a93 feat: add global focus mode with cross-component dimming on hover
When hovering a constellation node, skill pill, or timeline item,
non-related UI elements across all components dim to 0.25 opacity,
creating a focused visual relationship view. The constellation axis
and year labels also dim via CSS class. Respects reduced-motion.
2026-02-17 14:17:21 +00:00
admin 82db5fda54 Substantial refinement/polish on content of webpage (not just structural/coding elements) 2026-02-17 14:05:32 +00:00
admin 38e40d36c0 chore: auto-commit before merge (loop primary) 2026-02-17 03:30:44 +00:00
admin 841c1869d6 chore: finalise unverified-content.md audit summary
- Updated removed content section (no content fully removed)
- Added skills.ts, investigations.ts, and llm-prompt.ts corrections
- All sections complete: flagged-but-retained, corrections, missed opportunities
2026-02-17 03:29:12 +00:00
admin a867c75e9b fix: re-enable boot sequence after redesign
Changed initial phase from 'pmr' back to 'boot' to restore the
full boot → login → dashboard flow for production.
2026-02-17 03:28:41 +00:00
admin 150b452bb5 feat: redesign boot-to-login transition
New boot flow: typing → holding → loading (progress bar) → fade → login

- Added ProgressBar component with ease-out animation during loading phase
- Terminal text slides up and fades during exit transition
- Cursor shrinks during loading phase for visual continuity
- Progress bar appears below terminal text, fills over 1.2s
- Entire container fades out smoothly before transitioning to login
- Reduced motion: instant render, no animation (unchanged)
- Changed "Rendering CV" → "Launching CV" for better software-launch feel
- Tuned timing: shorter hold (600ms), loading (1200ms), faster fade (500ms)
2026-02-17 03:28:00 +00:00
admin b266f1f149 feat: remove ECG phase entirely
- Deleted src/components/ECGAnimation.tsx (686 lines)
- Removed 'ecg' from Phase type
- Removed ECG import, rendering, and cursor position handoff from App.tsx
- Cleaned up BootSequence: removed onCursorPositionReady prop,
  captureCursorPosition callback, cursorRef, and ECG-specific naming
- Renamed ecgStartDelay → completionDelay, ecg-seed-dot → boot-seed-dot
- Skip button now goes directly to dashboard ('pmr' phase)
- Boot flow simplified: boot → login → pmr (no ECG intermediary)
- Bundle size reduced ~8KB
2026-02-17 03:26:17 +00:00
admin 0fc7985a7c audit: final sweep for hardcoded strings in components
Searched all components for hardcoded factual claims. Found:
- BootSequence.tsx: all content verified (done in 2.10)
- EducationSubsection.tsx: hardcoded education data duplicates data layer
  but all content verified against CV
- LastConsultationCard.tsx: NHS Band "8a" not in references, flagged
- ChatWidget.tsx: suggested questions are UI prompts, not claims
All findings logged in unverified-content.md.
2026-02-17 03:23:33 +00:00
admin 49bddeaa45 audit: verify llm-prompt.ts against reference documents
- Profile: "Informatics pharmacist" → "Healthcare leader" per CV
- Interim Head: "practice-level aggregate reporting" → "practice-level data"
- HCD: "Authored most" → "Wrote most" per CV
- Duty Pharm Mgr: "Led" → "Co-led", removed national quality payments claim
- Pre-Reg: aligned PGD and palliative care descriptions with secondary ref
- PharMetrics: corrected from "Interactive Platform" to "Switching Dashboard"
- Skills: Python 6yr→8yr, SQL 7yr→3yr per corrected skills.ts data
2026-02-17 03:21:43 +00:00
admin e2ba2575b6 audit: verify profile-content.ts against reference documents
- Narrative: aligned with CV Profile — "Healthcare leader" instead of
  unverified "Informatics pharmacist", tightened phrasing throughout
- Achievement subtitle: "Full analytical accountability to ICB board" →
  "Prescribing budget with forecasting models" per CV
- Skills summary: "data engineering" → "data pipeline development" per CV;
  removed unverified "clinical decision support"
- Flagged retained "Informatics Pharmacist" roleTitle in unverified log
2026-02-17 03:20:24 +00:00
admin 61299100d9 audit: verify documents.ts and educationExtras.ts against references
- Research description: removed embellished "investigating cocrystal
  formation for improved drug delivery properties", replaced with CV
  phrasing "on drug delivery and cocrystals"
- All education credentials, grades, dates, and institutions verified
- Extracurriculars verified against secondary reference
2026-02-17 03:18:53 +00:00
admin abb4fcd909 audit: verify skills.ts against reference documents
- Python: startYear corrected 2019→2017 (self-taught during Tesco night
  shifts per secondary ref); yearsOfExperience 6→8
- SQL: startYear corrected 2018→2022 (learned after gaining NHS database
  access per secondary ref); removed unverified pre-2022 history entries
- Power BI: fixed prescribing history year inconsistency (2019→2020);
  removed "PharMetrics real-time expenditure dashboard" reference
- All skill categories and names verified against CV Core Competencies
2026-02-17 03:18:04 +00:00
admin 0fba10d469 audit: verify investigations.ts against reference documents
- PharMetrics: corrected from "Interactive Platform" to "Switching Dashboard"
  to align with references (PharMetrics is the switching algorithm project);
  updated methodology to match secondary ref's dashboard description
- All other investigations (switching algorithm, Blueteq, CD monitoring,
  Sankey tool) verified against CV and secondary reference — no changes needed
2026-02-17 03:16:53 +00:00
admin 3c5f9a506c audit: verify kpis.ts against reference documents
- Budget KPI: removed unverified "monthly" reporting frequency,
  aligned with CV's "bimonthly" CMO presentations
- Population KPI: removed "monitor medicines safety" to match CV;
  "aggregate reporting" → "practice-level data" per CV wording
- Also fixed timeline.ts Interim Head detail to match CV wording
- All KPI values (£220M, £14.6M, £2.6M, 1.2M) verified against CV
2026-02-17 03:15:45 +00:00
admin de5b5939d6 audit: verify timeline.ts against reference documents
Key corrections:
- HCD role: "Authored most" → "Wrote most" to match CV phrasing
- HCD role: "formulary adherence opportunities" → "improvement opportunities"
- Deputy Head: removed embellished description additions not in CV
- Duty Pharmacy Manager: "Led" → "Co-led" per secondary reference;
  removed national quality payments claim (belongs to Pharmacy Manager)
- Pre-Reg: aligned PGD and palliative care descriptions with secondary ref
- UEA MPharm: removed "academic excellence" (misleading given exam failures)

Updated unverified-content.md with corrections log and missed opportunities
including McDonald's role, Mary Seacole programme, self-taught coding
narrative, AI/LLM work, and opioid deprescribing metrics.
2026-02-17 03:14:40 +00:00
admin 661dba4b75 audit: verify patient.ts against reference documents
- Address corrected from 'Norwich, NR1' to 'Norwich, UK' to match CV
- Created References/unverified-content.md to track audit findings
- Flagged retained-but-unverifiable personal data (DOB, GPhC number, LinkedIn slug)
- All other fields verified against CV_v4.md
2026-02-17 03:11:47 +00:00
admin 9e31843fc9 chore: merge secondary reference documents into single deduplicated source
Create References/andy_charlwood_complete_reference.md by merging
andy_charlwood_career_knowledge.md and andy_charlwood_career_knowledge_dump.md.
Structured by career timeline, projects, skills, education, leadership, and
career goals. Where sources conflicted, the more detailed version was preferred.
Original files preserved unchanged.
2026-02-17 03:09:55 +00:00
admin f7469f487f chore: bypass boot sequence for faster dev iteration
Temporarily set initial phase to 'pmr' to skip boot/ECG/login
during content audit work. Will be reverted in Phase 3.3.
2026-02-17 03:04:51 +00:00
admin 9a58b3c312 chore: auto-commit before merge (loop primary) 2026-02-17 02:26:42 +00:00
admin 01a48ce691 fix: re-enable boot sequence after refactor
Restore useState<Phase>('boot') in App.tsx, completing the refactoring
cycle (was temporarily set to 'pmr' for faster visual review).
2026-02-17 02:24:44 +00:00
admin 5eb46b02d8 refactor: remove dead code — orphaned files, unused types and functions
Delete 3 orphaned files (SubNav, TopBar, problems.ts), remove 4 unused
type definitions from pmr.ts (ViewId, NavItem, ReferralFormData, Problem),
trim types/index.ts to only Phase, and remove unused utility functions
(calculateSkillOffset, formatBootLine, getProfileContent, DotColorName).
2026-02-17 02:24:40 +00:00
admin 1b19087782 refactor: extract LastConsultationCard from DashboardLayout
Move the self-contained LastConsultationSubsection component (191 lines)
into its own file as LastConsultationCard. It uses only context and one
prop, with no dependency on DashboardLayout state. DashboardLayout drops
from 493 to 293 lines.
2026-02-17 02:16:10 +00:00
admin 49c9e0cecf refactor: centralise detail panel inline styles into detail-styles.ts
Extract 5 shared style constants (detailRootStyle, sectionHeadingStyle,
bulletListStyle, bodyTextStyle, paragraphStyle) used across 5 of 6 detail
components. Replaces ~30 inline style object definitions with imports.
Net reduction: 274 lines across detail panel components.
2026-02-17 02:09:26 +00:00
admin 7528935d2b refactor: extract ExpandableCardShell from WorkExperience and TimelineInterventions subsections
Single source of truth for expand/collapse card interaction pattern:
container styling, keyboard handling, chevron rotation, AnimatePresence
animation, and expanded content wrapper. Each consumer retains unique
header and body content via render props.
2026-02-17 02:03:13 +00:00
admin 8f4ddc454a refactor: centralise color maps, org color fallback, and motion-safe transitions
Create src/lib/theme-colors.ts with DOT_COLORS, KPI_COLORS,
PROJECT_STATUS_COLORS, and DEFAULT_ORG_COLOR constants. Add
motionSafeTransition() utility to src/lib/utils.ts.

Removes 6 duplicate color map definitions across Card, DetailPanel,
PatientSummaryTile, KPIDetail, ProjectsTile, and ProjectDetail.
Replaces 9 hardcoded '#0D6E6E' fallbacks and 7 inline motion ternaries.
Fixes project status color inconsistency between ProjectsTile and
ProjectDetail (Ongoing was teal in tile, amber in detail).
2026-02-17 01:58:10 +00:00
admin 296b18f025 refactor: extract hexToRgba and prefersReducedMotion to shared utils
Move hexToRgba() (3 identical copies) and prefersReducedMotion (5 module-level
copies) to src/lib/utils.ts. Re-export prefersReducedMotion from
constellation/constants.ts to preserve existing importers. Add clarifying
comments to constellation.ts and tags.ts re-export layers (Phase 1.4).
2026-02-17 01:48:43 +00:00
admin 45b87466be refactor: extract LLM system prompt from profile-content to dedicated module
Move the ~110-line LLM system prompt to src/data/llm-prompt.ts, removing
the LLMCopy type, getLLMCopy() accessor, and llm field from ProfileContent.
llm.ts now imports the prompt directly. profile-content.ts drops from 246
to 133 lines, retaining only UI copy and search metadata.
2026-02-17 01:42:55 +00:00
admin bbe7900968 refactor: inline timeline narrative into timeline.ts, remove indirection
Timeline entities now contain their narrative data (description, details,
outcomes, codedEntries) directly instead of fetching via
getTimelineNarrativeEntry(). Removes ~155 lines from profile-content.ts,
the accessor function, and three dead types.
2026-02-17 01:37:32 +00:00
admin 0ee7b5d44c refactor: skip boot/ECG/login sequence for dev iteration speed
Temporarily set initial phase to 'pmr' to bypass the ~10s boot animation
during the refactoring process. Will be reverted in Phase 4.3.
2026-02-17 01:28:48 +00:00
admin 83b327d58e Refactor to pull all text enteries into single location 2026-02-17 01:10:31 +00:00
admin 6605966fab feat: add canonical profile content schema and access helpers 2026-02-16 23:32:25 +00:00
admin 8178d03cb2 Rehaul of graph component 2026-02-16 23:16:46 +00:00
admin e9a7581aa5 chore: auto-commit before merge (loop primary) 2026-02-16 15:06:20 +00:00
admin aca57714e4 chore: auto-commit before merge (loop primary) 2026-02-16 14:36:25 +00:00
admin 9276955fa8 refactor: extract PlayPauseButton + screen-reader-description from orchestrator
Reduces CareerConstellation orchestrator from 334 to 285 lines to meet
the <300 line success criterion.
2026-02-16 14:35:15 +00:00
admin 8b674ffe14 feat: phase 3+4 timeline animation + education entities
- Add education entities (A-Levels, MPharm) to constellation data
- Add 'education' node type with dashed border styling
- Create useTimelineAnimation hook with rAF scheduler + state machine
  (IDLE → PLAYING → PAUSED → HOLDING → RESETTING → loop)
- Chronological reveal: entities oldest-first with skill stagger,
  link draw-on, reinforcement pulse for already-visible skills
- Year indicator overlay (monospace, top-left)
- Multiplicative opacity: animation visibility × highlight emphasis
- Highlight system respects visibleNodeIdsRef (unrevealed stay hidden)
- Interaction pause/resume wired to animation hook
- Play/pause button (bottom-right, larger touch target on mobile)
- prefers-reduced-motion: shows final state immediately, no animation
- Remove Phase 2 entry animation (replaced by timeline animation)
2026-02-16 14:31:11 +00:00
admin 7d7628c8a7 feat: phase 2 visual improvements for CareerConstellation
- Links: domain-colored with strength-weighted width/opacity, improved bezier curves
- Skill nodes: domain-colored stroke, size encoding by connected role count, glow filter on highlight
- Role nodes: gradient fill (orgColor 0.08→0.18), enhanced highlight with fill-opacity and stroke-width
- Entry animation: staggered reveal (guides→roles→skills→links with stroke-dashoffset), skipped under prefers-reduced-motion
- Legend: domain node counts displayed
2026-02-16 14:16:36 +00:00
admin 65b265733e refactor: decompose CareerConstellation monolith into focused modules
Break 1102-line CareerConstellation.tsx into:
- constellation/constants.ts: sizing, opacity, domain color tokens
- constellation/types.ts: SimNode, SimLink, LayoutParams interfaces
- hooks/useForceSimulation.ts: D3 simulation lifecycle
- hooks/useConstellationHighlight.ts: highlight/dim logic
- hooks/useConstellationInteraction.ts: mouse/touch/pin handlers
- constellation/MobileAccordion.tsx: tap-to-expand role details
- constellation/ConstellationLegend.tsx: domain legend
- constellation/AccessibleNodeOverlay.tsx: keyboard navigation buttons
- constellation/CareerConstellation.tsx: 288-line orchestrator

All existing behaviour preserved. Quality gates pass.
2026-02-16 14:06:41 +00:00
admin b34ecb89e2 clean up 2026-02-16 13:27:51 +00:00
admin 4dfb1607c1 Updated chart 2026-02-16 13:23:04 +00:00
admin 2e242a650a chore: auto-commit before merge (loop primary) 2026-02-16 12:44:34 +00:00
admin 683275416e Removed top bar, and updating sidebar 2026-02-16 12:25:19 +00:00
admin 18d2704677 Updated hats 2026-02-16 11:39:13 +00:00
admin c3a72d0bee Cleanup 2026-02-16 11:33:47 +00:00
admin 5a657c4aac chore: add ralph sidebar workflow setup files 2026-02-16 11:33:13 +00:00
admin 78e994ec5e chore: auto-commit before merge (loop primary) 2026-02-16 11:04:21 +00:00
admin 68f92fb9a0 feat: polish interventions carousel responsiveness 2026-02-16 11:04:08 +00:00
admin be7a65ef8a feat: tune carousel autoplay for reduced motion 2026-02-16 11:02:32 +00:00
admin 5fa01b8d66 feat: implement Embla carousel in ProjectsTile 2026-02-16 11:00:46 +00:00
admin 98d767fa7f feat: rename Active Projects references to Significant Interventions 2026-02-16 10:58:29 +00:00
admin a6df900605 merge codex/kpi (prefer codex/kpi on conflicts) 2026-02-16 10:52:24 +00:00
admin 5637d56e02 Cleanup 2026-02-16 10:50:14 +00:00
admin 24ffe03c0f chore: auto-commit before merge (loop primary) 2026-02-16 10:43:44 +00:00
admin e5c7d9bb41 chore: document KPI objective verification 2026-02-16 10:43:28 +00:00
admin 960c9b7729 Init 2026-02-16 10:41:41 +00:00
admin dad638e68e Added task files 2026-02-16 10:37:11 +00:00
admin b67c3b041f chore: auto-commit before merge (loop primary) 2026-02-16 10:36:47 +00:00
admin ab80d65958 feat: compact latest results kpi section 2026-02-16 10:36:30 +00:00
admin 2306d2ec2e codex setup 2026-02-16 10:27:55 +00:00
admin b418338cd7 feat: US-008 - Re-tune force simulation for 8 timeline entries in narrower column 2026-02-16 10:23:03 +00:00
admin c9dd93ac70 feat: US-007 - Colour-match work experience cards to constellation node colours 2026-02-16 10:09:34 +00:00
admin a258706bf3 feat: US-006 - Mobile accordion expansion for role details 2026-02-16 10:04:35 +00:00
admin 67fe5567a9 feat: US-005 - Hover-to-highlight interaction on desktop 2026-02-16 09:58:27 +00:00
admin f3e9b58e8d feat: US-004 - Viewport-proportional scaling for large screens 2026-02-16 09:50:07 +00:00
admin 76692682da feat: US-003 - Increase default skill visibility and reduce constellation column width 2026-02-16 09:44:10 +00:00
admin f3e6f6670b feat: US-002 - Add UEA MPharm and Highworth A-Levels education entries 2026-02-16 09:38:00 +00:00
admin 354096fd70 feat: US-001 - Add Duty Pharmacy Manager and Pre-Reg Pharmacist roles + fix Pharmacy Manager colour 2026-02-16 09:34:35 +00:00
admin f48d98b7fc feat: US-012 - Responsive behaviour for mobile and tablet constellation
- Add mobile-specific layout constants (MOBILE_ROLE_WIDTH=80, smaller skill radii)
- Use window.innerWidth for mobile breakpoint detection (container overflows on mobile)
- Reduce timelineX, padding, spacing, and force simulation parameters on mobile
- Truncate role pill labels and skill labels on narrow viewports
- Reduce charge/collision/link-distance forces for tighter mobile layout
- Fix CSS grid overflow: add min-width:0 and overflow:hidden to .pathway-graph-sticky
- MOBILE_FALLBACK_HEIGHT adjusted to 380px (within 360-400px spec)
- Legend wraps gracefully via existing flex-wrap
2026-02-16 03:22:21 +00:00
admin 408cd9573c feat: US-011 - Accessibility hardening for career constellation
Fix focusable buttons (pointerEvents 'auto'), sort tab order
(roles reverse-chronological, skills by domain), add skill focus
rings, update aria-label to mention clinical pathway, and trigger
graph highlights on keyboard focus.
2026-02-16 03:12:33 +00:00
admin 622baeb449 feat: US-010 - Content audit verifying role data against CV source 2026-02-16 03:08:06 +00:00
admin 21233c98bb feat: US-009 - Force simulation tuning for clinical layout 2026-02-16 03:04:44 +00:00
admin 89d778b2df feat: US-008 - Compact domain legend as HTML below SVG 2026-02-16 02:58:06 +00:00
admin 13b341abcd feat: US-007 - Curved link lines between roles and skills 2026-02-16 02:55:04 +00:00
admin 752f1c2947 chore: mark US-006 complete, update progress log 2026-02-16 02:50:18 +00:00
admin 743fb625d5 feat: US-006 - Bidirectional hover highlighting between graph and timeline 2026-02-16 02:49:43 +00:00
admin 52238c5662 feat: US-005 - Skill node redesign with muted default and reveal on interaction 2026-02-16 02:42:41 +00:00
admin 46cc22500b feat: US-004 - Role node redesign with clinical record pill badges
Role nodes now render as rounded rectangle pills (104x32px) with orgColor
badge styling, connector lines to timeline, and SVG drop shadow effects
on hover/pinned states.
2026-02-16 02:37:16 +00:00
admin 832c904376 chore: mark US-003 complete, update progress log 2026-02-16 02:27:23 +00:00
admin 8c8329f6e3 feat: US-003 - Clinical pathway background and timeline structure 2026-02-16 02:26:52 +00:00
admin 634eb10b2c feat: US-002 - Dynamic height matching with work experience column 2026-02-16 02:21:45 +00:00
admin 5fcc59414f feat: US-001 - Reverse timeline direction to top = most recent 2026-02-16 02:15:46 +00:00
admin 68b293dc6d Merge branch 'ralph/llm-cv-knowledge'
Merge LLM context rewrite
2026-02-16 01:38:05 +00:00
admin c9c69d2417 Merge branch 'master' of http://192.168.8.143:3000/admin/portfolio
Hope this works...
2026-02-16 01:36:23 +00:00
admin b41a422cf0 Rearranged graph vs timeline 2026-02-16 01:35:24 +00:00
admin d2efc7030a feat: US-019 - Run benchmark and validate accuracy
Benchmark passes 19/20 (threshold 18/20) with no zeros.
Structural improvements: Employment Timeline section, leadership
labels on Tesco bullets, GPhC clarification, prompt trimming.
Fixed Q10 expected answer to match actual CV data.
2026-02-16 00:59:37 +00:00
admin c9cc832382 feat: US-018 - Enrich embedding texts and regenerate embeddings 2026-02-16 00:47:37 +00:00
admin f0870cf320 feat: US-017 - Improve system prompt instructions and LLM parameters 2026-02-16 00:42:58 +00:00
admin 194f83f490 feat: US-016 - Enrich system prompt with full CV context 2026-02-16 00:39:38 +00:00
admin 8cc7038942 feat: US-015 - Migrate benchmark script to OpenRouter 2026-02-16 00:31:16 +00:00
admin 4bab9b369c feat: US-014 - Migrate production chat from Gemini to OpenRouter 2026-02-16 00:24:53 +00:00
admin 7f3428184f Cleaning up branches 2026-02-16 00:12:53 +00:00
admin be443907ee docs: mark US-014 complete, update progress log 2026-02-15 23:53:40 +00:00
admin 0bcdc89427 feat: US-014 - Update to Gemini 3 Flash Preview with model indicator 2026-02-15 23:53:06 +00:00
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
admin 19a4360a8c Next stage 2026-02-15 17:36:53 +00:00
admin 0e450c4b17 chore: mark US-011 complete, update progress log 2026-02-15 14:36:19 +00:00
admin 4fe68aa1b2 feat: US-011 - Re-enable boot sequence 2026-02-15 14:35:54 +00:00
admin 526ee7dd90 feat: US-010 - Fix minor typography inconsistencies 2026-02-15 14:33:53 +00:00
admin 615198b080 feat: US-009 - Replace hardcoded colors with design tokens 2026-02-15 14:30:21 +00:00
admin fc1581a9ff feat: US-008 - Align login card border radius and shadow with dashboard design system 2026-02-15 14:26:53 +00:00
admin 274188b6aa feat: US-007 - Reduce backdrop blur intensity by ~50% 2026-02-15 14:23:56 +00:00
admin cd7184cfd4 feat: US-006 - Extend backdrop blur to cover full dashboard including TopBar 2026-02-15 14:22:49 +00:00
admin 42293c5336 feat: US-005 - Add overlap blend effect on fanning capsules 2026-02-15 14:21:08 +00:00
admin 49f0f1aaf8 feat: US-004 - Increase branding text to match dashboard typography scale 2026-02-15 14:18:07 +00:00
admin c8eb38f083 feat: US-003 - Scale logo and branding block to ~50% of login card height 2026-02-15 14:14:37 +00:00
admin a56a4dd848 feat: US-002 - Extract animation timing into named constants 2026-02-15 14:11:22 +00:00
admin e5be969308 feat: US-001 - Skip to login phase for dev iteration 2026-02-15 14:07:25 +00:00
admin 83b941262e Added logo animation to login screen, initial work 2026-02-15 13:49:15 +00:00
admin 7fbf1dcb95 Merge branch 'ralph/login-screen-rework'
# Conflicts:
#	Ralph/prd.json
#	Ralph/progress.txt
#	src/components/TopBar.tsx
2026-02-15 02:20:32 +00:00
admin 598b1c11f6 Final commit 2026-02-15 02:14:51 +00:00
admin c4d73f970d feat: US-010 - Re-enable boot sequence 2026-02-15 02:14:12 +00:00
admin 1bd735e90a feat: US-009 - Login dissolve transition to reveal dashboard 2026-02-15 02:12:45 +00:00
admin 939b2cddf2 feat: US-008 - Login button pulse animation on activation 2026-02-15 02:10:48 +00:00
admin 73a390ce76 feat: US-007 - Connection status indicator with animated dots and typing-linked timing 2026-02-15 02:09:03 +00:00
admin cfc1c5797d chore: update progress and PRD for US-006 2026-02-15 02:05:46 +00:00
admin 8c04e517bc feat: US-006 - Render live dashboard behind login with blur overlay 2026-02-15 02:05:19 +00:00
admin 09af29f32d chore: update progress and PRD for US-005 2026-02-15 02:02:17 +00:00
admin 3f6ac5ef8d feat: US-005 - Replace Home icon with CVMIS logo on TopBar 2026-02-15 02:02:04 +00:00
admin 0ffacf8a0a feat: US-004 - Rebrand to CVMIS and integrate animated logo 2026-02-15 01:57:13 +00:00
admin 38fdb6fa27 feat: US-003 - Responsive login card sizing and dashboard style alignment 2026-02-15 01:50:09 +00:00
admin f28693b0ad feat: US-002 - Create CvmisLogo React SVG component 2026-02-15 01:47:44 +00:00
admin 05b48b995e feat: US-001 - Skip to login phase for dev iteration 2026-02-15 01:45:45 +00:00
admin 962729ae92 Supporting info for login screen rework 2026-02-15 01:44:02 +00:00
admin 6e66cd631b chore: mark US-028 complete, update progress log 2026-02-14 21:42:17 +00:00
admin c5b0f7da43 feat: US-028 - Re-enable boot/login sequence 2026-02-14 21:41:46 +00:00
admin 649f4d7c68 chore: mark US-027 complete, update progress log 2026-02-14 21:39:11 +00:00
admin b515b3d70c feat: US-027 - Visual regression check across all breakpoints 2026-02-14 21:38:22 +00:00
admin 2d7ff7e77a feat: US-026 - Adjust ParentSection headings for new proportions 2026-02-14 20:41:31 +00:00
admin f88cbf6e08 chore: mark US-025 complete, update progress log 2026-02-14 20:37:55 +00:00
admin daabfb7fd2 feat: US-025 - Scale WorkExperienceSubsection and RepeatMedicationsSubsection 2026-02-14 20:37:22 +00:00
admin 3f026a0701 feat: US-024 - Scale ProjectsTile and EducationSubsection 2026-02-14 20:34:20 +00:00
admin cf1f466452 feat: US-023 - Scale LastConsultationSubsection and main content padding 2026-02-14 20:31:22 +00:00
admin 64973176fb feat: US-022 - Scale PatientSummaryTile content and KPIs 2026-02-14 20:27:25 +00:00
admin 364efb8805 feat: US-021 - Scale Sidebar proportions 2026-02-14 20:24:34 +00:00
admin a7537083e6 feat: US-020 - Scale TopBar and SubNav 2026-02-14 20:21:05 +00:00
admin 645088bbc1 feat: US-019 - Update global layout tokens and Card/CardHeader component 2026-02-14 20:17:27 +00:00
admin f6463cc4b1 feat: US-018 - Skip boot/login sequence for dev iteration 2026-02-14 20:15:11 +00:00
admin 5ef7cdb259 feat: US-017 - Re-enable boot/login sequence for production
Reverted initial Phase from 'pmr' to 'boot', restoring the full
boot → ECG → login → dashboard flow. All 17 user stories now pass.
2026-02-14 18:56:31 +00:00
admin a961518ebf feat: US-016 - Apply chosen parent header typography
Elvaro Grotesque 600, -0.02em tracking with responsive scale from
1.375rem (mobile) to 2.2rem (desktop). Refined from the baseline
700/2.4rem for a more premium, intentional feel.
2026-02-14 18:53:49 +00:00
admin 1ca6175b93 feat: US-015 - Explore parent header typography options
Tested 14 combinations across Elvaro Grotesque (300-900) and Blumir
(100-700), varying size, case, and letter-spacing. Selected Elvaro
Grotesque 600, 2.2rem, title case, -0.02em tracking for premium
clinical feel with clear visual hierarchy.
2026-02-14 18:51:05 +00:00
admin f9b4062dd5 feat: US-014 - Responsive verification and fixes across breakpoints
- Fix grid overflow at mobile: add minWidth:0 + overflow:hidden to Card
- Scale ParentSection h2 responsively (1.5rem mobile → 2.4rem desktop)
- Make KPI grid single-column below 480px, 2-column above
- Fix SubNav alignment: left-aligned on mobile, centered on md+
- Fix SubNav tileIds to match restructured dashboard sections
- Add data-tile-id anchors to experience/skills/education subsections
2026-02-14 18:41:07 +00:00
admin 6bd12dd776 feat: US-013 - Update command palette data for restructured dashboard 2026-02-14 18:32:36 +00:00
admin 9e9962f114 feat: US-012 - Add hover-highlighting between experience/skills and constellation graph 2026-02-14 18:28:44 +00:00
admin b90706a3f6 feat: US-011 - Improve constellation graph visual clarity 2026-02-14 18:22:21 +00:00
admin fcc1232d9b feat: US-010 - Clean up removed standalone tiles and verify layout 2026-02-14 18:19:14 +00:00
admin 9ffed8d153 feat: US-009 - Move Education into Patient Pathway as subsection 2026-02-14 18:14:16 +00:00
admin b5de609cd5 feat: US-008 - Add two-column experience and skills layout in Patient Pathway 2026-02-14 18:11:26 +00:00
admin 0e7bef0206 feat: US-007 - Move Last Consultation into Patient Pathway as subsection 2026-02-14 18:06:18 +00:00
admin 7285ea8f45 feat: US-006 - Create Patient Pathway parent section with constellation graph 2026-02-14 18:02:46 +00:00
admin c86b252629 feat: US-005 - Restructure Patient Summary as parent section with Latest Results subsection 2026-02-14 17:59:41 +00:00
admin 9be2fb9017 chore: update PRD and progress log for US-004 2026-02-14 17:56:23 +00:00
admin 4bfc4de956 feat: US-004 - Create ParentSection component for hierarchical layout 2026-02-14 17:55:59 +00:00
admin 80d4cc9d7a feat: US-003 - Fix inaccurate timeline entries in CareerActivityTile 2026-02-14 17:54:03 +00:00
admin 13131e4c3e feat: US-002 - Remove inaccurate CV data from consultations and constellation 2026-02-14 17:50:35 +00:00
admin 1a3d3515f8 chore: add dashboard restructure PRD and update progress 2026-02-14 17:47:22 +00:00
admin fa64c98406 feat: US-001 - Skip boot/login sequence for dev iteration 2026-02-14 17:44:46 +00:00
admin 0d42db7111 US-032: Update PRD and progress log 2026-02-14 03:21:20 +00:00
admin 088b783731 US-032: Reduced motion audit, final cleanup, and visual review
- Add prefers-reduced-motion overrides for SubNav button transitions
- Add prefers-reduced-motion overrides for smooth scroll behavior
- Fix connection status dot/text transitions to respect reduced motion
- Create ProjectDetail.tsx renderer and wire into DetailPanel
- Remove placeholder fallback from DetailPanel (all types now covered)
- Delete unused files: useBreakpoint.ts, profile.ts
- Remove unused legacy --pmr-* CSS variables (18 properties)
- Remove unused .pmr-theme CSS utility class
2026-02-14 03:20:31 +00:00
admin 071b1b78ae US-031: Responsive testing and fixes for all new components
SubNav: horizontal scroll with hidden scrollbar, 44px touch targets.
DetailPanel: close button enlarged to 44px. Touch target fixes on
CoreSkillsTile, ProjectsTile, and LastConsultationTile interactive elements.
2026-02-14 03:14:30 +00:00
admin 97d353930c US-030: Update CommandPalette for expanded content and panel actions 2026-02-14 03:08:54 +00:00
admin dbdd51243d US-029: Add post-login loading state and update TopBar session name 2026-02-14 03:04:16 +00:00
admin a8c7d5b41d US-028: Change login username to a.recruiter and add connection status indicator 2026-02-14 03:00:15 +00:00
admin 120d8a7a7b US-027: Restyle LoginScreen with teal accents 2026-02-14 02:56:33 +00:00
admin 4c92a3a559 US-026: Add hover and click interactions to CareerConstellation 2026-02-14 02:52:47 +00:00
admin 24e0f8963f US-025: Add accessibility to CareerConstellation 2026-02-14 02:49:14 +00:00
admin 6956ad001b US-024: Build D3 force-directed graph rendering in CareerConstellation 2026-02-14 02:46:00 +00:00
admin 75c03029bf US-023: Install D3 and scaffold CareerConstellation component 2026-02-14 02:41:50 +00:00
admin 2f8db26cc4 US-022: Create EducationDetail renderer for detail panel 2026-02-14 02:37:42 +00:00
admin a5deb0ea8b US-021: Create SkillsAllDetail renderer for detail panel 2026-02-14 02:34:26 +00:00
admin bbe17fc66a chore: update progress log and PRD for US-018, US-020 2026-02-14 02:31:30 +00:00
admin 9ec71ae0ed US-020: Create SkillDetail renderer for detail panel 2026-02-14 02:30:53 +00:00
admin 9d61d2c8ca powershell woes 2026-02-14 02:21:20 +00:00
admin fbfd25ffff US-018: Create ConsultationDetail renderer for detail panel
- Created ConsultationDetail.tsx component to render full role details
- Displays role title, organization, dates with current badge
- Renders history paragraph (consultation.history)
- Shows achievement bullets (consultation.examination)
- Displays outcomes/impact (consultation.plan)
- Renders coded entries as badges with code + description
- Wired into DetailPanel for both 'consultation' and 'career-role' types
- Styled consistently with dashboard design system
- Typecheck and build pass successfully

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 02:14:54 +00:00
admin f38e67252b US-017: Create KPIDetail renderer for detail panel
Created src/components/detail/KPIDetail.tsx that renders rich KPI story
content inside the detail panel. Wired into DetailPanel so content.type
=== 'kpi' renders this component.

Component displays:
- Large headline number (48px, colored by kpi.colorVariant)
- KPI label and subtitle
- Period badge (if story.period exists)
- Context paragraph (story.context)
- Your role paragraph (story.role)
- Key outcomes as bullet list (story.outcomes)

Graceful fallback implemented: if story is undefined, shows kpi.value
and kpi.explanation instead.

Styling matches dashboard design system with fonts (Elvaro Grotesque,
Geist Mono), colors (CSS custom properties), and spacing conventions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:40:58 +00:00
admin 0c87d9f5a4 US-016: Modify PatientSummaryTile: structured presentation with highlight strip
- Added visual hierarchy to profile text using bold key phrases
- Key terms highlighted: Healthcare leader, Python/SQL/data analytics, leading population health analytics, financial scenario modelling, pharmaceutical rebate negotiation, algorithm design, population-level pathway development, £14.6M+, executive stakeholders
- Profile text no longer a wall of text - strategic bolding creates visual structure
- Removed unused personalStatement import
- Highlight strip with key stats already implemented (9+ Years, 1.2M, £220M, £14.6M+)
- Profile text sourced from CV_v4.md Profile section

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:32:54 +00:00
admin 8830c223aa US-016: Modify PatientSummaryTile: structured presentation with highlight strip
Add visual highlight strip showing key stats (9+ Years, 1.2M Population, £220M Budget, £14.6M+ Savings) above the profile text. Stats displayed as teal-colored badges with labels for improved visual hierarchy and scanability.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 01:27:10 +00:00
admin 52ee98d8aa US-015: Modify EducationTile: richer content, panel trigger
- Add OSCE score (80%) to MPharm inline details via educationExtras data
- Show research project with full description (Drug delivery & cocrystals, 75.1%)
- Display A-level grades as Mathematics (A*) · Chemistry (B) · Politics (C)
- Include Mary Seacole programme detail from educationExtras
- Import and use educationExtras data for dynamic inline content
- Add osceScore field to EducationExtra type
- Each entry clickable to open detail panel, hover border shift intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 00:33:44 +00:00
admin 03b4c6cafb US-015: Modify EducationTile: richer content, panel trigger
- Show richer inline content: MPharm research score (75.1%), Mary Seacole score (78%), A-level grades
- Each education entry is now clickable -> opens detail panel
- Hover state: border color shift to teal with shadow deepening
- Use documents data from documents.ts for accurate content
- Maintains existing visual hierarchy and spacing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:59:54 +00:00
admin 9ed77f99a8 US-014: Modify CareerActivityTile: panel triggers and hover preview
- Replace in-place accordion expansion with detail panel triggers for role items
- Add hover preview showing lift effect, shadow deepens, and 1-2 lines preview text
- Integrate with DetailPanelContext to open career-role panels on click
- Keep color-coded dots and entry type styling (teal, amber, green, purple)
- Add placeholder container for CareerConstellation component (to be implemented later)
- Remove unused AnimatePresence, motion imports and accordion-related code
- Remove prefersReducedMotion and borderColorMap (no longer needed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:57:55 +00:00
admin afc3876210 US-013: Add detail panel trigger to LastConsultationTile
- Import useDetailPanel hook and ChevronRight icon
- Make header info row clickable to open consultation detail panel
- Add "View full record" button at bottom of tile
- Both triggers call openPanel({ type: 'consultation', consultation })
- Add hover states to clickable areas
- Include keyboard navigation support (Enter/Space)
- Add aria-labels for accessibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:54:59 +00:00
admin c37fdab8fa US-012: Modify ProjectsTile: half width, compact card grid, panel trigger
- Remove full prop from Card (now half-width, single grid column)
- Replace accordion expansion with detail panel trigger
- Compact project cards with status dot + name + year (right-aligned)
- Tech stack shown as small inline tags (9px, monospace)
- Each project card clickable → openPanel({ type: 'project', investigation })
- Hover effects: border color shift to accent + shadow deepens
- Remove AnimatePresence and expansion state management
- Simplified component with focus on panel delegation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:52:36 +00:00
admin 980297ea92 US-011: Redesign CoreSkillsTile with categorised groups and panel triggers
Full-width card with skills grouped by Technical, Healthcare Domain, and
Strategic & Leadership categories. Top 4 per category sorted by proficiency.
Individual skills open detail panel; categories with >4 skills show 'View all'
button triggering panel. Removed old single-expand accordion. Category headers
use sidebar section divider styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:50:19 +00:00
admin 8bdb162a07 US-010: Redesign KPI cards - remove flip, bigger numbers, panel trigger
- Removed flip card animation entirely (CSS classes .metric-card, .metric-card-inner, .metric-card-front, .metric-card-back)
- Redesigned KPI cards as clickable buttons with larger value font (28px, weight 700)
- Each KPI card now triggers detail panel on click via openPanel({ type: 'kpi', kpi })
- Added hover states: border color shift + shadow deepens (150ms transition)
- Keyboard accessible: Enter/Space keys open panel
- Card styling: 16px padding, white background, border with var(--border-light), border-radius var(--radius-sm)
- Sub-text uses Geist Mono font family
- Cleaned up unused flip animation CSS from index.css

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:19:36 +00:00
admin 2886685573 US-009: Create constellation data mapping file
- Create src/data/constellation.ts with role-skill mapping for D3 career graph
- Export RoleSkillMapping interface defining roleId and skillIds structure
- Map 6 career roles to their associated skill IDs from skills.ts
- Export constellationNodes array (5 role nodes + 21 skill nodes) with organization, startYear, endYear, orgColor for roles and domain for skills
- Export constellationLinks array connecting skills to roles with strength values (0-1)
- Role orgColors: Tesco (#00897B), NHS (#005EB8) for distinct visual grouping
- All role IDs match consultation IDs from consultations.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:17:20 +00:00
admin b18746ecee US-008: Restructure DashboardLayout with SubNav, new tile order, and DetailPanel
- Wrap DashboardLayout with DetailPanelProvider in App.tsx
- Import and render DetailPanel component alongside CommandPalette
- Reorder tiles: PatientSummary (full) → LatestResults (half) + Projects (half) → CoreSkills (full) → LastConsultation (full) → CareerActivity (full) → Education (full)
- Update ProjectsTile from full-width to half-width (remove full prop)
- Update CoreSkillsTile from half-width to full-width (add full prop)
- SubNav already renders between TopBar and content
- Content area marginTop already accounts for both TopBar and SubNav heights
- All tiles already have data-tile-id attributes for SubNav scrolling

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:14:58 +00:00
admin 6c26518806 US-007: Create education extras data file
Add educationExtras.ts with expanded detail for education entries:
- MPharm: extracurriculars and research description from CV
- Mary Seacole: programme detail about leadership qualification

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:11:47 +00:00
admin f4a6b5e32c US-006: Add KPI story data and update 4th KPI to Population Served
- Change 4th KPI from 'Team Size Led' to 'Population Served' (1.2M)
- Add story field to all 4 KPIs with context, role, outcomes, and period
- £220M story: ICB prescribing budget oversight with forecasting models
- £14.6M story: Efficiency programme identification through data analysis
- 9+ Years story: Career progression from community pharmacy to system leadership
- 1.2M story: Population health analytics for Norfolk & Waveney ICS
- All story content sourced from References/CV_v4.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:10:14 +00:00
admin 92502beb03 US-005: Expand skills data from 5 to 21 with three categories
- Technical category (8 skills): Data Analysis, Python, SQL, Power BI,
  JavaScript/TypeScript, Excel, Algorithm Design, Data Pipelines
- Healthcare Domain category (6 skills): Medicines Optimisation,
  Population Health, NICE TA Implementation, Health Economics, Clinical
  Pathways, Controlled Drugs
- Strategic & Leadership category (7 skills): Budget Management,
  Stakeholder Engagement, Pharmaceutical Negotiation, Team Development,
  Change Management, Financial Modelling, Executive Communication
- All skills sourced from CV_v4.md Core Competencies
- Each skill includes medication metaphor properties: frequency,
  startYear, yearsOfExperience, proficiency, category, status, icon
- Frequency and proficiency values reflect realistic usage patterns from
  CV role descriptions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:08:04 +00:00
admin a596b5ac82 US-004: Create SubNav component and useActiveSection hook
- Create SubNav component with sticky positioning below TopBar
- 5 sections: Overview, Skills, Experience, Projects, Education
- Active tab indicated with teal underline and 200ms slide transition
- Click scrolls smoothly to corresponding tile via data-tile-id
- Create useActiveSection hook using IntersectionObserver
- Maps tile IDs to section IDs for navigation
- Integrate SubNav into DashboardLayout with adjusted margins
- All styles follow design system (--accent, --surface, --border-light)
- TypeScript strict typing throughout

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:05:56 +00:00
admin cf5399a767 US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook
Implements core detail panel infrastructure for slide-in content panels:

- DetailPanelContext: Manages panel state (content, open/close, isOpen)
- DetailPanel: Slide-in panel component with backdrop, header, and scrollable body
- useFocusTrap: Keyboard focus trap hook for modal accessibility
- Width mapping: narrow (400px) for kpi/skill/education, wide (60vw) for consultation/project/career-role
- Title mapping derives from content data (kpi.label, skill.name, etc.)
- Close triggers: backdrop click, Escape key, X button
- ARIA: aria-modal, role=dialog, aria-labelledby
- Mobile responsive: both widths become 100vw on <768px
- prefers-reduced-motion: instant appear, no animations
- Placeholder content (real renderers in later stories)
- Export CardHeaderProps interface from Card.tsx
- Add responsive panel width CSS rules

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 23:02:59 +00:00
admin f7e9c88762 US-002: Add TypeScript types and CSS custom properties for depth features
Add foundation types and styles for upcoming depth enhancements:

TypeScript types (src/types/pmr.ts):
- SkillCategory type for grouping skills
- KPIStory interface for rich KPI detail content
- story? field added to KPI interface
- ConstellationNode and ConstellationLink for D3 career graph
- DetailPanelContent discriminated union for panel routing
- EducationExtra interface for expanded education detail

CSS custom properties (src/index.css):
- --subnav-height: 36px (section jump bar)
- --panel-narrow: 400px, --panel-wide: 60vw (detail panel widths)
- --backdrop-blur: 4px, --backdrop-bg (panel overlay)
- @keyframes panel-slide-in, panel-slide-out, backdrop-fade-in
- prefers-reduced-motion overrides for instant panel animations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 22:59:26 +00:00
admin ee73efce11 US-001: Remove unused legacy components and hooks
Delete 23 dead files: old portfolio components (Contact, Education,
Experience, FloatingNav, Footer, Hero, Projects, Skills), legacy PMR
components (PMRInterface, PatientBanner, ClinicalSidebar, Breadcrumb,
MobileBottomNav), all 7 views/ directory files, and 3 unused hooks
(useScrollCondensation, useActiveSection, useScrollReveal).

No imports referenced any of these files — clean removal with zero
build or type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:57:28 +00:00
admin 72c75fd1a9 Depth workflow 2026-02-13 21:45:53 +00:00
admin 7c31ec07ba Update progress: Task 20 completed (accessibility audit) 2026-02-13 18:05:58 +00:00
admin 6a4fc86387 Task 20: Accessibility audit improvements
Semantic HTML:
- Changed Card component from div to article element
- Added id="main-content" to main element for skip link target

Keyboard Navigation & ARIA:
- Added skip link to TopBar (visible only on focus, navigates to #main-content)
- Added aria-label="Active session information" to session info container
- Added aria-hidden="true" to all decorative colored dots (CardHeader, CareerActivity, Projects, Sidebar status badge)
- All expandable items already have role="button", tabIndex={0}, aria-expanded
- All KPI cards already have proper aria-label describing flip state
- Command palette already has full ARIA implementation (combobox, listbox, dialog)

Focus Management:
- Added global focus-visible styles in index.css (2px accent outline, 2px offset)
- Buttons, links, inputs all have proper focus rings with accent color
- Command palette focus trap already implemented

Reduced Motion:
- All components already check prefers-reduced-motion at module scope
- Dashboard entrance, tile expansion, KPI flip, palette animations respect reduced motion
- Added reduced motion override for pulse animation (disables pulse, keeps static dot)

Color Contrast:
- All color tokens already meet WCAG AA standards per ref spec
- Tertiary text (#8DA8A5) used only for supplementary labels where information is conveyed elsewhere

Quality checks: typecheck ✓, lint ✓ (1 pre-existing warning), build ✓
2026-02-13 18:04:52 +00:00
admin 8dc27ff8a9 Update progress: Task 19 completed (responsive design) 2026-02-13 18:01:21 +00:00
admin 29956665ac Task 19: Add responsive design for mobile and tablet
- DashboardLayout: Hide sidebar on <lg (1024px), responsive padding
- Dashboard grid: Mobile-first (1 col → 2 col at md/768px)
- Activity grid: Mobile-first (1 col → 2 col at md/768px)
- TopBar: Truncate brand text on mobile, hide 'Remote' on <md
- TopBar session: Show time-only on <xs (480px)
- CommandPalette: Full-width on mobile with reduced padding
- CommandPalette footer: Hidden on mobile
- Touch targets: All interactive elements 48px+ on mobile

All breakpoints follow Tailwind responsive prefixes (xs/sm/md/lg/xl).
Quality checks: typecheck ✓, lint ✓ (1 pre-existing warning), build ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 18:00:16 +00:00
admin f65bf2ef5c Update progress: Task 18 completed (command palette)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:55:57 +00:00
admin aafdeba93e Task 18: Add command palette (Ctrl+K)
- Create CommandPalette.tsx with overlay, search input, grouped results,
  keyboard navigation (arrows, Enter, Escape), and footer hints
- Rebuild search.ts with PaletteItem model: 24 entries across 6 sections
  (Experience, Core Skills, Active Projects, Achievements, Education,
  Quick Actions) matching concept HTML structure
- Fuzzy search via fuse.js with weighted keys (title, subtitle, keywords)
- Wire into DashboardLayout with global Ctrl+K listener and TopBar click
- Action system: scroll-to-tile, expand-item, external links, download CV
- Add data-tile-id to all Card/tile components for scroll targeting
- CSS animations: palette-overlay-in, palette-modal-in with
  prefers-reduced-motion support
- Maintain backward-compatible legacy exports for ClinicalSidebar
  (will be removed in Task 21)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:54:31 +00:00
admin acee97a579 Update progress: Task 17 completed (KPI flip cards)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:46:26 +00:00
admin 38b8e36fab Task 17: Add KPI flip card interaction
Add click-to-flip interaction on LatestResults metric cards:
- CSS perspective-based 3D flip (400ms ease-in-out)
- Front face shows value/label/sub, back shows explanation text
- Single-card accordion: only one card flipped at a time
- Keyboard accessible: Enter/Space to flip, aria-label with state
- prefers-reduced-motion: instant visibility swap, no 3D animation
- Back face: accent-light background, 12px secondary text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:45:59 +00:00
admin 3ad368f935 Update progress: Task 16 completed (tile expansion system)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:43:09 +00:00
admin d89ae0c64a Task 16: Add tile expansion system
CareerActivity: role items expand to show consultation achievements + coded entries
Projects: items expand to show methodology, tech stack tags, results, external links
CoreSkills: items expand to show prescribing history timeline from medications data

All expansions use:
- Framer Motion AnimatePresence with height-only animation (200ms, ease-out)
- Single-expand accordion (one item at a time per tile)
- Keyboard support (Enter/Space toggle, Escape collapse)
- aria-expanded attributes
- Colored left border on expanded panels
- prefers-reduced-motion support (instant expand/collapse)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:42:21 +00:00
admin 7dae67d954 Update progress: Task 15 completed (ProjectsTile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:38:07 +00:00
admin 334ea2c02f Task 15: Build ProjectsTile
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:37:37 +00:00
admin 2c360176c8 Update progress: Task 14 completed (EducationTile)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:35:24 +00:00
admin 4be1b10137 Task 14: Build EducationTile
- Created EducationTile.tsx with purple CardHeader
- Displays three education entries in vertical stack
- MPharm (Hons) from UEA, NHS Leadership Academy Mary Seacole, A-Levels
- White surface background with light border and 6px radius
- Simple display-only format (no expansion yet)
- Updated DashboardLayout to render EducationTile below CareerActivity

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:34:26 +00:00
admin 905b3d957a Update progress: Task 13 completed (CareerActivity tile) 2026-02-13 17:32:14 +00:00
admin c8032f80df Task 13: Build CareerActivity tile
Created CareerActivityTile component with full timeline merged from multiple data sources:
- Builds 10 activity entries matching the concept HTML spec exactly
- Color-coded dots by type: role (teal), project (amber), cert (green), edu (purple)
- Two-column responsive grid (1 column below 900px)
- Entry types: 4 roles, 2 projects, 3 certifications, 1 education
- Data sources: consultations, investigations, documents
- Sorted newest-first with stable ordering for same-year entries
- Added .activity-grid responsive CSS class to index.css
- Wired into DashboardLayout below LastConsultationTile

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:31:14 +00:00
admin e2409183f3 Update progress: Task 12 completed (LastConsultation tile) 2026-02-13 17:28:16 +00:00
admin d0df9137f9 Task 12: Build LastConsultation tile
Created LastConsultationTile.tsx displaying the most recent role:
- Full-width card with green dot header
- Info row: Date, Organisation, Type (employment), Band
- Role title in accent color
- Bullet list of key achievements from examination array
- Data sourced from consultations[0] (most recent)
- Styling matches ref-06 spec: 8px card radius, border-light,
  info labels 10px uppercase, values 11.5px 600 weight
- Integrated into DashboardLayout in proper sequence

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:27:28 +00:00
admin dec8ec9769 Update progress: Task 11 completed (CoreSkills tile) 2026-02-13 17:25:00 +00:00
admin 6d47f2a948 Task 11: Build CoreSkills tile ("Repeat Medications")
Created CoreSkillsTile component that presents skills as medications
with frequency dosing metaphor:
- Half-width card with amber dot header "REPEAT MEDICATIONS"
- 5 skill items with teal icon containers (lucide-react icons)
- Each item shows: skill name, frequency (e.g., "Twice daily"),
  start year, years of experience, and "Active" status badge
- Uses medication metaphor: "Data Analysis · Twice daily · Since
  2016 · 9 yrs"
- Data from src/data/skills.ts with user-specified frequencies
- Styled to match GP System concept with 6px radius items on
  dashboard background (#F0F5F4)
- Integrated into DashboardLayout in right column next to
  LatestResultsTile

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:24:26 +00:00
admin 5ad67a512f Update progress: Task 10 completed (LatestResults tile)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:22:01 +00:00
admin 040e46cbea Task 10: Build LatestResults tile
- Created LatestResultsTile with 2x2 metric grid displaying four KPIs
- Each MetricCard shows value (22px, colored by variant), label, and sub text
- Metric cards use 6px radius, border-light, dashboard background
- Data sourced from src/data/kpis.ts (Budget £220M, Savings £14.6M, Years 9+, Team 12)
- CardHeader with teal dot + "LATEST RESULTS" + "Updated May 2025" right text
- Added data-kpi-id attributes for Task 17 flip card interaction
- Wired into DashboardLayout as half-width tile (left column)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:21:32 +00:00
admin 6501439cef Update progress: Task 9 completed (PatientSummary tile) 2026-02-13 17:19:17 +00:00
admin 41ddbf6d1d Task 9: Build PatientSummary tile
Created PatientSummaryTile component displaying personal statement from profile.ts.
Full-width card with teal dot header, 13px body text, line-height 1.6.
Wired into DashboardLayout as first tile in grid.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:18:53 +00:00
admin 00a5dd0105 Update progress: Task 8 completed (Card component) 2026-02-13 17:17:02 +00:00
admin 69e322af28 Task 8: Build reusable Card component with CardHeader
- Create Card component with base styling (white bg, 8px radius, shadow-sm)
- Hover state: deepens shadow to shadow-md, strengthens border
- Full-width variant spans both grid columns
- CardHeader sub-component with colored dot, title, optional right text
- Dot colors: teal, amber, green, alert, purple
- Header styling: 12px uppercase title, 10px mono right text
- All styles use CSS custom properties from design tokens
2026-02-13 17:16:06 +00:00
admin a2e01270a1 Update progress: Task 7 completed (DashboardLayout)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:14:23 +00:00
admin adc32b9005 Task 7: Build DashboardLayout and wire up App.tsx
Three-zone layout: TopBar (fixed) + Sidebar (fixed left) + Main
(scrollable card grid). Framer Motion staggered entrance animations
with prefers-reduced-motion support. Card grid responsive at 900px.
Replaces PMRInterface in the pmr phase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:14:18 +00:00
admin a60b0701c2 Update progress: Tasks 5-6 completed (Sidebar component) 2026-02-13 17:10:32 +00:00
admin 670c9cc74c Tasks 5-6: Build Sidebar with PersonHeader, Tags, and Alerts
- Created src/components/Sidebar.tsx:
  - PersonHeader section with 52px avatar, name, title, status badge with pulse animation
  - Details grid: GPhC No. (monospace), Education, Location, Phone (link), Email (link), Registered
  - Tags section with colored pill badges (teal/amber/green variants)
  - Alerts/Highlights section with severity-based styling (alert/amber)
  - Section title component with divider line
  - Custom scrollbar styling (4px, transparent track, border-colored thumb)

- Added animations to src/index.css:
  - @keyframes pulse for status badge dot (opacity 1→0.4→1, 2s infinite)
  - .pmr-scrollbar custom scrollbar styles

Data sources: patient.ts, tags.ts, alerts.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:09:56 +00:00
admin 37c08387af Update progress: Task 4 completed (TopBar component)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:07:26 +00:00
admin 62b6718cc3 Task 4: Build TopBar component
Fixed 48px header with three zones — brand (Home icon + name + version),
center search bar (button triggering command palette), and session info
(doctor name + active session pill with live time). Uses GP System
Dashboard tokens. Responsive: search bar hidden on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:07:22 +00:00
admin 2e48cefc6f Update progress: Task 2 completed (data files and types) 2026-02-13 17:04:18 +00:00
admin 2b9a6210ec Task 2: Create new data files and update types
Created five new data files for GP System Dashboard:
- src/data/profile.ts: Personal statement from CV_v4.md
- src/data/tags.ts: Sidebar tags (5 entries with color variants)
- src/data/alerts.ts: Sidebar alert flags (2 entries)
- src/data/kpis.ts: Latest Results metrics (4 KPI entries with explanations)
- src/data/skills.ts: Core technical skills as "medications" (5 entries with user-specified frequencies)

Updated src/types/pmr.ts with new interfaces:
- Tag: label + colorVariant
- Alert: message + severity + icon
- KPI: id + value + label + sub + colorVariant + explanation
- SkillMedication: full medication structure with frequency, years, proficiency, category, status, icon

All CV content matches References/CV_v4.md exactly. All quality checks pass.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 17:03:45 +00:00
admin c88ceba136 Update progress: Task 1 completed (design tokens)
Also includes manual intervention files: updated CLAUDE.md,
IMPLEMENTATION_PLAN.md, and ref files for GP System Dashboard redesign.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:01:29 +00:00
admin 3176761d9c Task 1: Update design tokens for GP System Dashboard
- Replace old dark PMR palette with light teal GP System tokens
- Add three-tier shadow system (sm/md/lg) with warm green-gray tints
- Update border-radius: cards now 8px, inner elements 6px
- Add layout vars (sidebar-width 272px, topbar-height 48px)
- Fix font-ui/font-ui-alt swap: Elvaro Grotesque is now primary
- Add status color tokens (success, amber, alert, purple) with light/border variants
- Keep legacy --pmr-* aliases for backward compat during transition
- Update pmr Tailwind colors to new palette values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:00:44 +00:00
admin e13a073a6f Redesign CVMIS system 2 2026-02-13 16:42:45 +00:00
admin 000df670a3 Redesign CVMIS system 2026-02-13 16:42:23 +00:00
admin b9db2f5401 Update progress: Task 15 completed (Accessibility audit)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:42:59 +00:00
admin c3316b9c45 Task 15: Accessibility audit complete
- Sidebar: Replace <aside role="navigation"> with <nav> to avoid conflicting roles
- Sidebar search: Add combobox role, aria-expanded, aria-controls, aria-autocomplete
- Search results: Add listbox/option roles, group labels for screen reader navigation
- PMRInterface: Remove redundant role="main", fix aria-label to use CV-friendly labels
- Mobile search: Add aria-label and type="search" for proper semantics
- Breadcrumb: Add aria-current="page" to current item, aria-hidden on separators
- Clinical alert: Add aria-label="Acknowledge clinical alert" on button per spec
- Patient banner: Change focus:ring to focus-visible:ring on action buttons
- Patient banner: Add role="img" to StatusDot for aria-label accessibility
- Login screen: Change role="status" to role="dialog" with aria-modal
- Login screen: Add loginButtonRef with auto-focus when typing completes
- Login screen: Add focus-visible ring style to Log In button
- Medications tabs: Add id="tab-{id}" to tab buttons, fix aria-labelledby on panels
- Consultations: Wrap entries in <article> per semantic HTML spec
- Problems: Change TrafficLight dot from role="img" to aria-hidden (text label handles it)
- App: Add sr-only live region announcing "Patient Record for Charlwood, Andrew" on PMR entry
- Skip button: Add focus-visible ring for keyboard users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:42:05 +00:00
admin b3ebff26bf Update progress: Task 14 completed (Responsive design audit) 2026-02-13 01:25:43 +00:00
admin 85ac1b879f Task 14: Responsive design audit complete 2026-02-13 01:25:07 +00:00
admin 4db3be0abb Update progress: Task 13 completed (Fuzzy search with fuse.js) 2026-02-13 01:21:19 +00:00
admin f96c6a99d1 Task 13: Implement fuzzy search with fuse.js
- Installed fuse.js for fuzzy search functionality
- Created src/lib/search.ts with buildSearchIndex and groupResultsBySection functions
- Search index includes all consultations, medications, problems, investigations, and documents
- Updated ClinicalSidebar to use fuse.js instead of simple filter
- Search results grouped by section (Experience, Skills, Achievements, Projects, Education)
- Section headers show icon and count
- Each result shows title and highlight text (truncated)
- Clicking a result navigates to the section and expands the matching item
- Minimum 2 characters required for search
- Top 10 results displayed
- Clean dropdown styling with hover states
- Integrates with AccessibilityContext to set expandedItem

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 01:20:08 +00:00
admin 7461a83b9d Update progress: Task 12 completed (ReferralsView rebuild) 2026-02-13 01:15:11 +00:00
admin b480b742c8 Task 12: Rebuild ReferralsView (Contact) with premium fonts and refined styling
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 01:14:25 +00:00
admin bfd17a3e80 Update progress: Task 11 completed (InvestigationsView + DocumentsView rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:09:56 +00:00
admin bba61f73b6 Task 11: Rebuild InvestigationsView + DocumentsView (Projects + Education)
- Replace CSS height transitions with Framer Motion AnimatePresence
- Add tree-indented monospace content with box-drawing characters
- Add StatusBadge pills (Complete/Ongoing/Live with pulse)
- Replace font-inter with font-ui, font-mono with font-geist
- Add multi-layered shadows (shadow-pmr), proper borders
- Add document type icons (FileText, Award, GraduationCap, FlaskConical)
- Color-coded left borders on expanded panels by status/type
- Alternating row backgrounds, hover:bg-[#EFF6FF]
- AccessibilityContext integration for breadcrumb updates
- Framer Motion chevron rotation, keyboard navigation
- Mobile card layouts with same animations
- prefers-reduced-motion support throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 01:08:57 +00:00
admin 8765470627 Update progress: Task 10 completed (ProblemsView rebuild) 2026-02-13 01:03:30 +00:00
admin 43aa836317 Task 10: Rebuild ProblemsView (Achievements view)
- Replaced all font-inter references with font-ui (Elvaro Grotesque)
- Updated font-mono to font-geist for codes and dates ([MGT001], Jul 2024, etc.)
- Changed hover colors from bg-blue-50 to bg-[#EFF6FF] (blue tint)
- Added shadow-pmr to both Active and Resolved Problems cards
- Switched from CSS transitions to Framer Motion for expand/collapse animations
  - AnimatePresence with height-only animation (no opacity fade per guardrail)
  - Chevron rotation via motion.div (180° when expanded)
  - prefersReducedMotion support (duration: 0)
- Updated font sizes: text-[13px] for headers, text-[14px] for body, text-xs for labels
- TrafficLight component now uses font-ui for text labels
- Added AccessibilityContext integration (setExpandedItem for breadcrumb)
- Mobile cards: added shadow-pmr, updated all font references to font-ui/font-geist
- Added focus-visible rings on linked consultation buttons

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 01:02:35 +00:00
admin f0cb6b924f Update progress: Task 9 completed (MedicationsView rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:57:26 +00:00
admin 06f0d658b0 Task 9: Rebuild MedicationsView (Skills view)
Rebuild medications/skills view from ref-medications.md spec with
Clinical Luxury design direction. Three category tabs with count
badges, semantic table with sortable columns, expandable prescribing
history with vertical timeline, and Framer Motion height animation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:56:35 +00:00
admin ad1ce81948 Update progress: Task 8 completed (ConsultationsView rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:52:08 +00:00
admin 2be346144c Task 8: Rebuild ConsultationsView (Experience view)
Rebuilt from ref-consultations.md spec with Clinical Luxury styling:
- Framer Motion height-only expand/collapse (no opacity fade)
- font-ui (Elvaro Grotesque) throughout, Geist Mono for dates/codes
- 3px left border color-coded by employer (NHS blue / Tesco teal)
- Multi-layered card shadows (shadow-pmr)
- Blue tint hover state (#EFF6FF)
- H/E/P section headers: uppercase, 12px, letter-spacing 0.05em
- Coded entries in Geist Mono with bracket codes
- Single-expand accordion behavior
- Chevron rotation via Framer Motion
- Proper font sizes per spec (13px body, 15px titles, 12px codes)
- Focus-visible ring on entry buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:51:23 +00:00
admin 1d8cb78143 Update progress: Task 7 completed (SummaryView + ClinicalAlert)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:47:02 +00:00
admin cd4aa1e240 Task 7: Rebuild SummaryView + ClinicalAlert
- ClinicalAlert: Framer Motion spring animation entrance, icon crossfade
  (AlertTriangle → CheckCircle), hold beat, height collapse sequence
- Demographics card: Full-width 2-column key-value layout with proper
  label alignment, monospace data values
- Active Problems card: Traffic light dots with text labels (guardrail)
- Quick Medications table: Semantic <table>, alternating rows, hover states
- Last Consultation card: Date in Geist Mono, NHS blue org, role preview
- All cards: font-ui (Elvaro Grotesque), multi-layered shadows, #E5E7EB borders
- Grid: 2-column desktop layout, single column mobile
- prefers-reduced-motion: instant alert, no animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:46:14 +00:00
admin fd9dd7d00e Update progress: Task 6 completed (PMRInterface layout + Breadcrumb) 2026-02-13 00:40:31 +00:00
admin 8f6bfd0b5e Task 6: Rebuild PMRInterface layout + Breadcrumb
Changes made:
- Created Breadcrumb.tsx component with Patient Record > [View] > [Expanded Item] navigation
- Integrated Breadcrumb into PMRInterface (desktop/tablet only, not mobile)
- Breadcrumb receives currentView, expandedItem props and handles navigation callbacks
- Updated all font references from font-inter to font-ui (Elvaro Grotesque)
- Added shadow-pmr to default view placeholder card
- Mobile back button updated to use font-ui

Visual verification:
- Breadcrumb renders correctly with gray-400 text, chevron separators, 13px font size
- Navigation updates breadcrumb path correctly (tested Summary → Experience)
- Layout: fixed sidebar, sticky banner, scrollable content all working
- View switching is instant (no animation between views)
- Premium font (Elvaro Grotesque) rendering throughout interface

Quality checks: All passed (typecheck, lint, build — 396.39 KB bundle)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 00:39:41 +00:00
admin 803c4f8a48 Update progress: Task 5 completed (ClinicalSidebar rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:36:22 +00:00
admin 5533cded82 Task 5: Rebuild ClinicalSidebar with CV-friendly labels and premium font
- Replace clinical jargon labels with CV-friendly terms: Experience,
  Skills, Achievements, Projects, Education, Contact
- Replace all font-inter references with font-ui (Elvaro Grotesque)
- Fix Tailwind opacity syntax: bg-white/12 → bg-white/[0.12] etc.
- Add right edge border (border-r border-[#334155]) for sidebar depth
- Add focus-visible ring styles on all nav buttons
- Set explicit h-[44px] and font-[14px] per design spec
- Add border-transparent on inactive items to prevent layout shift
- Update footer text color to #64748B per spec
- Update MobileBottomNav labels to match sidebar convention
- Update PMRInterface viewLabels to CV-friendly names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:35:43 +00:00
admin 86e0015393 Update progress: Task 4b completed (scroll condensation fix)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:31:03 +00:00
admin d16656b954 Task 4b: Fix PatientBanner scroll condensation
Root cause: sentinel element with `absolute top-0` inside PatientBanner was
positioned at viewport top, always triggering the IntersectionObserver's
-100px rootMargin threshold — banner was permanently stuck in condensed state.

Fix: Restructured PMRInterface layout from document-scroll to flex container
with explicit scroll container (`overflow-y-auto` on main). Lifted scroll
condensation logic to PMRInterface, passing `isCondensed` prop down to
PatientBanner. Replaced IntersectionObserver with scroll event listener on
the main element for reliable scroll position detection.

Key changes:
- PMRInterface: flex h-screen overflow-hidden layout (sidebar + content column)
- PatientBanner: accepts isCondensed prop, removed sticky/sentinel/hook
- ClinicalSidebar: h-full instead of h-screen sticky (parent handles sizing)
- useScrollCondensation: scroll event on container element via callback ref

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:30:23 +00:00
admin b7471c5cf8 Updated prompts 2026-02-13 00:20:25 +00:00
admin 5579e2741a Update progress: Task 4 completed (PatientBanner rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:16:29 +00:00
admin f75a6b9a5f Task 4: Rebuild PatientBanner with premium fonts, tooltip, and animations
- Replace font-inter with font-ui (Elvaro Grotesque) throughout banner
- Add custom NHSNumberWithTooltip with Framer Motion animated reveal
- Add AnimatePresence crossfade between full/condensed banner states
- Animate mobile overflow menu enter/exit
- Add SkipButton to App.tsx for boot/ECG phase skip
- Add shadow-pmr-banner, focus ring styles, prefers-reduced-motion support
- Fix mobile banner to use patient data instead of hardcoded values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:16:20 +00:00
admin 8094f74800 Update Ralph loop: replace Claude in Chrome with Playwright MCP for visual review
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 00:11:50 +00:00
admin 4324f06186 Update progress: Task 3 completed (LoginScreen rebuild)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:45:09 +00:00
admin 5e1c96edfa Task 3: Rebuild LoginScreen with interactive login and premium font
- Typing speed: 80ms/char username, 60ms/dot password (was 30ms/20ms)
- Login button is now user-interactive (not auto-triggered)
- Button disabled/dimmed during typing, fully interactive after
- Hover state on button (darkens to #004D9F)
- Font changed from Inter to Elvaro Grotesque (var(--font-ui))
- Card shadow upgraded to multi-layered per design system
- Added 'done' activeField state for post-typing phase
- Proper timer cleanup via tracked timeout refs
- Reduced motion: typing instant, button immediately clickable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:44:33 +00:00
admin 556940c3c8 Update progress: Task 2 completed (premium font setup) 2026-02-12 23:41:17 +00:00
admin b8c1aedb5a Task 2: Set up premium font system (Elvaro Grotesque + Blumir)
Added @font-face declarations for both premium font candidates:
- Elvaro Grotesque: 7 weights (Light 300 → Black 900) from WOFF2/WOFF files
- Blumir: Variable font (100-700 weight range) from WOFF2/WOFF files

Updated Tailwind config:
- Added font-ui (Elvaro Grotesque) and font-ui-alt (Blumir) families
- Removed font-inter references (replaced with font-ui)
- Enhanced shadow tokens: pmr, pmr-hover, pmr-banner for Clinical Luxury depth
- Kept font-geist (Geist Mono) for data/timestamps, font-mono (Fira Code) for boot/ECG

Updated CSS variables and utility classes:
- --font-ui: Elvaro Grotesque
- --font-ui-alt: Blumir
- .pmr-theme now uses var(--font-ui) instead of var(--font-inter)

Fixed ESLint errors in ECGAnimation.tsx (viewOff/headSX should be const).

Quality checks: All passed (typecheck, lint, build). Font files bundled correctly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 23:40:32 +00:00
admin 5a000d6457 Design direction changed from Clinical Utilitarian to Clinical Luxury, updated all plans etc 2026-02-12 23:31:17 +00:00
admin 3afadbdc73 Completed boot loading to ECG, to name written 2026-02-12 22:31:34 +00:00
admin 4eeeb05744 Ralph iteration 1: work in progress 2026-02-11 22:55:02 +00:00
admin 959f0e1842 Task 1b: Rebuild boot sequence and ECG animation
- Refactored BootSequence to config-driven architecture with type-safe line components
- Added cursor position capture and smooth cursor-to-dot morph transition
- Rebuilt ECGAnimation with mask-based text reveal technique
- Implemented connector lines between letters with per-character profiles
- ECG trace now starts from cursor position (no teleport)
- Added prefers-reduced-motion support for both phases
- Updated App.tsx to pass cursor position between components

Quality checks: typecheck ✓, lint ✓, build ✓
2026-02-11 22:54:44 +00:00
admin cfd0283c78 reference files updated 2026-02-11 22:48:31 +00:00
admin 192d629125 Completed login screen transition, and started the spec work on design file info 2026-02-11 22:15:29 +00:00
admin 1a1f1f1938 docs: mark Task 1 complete, update progress log 2026-02-11 20:49:58 +00:00
admin 93051021fc feat(pmr): configure design system foundation
Task 1: Design system foundation and font setup
- Add Geist Mono font to Google Fonts import for PMR coded entries and timestamps
- Extend PMR color tokens in Tailwind config (card, text variants, borders, alert colors)
- Update border-radius defaults: 4px for cards/inputs, 12px for login card
- Add PMR-specific CSS custom properties in index.css
- Add .pmr-theme, .font-inter, .font-geist-mono utility classes
- Add pmr shadow token (minimal clinical system shadow)

All PMR color tokens now match ref-design-system.md spec exactly.
2026-02-11 20:49:36 +00:00
admin a52cb9f84b docs: mark Task 16 complete, document project status as COMPLETE
- Reviewed build against goal.md design specification
- Documented known gaps as scope decisions:
  - Breadcrumb navigation (not essential)
  - Context menus (not essential)
  - Full search (nav-only sufficient)
  - Download CV (placeholder, PDF out of scope)
- All 16 implementation tasks complete
- Core PMR system fully functional
2026-02-11 03:24:43 +00:00
admin 06ebef80c1 feat(pmr): add interface materialization animations
- Login card fades out with scale animation (200ms)
- Patient banner slides down from top (200ms)
- Sidebar slides in from left (250ms, 50ms delay)
- Main content fades in (300ms, 150ms delay)
- Mobile nav slides up (200ms)
- All animations respect prefers-reduced-motion
- Mark Task 15 complete in IMPLEMENTATION_PLAN.md
2026-02-11 03:22:29 +00:00
admin ef5bc9c3a6 Ralph iteration 10: work in progress 2026-02-11 03:07:52 +00:00
admin ac113f23c7 docs: Mark Task 14 complete, update progress with responsive design iteration 2026-02-11 03:07:32 +00:00
admin 4ec108484e feat: Implement responsive design for tablet and mobile breakpoints
- Add useBreakpoint hook for responsive breakpoint detection
- Add MobileBottomNav component for mobile navigation
- Update ClinicalSidebar with tablet icon-only mode and tooltips
- Update PatientBanner with mobile minimal mode and overflow menu
- Update PMRInterface to handle responsive layouts and mobile search
- Add mobile card layouts to MedicationsView, ProblemsView,
  InvestigationsView, and DocumentsView
- Desktop: 220px sidebar, full banner, tables
- Tablet: 56px icon sidebar, condensed banner, scrollable tables
- Mobile: Bottom nav, minimal banner, card layouts, search bar
2026-02-11 03:07:25 +00:00
admin a7df2d0037 Ralph iteration 9: work in progress 2026-02-11 02:50:28 +00:00
admin f7f7e0db8c feat(a11y): Implement keyboard shortcuts and accessibility (Task 13)
- Create AccessibilityContext for global focus management and expanded state
- Add roving tabindex to sidebar with Up/Down/Enter/Home/End navigation
- Focus management: after login, after view change, after item expansion
- Global Escape closes expanded items across all views
- Add scope='col' to SummaryView table headers
- Add focus-after-expand to ConsultationsView
- Update ARIA roles: role='menu', role='menuitem', aria-current
2026-02-11 02:49:51 +00:00
admin fc3c0659b2 Ralph iteration 8: work in progress 2026-02-11 02:30:01 +00:00
admin 89e93b805e Mark Task 12 complete and update progress log 2026-02-11 02:28:40 +00:00
admin 4104dd32d8 Task 12: Build ReferralsView with clinical referral form
- Created ReferralsView component with clinical referral form UI
- Pre-filled patient info (CHARLWOOD, Andrew; NHS Number)
- Priority toggle: Urgent/Routine/Two-Week Wait with tooltips
- Form fields: Referrer Name/Email/Org, Reason textarea
- Contact method radio: Email/Phone/LinkedIn
- Form validation for required fields
- Loading state with spinner on submit
- Success state with REF-YYYY-MMDD-NNN reference number
- Direct Contact table with clickable email/phone/LinkedIn links
- Responsive two-column layout for form fields
- Consistent clinical system styling with NHS blue accents
2026-02-11 02:27:41 +00:00
admin 47afa9171e Ralph iteration 7: work in progress 2026-02-11 02:16:56 +00:00
admin f648e7a4fc Progress update: Mark Task 11 complete and log iteration 2026-02-11 02:14:55 +00:00
admin 315259f44e Task 11: Build DocumentsView for education/certifications 2026-02-11 02:11:55 +00:00
admin a0bc1e34f1 Ralph iteration 6: work in progress 2026-02-11 02:05:53 +00:00
admin e73a1c6cb8 Progress update: Mark Task 10 complete and log iteration 2026-02-11 02:05:38 +00:00
admin 53b633bfd7 Task 10: Build InvestigationsView with results panel
- Created InvestigationsView component with expandable rows
- Status badges: Complete (green), Ongoing (amber), Live (pulsing green)
- Tree-indented results panel with methodology, results, tech stack
- View Results button for PharMetrics linking to medicines.charlwood.xyz
- Proper semantic table markup with scope attributes
- Traffic lights always accompanied by text labels
- Accordion behavior: only one row expanded at a time
- Height animation 200ms ease-out for expand/collapse
- Respects prefers-reduced-motion
- Updated PMRInterface to include InvestigationsView
2026-02-11 02:04:27 +00:00
admin 3bce29efe4 Ralph iteration 5: work in progress 2026-02-11 01:58:48 +00:00
admin f20791a7ff feat: build ProblemsView with traffic light status system
- Create ProblemsView with two tables: Active Problems and Resolved Problems
- Traffic light indicators: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
- Expandable rows showing full narrative and linked consultations
- Linked consultations navigate to Consultations view
- Proper semantic table markup with scope="col"
- Height animation for expand/collapse (200ms, respects reduced motion)
- Task 9 complete
2026-02-11 01:58:32 +00:00
admin 81e8fdf7c7 Ralph iteration 4: work in progress 2026-02-11 01:50:07 +00:00
admin 511691ee78 docs: mark Task 8 complete, update progress with iteration 8 learnings 2026-02-11 01:49:51 +00:00
admin 1e1ba2d6a4 feat: implement MedicationsView with sortable table and prescribing history
- Create MedicationsView component with three category tabs (Active, Clinical, PRN)
- Implement sortable columns with visual sort indicators
- Add expandable rows showing prescribing history timeline
- Use proper semantic table markup with scope attributes
- Add fadeIn animation for expanded content
- Traffic light status dots with text labels for accessibility
- Alternating row colors and hover states (#EFF6FF)
- Respects prefers-reduced-motion preference

Task 8 of Clinical Record PMR implementation
2026-02-11 01:48:49 +00:00
admin 59962776df Ralph iteration 3: work in progress 2026-02-11 01:42:12 +00:00
admin a521b2ff2d Ralph iteration 2: work in progress 2026-02-11 01:41:17 +00:00
admin 4272ca4dfe Task 7: Build ConsultationsView with History/Examination/Plan structure
- Create ConsultationsView with 5 expandable consultation entries
- Each entry has color-coded left border by employer (NHS blue vs Teal)
- Collapsed state shows date, org, role, key coded entry
- Expanded state shows Duration, HISTORY, EXAMINATION, PLAN, CODED ENTRIES
- Accordion behavior: only one entry expanded at a time
- Expand animation 200ms ease-out, respects reduced motion
- Section headers in uppercase with letter-spacing
- Coded entries in [XXX000] format with Geist Mono font
2026-02-11 01:40:56 +00:00
admin 4bf4d1171f Ralph iteration 1: work in progress 2026-02-11 01:35:05 +00:00
admin 8ca61c6afc docs: update progress for Task 6 completion 2026-02-11 01:34:04 +00:00
admin f40b98a6e5 feat: implement SummaryView with Clinical Alert (Task 6)
- Create SummaryView component with clinical alert banner
- Clinical alert: amber background, warning icon, acknowledge interaction
- Alert animates in with spring effect, dismisses with checkmark → collapse
- Demographics card: full width, two-column key-value layout
- Active Problems card: 3 active/in-progress items with traffic lights
- Current Medications Quick View: 4-column table showing top 5 skills
- Last Consultation card: preview of most recent role
- Add navigation handlers for view switching
- Respects prefers-reduced-motion for all animations
- Proper table semantics and accessibility attributes
2026-02-11 01:33:55 +00:00
admin f73c626421 Update progress: Mark Task 5 complete, add iteration 5 learnings 2026-02-11 01:17:32 +00:00
admin 4434c6e437 Task 5: Build ClinicalSidebar with navigation and search
- Create ClinicalSidebar component with 7 navigation items
- NHS blue active state with 3px left border
- Search input with basic filtering (fuse.js integration pending)
- Keyboard shortcuts Alt+1-7 for navigation
- URL hash routing (#summary, #consultations, etc.)
- Session footer with current time
- Create PMRInterface container component
- Update App.tsx to use 'pmr' phase instead of 'content'
2026-02-11 01:16:19 +00:00
admin 65fc23e79b Update progress: Mark Task 4 complete, add iteration 4 learnings 2026-02-11 01:05:40 +00:00
admin 8d26049b17 Task 4: Add PatientBanner component with full/condensed modes
- Create useScrollCondensation hook with IntersectionObserver
- PatientBanner with 80px full mode and 48px condensed mode
- Smooth height transition (200ms) on scroll past 100px
- Status dot (green) and badge for 'Open to opportunities'
- Action buttons: Download CV, Email, LinkedIn
- NHS blue outlined buttons with hover fill effect
- Proper GPhC number formatting with tooltip
- Sticky positioning for persistent visibility
2026-02-11 01:04:51 +00:00
admin de34ec48cc Ralph iteration 3: work in progress 2026-02-11 00:57:35 +00:00
admin a9300501fd Update progress: Mark Task 3 complete, log iteration 3 2026-02-11 00:57:16 +00:00
admin 8ee9046bb3 Task 3: Build LoginScreen component with typing animation
- Created LoginScreen.tsx with character-by-character username typing (30ms/char)
- Password dots fill at 20ms per dot
- Button shows pressed state before transition
- Added 'login' phase to App.tsx flow
- Added PMR colors and fonts to tailwind.config.js
- Added Inter font family to index.html
- Respects prefers-reduced-motion: instant completion in ~500ms
2026-02-11 00:54:48 +00:00
admin 02d7dcabd9 Ralph iteration 2: work in progress 2026-02-11 00:46:50 +00:00
admin 9ec62e9c12 Update progress: Task 2 complete, ECG flatline transition implemented 2026-02-11 00:46:18 +00:00
admin 2692e7ee86 Task 2: Modify ECGAnimation for PMR flatline transition
- Changed exit phase from fade-to-white to clinical flatline transition
- After name tracing: hold 300ms, draw flatline rightward 300ms
- Fade canvas to black 200ms, then transition background to #1E293B (login screen color)
- Total ECG phase timing preserved (~5-6 seconds), exit adds ~1 second
- Prepares for LoginScreen component in Task 3
2026-02-11 00:45:05 +00:00
admin 6a5de6ee33 Ralph iteration 1: work in progress 2026-02-11 00:37:48 +00:00
admin 9d078420bc docs: mark Task 1 complete and update progress log 2026-02-11 00:37:27 +00:00
admin 2033a93ecb feat(pmr): create PMR data layer and TypeScript types
- Add src/types/pmr.ts with interfaces for Patient, Consultation, Medication, Problem, Investigation, Document
- Add src/data/consultations.ts with 5 roles mapped to clinical consultation format
- Add src/data/medications.ts with 18 skills as medications across Active/Clinical/PRN categories
- Add src/data/problems.ts with 11 achievements/problems using traffic light status system
- Add src/data/investigations.ts with 5 projects as clinical investigations
- Add src/data/documents.ts with 5 education/certification documents
- Add src/data/patient.ts with patient demographic data

All data matches CV_v4.md exactly (dates, numbers, achievements).
Task 1 of 15 complete.
2026-02-11 00:37:20 +00:00
admin 9e01356df8 Design files 2026-02-10 23:38:00 +00:00
admin 27740968b7 Updated design plans 2026-02-10 23:33:48 +00:00
admin dfdf1d212d Ralph iteration 11: work in progress 2026-02-10 17:52:45 +00:00
admin 1e20724215 Task 12: Final integration, testing, and polish complete - all 12 tasks finished 2026-02-10 17:40:00 +00:00
admin 0dec533389 Ralph iteration 10: work in progress 2026-02-10 17:21:39 +00:00
admin 5c37818ebd Update progress: Task 11 completed 2026-02-10 17:20:45 +00:00
admin 6cc54d8a29 Task 11: Implement scroll animations and responsive design
- Add xs (480px) breakpoint to tailwind config for mobile
- Standardize scroll-reveal animations to opacity 0→1, y 24→0
- Add responsive padding to main container (px-5 xs:px-6 md:px-8)
- Add responsive section padding (py-12 xs:py-16 md:py-20)
- FloatingNav: responsive width and font/padding on mobile
- Hero: responsive vitals grid, title font clamp to 28px min
- Skills: responsive grid (2→3→auto-fit), smaller gauges on mobile
- Experience: responsive card padding, ECG decoration size
- Education/Projects: responsive grids matching concept.html
- Contact/Footer: responsive padding
2026-02-10 17:20:27 +00:00
admin 30eff4dde2 Ralph iteration 9: work in progress 2026-02-10 17:04:38 +00:00
admin 80a536676f Update progress: Task 10 completed 2026-02-10 17:04:22 +00:00
admin 1a2c43323b Task 10: Build Footer component with ECG decoration
- Created Footer.tsx with decorative ECG waveform SVG
- Footer uses Framer Motion for scroll-triggered entrance animation
- Centered layout with border-top, muted attribution text
- Integrated Footer into App.tsx content phase
- Three-phase orchestration (boot → ecg → content) already working
2026-02-10 17:03:11 +00:00
admin 75c6cf11dc Ralph iteration 8: work in progress 2026-02-10 16:57:05 +00:00
admin 318e7f0cf7 docs: Mark Task 9 complete and update progress log 2026-02-10 16:56:14 +00:00
135 changed files with 72553 additions and 4407 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"Bash(powershell -Command \"$lines = Get-Content ''4-vitals-monitor.html''; $before = $lines[0..1757]; $after = $lines[2028..\\($lines.Length-1\\)]; \\($before + $after\\) | Set-Content ''4-vitals-monitor.html'' -Encoding UTF8\")",
"Bash(powershell -Command \"$lines = Get-Content ''4-vitals-monitor.html''; $before = $lines[0..1757]; $after = $lines[2028..\\($lines.Length-1\\)]; $result = $before + $after; $result | Set-Content ''4-vitals-monitor.html'' -Encoding UTF8\")",
"Bash(powershell -ExecutionPolicy Bypass -File:*)",
"Bash(del \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\remove-lines.ps1\")",
"Bash(start \"\" \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\cv-4-vitals-monitor\\\\4-vitals-monitor.html\")"
]
}
}
+45
View File
@@ -7,9 +7,16 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment
.env
# Dependencies
node_modules
# Build output
dist
dist-ssr
dist-server
*.local
# Editor directories and files
@@ -25,3 +32,41 @@ dist-ssr
# TypeScript
*.tsbuildinfo
# Playwright screenshots
*.png
!public/meta.png
# AI agent tooling
.claude/
.codex/
.ralph/
.playwright-mcp/
AGENTS.md
PROMPT.md
hats.yml
ralph.yml
scripts/ralph/
scripts/benchmark-results/
# Reference / personal materials
References/
# Font source archives (used fonts are in public/fonts/)
Fonts/
# Logo animation source (Remotion)
LogoAnimation/
# Design notes
carousel-design-debate*.md
# Misc
*:Zone.Identifier
__MACOSX
andy-charlwood-cv@0.0.0
lighthouse.pdf
logo/
graph.png
node
nul
-143
View File
@@ -1,143 +0,0 @@
{
"iterations": [
{
"iteration": 1,
"startedAt": "2026-02-10T15:16:12.277Z",
"endedAt": "2026-02-10T15:48:45.761Z",
"durationMs": 1951644,
"toolsUsed": {},
"filesModified": [
".gitignore",
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"eslint.config.js",
"index.html",
"package-lock.json",
"package.json",
"postcss.config.js",
"public/vite.svg",
"src/App.tsx",
"src/index.css",
"src/lib/utils.ts",
"src/main.tsx",
"src/types/index.ts",
"src/vite-env.d.ts",
"tailwind.config.js",
"tsconfig.app.json",
"tsconfig.build.json",
"tsconfig.json",
"tsconfig.node.json",
"vite.config.ts"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 2,
"startedAt": "2026-02-10T15:48:48.223Z",
"endedAt": "2026-02-10T16:00:56.982Z",
"durationMs": 727496,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/BootSequence.tsx"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 3,
"startedAt": "2026-02-10T16:00:59.367Z",
"endedAt": "2026-02-10T16:13:49.296Z",
"durationMs": 768766,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/ECGAnimation.tsx"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 4,
"startedAt": "2026-02-10T16:13:51.563Z",
"endedAt": "2026-02-10T16:27:46.934Z",
"durationMs": 834192,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/FloatingNav.tsx",
"src/hooks/useActiveSection.ts",
"src/index.css"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 5,
"startedAt": "2026-02-10T16:27:49.261Z",
"endedAt": "2026-02-10T16:34:23.835Z",
"durationMs": 393418,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/Hero.tsx"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 6,
"startedAt": "2026-02-10T16:34:26.160Z",
"endedAt": "2026-02-10T16:42:15.177Z",
"durationMs": 467801,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/Skills.tsx",
"src/hooks/useScrollReveal.ts"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
},
{
"iteration": 7,
"startedAt": "2026-02-10T16:42:17.521Z",
"endedAt": "2026-02-10T16:49:57.593Z",
"durationMs": 458586,
"toolsUsed": {},
"filesModified": [
"Ralph/IMPLEMENTATION_PLAN.md",
"Ralph/progress.txt",
"src/App.tsx",
"src/components/Experience.tsx",
"src/hooks/useScrollReveal.ts"
],
"exitCode": 0,
"completionDetected": false,
"errors": []
}
],
"totalDurationMs": 5601903,
"struggleIndicators": {
"repeatedErrors": {},
"noProgressIterations": 0,
"shortIterations": 0
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"active": true,
"iteration": 7,
"minIterations": 1,
"maxIterations": 0,
"completionPromise": "COMPLETE",
"tasksMode": false,
"taskPromise": "READY_FOR_NEXT_TASK",
"prompt": "# Ralph Wiggum Loop - Iteration Prompt\n\nYou are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.\n\nYou are converting the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The goal is a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept.\n\n## Your Task This Iteration\n\n1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any component with CSS/styling. This skill gives you access to specialized frontend design capabilities for higher quality, polished output.\n\n2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.\n\n3. **Read accumulated learnings**: Open `progress.txt` and read the \"Codebase Patterns\" section. This contains learnings from previous iterations.\n\n4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Violating a guardrail is a quality check failure.\n\n5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that is artistic, creative, and visually polished. This is a design showcase - the output should make someone say \"wow, that's slick.\"\n\n6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under \"Quality Checks\". Fix any issues before proceeding.\n\n7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.\n\n8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.\n\n9. **Update progress.txt**: Append to the \"Iteration Log\" section with:\n - Which task you completed\n - Any learnings or codebase patterns discovered (add to \"Codebase Patterns\" section)\n - Any issues encountered\n - Design decisions made (if visual component)\n\n10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.\n\n11. **Check for completion**: If ALL items in the task checklist are now checked (`- [x]`), output the following completion signal on its own line:\n\n```\n<promise>COMPLETE</promise>\n```\n\n## Critical Rules\n\n- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component\n- **Only work on ONE task per iteration**\n- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context\n- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item\n- **Keep commits atomic and well-described**\n- **If quality checks fail, fix the issues before committing**\n- **The visual quality bar is HIGH** — this is a design portfolio piece\n- **Preserve all animations exactly** — timing, easing, and visual effects must match concept.html\n- **Use TypeScript strictly** — no `any` types, proper interfaces for all data structures\n- **Follow the established project structure** — components in `src/components/`, hooks in `src/hooks/`, etc.\n\n## Reference Files\n\n- `References/concept.html` — The complete working HTML implementation (your source of truth for animations, styling, timing)\n- `References/CV_v4.md` — CV content to populate sections\n- `References/ECGVideo/` — Remotion video project with ECG animation patterns\n",
"startedAt": "2026-02-10T15:16:11.835Z",
"model": "openrouter/openrouter/pony-alpha",
"agent": "opencode"
}
-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"read": "allow",
"edit": "allow",
"glob": "allow",
"grep": "allow",
"list": "allow",
"bash": "allow",
"task": "allow",
"webfetch": "allow",
"websearch": "allow",
"codesearch": "allow",
"todowrite": "allow",
"todoread": "allow",
"question": "allow",
"lsp": "allow",
"external_directory": "allow"
}
}
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Vite dev server (localhost:5173)
npm run build # TypeScript compile + Vite production build
npm run preview # Preview production build locally
npm run lint # ESLint
npm run typecheck # TypeScript checks (no emit)
npm run generate-embeddings # Regenerate semantic search embeddings (src/data/embeddings.json)
```
**Validation gate (run before any PR):** `npm run lint && npm run typecheck && npm run build`
No automated test framework — lint, typecheck, and build are the quality gates. For UI changes, verify manually (responsive behavior, accessibility, keyboard navigation).
## Architecture
**Interactive CV/portfolio** with a PMR (patient medical record) interface aesthetic. Three-phase UX: terminal boot → ECG heartbeat → dashboard.
### App lifecycle (`src/App.tsx`)
Phase orchestrator managing: BootSequence → ECGAnimation → LoginScreen → DashboardLayout
### Data flow
- **Canonical source:** `src/data/timeline.ts` — all career + education entities live here
- **Derived data:** `constellation.ts` builds D3 graph data from timeline; `consultations.ts` re-exports for legacy consumers; `tags.ts` derived from skills; `kpis.ts` standalone
- **Types:** `src/types/pmr.ts` has all domain types (Consultation, TimelineEntity, ConstellationNode, etc.)
### Key subsystems
| Subsystem | Entry point | Notes |
|-----------|-------------|-------|
| Dashboard | `DashboardLayout.tsx` | Orchestrates tiles, constellation, timeline, detail panel |
| Career Constellation | `CareerConstellation.tsx` | D3 force simulation; roles as clusters, skills as nodes; hover/click/tap/keyboard |
| Detail Panel | `DetailPanelContext.tsx` + `DetailPanel.tsx` | Right-side slide-out; context-aware views per entity type |
| Semantic Search | `lib/semantic-search.ts` + `lib/embedding-model.ts` | Pre-computed embeddings + local Xenova transformer model in browser |
| Command Palette | `CommandPalette.tsx` | Ctrl+K; fuzzy (Fuse.js) + semantic search |
| Chat Widget | `ChatWidget.tsx` + `lib/llm.ts` | Gemini/OpenRouter LLM integration; requires `.env` API keys |
| Accessibility | `AccessibilityContext.tsx` | Focus management, reduced motion, ARIA |
### D3 integration pattern
`CareerConstellation.tsx` manages D3 force simulation imperatively via refs. Highlight state tracked with refs (not React state) to avoid unnecessary re-renders. Touch: tap to pin, background tap to clear. Keyboard: Tab through nodes, Enter/Space activate, Escape reset.
## Conventions
- **TypeScript strict mode** — `noUnusedLocals`, `noUnusedParameters` enforced
- **Path alias:** `@/*``src/*` (configured in vite.config.ts + tsconfig.json)
- **Components:** PascalCase (`DashboardLayout.tsx`); Hooks: `useCamelCase`; Utilities: kebab-case (`semantic-search.ts`)
- **Styling:** Tailwind utility classes + inline `CSSProperties` for dynamic/theme values
- **Animations:** Framer Motion; respects `prefers-reduced-motion`
- **Commits:** Conventional Commit prefixes (`feat:`, `chore:`, `fix:`) + optional story IDs
## Design tokens
- **Primary:** Teal `#00897B` / **Accent:** Coral `#FF6B6B`
- **PMR palette:** GP system-inspired greens, teals, greys (defined in `tailwind.config.js`)
- **Font tokens (CSS custom properties):**
- `--font-ui`: Elvaro Grotesque (dashboard UI)
- `--font-geist-mono`: Geist Mono / Fira Code fallback (canonical mono token)
- `--font-primary` / `--font-secondary`: Plus Jakarta Sans / Inter Tight
- **Breakpoints:** xs 480, sm 640, md 768, lg 1024, xl 1280
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andy Charlwood
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# Andy Charlwood - Interactive CV
An interactive portfolio styled as a PMR (patient medical record) system — the kind of GP clinical interface NHS staff use daily. Features a cinematic boot sequence, D3 career constellation, semantic search, and an LLM-powered chat widget. Built with React, TypeScript, and Vite.
## Features
- **Four-Phase Loading**: Terminal boot → login screen → PMR dashboard (skippable; session-cached for returning visitors)
- **Career Constellation**: D3 force simulation mapping roles as clusters and skills as nodes — interactive via hover, click, tap, and keyboard
- **Semantic Search**: Pre-computed embeddings + local Xenova transformer model running in-browser
- **Command Palette**: `Ctrl+K` hybrid search (Fuse.js fuzzy + semantic)
- **Chat Widget**: Gemini/OpenRouter LLM integration for conversational Q&A about career history
- **Detail Panel**: Context-aware slide-out panel for deep-diving into any entity
- **Responsive Design**: Tailwind CSS with mobile-specific navigation and layout
- **Accessibility**: Focus management, reduced motion support, ARIA throughout
## Tech Stack
| Category | Technologies |
|----------|-------------|
| **Framework** | React 18 + TypeScript (strict mode) |
| **Build** | Vite 6 |
| **Styling** | Tailwind CSS 3 + CSS custom properties |
| **Animations** | Framer Motion + Canvas API |
| **Visualisation** | D3 v7 (force simulation) |
| **Search** | Fuse.js (fuzzy) + @xenova/transformers (semantic) |
| **Backend** | Express + Nodemailer (contact form, chat proxy) |
| **UI** | Lucide React, Embla Carousel, react-markdown |
| **Linting** | ESLint 9 |
## Getting Started
```bash
npm install
npm run dev # Starts Vite + Express backend concurrently
```
The chat widget and contact form require API keys in a `.env` file — see `.env.example` if available.
## Available Scripts
| Command | Description |
|---------|-------------|
| `npm run dev` | Vite dev server + Express backend (concurrently) |
| `npm run dev:frontend` | Vite only (no backend) |
| `npm run build` | TypeScript compile + Vite production build |
| `npm run start` | Run production server |
| `npm run typecheck` | TypeScript type checking only |
| `npm run lint` | Run ESLint |
| `npm run preview` | Preview production build |
| `npm run generate-embeddings` | Regenerate semantic search embeddings |
| `npm run benchmark` | Run performance benchmarks |
## Project Structure
```
src/
├── components/ # React components (PascalCase)
│ ├── constellation/ # D3 career constellation + legend
│ ├── detail/ # Detail panel views per entity type
│ └── tiles/ # Dashboard tile components
├── contexts/ # React contexts (DetailPanel, Accessibility)
├── data/ # Canonical data sources (timeline, skills, kpis, etc.)
├── hooks/ # Custom hooks (use* prefix)
├── lib/ # Utilities (semantic-search, embedding-model, llm)
├── types/ # TypeScript interfaces (pmr.ts)
├── App.tsx # Phase orchestrator (boot → login → dashboard)
└── index.css # Global styles + Tailwind
```
### Data architecture
- **Canonical source**: `src/data/timeline.ts` — all career and education entities
- **Derived**: `constellation.ts` (D3 graph), `tags.ts` (from skills), `kpis.ts` (standalone)
- **Profile copy**: `src/data/profile-content.ts` with typed selectors in `src/lib/profile-content.ts`
## Design Tokens
- **Primary**: Teal `#00897B` / **Accent**: Coral `#FF6B6B`
- **Palette**: GP system-inspired greens, teals, and greys
- **Fonts**: Elvaro Grotesque (UI), Geist Mono / Fira Code (mono), Plus Jakarta Sans / Inter Tight (fallback)
- **Breakpoints**: xxs 360px, xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
-137
View File
@@ -1,137 +0,0 @@
# Implementation Plan — React Conversion
## Project Overview
Convert the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The project will be a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept while following React best practices.
**Key Features to Port:**
- Boot sequence with terminal typing animation
- ECG flatline and heartbeat SVG animations
- Branching lines that trace UI elements into existence
- Color transition from green ECG to teal/coral design system
- Floating pill navigation with active section tracking
- SVG circular skill gauges with scroll-triggered animations
- Experience timeline with ECG decoration
- Scroll-reveal animations using IntersectionObserver
- Fully responsive design (desktop/tablet/mobile)
**Tech Stack:**
- React 18+ with TypeScript
- Vite for build tooling
- Tailwind CSS for styling
- Framer Motion for complex animations (boot sequence, ECG transitions)
- React Intersection Observer for scroll-triggered animations
- Lucide React for icons (replacing unicode symbols)
**Project Structure:**
```
src/
├── components/
│ ├── BootSequence.tsx # Terminal typing animation
│ ├── ECGAnimation.tsx # Flatline, heartbeats, branching
│ ├── FloatingNav.tsx # Pill navigation with active tracking
│ ├── Hero.tsx # About section with vitals
│ ├── Skills.tsx # Skill gauges with SVG circles
│ ├── Experience.tsx # Timeline layout
│ ├── Education.tsx # Education cards
│ ├── Projects.tsx # Project cards with gradient borders
│ ├── Contact.tsx # Contact grid
│ └── Footer.tsx # Footer with ECG decoration
├── hooks/
│ ├── useScrollReveal.ts # IntersectionObserver for scroll animations
│ └── useActiveSection.ts # Track active nav section
├── lib/
│ └── utils.ts # Utility functions (skill gauge math)
├── types/
│ └── index.ts # TypeScript interfaces
├── App.tsx # Main app with boot/ECG/CV phases
├── main.tsx # Entry point
└── index.css # Tailwind + custom CSS variables
```
**Reference Materials:**
- `References/concept.html` — Complete working HTML implementation with all animations
- `References/CV_v4.md` — Source CV content to populate sections
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
## Quality Checks
- `npm run dev` — Development server starts without errors
- `npm run build` — Production build completes without errors
- `npm run lint` — No ESLint errors
- `npm run typecheck` — No TypeScript errors
- Open `http://localhost:5173` and verify:
- Boot sequence plays exactly as in concept.html (terminal typing, 4 second duration)
- ECG flatline draws left-to-right
- Three heartbeats animate with increasing amplitude
- Branching lines trace outward on third beat
- Background transitions from black to white
- Final CV design renders with all sections
- Floating pill nav tracks active section on scroll
- Skill gauges animate when scrolled into view
- All hover effects work (card elevation, gradient borders)
- Responsive layouts work at 768px and 480px
- No console errors
## Tasks
- [x] **Task 1: Initialize React project with Vite + TypeScript + Tailwind**
Run `npm create vite@latest . -- --template react-ts` to scaffold the project. Install dependencies: `npm install framer-motion lucide-react`. Initialize Tailwind: `npm install -D tailwindcss postcss autoprefixer && npx tailwindcss init -p`. Configure `tailwind.config.js` with custom colors (teal #00897B, coral #FF6B6B, etc.). Set up `src/index.css` with Tailwind directives and CSS custom properties matching concept.html.
- [x] **Task 2: Set up project structure and types**
Create the folder structure (`components/`, `hooks/`, `lib/`, `types/`). Define TypeScript interfaces in `types/index.ts` for: `Skill` (name, level, category, color), `Experience` (role, org, date, bullets), `Education` (degree, institution, period, detail), `Project` (title, description, link?). Create `lib/utils.ts` with helper function `calculateSkillOffset(level: number, radius: number): number` that returns `2 * Math.PI * radius * (1 - level / 100)`.
- [x] **Task 3: Build BootSequence component**
Create `components/BootSequence.tsx`. Implement terminal typing animation using Framer Motion or CSS transitions. Display boot lines with correct colors (cyan labels, green values, dim text). Use exact boot text from concept.html: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION, module loading, [OK] lines, READY. Duration: ~4 seconds. Emit `onComplete` callback when finished. Styling: black background, Fira Code font.
- [x] **Task 4: Build ECGAnimation component**
Create `components/ECGAnimation.tsx`. Port the ECG logic from concept.html:
- SVG flatline drawing left-to-right (1000ms)
- Three PQRST heartbeats with increasing amplitude (40px → 60px → 100px)
- Color interpolation: #00ff41#00C9A7#00897B
- Branching lines from third R peak tracing UI outlines (pill nav, hero, cards)
- Background transition from black to white
- Emit `onComplete` callback when animation finishes
Use Framer Motion for path drawing animations (pathLength).
- [x] **Task 5: Build FloatingNav component**
Create `components/FloatingNav.tsx`. Floating pill navigation bar fixed at top center. Links: About, Skills, Experience, Education, Projects, Contact. Active link tracking via `useActiveSection` hook (IntersectionObserver). Smooth scroll to sections on click. Responsive: horizontal scroll on mobile. Styling: white bg, rounded-full, shadow-md, teal active state with dot indicator.
- [x] **Task 6: Build Hero section component**
Create `components/Hero.tsx`. Port hero section from concept.html: centered layout, name (clamp 36-52px), job title (muted), location pill (teal border), summary paragraph (max-width 560px). Four vital sign metric cards in a row: "10+ Years Experience", "Python/SQL/BI Analytics Stack", "Pop. Health Focus Area", "NHS N&W System". Cards have teal border-top, hover elevation. Responsive: 2x2 grid on tablet, stacked on mobile.
- [x] **Task 7: Build Skills section with SVG gauges**
Create `components/Skills.tsx`. Three skill categories: Technical (8 skills, teal), Clinical (6 skills, coral), Strategic (4 skills, teal). Each skill has circular SVG progress gauge using calculated stroke-dashoffset. Scroll-triggered animation: gauges fill when section enters viewport, staggered by 100ms. Port all 18 skills with correct percentages from concept.html.
- [x] **Task 8: Build Experience section with timeline**
Create `components/Experience.tsx`. Vertical timeline with 5 roles: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs (May 2022-Jul 2024), Pharmacy Manager (Nov 2017-May 2022), Duty Pharmacy Manager (Aug 2016-Nov 2017). Decorative ECG waveform SVG beside heading. Timeline dot filled for current roles. Cards with hover effect (scale, shadow, left border). Responsive: hide timeline line on mobile, stack cards.
- [ ] **Task 9: Build Education, Projects, Contact sections**
Create `components/Education.tsx`, `components/Projects.tsx`, `components/Contact.tsx`.
**Education:** 2-column grid. MPharm (Hons) UEA 2011-2015 (2:1). Mary Seacole Leadership Programme 2018. Gradient top border (teal→coral). A-Levels line below.
**Projects:** 2x2 grid. PharMetrics (with link), Patient Pathway Analysis, Blueteq Generator, NMS Video. Gradient border hover effect.
**Contact:** 4-column grid. Phone, Email, LinkedIn, Location. Use Lucide icons (Phone, Mail, Linkedin, MapPin). Responsive: 2x2 on mobile.
- [ ] **Task 10: Build Footer component and main App.tsx**
Create `components/Footer.tsx`. Decorative ECG waveform SVG, attribution text. Update `App.tsx` to orchestrate the three phases: 1) BootSequence (4s), 2) ECGAnimation (4s), 3) CV Content (with all sections). Use React state to track current phase. Ensure smooth transitions between phases.
- [ ] **Task 11: Implement scroll animations and responsive design**
Create `hooks/useScrollReveal.ts`. IntersectionObserver-based hook for scroll-triggered section reveals. Add scroll-reveal animations to all sections (opacity 0→1, translateY 24px→0). Ensure animations only trigger once. Add responsive breakpoints: tablet (768px), mobile (480px). Test all layouts.
- [ ] **Task 12: Final integration, testing, and polish**
Run all quality checks. Verify TypeScript compiles without errors. Verify no console errors. Test boot sequence timing matches concept.html (~4s). Test ECG animation timing and easing. Verify all CV content accuracy against CV_v4.md. Test all interactive elements (nav, hover effects, scroll animations). Verify responsive layouts at all breakpoints. Final build test.
-56
View File
@@ -1,56 +0,0 @@
# Ralph Wiggum Loop - Iteration Prompt
You are operating inside an automated loop. Each iteration you receive fresh context - you have NO memory of previous iterations. Your only persistence is the filesystem.
You are converting the completed `concept.html` (ECG Heartbeat CV Website) into a modern React application with TypeScript, Vite, and Tailwind CSS. The goal is a portfolio-grade React implementation that preserves all animations, interactions, and design details from the HTML concept.
## Your Task This Iteration
1. **Use the /frontend-design skill** (REQUIRED for visual components): Before writing ANY code for components that involve visual design, styling, animations, or UI elements, you MUST invoke the `/frontend-design` skill. This includes: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any component with CSS/styling. This skill gives you access to specialized frontend design capabilities for higher quality, polished output.
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in priority order - pick the first unchecked one.
3. **Read accumulated learnings**: Open `progress.txt` and read the "Codebase Patterns" section. This contains learnings from previous iterations.
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Violating a guardrail is a quality check failure.
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that is artistic, creative, and visually polished. This is a design showcase - the output should make someone say "wow, that's slick."
6. **Run quality checks**: Execute the quality check commands listed in `IMPLEMENTATION_PLAN.md` under "Quality Checks". Fix any issues before proceeding.
7. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task you completed.
8. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
9. **Update progress.txt**: Append to the "Iteration Log" section with:
- Which task you completed
- Any learnings or codebase patterns discovered (add to "Codebase Patterns" section)
- Any issues encountered
- Design decisions made (if visual component)
10. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
11. **Check for completion**: If ALL items in the task checklist are now checked (`- [x]`), output the following completion signal on its own line:
```
<promise>COMPLETE</promise>
```
## Critical Rules
- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component
- **Only work on ONE task per iteration**
- **Always read progress.txt AND guardrails.md before starting** — previous iterations may have left important context
- **If a task is blocked or unclear**, document why in progress.txt and move to the next unchecked item
- **Keep commits atomic and well-described**
- **If quality checks fail, fix the issues before committing**
- **The visual quality bar is HIGH** — this is a design portfolio piece
- **Preserve all animations exactly** — timing, easing, and visual effects must match concept.html
- **Use TypeScript strictly** — no `any` types, proper interfaces for all data structures
- **Follow the established project structure** — components in `src/components/`, hooks in `src/hooks/`, etc.
## Reference Files
- `References/concept.html` — The complete working HTML implementation (your source of truth for animations, styling, timing)
- `References/CV_v4.md` — CV content to populate sections
- `References/ECGVideo/` — Remotion video project with ECG animation patterns
-90
View File
@@ -1,90 +0,0 @@
# Guardrails — React Conversion
## Standard Guardrails
### Frontend-design skill requirement
- **When**: Writing ANY component with visual styling, animations, or UI elements
- **Rule**: You MUST invoke the `/frontend-design` skill before writing code. This applies to: BootSequence, ECGAnimation, FloatingNav, Hero, Skills, Experience, Education, Projects, Contact, Footer, and any styled component.
- **Why**: The frontend-design skill provides specialized capabilities for creating polished, professional-grade visual output. Skipping it results in lower quality design.
### Boot sequence consistency
- **When**: Implementing BootSequence component
- **Rule**: Boot text must match concept.html exactly: "CLINICAL TERMINAL v3.2.1", "Initialising pharmacist profile...", SYSTEM/USER/ROLE/LOCATION labels with values, loading modules line, three [OK] lines, "---", and final ready line. Use Fira Code font, green #00ff41 for [OK] and values, cyan #00e5ff for labels, dim green #3a6b45 for other text.
- **Why**: Boot sequence is the shared identity across all concepts. Must be identical.
### ECG animation fidelity
- **When**: Implementing ECGAnimation component
- **Rule**: Timing and visual effects must match concept.html exactly: flatline 1000ms, three heartbeats with amplitudes 40px→60px→100px, color shift #00ff41#00C9A7#00897B, branching lines from third R peak, background transition black→white.
- **Why**: The ECG animation is the signature visual effect. Any deviation breaks the experience.
### CV content accuracy
- **When**: Adding CV content to components
- **Rule**: Use the expanded CV_v4.md content. Key roles in order: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), High-Cost Drugs & Interface Pharmacist (May 2022-Jul 2024), Pharmacy Manager Tesco (Nov 2017-May 2022), Duty Pharmacy Manager Tesco (Aug 2016-Nov 2017). Include Mary Seacole Programme in education. Include key achievements with specific numbers (£14.6M, 14,000 patients, £2.6M, 70%, 200hrs, £1M).
- **Why**: The CV data must be accurate and complete. Missing roles or wrong dates would be a critical error.
### TypeScript strictness
- **When**: Writing any TypeScript code
- **Rule**: No `any` types. Define interfaces for all data structures. Use proper React.FC types or function component signatures with typed props. Enable strict mode in tsconfig.json.
- **Why**: Type safety is a core benefit of the React conversion. `any` defeats the purpose.
### Google Fonts loading
- **When**: Setting up index.html
- **Rule**: Use preconnect links to fonts.googleapis.com AND fonts.gstatic.com (with crossorigin), then the font CSS link. Load ALL fonts: Fira Code, Plus Jakarta Sans, Inter Tight. Test that fonts actually render.
- **Why**: Fonts are critical to the design identity. Missing fonts break the visual concept.
### Transition timing
- **When**: Building the boot-to-design transition
- **Rule**: Boot phase should take ~4 seconds. ECG animation should take ~5-6 seconds. Total time from page load to fully revealed design: no more than 10 seconds.
- **Why**: Too long and users will leave. Too short and the effect is lost.
### No console errors
- **When**: Writing JavaScript/TypeScript
- **Rule**: No errors in the browser console. Handle edge cases: elements that might not exist, animation cleanup on unmount, proper dependency arrays in hooks.
- **Why**: Console errors suggest broken functionality and are a quality check failure.
### Responsive breakpoints
- **When**: Adding responsive CSS/Tailwind classes
- **Rule**: Must work at 3 breakpoints: desktop (>768px), tablet (<=768px), mobile (<=480px). Navigation must be usable at all sizes. Content must not overflow horizontally. Touch targets must be reasonable size.
- **Why**: CVs are often viewed on mobile devices.
### Scroll animation observer
- **When**: Implementing scroll-triggered animations
- **Rule**: Use IntersectionObserver via custom hook (useScrollReveal), NOT scroll event listeners. Set appropriate threshold (0.1-0.15). Animations should only play once (don't re-trigger on scroll up).
- **Why**: IntersectionObserver is more performant and reliable than scroll listeners.
### Tailwind CSS usage
- **When**: Writing component styles
- **Rule**: Use Tailwind utility classes for all styling. Only use inline styles or CSS modules for dynamic values that can't be expressed with Tailwind (e.g., stroke-dashoffset calculations). Extend Tailwind config for custom colors.
- **Why**: Consistent styling approach, smaller bundle size, better maintainability.
## Project-Specific Guardrails
### Framer Motion for complex animations
- **When**: Animating the boot sequence, ECG paths, branching lines
- **Rule**: Use Framer Motion's `motion` components and props (initial, animate, transition). Use `pathLength` for SVG drawing animations. Use `AnimatePresence` for exit animations. Define transition objects with exact timing from concept.html.
- **Why**: Framer Motion provides declarative, performant animations that are easier to maintain than imperative JS.
### Skill circle calculation
- **When**: Building SVG circular progress gauges in Skills component
- **Rule**: The circumference formula is `2 * Math.PI * radius`. `strokeDasharray = circumference`. `strokeDashoffset = circumference * (1 - level / 100)`. The circle MUST have `transform: rotate(-90deg)` to start progress from 12 o'clock position.
- **Why**: Wrong math or missing rotation produces circles that fill from the wrong position or have incorrect percentages.
### Component file structure
- **When**: Creating new components
- **Rule**: One component per file in `src/components/`. Named exports for components. Props interface defined at top of file. Follow naming: PascalCase for components (BootSequence.tsx), camelCase for hooks (useScrollReveal.ts).
- **Why**: Consistent organization makes the codebase maintainable.
### Lucide React icons
- **When**: Adding icons to Contact or other sections
- **Rule**: Use Lucide React icons instead of unicode symbols. Import specific icons: `import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'`. Size icons consistently (default 24px or specified size prop).
- **Why**: Lucide provides consistent, scalable SVG icons that match the design system.
### Custom hooks for reusable logic
- **When**: Implementing scroll reveal, active section tracking
- **Rule**: Extract reusable logic into custom hooks in `src/hooks/`. Hooks should be composable and return values/functions needed by components. Name with `use` prefix.
- **Why**: Keeps components clean, enables reuse, follows React best practices.
### Vite configuration
- **When**: Setting up the project build
- **Rule**: Use Vite's default React template. Configure path aliases in `vite.config.ts` for clean imports (e.g., `@/components/Hero`). Ensure `build.outDir` is set correctly.
- **Why**: Vite provides fast dev server and optimized production builds.
-176
View File
@@ -1,176 +0,0 @@
# Progress Log — React Conversion Phase
## Codebase Patterns
- **Source of truth**: `References/concept.html` contains the complete working HTML implementation. All animations, timing, colors, and styling must be preserved exactly when porting to React.
- **Tech stack**: React 18+, TypeScript, Vite, Tailwind CSS, Framer Motion, Lucide React
- **Project structure**: Components in `src/components/`, hooks in `src/hooks/`, types in `src/types/`, utilities in `src/lib/`
- **Animation approach**: Framer Motion for complex sequences (boot, ECG), CSS transitions for simple hover effects, IntersectionObserver (via hook) for scroll-triggered animations
- **SVG animations**: Use Framer Motion's `pathLength` prop for drawing effects, or CSS `stroke-dasharray`/`stroke-dashoffset` for skill gauges
- **Skill gauge math**: `circumference = 2 * Math.PI * radius`, `strokeDashoffset = circumference * (1 - level / 100)`, rotate -90deg to start from top
- **Boot sequence timing**: 14 lines × 220ms = ~3080ms, plus 400ms pause, 800ms fade = ~4.28s total
- **ECG timing**: Flatline 1000ms + 3 beats × 600ms + holds 300ms + branching 1500ms + fade 500ms = ~5.5s
- **Color palette**:
- ECG phase: #000 (black), #00ff41 (green), #00e5ff (cyan), #3a6b45 (dim green)
- Final design: #00897B (teal), #FF6B6B (coral), #0F172A (heading), #334155 (text), #94A3B8 (muted)
- **Fonts**: Fira Code (boot), Plus Jakarta Sans (primary), Inter Tight (secondary)
- **Responsive breakpoints**: 768px (tablet), 480px (mobile)
## Iteration Log
### Phase Transition — React Conversion Setup
- Previous phase completed: Single HTML file `concept.html` fully built with all 9 tasks
- New phase started: Convert HTML concept to React + TypeScript + Vite project
- IMPLEMENTATION_PLAN.md updated with 12 React-specific tasks
- RALPH_PROMPT.md updated with explicit /frontend-design skill requirement for all visual components
- This progress.txt reset for new phase
### Iteration 4 — Task 5: Build FloatingNav component
- **Completed**: Task 5 - Build FloatingNav component
- **Files created**:
- `src/hooks/useActiveSection.ts` - IntersectionObserver hook for tracking active nav section
- `src/components/FloatingNav.tsx` - Floating pill navigation with active tracking
- **Files modified**:
- `src/index.css` - Added scrollbar-hide utility and smooth scroll behavior
- `src/App.tsx` - Integrated FloatingNav and added section IDs for scroll targets
- **Design decisions**:
- Used IntersectionObserver with rootMargin '-20% 0px -70% 0px' for accurate section detection
- Framer Motion layoutId for smooth indicator dot animation between nav items
- Active section is the topmost visible section (sorted by DOM order)
- Navigation uses button elements for accessibility and proper click handling
- Smooth scroll behavior via CSS `scroll-behavior: smooth` on html element
- Responsive: horizontal scroll with hidden scrollbar on mobile
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- IntersectionObserver thresholds array allows precise tracking of section visibility
- Using a ref to track visible sections prevents React re-render race conditions
<!-- Iterations will be logged here as tasks are completed -->
### Iteration 5 — Task 6: Build Hero section component
- **Completed**: Task 6 - Build Hero section component
- **Files created**:
- `src/components/Hero.tsx` - Hero section with name, title, location, summary, and vital cards
- **Files modified**:
- `src/App.tsx` - Replaced inline hero section with Hero component import
- **Design decisions**:
- Used Framer Motion for staggered entrance animations (name first, then title, location, summary, then vital cards with 0.1s delays)
- VitalCard component with three value size variants: default (28px), small (16px), medium (18px)
- Hover effects: elevation (-translate-y-0.5) and shadow-md transition
- Responsive: flex-wrap with gap-4 for automatic wrapping on smaller screens
- Preserved exact content from concept.html including full summary paragraph
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- CSS clamp() for responsive font sizing works well inline with Framer Motion
- Using a separate VitalCard component with delay prop creates clean staggered animation pattern
<!-- Iterations will be logged here as tasks are completed -->
### Iteration 6 — Task 7: Build Skills section with SVG gauges
- **Completed**: Task 7 - Build Skills section with SVG gauges
- **Files created**:
- `src/hooks/useScrollReveal.ts` - IntersectionObserver hook for scroll-triggered animations
- `src/components/Skills.tsx` - Skills section with SVG circular progress gauges
- **Files modified**:
- `src/App.tsx` - Replaced skills placeholder with Skills component
- **Design decisions**:
- SkillGauge component with SVG circular progress using stroke-dashoffset animation
- IntersectionObserver triggers when section is 15% visible
- Staggered animation: 100ms delay between each gauge
- Gauge radius 34px, circumference 213.628, rotates -90deg to start from top
- Transition duration 1.2s ease-out for gauge fill animation
- Framer Motion for card entrance animations (opacity 0→1, y 16→0)
- Color-coded: Technical (teal), Clinical (coral), Strategic (teal)
- Responsive grid: auto-fit with minmax(140px, 1fr)
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- SVG stroke-dashoffset animation triggered via React state + CSS transition works smoothly
- IntersectionObserver cleanup is critical to avoid memory leaks
- Calculating baseDelay per category allows grouped stagger effects
### Iteration 3 — Task 4: Build ECGAnimation component
- **Completed**: Task 4 - Build ECGAnimation component
- **Files created**:
- `src/components/ECGAnimation.tsx` - Canvas-based ECG animation with heartbeat waveforms and name drawing
- **Files modified**:
- `src/App.tsx` - Updated to use ECGAnimation component instead of placeholder
- **Design decisions**:
- Used canvas API with requestAnimationFrame for smooth 60fps animation
- Ported exact ECG waveform generation from concept.html (PQRST pattern)
- Ported letter waveform interpolation for "ANDREW CHARLWOOD" name drawing
- Implemented glow effects using canvas shadowBlur
- Added scanline overlay (4px horizontal lines) for retro effect
- Added radial gradient vignette for atmosphere
- Background transitions from black to white during exit phase
- Used Framer Motion AnimatePresence for component-level exit animation
- **Animation timing preserved**:
- 4 heartbeat complexes with amplitudes: 0.3, 0.55, 0.85, 1.0
- Trace speed: 450px/s (scaled responsively)
- Hold time after text: 0.75s
- Exit fade: 0.8s
- Total duration: ~5-6 seconds
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- Canvas-based animations need careful cleanup on unmount (cancelAnimationFrame)
- Device pixel ratio (dpr) handling required for crisp rendering on high-DPI displays
- Responsive scaling: `Math.min(1.2, Math.max(0.35, vw / 1400))`
### Iteration 1 — Task 1: Initialize React project
- **Completed**: Task 1 - Initialize React project with Vite + TypeScript + Tailwind
- **Files created**:
- `package.json` with dependencies: React 18, Framer Motion, Lucide React, Tailwind
- `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json` for TypeScript
- `vite.config.ts` with path alias `@/` -> `./src/`
- `tailwind.config.js` with custom colors (teal, coral, ecg-green), fonts, shadows
- `postcss.config.js` for Tailwind processing
- `index.html` with Google Fonts (Fira Code, Plus Jakarta Sans, Inter Tight)
- `src/index.css` with Tailwind directives and CSS custom properties
- `src/main.tsx` entry point
- `src/App.tsx` placeholder component
- `src/types/index.ts` with TypeScript interfaces
- `src/lib/utils.ts` with skill gauge calculation helper
- `eslint.config.js` with React hooks and refresh rules
- **Project structure created**: `src/components/`, `src/hooks/`, `src/lib/`, `src/types/`
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
- **Learnings**:
- Need `src/vite-env.d.ts` with `/// <reference types="vite/client" />` for CSS imports
- Vite refuses to scaffold in non-empty directory, so manual setup was needed
### Iteration 2 — Task 2 & 3: Project structure and BootSequence
- **Completed**: Task 2 (Set up project structure and types) - was already done in Task 1
- **Completed**: Task 3 - Build BootSequence component
- **Files created**:
- `src/components/BootSequence.tsx` - Terminal typing animation using Framer Motion
- **Design decisions**:
- Used Framer Motion's `motion.div` with `initial`/`animate` props for line reveals
- Each line animates with opacity 0→1, translateY 8px→0 over 400ms
- Staggered delays calculated from cumulative 220ms per line
- Blinking cursor implemented with CSS animation class `animate-blink`
- Used `AnimatePresence` for smooth exit fade (800ms)
- **Boot sequence timing preserved**: 14 lines × 220ms + 400ms pause + 800ms fade = ~4.28s
- **Quality checks**: `npm run typecheck` ✓, `npm run build` ✓, `npm run lint` ✓
- **Learnings**:
- Framer Motion's delay prop uses seconds, not milliseconds
- Used `dangerouslySetInnerHTML` for colored spans within boot lines (matches concept.html structure)
- CSS classes for blink/seed-pulse animations already existed in index.css from Task 1
### Iteration 7 — Task 8: Build Experience section with timeline
- **Completed**: Task 8 - Build Experience section with timeline
- **Files created**:
- `src/components/Experience.tsx` - Timeline component with 5 roles and ECG decoration
- **Files modified**:
- `src/App.tsx` - Replaced Experience placeholder with Experience component
- `src/hooks/useScrollReveal.ts` - Fixed ref type for React 18+ compatibility
- **Design decisions**:
- Vertical timeline with 20% left offset for timeline line and dots
- ECG waveform SVG decoration beside heading (matches concept.html)
- Timeline dots filled (bg-teal) for current roles, outline for past roles
- Cards have hover effects: scale(1.01), shadow-md, left border teal/30
- Framer Motion for staggered entry animations (100ms delay per card)
- useScrollReveal hook triggers animations when section is 10% visible
- Responsive: timeline line and dots hidden on mobile (md:block)
- **Experience data**: 5 roles from Interim Head (May-Nov 2025) to Duty Pharmacy Manager (Aug 2016-Nov 2017)
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- React 18+ RefObject types require non-nullable type param for ref props
- Fixed useScrollReveal to return `RefObject<T>` instead of `RefObject<T | null>`
- data-visible attribute pattern works well for CSS transitions based on JS state
-362
View File
@@ -1,362 +0,0 @@
<#
.SYNOPSIS
Ralph Wiggum Loop - Visualization Improvements variant.
.DESCRIPTION
Outer loop for iterative chart improvement (bug fixes, polish, new analytics).
Each iteration spawns a fresh `claude --print` invocation.
Memory persists via filesystem only: git commits, progress.txt, IMPLEMENTATION_PLAN.md, guardrails.md.
Runs until completion (<promise>COMPLETE</promise>) or circuit breaker trips.
No arbitrary iteration limit — the loop continues until done.
Circuit breakers prevent runaway costs:
- No git changes for N consecutive iterations (stalled)
- Same error repeated N consecutive iterations (stuck)
.PARAMETER Model
Claude model to use. Default: "opus".
.PARAMETER BranchName
Optional git branch name. If provided, creates/checks out the branch before starting.
.PARAMETER MaxNoProgress
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
.PARAMETER MaxSameError
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
.EXAMPLE
.\ralph.ps1 -Model "opus" -BranchName "feature/dash-migration"
.EXAMPLE
.\ralph.ps1 -Model "sonnet" -MaxNoProgress 2
#>
param(
[string]$Model = "opus",
[string]$BranchName,
[int]$MaxNoProgress = 3,
[int]$MaxSameError = 3
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$promptFile = Join-Path $scriptDir "RALPH_PROMPT.md"
$planFile = Join-Path $scriptDir "IMPLEMENTATION_PLAN.md"
$guardrailsFile = Join-Path $scriptDir "guardrails.md"
$progressFile = Join-Path $scriptDir "progress.txt"
$logDir = Join-Path $scriptDir "logs"
# --- Validation ---
if (-not (Test-Path $promptFile)) {
Write-Error "RALPH_PROMPT.md not found at $promptFile"
exit 1
}
if (-not (Test-Path $planFile)) {
Write-Error "IMPLEMENTATION_PLAN.md not found at $planFile"
exit 1
}
if (-not (Test-Path $guardrailsFile)) {
Write-Warning "guardrails.md not found at $guardrailsFile - loop may miss known failure patterns"
}
# Ensure progress.txt exists
if (-not (Test-Path $progressFile)) {
@"
# Progress Log
## Design Context
<!-- Design decisions and context go here -->
## Reflex Patterns
<!-- Reusable Reflex patterns discovered during development -->
## Iteration Log
<!-- Each iteration appends a structured entry below. See RALPH_PROMPT.md for format. -->
"@ | Set-Content -Path $progressFile -Encoding UTF8
Write-Host "Created progress.txt"
}
# Ensure logs directory exists
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir | Out-Null
Write-Host "Created logs directory"
}
# --- Git Setup ---
$gitInitialised = $false
try {
$result = git rev-parse --is-inside-work-tree 2>&1
if ($LASTEXITCODE -eq 0 -and $result -eq "true") {
$gitInitialised = $true
}
} catch {
# Not a git repo — expected on first run
}
if (-not $gitInitialised) {
Write-Host "Initialising git repository..."
git init
git add -A
git commit -m "Initial commit before Ralph loop"
}
if ($BranchName) {
$currentBranch = git branch --show-current
if ($currentBranch -ne $BranchName) {
$branchExists = git branch --list $BranchName
if ($branchExists) {
Write-Host "Switching to existing branch: $BranchName"
git checkout $BranchName
} else {
Write-Host "Creating branch: $BranchName"
git checkout -b $BranchName
}
}
}
# --- Circuit Breaker State ---
$noProgressCount = 0
$lastErrorSignature = ""
$sameErrorCount = 0
# Capture the HEAD commit hash before the loop starts
$preLoopHead = git rev-parse HEAD 2>$null
# --- Main Loop ---
$promptContent = Get-Content -Path $promptFile -Raw
# Count existing iterations from progress.txt to track total across runs
$existingIterations = 0
if (Test-Path $progressFile) {
$existingIterations = (Select-String -Path $progressFile -Pattern "## Iteration" -AllMatches | Measure-Object).Count
}
Write-Host ""
Write-Host "===== Ralph Wiggum Loop (Visualization Improvements) =====" -ForegroundColor Cyan
Write-Host "Model: $Model | Runs until COMPLETE" -ForegroundColor Cyan
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
if ($BranchName) { Write-Host "Branch: $BranchName" -ForegroundColor Cyan }
if ($existingIterations -gt 0) { Write-Host "Previous iterations: $existingIterations" -ForegroundColor Cyan }
Write-Host "===========================================" -ForegroundColor Cyan
Write-Host ""
$i = 0
while ($true) {
$i++
$totalIteration = $existingIterations + $i
Write-Host ""
Write-Host "--- Iteration $i (Total: $totalIteration) ---" -ForegroundColor Yellow
# Record HEAD before this iteration
$headBefore = git rev-parse HEAD 2>$null
# Show start time and status
$iterStart = Get-Date
Write-Host " Started: $($iterStart.ToString('HH:mm:ss'))" -ForegroundColor DarkGray
Write-Host " Spawning Claude ($Model)..." -ForegroundColor DarkGray
Write-Host ""
# Spawn fresh Claude instance with stream-json for tool call visibility
$logFile = Join-Path $logDir "iteration_$totalIteration.log"
$rawLogFile = Join-Path $logDir "iteration_$totalIteration.raw.jsonl"
$maxRetries = 10
$retryCount = 0
$outputString = ""
$apiOverloaded = $false
do {
$apiOverloaded = $false
$textBuilder = [System.Text.StringBuilder]::new()
$toolCount = 0
# Clear raw log file for this attempt
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
if ($retryCount -gt 0) {
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
Start-Sleep -Seconds $backoffSeconds
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
}
$promptContent | claude --print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json 2>&1 | ForEach-Object {
$line = $_.ToString().Trim()
if (-not $line) { return }
# Save raw event for debugging (with error handling for stream closure)
try {
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
} catch {
# Stream closed or file locked - ignore and continue
}
try {
$evt = $line | ConvertFrom-Json -ErrorAction Stop
# --- Tool use start (show tool name) ---
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
$toolCount++
$toolName = $evt.content_block.name
Write-Host " [$toolName]" -ForegroundColor DarkCyan
}
# --- Assistant text content (streaming deltas) ---
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
Write-Host -NoNewline $evt.delta.text
[void]$textBuilder.Append($evt.delta.text)
}
# --- Result event (error display + text capture for circuit breakers) ---
elseif ($evt.type -eq 'result') {
if ($evt.subtype -eq 'error_result' -and $evt.error) {
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
}
elseif ($evt.result) {
# Capture for circuit breaker detection; don't print
# (text already displayed via streaming deltas above)
[void]$textBuilder.AppendLine($evt.result)
}
}
# --- Message-level content (final message summary) ---
elseif ($evt.message -and $evt.message.content) {
foreach ($block in $evt.message.content) {
if ($block.type -eq 'text' -and $block.text) {
Write-Host $block.text
[void]$textBuilder.AppendLine($block.text)
}
elseif ($block.type -eq 'tool_use') {
$toolCount++
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
}
# Silently ignore tool_result and other block types
}
}
# All other JSON events (input_json_delta, content_block_stop,
# message_start, message_stop, ping, etc.) are silently ignored
} catch {
# Not valid JSON — only print if it looks like meaningful stderr
# (filter out JSON fragments from multi-line events)
if ($line -and $line -notmatch '^\s*[\{\[\}\]"]') {
Write-Host $line -ForegroundColor DarkYellow
[void]$textBuilder.AppendLine($line)
}
}
}
$outputString = $textBuilder.ToString()
# Check for 529 overloaded error
if ($outputString -match "529.*overloaded|overloaded_error") {
$apiOverloaded = $true
$retryCount++
if ($retryCount -ge $maxRetries) {
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
}
}
# Check for usage limit with cooldown (e.g. "Usage limit reached. Reset at 3 pm")
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
$resetHour = [int]$Matches[1]
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
$resetAmPm = $Matches[3]
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
$now = Get-Date
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
$resetTime = $resetTime.AddMinutes(2)
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
Write-Host ""
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
Start-Sleep -Seconds $waitSeconds
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying iteration..." -ForegroundColor Green
$apiOverloaded = $true
# Don't increment retryCount — deterministic wait, not a flaky error
}
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
$outputString | Set-Content -Path $logFile -Encoding UTF8
# Show elapsed time and tool count
$elapsed = (Get-Date) - $iterStart
Write-Host ""
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
# --- Circuit Breaker: No Progress ---
$headAfter = git rev-parse HEAD 2>$null
if ($headAfter -eq $headBefore) {
$noProgressCount++
Write-Host " [Circuit Breaker] No git commits this iteration ($noProgressCount/$MaxNoProgress)" -ForegroundColor DarkYellow
if ($noProgressCount -ge $MaxNoProgress) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
Write-Host "No git commits for $MaxNoProgress consecutive iterations. The loop is stalled." -ForegroundColor Red
Write-Host "Check progress.txt and logs/ for details on what went wrong." -ForegroundColor Red
exit 1
}
} else {
$noProgressCount = 0
}
# --- Circuit Breaker: Repeated Error ---
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
if ($errorLines) {
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
$sameErrorCount++
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
if ($sameErrorCount -ge $MaxSameError) {
Write-Host ""
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
Write-Host "Same error pattern for $MaxSameError consecutive iterations:" -ForegroundColor Red
Write-Host " $currentErrorSignature" -ForegroundColor Red
Write-Host "Check progress.txt and logs/ for details." -ForegroundColor Red
exit 1
}
} elseif ($currentErrorSignature) {
$sameErrorCount = 0
}
$lastErrorSignature = $currentErrorSignature
} else {
$sameErrorCount = 0
$lastErrorSignature = ""
}
# --- Push to Remote ---
$hasRemote = git remote 2>$null
if ($hasRemote) {
$currentBranch = git branch --show-current
git push origin $currentBranch 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host " Pushed to remote." -ForegroundColor Green
} else {
Write-Host " Push failed or no remote configured - continuing." -ForegroundColor DarkYellow
}
}
# --- Check for Completion ---
if ($outputString -match "<promise>COMPLETE</promise>") {
Write-Host ""
Write-Host "===== COMPLETE =====" -ForegroundColor Green
Write-Host "Visualization improvements finished after $i iteration(s) this run ($totalIteration total)." -ForegroundColor Green
exit 0
}
# Brief pause between iterations
Start-Sleep -Seconds 2
}
-91
View File
@@ -1,91 +0,0 @@
# Andy Charlwood
**MPharm, GPhC Registered Pharmacist**
Norwich, UK • 07795553088 • andy@charlwood.xyz
---
## Profile
Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights—from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.
---
## Core Competencies
**Technical:** Python • SQL • Power BI • JavaScript/TypeScript • Real-world data analysis • Dashboard and tool development • Algorithm design • Data pipeline development
**Healthcare Domain:** Medicines optimisation • Population health analytics • NICE technology appraisal implementation • Health economics and outcomes • Clinical pathway development • Controlled drug assurance
**Strategic & Leadership:** Budget management (£220M) • Stakeholder engagement • Pharmaceutical negotiation • Team development and training • Change management • Financial scenario modelling • Executive communication
---
## Professional Experience
### Interim Head, Population Health & Data Analysis
**NHS Norfolk & Waveney ICB** | MayNov 2025 | Norwich, England
Returned to substantive Deputy Head role following commencement of ICB-wide organisational consultation.
Led strategic delivery of population health initiatives and data-driven medicines optimisation across Norfolk & Waveney ICS, reporting to Associate Director of Pharmacy with presentation accountability to Chief Medical Officer and system-level programme boards.
- Identified and prioritised a £14.6M efficiency programme through comprehensive data analysis; achieved over-target performance by October 2025 through targeted, evidence-based interventions across the integrated care system.
- Built Python-based switching algorithm using real-world GP prescribing data to automatically identify patients on expensive drugs suitable for cost-effective alternatives—compressing months of manual analysis into 3 days, identifying 14,000 patients and £2.6M in annual savings, of which £2M is on target for delivery this financial year.
- Automated incentive scheme analysis, improving accuracy and targeting precision whilst enabling a novel GP payment system linking rewards to delivered savings; achieved 50% reduction in targeted prescribing within the first two months of deployment.
- Presented strategy, programme progress, and financial position to Chief Medical Officer on a bimonthly basis, providing evidence-based recommendations to inform executive decision-making.
- Led transformation from practice-level data to patient-level SQL analytics, enabling targeted interventions and a self-serve model for the wider team.
### Deputy Head, Population Health & Data Analysis
**NHS Norfolk & Waveney ICB** | Jul 2024Present | Norwich, England
Driving data analytics strategy for medicines optimisation, developing bespoke datasets and analytical frameworks from messy, real-world GP prescribing data to identify efficiency opportunities and address health inequalities across the integrated care system.
- Managed £220M prescribing budget with sophisticated forecasting models identifying cost pressures and enabling proactive financial planning.
- Collaborated with the ICB data engineering team to create a comprehensive medicines data table integrating all dm+d products with standardised strength calculations, morphine equivalent conversions, and Anticholinergic Burden scoring—providing a single source of truth for all medicines analytics across the system.
- Led financial scenario modelling for a system-wide DOAC switching programme, building an interactive dashboard incorporating rebate mechanics, clinician switching capacity, workforce constraints, and patent expiry timelines to quantify risk trade-offs for senior decision-makers.
- Led renegotiation of pharmaceutical rebate terms ahead of patent expiry, securing improved commercial position for the ICB.
- Supported commissioning of tirzepatide (NICE TA1026) including financial projections identifying eligible cohorts from real-world data; authored the initial executive paper advocating a primary care delivery model over a specialist provider on cost-effectiveness and accessibility grounds, driving the system's shift to a GP-led model following executive sign-off.
- Developed Python-based controlled drug monitoring system calculating oral morphine equivalents across all opioid prescriptions to track patient-level exposure over time, identifying high-risk patients and potential diversion—enabling previously impossible patient safety analysis at population scale.
- Educated colleagues on data interpretation and analytics best practices, improving data fluency across the team through training, documentation, and self-serve tools.
### High-Cost Drugs & Interface Pharmacist
**NHS Norfolk & Waveney ICB** | May 2022Jul 2024 | Norwich, England
Led implementation of NICE technology appraisals and high-cost drug pathways across the ICS. Wrote most of the system's high-cost drug pathways—spanning rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, and migraine—balancing legal requirements to implement TAs against financial costs and local clinical preferences. Engaged clinical leads across all sectors of care to agree pathways and secure system-wide adoption.
- Developed software automating Blueteq prior approval form creation: 70% reduction in required forms, 200 hours immediate savings, and ongoing 78 hours weekly efficiency gains.
- Integrated Blueteq data with secondary care activity databases, resolving critical data-matching limitations and enabling accurate high-cost drug to spend tracking.
- Created Python-based Sankey chart analysis tool visualising patient journeys through high-cost drug pathways, enabling trusts to audit compliance and identify improvement opportunities.
### Pharmacy Manager
**Tesco PLC** | Nov 2017May 2022 | Great Yarmouth, Norfolk
Managed all pharmacy operations with full autonomy across a 100-hour contract, leading regional KPI delivery initiatives and contributing to national operational improvements. Served as Local Pharmaceutical Committee representative for Norfolk.
- Identified and shared an asthma screening process that was adopted nationally across the Tesco pharmacy estate (~300 branches), reducing pharmacist time from approximately 60 hours to 6 hours per store per month and enabling the network to claim approximately £1M in revenue.
- Led creation of national induction training plan and eLearning modules for all new pharmacy staff, with enhanced focus on leadership development for non-pharmacist team members.
- Supervised two staff members through NVQ3 qualifications to pharmacy technician registration: full HR responsibilities including recruitment, performance management, and grievances.
---
## Education, Professional Development & Registration
**Master of Pharmacy (MPharm), 2:1 Honours** | University of East Anglia | 20112015
Research project on drug delivery and cocrystals: 75.1% (Distinction)
**NHS Leadership Academy Mary Seacole Programme** | 2018 | 78%
NHS leadership qualification: change management, healthcare leadership, system-level thinking
**A-Levels:** Mathematics (A\*), Chemistry (B), Politics (C) | Highworth Grammar School | 20092011
**GPhC Registered Pharmacist** | General Pharmaceutical Council | August 2016Present
---
*References available upon request.*
Submodule References/ECGVideo deleted from a5855fc226
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{ ignores: ['dist', 'dist-server', 'server.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
+15 -3
View File
@@ -2,12 +2,24 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Andy Charlwood — MPharm | CV</title>
<meta name="description" content="Andy Charlwood — Deputy Head of Population Health & Data Analysis. Interactive CV and portfolio showcasing pharmacist expertise, data analytics, and population health management.">
<meta property="og:title" content="CVMIS: CHARLWOOD, A.">
<meta property="og:description" content="Interactive CV presented as a clinical management information system. Explore Andy Charlwood's career in pharmacy, population health, and data analytics.">
<meta property="og:image" content="https://andy.charlwood.xyz/meta.png">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:url" content="https://andy.charlwood.xyz">
<meta name="twitter:image" content="https://andy.charlwood.xyz/meta.png">
<title>CVMIS: CHARLWOOD, A.</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://analytics.charlwood.xyz" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap"></noscript>
<link rel="preload" href="/fonts/elvaro/TBJElvaro-Regular.woff2" as="font" type="font/woff2" crossorigin>
<script defer src="https://analytics.charlwood.xyz/script.js" data-website-id="075e79d5-433a-4192-91c0-0b5b9c4334ab"></script>
</head>
<body>
<div id="root"></div>
+3958 -15
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -4,20 +4,37 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "concurrently \"vite\" \"npx tsx server.ts\"",
"dev:frontend": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
"benchmark": "npx tsx scripts/benchmark.ts",
"start": "node dist-server/server.js"
},
"dependencies": {
"@types/d3": "^7.4.3",
"@xenova/transformers": "^2.17.2",
"concurrently": "^9.2.1",
"d3": "^7.9.0",
"dotenv": "^17.3.1",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.0",
"framer-motion": "^11.15.0",
"fuse.js": "^7.1.0",
"lucide-react": "^0.468.0",
"nodemailer": "^6.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/express": "^5.0.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
/assets/*
Cache-Control: public, max-age=31536000, immutable
/Fonts/*
Cache-Control: public, max-age=31536000, immutable
+55
View File
@@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 300">
<defs>
<clipPath id="cp">
<rect x="250" y="50" width="100" height="225" rx="50"/>
</clipPath>
</defs>
<!-- Teal pill — fanned left: translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275) -->
<g transform="translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275)">
<g transform="translate(250,50)">
<rect width="100" height="225" rx="50" fill="#0E7A7D"/>
<g transform="translate(21,50) scale(0.6)">
<path d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38" stroke="white" stroke-width="10" stroke-linecap="butt" stroke-linejoin="miter" fill="none"/>
</g>
</g>
</g>
<!-- Green pill — fanned right: translate(10,0) translate(300,275) rotate(55) translate(-300,-275) -->
<g transform="translate(10,0) translate(300,275) rotate(55) translate(-300,-275)">
<g transform="translate(250,50)">
<rect width="100" height="225" rx="50" fill="#109E6C"/>
<g transform="translate(22.5,50) scale(0.5)">
<rect x="0" y="60" width="20" height="40" fill="white"/>
<rect x="30" y="40" width="20" height="60" fill="white"/>
<rect x="60" y="20" width="20" height="80" fill="white"/>
<rect x="90" y="0" width="20" height="100" fill="white"/>
</g>
</g>
</g>
<!-- Amber pill — center (no fan) -->
<g transform="translate(250,50)">
<rect width="100" height="225" rx="50" fill="#E38B16"/>
<g transform="translate(25,50) scale(0.6)">
<path d="M10 0 L50 30 L10 60" stroke="white" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<line x1="55" y1="65" x2="85" y2="65" stroke="white" stroke-width="10" stroke-linecap="round"/>
</g>
</g>
<!-- Blend overlays clipped to center pill -->
<g clip-path="url(#cp)">
<g transform="translate(-10,0) translate(300,275) rotate(-55) translate(-300,-275)" style="mix-blend-mode:multiply" opacity="0.3">
<g transform="translate(250,50)">
<rect width="100" height="225" rx="50" fill="#0E7A7D"/>
</g>
</g>
</g>
<g clip-path="url(#cp)">
<g transform="translate(10,0) translate(300,275) rotate(55) translate(-300,-275)" style="mix-blend-mode:multiply" opacity="0.3">
<g transform="translate(250,50)">
<rect width="100" height="225" rx="50" fill="#109E6C"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

@@ -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]"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+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%). At Tesco, he created a national induction training plan and eLearning modules, and supervised two staff through NVQ3 to pharmacy technician registration.",
"keyFacts": [
"Mary Seacole Programme",
"2018",
"78%",
"created national induction training at Tesco",
"supervised staff through NVQ3"
]
}
]
}
+454
View File
@@ -0,0 +1,454 @@
import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
// 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[]
}
// --- OpenRouter API ---
const LLM_MODEL = 'z-ai/glm-5'
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
function getApiKey(): string {
const key = process.env.VITE_OPEN_ROUTER_API_KEY
if (!key) {
throw new Error('VITE_OPEN_ROUTER_API_KEY not set. Ensure .env file exists with this key.')
}
return key
}
// Mirrors buildSystemPrompt() from src/lib/llm.ts — kept in sync manually
// because llm.ts uses import.meta.env (Vite) and window.location (browser)
function buildSystemPrompt(): string {
return `You are a helpful assistant on Andy Charlwood's portfolio website. Answer questions about Andy's professional background using ONLY the information below.
## Profile
Andy Charlwood — MPharm, GPhC Registered Pharmacist. Norwich, UK.
Healthcare leader combining clinical pharmacy with Python, SQL, and data analytics (self-taught). Leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2M people. Specialises in prescribing data at scale — financial modelling, algorithm design, pathway development. Identified efficiency programmes worth £14.6M+ through automated analysis.
## Employment Timeline (IMPORTANT)
- **NHS employment**: May 2022present (all roles at NHS Norfolk & Waveney ICB). Total NHS service: ~4 years.
- **Private sector**: Nov 2017May 2022 at Tesco PLC (community pharmacy). This was NOT NHS employment.
- GPhC registration (Aug 2016) is a professional licence, NOT an employer or NHS role.
## Career History
### [exp-interim-head-2025] Interim Head, Population Health & Data Analysis
NHS Norfolk & Waveney ICB | MayNov 2025
Led population health initiatives and data-driven medicines optimisation, reporting to Associate Director of Pharmacy with accountability to CMO.
- Identified £14.6M efficiency programme; achieved over-target performance by October 2025
- Built Python switching algorithm: real-world GP prescribing data, 14,000 patients, £2.6M annual savings (£2M on target), compressed months into 3 days
- Novel GP payment system linking rewards to savings; 50% prescribing reduction within 2 months
- Presented to CMO bimonthly; led transformation to patient-level SQL analytics
### [exp-deputy-head-2024] Deputy Head, Population Health & Data Analysis
NHS Norfolk & Waveney ICB | Jul 2024Present (substantive role)
Data analytics strategy for medicines optimisation from real-world GP prescribing data.
- Managed £220M prescribing budget with forecasting models for proactive financial planning
- Created comprehensive dm+d medicines data table: standardised strengths, morphine equivalents, Anticholinergic Burden scoring — single source of truth for all medicines analytics
- Led DOAC switching financial modelling: interactive dashboard with rebate mechanics, patent expiry timelines
- Renegotiated pharmaceutical rebate terms ahead of patent expiry
- Tirzepatide commissioning (NICE TA1026): financial projections, cohort identification; authored executive paper advocating primary care model, driving system shift to GP-led delivery
- Built Python controlled drug monitoring: oral morphine equivalents across all opioid prescriptions, patient-level tracking, high-risk identification, diversion detection
- Improved team data fluency through training and self-serve tools
### [exp-high-cost-drugs-2022] High-Cost Drugs & Interface Pharmacist
NHS Norfolk & Waveney ICB | May 2022Jul 2024
Led NICE TA implementation and high-cost drug pathways across the ICS. Pathways spanning: rheumatology, ophthalmology (wet AMD, DMO, RVO), dermatology, gastroenterology, neurology, migraine.
- Blueteq automation: 70% form reduction, 200 hours immediate savings, 78 hours ongoing weekly gains
- Integrated Blueteq with secondary care databases for accurate high-cost drug spend tracking
- Python Sankey chart tool for patient pathway visualisation and trust compliance auditing
### [exp-pharmacy-manager-2017] Pharmacy Manager
Tesco PLC (private sector, NOT NHS) | Nov 2017May 2022
Community pharmacy with full operational autonomy (100-hour contract). LPC representative for Norfolk.
- Asthma screening process adopted nationally (~300 branches): reduced pharmacist time 60→6 hours/store/month, ~£1M revenue
- Leadership training: Created national induction training plan and eLearning modules for Tesco pharmacy staff
- Leadership development: Supervised two staff through NVQ3 to pharmacy technician registration; full HR responsibilities
## Projects
### [proj-inv-pharmetrics] PharMetrics Interactive Platform (2024, Live)
Real-time medicines expenditure dashboard for NHS decision-makers. Tech: Power BI, SQL, DAX. Tracks £220M prescribing budget.
### [proj-inv-switching-algorithm] Patient Switching Algorithm (2025, Complete)
Python algorithm using GP prescribing data to auto-identify patients for cost-effective alternatives. Tech: Python, Pandas, SQL. 14,000 patients, £2.6M annual savings, novel GP payment system.
### [proj-inv-blueteq-gen] Blueteq Generator (2023, Complete)
Automated Blueteq prior approval form creation. Tech: Python, SQL. 70% form reduction, 200 hours immediate savings, 78 hours ongoing weekly gains.
### [proj-inv-cd-monitoring] CD Monitoring System (2024, Complete)
Controlled drug monitoring calculating oral morphine equivalents (OME) across all opioid prescriptions. Tech: Python, SQL. Patient-level tracking, high-risk identification, diversion detection.
### [proj-inv-sankey-tool] Sankey Chart Analysis Tool (2023, Complete)
Patient journey visualisation through high-cost drug pathways. Tech: Python, Matplotlib, SQL. Trust compliance auditing.
## Education
### [edu-0] NHS Mary Seacole Programme (2018)
NHS Leadership Academy. Score: 78%. Covers change management, healthcare leadership, system-level thinking.
### [edu-1] MPharm (Hons) 2:1 — University of East Anglia (20112015)
4-year integrated Master's degree. Research project on drug delivery and cocrystals: 75.1% (Distinction).
### [edu-2] A-Levels — Highworth Grammar School (20092011)
Mathematics A*, Chemistry B, Politics C.
### [edu-3] GPhC Registration — General Pharmaceutical Council (August 2016Present)
Professional registration required to practise as a pharmacist in Great Britain.
## Skills
Technical: [skill-data-analysis] Data Analysis (9yr, 95%), [skill-python] Python (6yr, 90%), [skill-sql] SQL (7yr, 88%), [skill-power-bi] Power BI (5yr, 92%), [skill-javascript-typescript] JavaScript/TypeScript (3yr, 70%), [skill-excel] Excel (9yr, 85%), [skill-algorithm-design] Algorithm Design (3yr, 82%), [skill-data-pipelines] Data Pipelines (2yr, 75%)
Domain: [skill-medicines-optimisation] Medicines Optimisation (9yr, 95%), [skill-population-health] Population Health (3yr, 90%), [skill-nice-ta] NICE TA Implementation (3yr, 92%), [skill-health-economics] Health Economics (3yr, 80%), [skill-clinical-pathways] Clinical Pathways (3yr, 88%), [skill-controlled-drugs] Controlled Drugs (1yr, 85%)
Leadership: [skill-budget-management] Budget Management (1yr, 90%), [skill-stakeholder-engagement] Stakeholder Engagement (3yr, 88%), [skill-pharma-negotiation] Pharmaceutical Negotiation (1yr, 82%), [skill-team-development] Team Development (8yr, 85%), [skill-change-management] Change Management (7yr, 80%), [skill-financial-modelling] Financial Modelling (1yr, 78%), [skill-executive-comms] Executive Communication (1yr, 85%)
## Response Rules
1. Answer ONLY from the data above. If the answer is not in the data, say "I don't have that information" — never invent facts, roles, dates, achievements, URLs, or contact details.
2. Distinguish NHS employment (May 2022present, ~4 years, all at Norfolk & Waveney ICB) from private sector (Tesco PLC, Nov 2017May 2022, community pharmacy). Never conflate the two. GPhC registration is a professional licence, not NHS employment.
3. When asked broad questions about tools, skills, projects, or achievements across Andy's career, aggregate from ALL roles — do not limit your answer to one position.
4. Cite exact numbers, dates, percentages, and outcomes. Never say "approximately" or "around" when exact figures exist in the data.
5. For detailed or list-based questions, give a thorough answer covering all relevant items. For simple questions, be concise (2-4 sentences).
## Item References
End your response with a single line listing relevant item IDs from the square-bracketed IDs above:
[ITEMS: exp-deputy-head-2024, skill-python]
Only include IDs that directly support your answer. Omit the line if none are relevant.`
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callLLM(
systemPrompt: string,
userMessage: string,
temperature = 0.4,
maxTokens = 800,
): Promise<string> {
const apiKey = getApiKey()
const maxRetries = 5
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': 'https://andycharlwood.co.uk',
'X-Title': 'Andy Charlwood Portfolio',
},
body: JSON.stringify({
model: LLM_MODEL,
temperature,
max_tokens: maxTokens,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
}),
})
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(`OpenRouter API error ${response.status}: ${errorBody}`)
}
const data = await response.json()
const text = data?.choices?.[0]?.message?.content
if (!text) {
throw new Error(`No text in OpenRouter 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 callLLM(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 llm.ts)
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 LLM
console.log(' Getting answer...')
const actualAnswer = await callLLM(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: LLM_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)
})
+166
View File
@@ -0,0 +1,166 @@
import 'dotenv/config'
import express from 'express'
import nodemailer from 'nodemailer'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
app.use(express.json())
// Serve static files from Vite build (dist/ is at project root, one level up from dist-server/)
app.use(express.static(path.join(__dirname, '..', 'dist')))
// Contact API endpoint
app.post('/api/contact', async (req, res) => {
const { name, organisation, email, subject, message } = req.body
if (!name || !email || !subject || !message) {
return res.status(400).json({ success: false, message: 'All fields are required' })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
return res.status(400).json({ success: false, message: 'Invalid email address' })
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz'
try {
// Admin notification
await transporter.sendMail({
from: `"${name}" <${process.env.SMTP_USER}>`,
replyTo: email,
to: contactEmail,
subject: `Portfolio Referral: ${subject}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
New Patient Referral
</h2>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Referring Clinician:</strong> ${name}</p>
<p><strong>Organisation:</strong> ${organisation || 'Not specified'}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Subject:</strong> ${subject}</p>
</div>
<div style="padding: 20px 0;">
<h3 style="color: #333;">Clinical Details:</h3>
<p style="white-space: pre-wrap; line-height: 1.6;">${message}</p>
</div>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">
This message was sent from your portfolio contact form.
</p>
</div>
`,
})
// Auto-reply
await transporter.sendMail({
from: `"Andy Charlwood" <${process.env.SMTP_USER}>`,
to: email,
subject: 'Thanks for getting in touch!',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #00897B; border-bottom: 2px solid #00897B; padding-bottom: 10px;">
Thanks for your message, ${name}!
</h2>
<p style="line-height: 1.6;">
I've received your referral and will get back to you as soon as possible.
</p>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Your message:</strong></p>
<p style="white-space: pre-wrap; color: #555;">${message}</p>
</div>
<p style="line-height: 1.6;">
Best regards,<br/>
<strong>Andy Charlwood</strong><br/>
Informatics Pharmacist
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
<p style="color: #666; font-size: 12px;">
This is an automated confirmation. Please do not reply to this email.
</p>
</div>
`,
})
return res.status(200).json({ success: true, message: 'Referral sent successfully!' })
} catch (error) {
console.error('Email error:', error)
return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' })
}
})
// Chat proxy endpoint — keeps API key server-side
app.post('/api/chat', async (req, res) => {
const apiKey = process.env.OPEN_ROUTER_API_KEY
if (!apiKey) {
return res.status(500).json({ error: 'LLM API key not configured' })
}
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': req.headers.origin || req.headers.referer || '',
'X-Title': 'Andy Charlwood Portfolio',
},
body: JSON.stringify(req.body),
})
if (!response.ok) {
return res.status(response.status).json({ error: `LLM API error: ${response.status}` })
}
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
const reader = response.body?.getReader()
if (!reader) {
return res.status(500).json({ error: 'No response body' })
}
const pump = async () => {
while (true) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
res.end()
}
await pump()
} catch (error) {
console.error('Chat proxy error:', error)
if (!res.headersSent) {
return res.status(500).json({ error: 'Failed to proxy chat request' })
}
res.end()
}
})
// SPA fallback
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'))
})
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
+86 -31
View File
@@ -1,47 +1,102 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import type { Phase } from './types'
import { BootSequence } from './components/BootSequence'
import { ECGAnimation } from './components/ECGAnimation'
import { FloatingNav } from './components/FloatingNav'
import { Hero } from './components/Hero'
import { Skills } from './components/Skills'
import { Experience } from './components/Experience'
import { Education } from './components/Education'
import { Projects } from './components/Projects'
import { Contact } from './components/Contact'
import { LoginScreen } from './components/LoginScreen'
import { DashboardLayout } from './components/DashboardLayout'
import { AccessibilityProvider } from './contexts/AccessibilityContext'
import { DetailPanelProvider } from './contexts/DetailPanelContext'
import { initModel } from './lib/embedding-model'
function App() {
const [phase, setPhase] = useState<Phase>('boot')
function SkipButton({ onSkip }: { onSkip: () => void }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setVisible(true), 1500)
return () => clearTimeout(timer)
}, [])
return (
<div className="min-h-screen bg-white">
<button
onClick={onSkip}
aria-label="Skip intro animation"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] px-4 py-1.5 text-xs tracking-widest uppercase font-mono border rounded transition-all duration-700 cursor-pointer select-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
style={{
color: '#555',
borderColor: '#333',
backgroundColor: 'rgba(255,255,255,0.03)',
opacity: visible ? 1 : 0,
pointerEvents: visible ? 'auto' : 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#888'
e.currentTarget.style.borderColor = '#555'
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#555'
e.currentTarget.style.borderColor = '#333'
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.03)'
}}
>
Skip
</button>
)
}
function App() {
const [phase, setPhase] = useState<Phase>(() => {
if (typeof window !== 'undefined') {
const visitedAt = sessionStorage.getItem('portfolio-visited')
if (visitedAt && Date.now() - Number(visitedAt) < 60 * 60 * 1000) {
return 'pmr'
}
sessionStorage.removeItem('portfolio-visited')
}
return 'boot'
})
useEffect(() => {
if (phase === 'login' || phase === 'pmr') {
initModel()
}
if (phase === 'pmr') {
sessionStorage.setItem('portfolio-visited', String(Date.now()))
}
}, [phase])
const skipToDashboard = () => setPhase('pmr')
return (
<AccessibilityProvider>
<div className="min-h-screen bg-black">
{/* Screen reader announcement for PMR phase */}
{phase === 'pmr' && (
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
Patient Record for Charlwood, Andrew. Summary view.
</div>
)}
{phase === 'boot' && (
<BootSequence onComplete={() => setPhase('ecg')} />
<BootSequence
onComplete={() => setPhase('login')}
/>
)}
{phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('content')} />
{(phase === 'login' || phase === 'pmr') && (
<DetailPanelProvider>
<DashboardLayout />
</DetailPanelProvider>
)}
{phase === 'content' && (
<>
<FloatingNav />
<main className="max-w-[1000px] mx-auto px-8">
<Hero />
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
<Skills />
<Experience />
<Education />
<Projects />
<Contact />
</main>
</>
{(phase === 'boot' || phase === 'login') && (
<SkipButton onSkip={skipToDashboard} />
)}
</div>
</AccessibilityProvider>
)
}
+534 -58
View File
@@ -1,95 +1,571 @@
import { useEffect, useLayoutEffect, useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useEffect, useState } from 'react'
// =============================================================================
// Types
// =============================================================================
type BootLineType = 'header' | 'status' | 'separator' | 'field' | 'module' | 'ready'
type BootLineStyle = 'bright' | 'dim' | 'cyan'
interface BootLine {
html: string
delay: number
type: BootLineType
text?: string
label?: string
value?: string
style?: BootLineStyle
}
const bootLines: BootLine[] = [
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk &amp; Waveney ICB</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health &amp; Data Analysis</span>', delay: 220 },
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">pharmacist_core.sys</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">population_health.mod</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
{ html: '<span class="text-[#00ff41] font-bold">&gt; READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
]
interface BootConfig {
header: string
lines: BootLine[]
timing: {
lineDelay: number
cursorBlinkInterval: number
holdAfterComplete: number
loadingDuration: number
fadeOutDuration: number
cursorShrinkDuration: number
}
colors: {
bright: string
dim: string
cyan: string
}
}
interface BootSequenceProps {
onComplete: () => void
}
export function BootSequence({ onComplete }: BootSequenceProps) {
const [isVisible, setIsVisible] = useState(true)
const [lineDelays, setLineDelays] = useState<number[]>([])
interface TypedSegment {
text: string
color: string
bold?: boolean
isSeedDot?: boolean
}
interface TypedLine {
segments: TypedSegment[]
totalChars: number
pauseAfter: number // ms to pause after this line completes
speed: number // ms per character (0 = instant)
}
// =============================================================================
// Configuration
// =============================================================================
// Global speed multiplier for typing animation.
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
const TYPING_SPEED = 1.0
const COLORS = {
bright: '#00ff41',
dim: '#3a6b45',
cyan: '#00e5ff',
}
const BOOT_CONFIG: BootConfig = {
header: 'CV Management Information System v1.0.0',
lines: [
{ type: 'status', text: 'Initialising pharmacist profile...', style: 'dim' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'field', label: 'SYSTEM', value: 'NHS Norfolk & Waveney ICB', style: 'cyan' },
{ type: 'field', label: 'USER', value: 'Andy Charlwood', style: 'bright' },
{ type: 'field', label: 'ROLE', value: 'Deputy Head of Population Health & Data Analysis', style: 'bright' },
{ type: 'field', label: 'LOCATION', value: 'Norwich, UK', style: 'bright' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'status', text: 'Loading modules...', style: 'dim' },
{ type: 'module', text: 'pharmacist_core.sys', style: 'dim' },
{ type: 'module', text: 'population_health.mod', style: 'dim' },
{ type: 'module', text: 'data_analytics.eng', style: 'dim' },
{ type: 'separator', text: '---', style: 'dim' },
{ type: 'ready', text: 'READY \u2014 Launching CV..', style: 'bright' },
],
timing: {
lineDelay: 220,
cursorBlinkInterval: 300,
holdAfterComplete: 1000,
loadingDuration: 2000,
fadeOutDuration: 500,
cursorShrinkDuration: 400,
},
colors: COLORS,
}
// Apply speed multiplier — instant lines (speed=0) stay instant
function s(ms: number): number {
return Math.round(ms * TYPING_SPEED)
}
// Build typed lines from BOOT_CONFIG
function buildTypedLines(): TypedLine[] {
const lines: TypedLine[] = []
// Header
const headerText = BOOT_CONFIG.header
lines.push({
segments: [{ text: headerText, color: COLORS.bright, bold: true }],
totalChars: headerText.length,
pauseAfter: s(40),
speed: s(18),
})
for (const line of BOOT_CONFIG.lines) {
switch (line.type) {
case 'status': {
const text = line.text || ''
lines.push({
segments: [{ text, color: COLORS.dim }],
totalChars: text.length,
pauseAfter: s(40),
speed: s(14),
})
break
}
case 'separator': {
const text = line.text || '---'
lines.push({
segments: [{ text, color: COLORS.dim }],
totalChars: text.length,
pauseAfter: s(50),
speed: 0, // instant
})
break
}
case 'field': {
const label = (line.label || '').padEnd(9)
const value = line.value || ''
const valueColor = line.style === 'cyan' ? COLORS.cyan : COLORS.bright
lines.push({
segments: [
{ text: label, color: COLORS.cyan },
{ text: value, color: valueColor },
],
totalChars: label.length + value.length,
pauseAfter: s(30),
speed: s(10),
})
break
}
case 'module': {
const prefix = '[OK] '
const name = line.text || ''
lines.push({
segments: [
{ text: '[OK]', color: COLORS.bright, bold: true },
{ text: ' ', color: COLORS.dim },
{ text: name, color: COLORS.dim },
],
totalChars: prefix.length + name.length,
pauseAfter: s(50),
speed: 0, // instant — stdout output
})
break
}
case 'ready': {
const prefix = '> '
const body = line.text || ''
const seedDot = '.'
lines.push({
segments: [
{ text: prefix + body, color: COLORS.bright, bold: true },
{ text: seedDot, color: COLORS.bright, bold: true, isSeedDot: true },
],
totalChars: prefix.length + body.length + seedDot.length,
pauseAfter: 0,
speed: s(16),
})
break
}
}
}
return lines
}
const TYPED_LINES = buildTypedLines()
const TOTAL_CHARS = TYPED_LINES.reduce((sum, l) => sum + l.totalChars, 0)
// =============================================================================
// ASCII Loading Screen Component
// =============================================================================
function LoadingBar({ active }: { active: boolean }) {
const [progress, setProgress] = useState(0)
useEffect(() => {
const delays: number[] = []
let totalDelay = 0
bootLines.forEach((line) => {
delays.push(totalDelay)
totalDelay += line.delay
})
setLineDelays(delays)
if (!active) return
const start = performance.now()
let raf: number
const totalBootTime = totalDelay
const fadeStartTime = totalBootTime + 400
const tick = (now: number) => {
const elapsed = now - start
const pct = Math.min(elapsed / BOOT_CONFIG.timing.loadingDuration, 1)
setProgress(1 - Math.pow(1 - pct, 2.5))
if (pct < 1) raf = requestAnimationFrame(tick)
}
const fadeTimer = setTimeout(() => {
setIsVisible(false)
}, fadeStartTime)
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [active])
const completeTimer = setTimeout(() => {
onComplete()
}, fadeStartTime + 800)
return (
<div
style={{
width: 'calc(100vw - 48px)',
position: 'relative',
overflow: 'hidden',
height: '1.2em',
fontFamily: 'monospace',
fontSize: 14,
letterSpacing: '0.02em',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
color: `${COLORS.bright}30`,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{'\u2591'.repeat(500)}
</div>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${progress * 100}%`,
color: COLORS.bright,
overflow: 'hidden',
whiteSpace: 'nowrap',
textShadow: `0 0 4px ${COLORS.bright}30`,
}}
>
{'\u2588'.repeat(500)}
</div>
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function BootSequence({ onComplete }: BootSequenceProps) {
const [typedCount, setTypedCount] = useState(0)
const [phase, setPhase] = useState<'typing' | 'holding' | 'loading' | 'fading' | 'done'>('typing')
const [isVisible, setIsVisible] = useState(true)
const cursorAnchorRef = useRef<HTMLSpanElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [cursorPos, setCursorPos] = useState<{ left: number; top: number } | null>(null)
const reducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Typing engine — runs as a self-scheduling setTimeout chain
useEffect(() => {
if (reducedMotion || phase !== 'typing') return
// All characters typed
if (typedCount >= TOTAL_CHARS) {
setPhase('holding')
return
}
// Find which line the cursor is on and position within it
let lineStart = 0
let lineIdx = 0
for (let i = 0; i < TYPED_LINES.length; i++) {
if (lineStart + TYPED_LINES[i].totalChars > typedCount) {
lineIdx = i
break
}
lineStart += TYPED_LINES[i].totalChars
}
const line = TYPED_LINES[lineIdx]
const posInLine = typedCount - lineStart
if (posInLine === 0 && line.speed === 0) {
// Instant line: show all chars at once after a brief pause
timeoutRef.current = setTimeout(() => {
setTypedCount(lineStart + line.totalChars)
}, line.pauseAfter || 10)
} else if (posInLine === 0 && lineIdx > 0) {
// Start of a new typed line — apply previous line's pauseAfter
timeoutRef.current = setTimeout(() => {
setTypedCount(prev => prev + 1)
}, TYPED_LINES[lineIdx - 1].pauseAfter)
} else {
// Type one character at the line's speed
timeoutRef.current = setTimeout(() => {
setTypedCount(prev => prev + 1)
}, line.speed)
}
return () => {
clearTimeout(fadeTimer)
clearTimeout(completeTimer)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [typedCount, phase, reducedMotion])
// Hold phase → loading
useEffect(() => {
if (phase !== 'holding') return
const timer = setTimeout(() => {
setPhase('loading')
}, BOOT_CONFIG.timing.holdAfterComplete)
return () => clearTimeout(timer)
}, [phase])
// Loading phase → fading (after progress bar completes)
useEffect(() => {
if (phase !== 'loading') return
const timer = setTimeout(() => {
setPhase('fading')
}, BOOT_CONFIG.timing.loadingDuration + 100)
return () => clearTimeout(timer)
}, [phase])
// Fade phase: notify parent immediately so login can mount alongside fade
useEffect(() => {
if (phase !== 'fading') return
onComplete()
const hideTimer = setTimeout(() => {
setIsVisible(false)
setPhase('done')
}, BOOT_CONFIG.timing.fadeOutDuration)
return () => clearTimeout(hideTimer)
}, [phase, onComplete])
// Reduced motion: skip animation
useEffect(() => {
if (!reducedMotion) return
const timer = setTimeout(onComplete, 500)
return () => clearTimeout(timer)
}, [reducedMotion, onComplete])
// Track cursor anchor position relative to the content container
useLayoutEffect(() => {
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
const anchor = cursorAnchorRef.current.getBoundingClientRect()
const container = containerRef.current.getBoundingClientRect()
setCursorPos({
left: anchor.left - container.left,
top: anchor.top - container.top,
})
}, [typedCount, phase])
// Render the typed lines up to typedCount
const renderLines = () => {
let remaining = typedCount
const renderedLines: React.ReactNode[] = []
let cursorPlaced = false
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
const line = TYPED_LINES[lineIdx]
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
remaining -= charsForLine
// During typing: cursor inline on the line being typed
// During holding/loading: cursor handled after the loop (on a new line)
const isCursorLine = phase === 'typing'
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
: false
// Render segments
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
const spans: React.ReactNode[] = []
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
const seg = line.segments[segIdx]
if (charBudget <= 0 && phase === 'typing') break
const visibleChars = phase === 'typing'
? Math.min(charBudget, seg.text.length)
: seg.text.length
const visibleText = seg.text.slice(0, visibleChars)
charBudget -= visibleChars
if (seg.isSeedDot && visibleChars > 0) {
spans.push(
<span
key={segIdx}
className={phase === 'holding' ? 'boot-seed-dot animate-seed-pulse' : 'boot-seed-dot'}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{visibleText}
</span>
)
} else if (visibleChars > 0) {
spans.push(
<span
key={segIdx}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{visibleText}
</span>
)
}
}
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
if (isCursorLine && phase !== 'done') {
cursorPlaced = true
spans.push(
<span
key="cursor-anchor"
ref={cursorAnchorRef}
className="inline-block align-middle"
style={{ width: 8, height: 16, marginLeft: 1 }}
/>
)
}
renderedLines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
{spans}
</div>
)
}
// After typing completes: cursor on new line, or loading bar replacing it
if (phase === 'holding') {
renderedLines.push(
<div key="cursor-line" className="font-mono text-sm leading-relaxed">
<span
ref={cursorAnchorRef}
className="inline-block align-middle"
style={{ width: 8, height: 16 }}
/>
</div>
)
} else if (phase === 'loading' || phase === 'fading') {
renderedLines.push(
<div key="bar-line" style={{ marginTop: 4 }}>
<LoadingBar active={phase === 'loading'} />
</div>
)
}
return renderedLines
}
const isFadingOut = phase === 'fading' || phase === 'done'
// Reduced motion: instant render
if (reducedMotion) {
return (
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden">
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
{(() => {
// Render all lines fully
const lines: React.ReactNode[] = []
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
const line = TYPED_LINES[lineIdx]
const spans: React.ReactNode[] = []
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
const seg = line.segments[segIdx]
spans.push(
<span
key={segIdx}
className={seg.isSeedDot ? 'boot-seed-dot' : undefined}
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
>
{seg.text}
</span>
)
}
lines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
{spans}
</div>
)
}
return lines
})()}
</div>
</div>
)
}
}, [onComplete])
return (
<AnimatePresence>
{isVisible && (
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-center bg-black p-10 font-mono text-sm overflow-hidden"
className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
>
<div className="flex flex-col gap-1 max-w-[640px]">
{bootLines.map((line, index) => (
{/* CRT Scanlines */}
<motion.div
key={index}
className="whitespace-nowrap leading-relaxed"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: lineDelays[index] / 1000,
duration: 0.4,
ease: 'easeOut',
className="absolute inset-0 pointer-events-none"
animate={{ opacity: isFadingOut ? 0 : 1 }}
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
style={{
background: `repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15) 0px,
transparent 1px,
transparent 2px,
rgba(0, 0, 0, 0.15) 3px
)`,
}}
dangerouslySetInnerHTML={{ __html: line.html }}
/>
))}
{/* Content container — text always visible, bar appears below during loading */}
<div ref={containerRef} className="flex flex-col gap-1 transform -translate-y-1/2 relative z-10">
<motion.div
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
animate={{
opacity: isFadingOut ? 0 : 1,
y: isFadingOut ? -20 : 0,
}}
transition={{
duration: BOOT_CONFIG.timing.fadeOutDuration / 1000,
ease: 'easeIn',
}}
>
{renderLines()}
</motion.div>
{/* Cursor — blinks during typing/holding, hidden when bar takes over */}
{cursorPos && phase !== 'loading' && !isFadingOut && (
<span
className="absolute animate-blink"
style={{
left: cursorPos.left,
top: cursorPos.top,
width: 8,
height: 16,
backgroundColor: COLORS.bright,
animationDuration: `${BOOT_CONFIG.timing.cursorBlinkInterval}ms`,
}}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+88
View File
@@ -0,0 +1,88 @@
import React from 'react'
import { DOT_COLORS } from '@/lib/theme-colors'
interface CardProps {
children: React.ReactNode
full?: boolean // spans both grid columns
className?: string
tileId?: string // data-tile-id for command palette scroll targeting
}
export function Card({ children, full, className, tileId }: CardProps) {
const [isHovered, setIsHovered] = React.useState(false)
const baseStyles: React.CSSProperties = {
background: 'var(--surface)',
border: isHovered
? '1px solid var(--border)'
: '1px solid var(--border-light)',
borderRadius: 'var(--radius)',
padding: '24px',
boxShadow: isHovered ? 'var(--shadow-md)' : 'var(--shadow-sm)',
transition: 'box-shadow 0.2s, border-color 0.2s',
gridColumn: full ? '1 / -1' : undefined,
minWidth: 0,
overflow: 'hidden',
}
return (
<article
style={baseStyles}
className={['card-base', className].filter(Boolean).join(' ')}
data-tile-id={tileId}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</article>
)
}
export interface CardHeaderProps {
dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple'
title: string
rightText?: string
}
export function CardHeader({ dotColor, title, rightText }: CardHeaderProps) {
const headerStyles: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '18px',
}
const dotStyles: React.CSSProperties = {
width: '9px',
height: '9px',
borderRadius: '50%',
backgroundColor: DOT_COLORS[dotColor],
flexShrink: 0,
}
const titleStyles: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-secondary)',
}
const rightTextStyles: React.CSSProperties = {
fontSize: '11px',
fontWeight: 400,
textTransform: 'none',
letterSpacing: 'normal',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
marginLeft: 'auto',
}
return (
<div style={headerStyles}>
<div style={dotStyles} aria-hidden="true" />
<span style={titleStyles}>{title}</span>
{rightText && <span style={rightTextStyles}>{rightText}</span>}
</div>
)
}
+813
View File
@@ -0,0 +1,813 @@
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 ReactMarkdown from 'react-markdown'
import {
sendChatMessage,
isLLMAvailable,
parseItemIds,
stripItemsSuffix,
LLM_DISPLAY_NAME,
type ChatMessage,
} from '@/lib/llm'
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
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: motionSafeTransition(0.3, 'easeOut', 1),
},
}
const panelVariants = {
hidden: prefersReducedMotion
? { opacity: 1, scale: 1 }
: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: motionSafeTransition(0.2),
},
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 isMobileNav = useIsMobileNav()
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set())
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const llmAvailable = isLLMAvailable()
// Nudge bubble: show once after 12s if user hasn't opened chat yet
const [showNudge, setShowNudge] = useState(false)
const hasInteracted = useRef(false)
useEffect(() => {
const timer = setTimeout(() => {
if (!hasInteracted.current) setShowNudge(true)
}, 5_000)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (!showNudge) return
const dismiss = () => {
hasInteracted.current = true
setShowNudge(false)
}
window.addEventListener('click', dismiss, { once: true })
return () => window.removeEventListener('click', dismiss)
}, [showNudge])
// 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"
data-chat-panel
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 lg:bottom-[100px] xl:bottom-[112px]"
style={{
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: clamp(380px, 30vw, 500px); height: calc(66vh); }
}
@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
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
}}
>
{/* 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)',
}}
>
{LLM_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"
>
{!llmAvailable && (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: 1.5,
}}
>
Chat is currently unavailable.
</div>
)}
{llmAvailable && 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: msg.role === 'user' ? 'pre-wrap' : undefined }}>
{msg.role === 'assistant' ? (
<div className="chat-markdown">
<ReactMarkdown>{getDisplayText(msg.content)}</ReactMarkdown>
</div>
) : (
getDisplayText(msg.content)
)}
</div>
{referencedItems.length > 0 && (() => {
const isExpanded = expandedItems.has(i)
const visibleItems = isExpanded ? referencedItems : referencedItems.slice(0, 3)
const hasMore = referencedItems.length > 3
return (
<div
style={{
borderTop: '1px solid var(--border-light)',
padding: '6px 8px',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
{visibleItems.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>
)
})}
{hasMore && !isExpanded && (
<button
onClick={() => setExpandedItems((prev) => new Set(prev).add(i))}
style={{
padding: '5px 8px',
borderRadius: '6px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '11.5px',
fontFamily: 'inherit',
color: 'var(--accent)',
textAlign: 'left',
transition: 'background-color 100ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
See {referencedItems.length - 3} more related items
</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 */}
{llmAvailable && (
<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={() => {
hasInteracted.current = true
setShowNudge(false)
setIsOpen((prev) => !prev)
}}
aria-label={isOpen ? 'Close chat' : 'Open chat'}
className={`fixed z-[101] cursor-pointer flex items-center justify-center bottom-4 right-4 h-12 w-12 md:bottom-6 md:right-6 md:h-14 md:w-14 lg:h-16 lg:w-16 xl:h-[4.5rem] xl:w-[4.5rem]${isOpen ? ' max-md:!hidden' : ''}`}
style={{
bottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
borderRadius: '50%',
border: 'none',
background: 'var(--accent)',
opacity: 0.85,
color: '#FFFFFF',
boxShadow: 'var(--shadow-md)',
animation: prefersReducedMotion ? 'none' : 'chat-pulse 3s ease-in-out infinite',
transition: 'box-shadow 150ms ease-out, transform 150ms ease-out, opacity 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = 'var(--shadow-lg)'
e.currentTarget.style.transform = 'scale(1.05)'
e.currentTarget.style.opacity = '1'
e.currentTarget.style.animation = 'none'
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.opacity = '0.85'
e.currentTarget.style.animation = prefersReducedMotion ? 'none' : 'chat-pulse 3s ease-in-out infinite'
}}
>
{isOpen ? (
<>
<X size={22} strokeWidth={2} className="lg:hidden" />
<X size={26} strokeWidth={2} className="hidden lg:block xl:hidden" />
<X size={30} strokeWidth={2} className="hidden xl:block" />
</>
) : (
<>
<MessageCircle size={22} strokeWidth={2} className="lg:hidden" />
<MessageCircle size={26} strokeWidth={2} className="hidden lg:block xl:hidden" />
<MessageCircle size={30} strokeWidth={2} className="hidden xl:block" />
</>
)}
</motion.button>
{/* Nudge bubble */}
<AnimatePresence>
{showNudge && !isOpen && (
<motion.div
initial={prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0, transition: motionSafeTransition(0.25, 'easeOut') }}
exit={{ opacity: 0, transition: { duration: 0 } }}
className="fixed z-[101] right-4 md:right-6 pointer-events-none"
style={{
/* Position above button: button-bottom + button-height + gap */
bottom: isMobileNav
? 'calc(56px + env(safe-area-inset-bottom) + 72px)'
: undefined,
}}
>
{/* Mobile: above 48px button at bottom-4 */}
<div
className="md:hidden px-3 py-2 rounded-xl text-xs font-medium max-w-[200px]"
style={{
position: 'fixed',
bottom: isMobileNav
? 'calc(56px + env(safe-area-inset-bottom) + 16px + 48px + 10px)'
: 'calc(16px + 48px + 10px)',
right: '16px',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-md)',
}}
>
Hey! I can help you learn more about Andy.
</div>
{/* md: above 56px button at bottom-6 */}
<div
className="hidden md:block lg:hidden px-3.5 py-2.5 rounded-xl text-sm font-medium max-w-[240px]"
style={{
position: 'fixed',
bottom: 'calc(24px + 56px + 10px)',
right: '24px',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-md)',
}}
>
Hey! I can help you learn more about Andy.
</div>
{/* lg: above 64px button */}
<div
className="hidden lg:block xl:hidden px-4 py-3 rounded-xl text-base font-medium max-w-[280px]"
style={{
position: 'fixed',
bottom: 'calc(24px + 64px + 12px)',
right: '24px',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-md)',
}}
>
Hey! I can help you learn more about Andy.
</div>
{/* xl: above 72px button */}
<div
className="hidden xl:block px-5 py-3 rounded-2xl text-base font-medium max-w-[300px]"
style={{
position: 'fixed',
bottom: 'calc(24px + 72px + 14px)',
right: '24px',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-md)',
}}
>
Hey! I can help you learn more about Andy.
</div>
</motion.div>
)}
</AnimatePresence>
{/* Spinner keyframes */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes chat-pulse {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.06); opacity: 0.85; }
}
`}</style>
</>
)
}
+455
View File
@@ -0,0 +1,455 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Search } from 'lucide-react'
import {
buildPaletteData,
buildSearchIndex,
groupBySection,
} 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'
import { prefersReducedMotion } from '@/lib/utils'
interface CommandPaletteProps {
isOpen: boolean
onClose: () => void
onAction?: (action: PaletteAction) => void
}
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
// Build data and search index once
const paletteData = useMemo(() => buildPaletteData(), [])
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
// 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(() => {
if (!query.trim()) {
return paletteData
}
if (semanticResults !== null) {
return semanticResults
}
return searchIndex.search(query).map(result => result.item)
}, [query, paletteData, searchIndex, semanticResults])
// Group visible items by section
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
// Flat list for keyboard navigation
const flatItems = useMemo(() => {
const flat: PaletteItem[] = []
for (const group of groupedResults) {
for (const item of group.items) {
flat.push(item)
}
}
return flat
}, [groupedResults])
// Reset state when opening/closing
useEffect(() => {
if (isOpen) {
setQuery('')
setSelectedIndex(-1)
setSemanticResults(null)
// Focus input on next frame
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [isOpen])
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(-1)
}, [query])
// Global Ctrl+K listener
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
if (!isOpen) {
// Parent controls isOpen, so we need onAction or an onOpen callback
// For now, the parent will handle Ctrl+K via its own listener
}
}
}
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [isOpen])
// Execute action for a palette item
const executeAction = useCallback((item: PaletteItem) => {
onClose()
if (onAction) {
onAction(item.action)
} else {
// Fallback: handle link and download actions directly
const { action } = item
if (action.type === 'link') {
window.open(action.url, '_blank', 'noopener,noreferrer')
}
}
}, [onClose, onAction])
// Keyboard navigation within the palette
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev + 1
return next >= flatItems.length ? 0 : next
})
break
}
case 'ArrowUp': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev - 1
return next < 0 ? flatItems.length - 1 : next
})
break
}
case 'Enter': {
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < flatItems.length) {
executeAction(flatItems[selectedIndex])
}
break
}
case 'Escape': {
e.preventDefault()
onClose()
break
}
}
}, [flatItems, selectedIndex, executeAction, onClose])
// Auto-scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !resultsRef.current) return
const selectedEl = resultsRef.current.querySelector(`[data-palette-index="${selectedIndex}"]`)
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex])
// Click on overlay (outside modal) to close
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
onClose()
}
}, [onClose])
if (!isOpen) return null
// Track flat index across groups
let flatIndex = 0
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-label="Command palette"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(26,43,42,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '8px',
paddingTop: 'max(8px, 10vh)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
animation: prefersReducedMotion ? 'none' : 'palette-overlay-in 0.2s ease-out forwards',
}}
onKeyDown={handleKeyDown}
>
{/* Palette modal */}
<div
className="w-full max-w-[calc(100vw-16px)] md:max-w-[calc(100vw-32px)] md:w-[580px]"
style={{
maxHeight: 'calc(100vh - 24vh)',
background: 'var(--surface)',
borderRadius: '12px',
boxShadow: '0 20px 60px rgba(26,43,42,0.2), 0 0 0 1px rgba(26,43,42,0.08)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: prefersReducedMotion ? 'none' : 'palette-modal-in 0.2s cubic-bezier(0.4,0,0.2,1) forwards',
}}
>
{/* Search input row */}
<div
className="px-3 py-3 md:px-[18px] md:py-[14px]"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
borderBottom: '1px solid var(--border-light)',
}}
>
<Search
size={18}
style={{ color: 'var(--accent)', flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search records, experience, skills..."
autoComplete="off"
className="font-ui"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: '15px',
color: 'var(--text-primary)',
}}
aria-label="Search"
aria-activedescendant={
selectedIndex >= 0 ? `palette-item-${flatItems[selectedIndex]?.id}` : undefined
}
role="combobox"
aria-expanded="true"
aria-controls="palette-results"
aria-autocomplete="list"
/>
<kbd
className="font-geist"
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '2px 7px',
borderRadius: '4px',
flexShrink: 0,
lineHeight: 1,
}}
>
ESC
</kbd>
</div>
{/* Results area */}
<div
id="palette-results"
ref={resultsRef}
role="listbox"
aria-label="Search results"
className="pmr-scrollbar p-2 md:p-[8px]"
style={{
overflowY: 'auto',
flex: 1,
}}
>
{flatItems.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
No results found for &ldquo;{query}&rdquo;
</div>
) : (
groupedResults.map((group) => {
const sectionItems = group.items.map((item) => {
const currentIndex = flatIndex
flatIndex++
const isSelected = currentIndex === selectedIndex
const IconComponent = iconByType[item.iconType]
const colorStyle = iconColorStyles[item.iconVariant]
return (
<div
key={item.id}
id={`palette-item-${item.id}`}
role="option"
aria-selected={isSelected}
data-palette-index={currentIndex}
onClick={() => executeAction(item)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '9px 10px',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'background 0.1s',
fontSize: '13px',
color: 'var(--text-primary)',
background: isSelected ? 'var(--accent-light)' : 'transparent',
outline: isSelected ? '1.5px solid var(--accent-border)' : 'none',
}}
>
{/* Icon container */}
<div
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: colorStyle.background,
color: colorStyle.color,
}}
>
{IconComponent && <IconComponent size={14} />}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{item.title}</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
marginTop: '1px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.subtitle}
</div>
</div>
</div>
)
})
return (
<div key={group.section}>
{/* Section label */}
<div
style={{
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
padding: '8px 10px 5px',
}}
>
{group.section}
</div>
{sectionItems}
</div>
)
})
)}
</div>
{/* Footer with keyboard hints */}
<div
className="hidden md:flex px-3 py-2 md:px-[18px] md:py-[10px]"
style={{
alignItems: 'center',
gap: '12px',
borderTop: '1px solid var(--border-light)',
fontSize: '11px',
color: 'var(--text-tertiary)',
}}
>
<span>
<Kbd>\u2191</Kbd> <Kbd>\u2193</Kbd> Navigate
</span>
<span>
<Kbd>Enter</Kbd> Select
</span>
<span>
<Kbd>Esc</Kbd> Close
</span>
</div>
</div>
</div>
)
}
// Small kbd element for the footer
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd
className="font-geist"
style={{
fontSize: '10px',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '1px 5px',
borderRadius: '3px',
color: 'var(--text-secondary)',
}}
>
{children}
</kbd>
)
}
-108
View File
@@ -1,108 +0,0 @@
import { motion } from 'framer-motion'
import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { ContactItem } from '@/types'
const contactData: ContactItem[] = [
{
icon: 'phone',
value: '07795553088',
label: 'Phone',
},
{
icon: 'mail',
value: 'andy@charlwood.xyz',
label: 'Email',
href: 'mailto:andy@charlwood.xyz',
},
{
icon: 'linkedin',
value: 'linkedin.com/in/andrewcharlwood',
label: 'LinkedIn',
href: 'https://linkedin.com/in/andrewcharlwood',
},
{
icon: 'mapPin',
value: 'Norwich, UK',
label: 'Location',
},
]
const iconMap = {
phone: Phone,
mail: Mail,
linkedin: Linkedin,
mapPin: MapPin,
}
const ContactItemCard = ({
item,
delay,
isVisible,
}: {
item: ContactItem
delay: number
isVisible: boolean
}) => {
const Icon = iconMap[item.icon]
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="text-center"
>
<div className="w-10 h-10 rounded-full bg-[rgba(0,137,123,0.08)] flex items-center justify-center mx-auto mb-2 text-teal">
<Icon size={18} />
</div>
<div className="font-secondary text-[13px] text-heading break-words">
{item.href ? (
<a
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-teal hover:text-[#00796B] transition-colors"
>
{item.value}
</a>
) : (
item.value
)}
</div>
<div className="font-secondary text-[10px] uppercase tracking-wider text-muted mt-0.5">
{item.label}
</div>
</motion.div>
)
}
export function Contact() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="contact" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Contact
</motion.h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{contactData.map((item, index) => (
<ContactItemCard
key={item.label}
item={item}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState, useMemo } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
interface CvmisLogoProps {
size?: number
cssHeight?: string
animated?: boolean
className?: string
}
// ── Animation timing constants ──────────────────────────────────────
// Rise phase: all pills rise together from below
const RISE_DURATION_MS = 1250 // duration of the upward rise (ms)
const RISE_DURATION_S = RISE_DURATION_MS / 1000
const RISE_OPACITY_DURATION_S = 0.25 // opacity fade-in during rise (s)
const RISE_EASING: [number, number, number, number] = [0.33, 1, 0.68, 1]
const RISE_START_Y = 350 // initial Y offset (viewBox units)
// Fan phase: left and right pills fan outward
const FAN_DELAY_AFTER_RISE_MS = RISE_DURATION_MS - 100 // delay before fan begins (ms from mount)
const FAN_DURATION_S = 2 // duration of fan-out (s)
const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const FAN_ROTATION_DEG = 55 // rotation angle for fanned pills (±degrees)
const FAN_HORIZONTAL_PX = -10 // horizontal offset for fanned pills (±px)
const FAN_RIGHT_STAGGER_S = 0 // stagger delay for right pill (s)
// Total animation = rise delay + fan duration
const TOTAL_ANIMATION_MS = FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000
// Overlap blend: multiply blend on fanning capsules (used by US-005)
const OVERLAY_BLEND_START_PROGRESS = 0.2 // fan progress at which blend fades in
const OVERLAP_BLEND_MAX_OPACITY = 0.3 // max blend opacity (20%)
const OVERLAP_BLEND_TRANSITION_DURATION_S = FAN_DURATION_S * (1 - OVERLAY_BLEND_START_PROGRESS)
// Pivot point: bottom-center of the pill stack (in viewBox coords)
const PX = 300
const PY = 275
// Build a CSS transform that rotates around (PX, PY) then offsets by dx
function fanTransform(rotation: number, dx: number): string {
return [
`translate(${dx}px, 0px)`,
`translate(${PX}px, ${PY}px)`,
`rotate(${rotation}deg)`,
`translate(${-PX}px, ${-PY}px)`,
].join(' ')
}
const IDENTITY_TRANSFORM = fanTransform(0, 0)
export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) {
const prefersReducedMotion = useReducedMotion()
const [phase, setPhase] = useState<'rising' | 'fanning' | 'done'>(
animated && !prefersReducedMotion ? 'rising' : 'done'
)
const [blendActive, setBlendActive] = useState(!animated || !!prefersReducedMotion)
// Blend starts at OVERLAY_BLEND_START_PROGRESS through the fan animation
const blendStartMs = useMemo(
() => FAN_DELAY_AFTER_RISE_MS + FAN_DURATION_S * 1000 * OVERLAY_BLEND_START_PROGRESS,
[]
)
useEffect(() => {
if (!animated || prefersReducedMotion) return
const fanTimer = setTimeout(() => setPhase('fanning'), FAN_DELAY_AFTER_RISE_MS)
const doneTimer = setTimeout(() => setPhase('done'), TOTAL_ANIMATION_MS)
const blendTimer = setTimeout(() => setBlendActive(true), blendStartMs)
return () => {
clearTimeout(fanTimer)
clearTimeout(doneTimer)
clearTimeout(blendTimer)
}
}, [animated, prefersReducedMotion, blendStartMs])
const skip = !animated || prefersReducedMotion
const isFanned = phase === 'fanning' || phase === 'done'
const fanTarget = isFanned || skip
const leftTransform = fanTarget ? fanTransform(-FAN_ROTATION_DEG, -FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
const rightTransform = fanTarget ? fanTransform(FAN_ROTATION_DEG, FAN_HORIZONTAL_PX) : IDENTITY_TRANSFORM
const fanTransition = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING}`
const fanTransitionDelayed = skip ? 'none' : `transform ${FAN_DURATION_S}s ${FAN_EASING} ${FAN_RIGHT_STAGGER_S}s`
return (
<svg
viewBox="0 0 600 300"
height={cssHeight ? undefined : size}
className={className}
role="img"
aria-label="CVMIS logo"
style={{
overflow: 'visible',
...(cssHeight ? { height: cssHeight, width: 'auto' } : {}),
}}
>
<defs>
<clipPath id="center-pill-clip">
<rect x="250" y="50" width="100" height="225" rx="50" />
</clipPath>
</defs>
{/* Rise group — all pills rise together from below */}
<motion.g
initial={skip ? false : { y: RISE_START_Y, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{
y: { duration: RISE_DURATION_S, ease: RISE_EASING },
opacity: { duration: RISE_OPACITY_DURATION_S },
}}
>
{/* Rx pill — teal, fans left (bottom layer) */}
<g style={{ transform: leftTransform, transition: fanTransition }}>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
<g transform="translate(21, 50) scale(0.6)">
<path
d="M25 70 V0 H55 C80 0 80 35 55 35 H25 M55 35 L85 70 M53 67 L87 38"
stroke="white"
strokeWidth="10"
strokeLinecap="butt"
strokeLinejoin="miter"
fill="none"
/>
</g>
</g>
</g>
{/* Data pill — green, fans right (middle layer) */}
<g style={{ transform: rightTransform, transition: fanTransitionDelayed }}>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#109E6C" />
<g transform="translate(22.5, 50) scale(0.5)">
<rect x="0" y="60" width="20" height="40" fill="white" />
<rect x="30" y="40" width="20" height="60" fill="white" />
<rect x="60" y="20" width="20" height="80" fill="white" />
<rect x="90" y="0" width="20" height="100" fill="white" />
</g>
</g>
</g>
{/* Code pill — amber, center (top layer, no fan) */}
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#E38B16" />
<g transform="translate(25, 50) scale(0.6)">
<path
d="M10 0 L50 30 L10 60"
stroke="white"
strokeWidth="10"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<line
x1="55"
y1="65"
x2="85"
y2="65"
stroke="white"
strokeWidth="10"
strokeLinecap="round"
/>
</g>
</g>
{/* Blend overlays — multiply-blend copies of fanning pills, clipped to center pill overlap */}
<g clipPath="url(#center-pill-clip)">
<g
style={{
transform: leftTransform,
transition: skip ? 'none' : `${fanTransition}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
mixBlendMode: 'multiply',
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
}}
>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#0E7A7D" />
</g>
</g>
</g>
<g clipPath="url(#center-pill-clip)">
<g
style={{
transform: rightTransform,
transition: skip ? 'none' : `${fanTransitionDelayed}, opacity ${OVERLAP_BLEND_TRANSITION_DURATION_S}s ease-out`,
mixBlendMode: 'multiply',
opacity: blendActive ? OVERLAP_BLEND_MAX_OPACITY : 0,
}}
>
<g transform="translate(250, 50)">
<rect width="100" height="225" rx="50" fill="#109E6C" />
</g>
</g>
</g>
</motion.g>
</svg>
)
}
+389
View File
@@ -0,0 +1,389 @@
import { useState, useEffect, useCallback, useRef, useMemo, lazy, Suspense } from 'react'
import { motion } from 'framer-motion'
import Sidebar from './Sidebar'
import { MobileBottomNav } from './MobileBottomNav'
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
import { ParentSection } from './ParentSection'
import { TimelineInterventionsSubsection } from './TimelineInterventionsSubsection'
import { LastConsultationCard } from './LastConsultationCard'
import { MobileOverviewHeader } from './MobileOverviewHeader'
const CommandPalette = lazy(() => import('./CommandPalette').then(m => ({ default: m.CommandPalette })))
const DetailPanel = lazy(() => import('./DetailPanel').then(m => ({ default: m.DetailPanel })))
const CareerConstellation = lazy(() => import('./constellation/CareerConstellation'))
const RepeatMedicationsSubsection = lazy(() => import('./RepeatMedicationsSubsection').then(m => ({ default: m.RepeatMedicationsSubsection })))
const ChatWidget = lazy(() => import('./ChatWidget').then(m => ({ default: m.ChatWidget })))
import { useActiveSection } from '@/hooks/useActiveSection'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { useIsTabletOrBelow } from '@/hooks/useIsTabletOrBelow'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineConsultations, timelineEntities } from '@/data/timeline'
import { skills } from '@/data/skills'
import { constellationNodes } from '@/data/constellation'
import type { PaletteAction } from '@/lib/search'
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
const sidebarVariants = {
hidden: prefersReducedMotion ? { x: 0, opacity: 1 } : { x: -272, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: motionSafeTransition(0.25, 'easeOut', 0.05),
},
}
const contentVariants = {
hidden: prefersReducedMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: motionSafeTransition(0.3, 'easeOut', 0.15),
},
}
export function DashboardLayout() {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [highlightedNodeId, setHighlightedNodeId] = useState<string | null>(null)
const [highlightedRoleId, setHighlightedRoleId] = useState<string | null>(null)
const [chronologyHeight, setChronologyHeight] = useState<number | null>(null)
const [constellationReady, setConstellationReady] = useState(false)
const isMobileNav = useIsMobileNav()
const isTabletOrBelow = useIsTabletOrBelow()
const chronologyRef = useRef<HTMLDivElement>(null)
const patientSummaryRef = useRef<HTMLDivElement>(null)
const constellationWrapperRef = useRef<HTMLDivElement>(null)
const activeSection = useActiveSection()
const { openPanel } = useDetailPanel()
const careerConsultationsById = useMemo(
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
[],
)
// Global focus mode: tracks which entity (skill or role) is being hovered across all components
const [globalFocusId, setGlobalFocusId] = useState<string | null>(null)
// Build lookup maps for resolving relationships between skills and roles
const nodeTypeById = useMemo(
() => new Map(constellationNodes.map(n => [n.id, n.type])),
[],
)
const skillToRoles = useMemo(() => {
const map = new Map<string, Set<string>>()
for (const entity of timelineEntities) {
for (const skillId of entity.skills) {
if (!map.has(skillId)) map.set(skillId, new Set())
map.get(skillId)!.add(entity.id)
}
}
return map
}, [])
const roleToSkills = useMemo(
() => new Map(timelineEntities.map(e => [e.id, new Set(e.skills)])),
[],
)
// Derive the set of all IDs related to the focused entity
const focusRelatedIds = useMemo(() => {
if (!globalFocusId) return null
const related = new Set<string>()
related.add(globalFocusId)
const nodeType = nodeTypeById.get(globalFocusId)
if (nodeType === 'skill') {
// Skill focused: related roles are those containing this skill
const roles = skillToRoles.get(globalFocusId)
if (roles) roles.forEach(r => related.add(r))
} else {
// Role/education focused: related skills are that entity's skills
const entitySkills = roleToSkills.get(globalFocusId)
if (entitySkills) entitySkills.forEach(s => related.add(s))
}
return related
}, [globalFocusId, nodeTypeById, skillToRoles, roleToSkills])
// Signal constellation animation readiness:
// Desktop (>=768): patient summary scrolls out of view OR constellation enters viewport
// Mobile (<768): constellation scrolls into view
useEffect(() => {
const isMobile = window.innerWidth < 768
const observers: IntersectionObserver[] = []
// Always observe the constellation entering the viewport
const constellationEl = constellationWrapperRef.current
if (constellationEl) {
const chartObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setConstellationReady(true)
},
{ threshold: 0.5 },
)
chartObserver.observe(constellationEl)
observers.push(chartObserver)
}
// Desktop: also trigger when patient summary scrolls out of view
if (!isMobile) {
const summaryEl = patientSummaryRef.current
if (summaryEl) {
const summaryObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) setConstellationReady(true)
},
{ threshold: 0 },
)
summaryObserver.observe(summaryEl)
observers.push(summaryObserver)
}
}
return () => observers.forEach((o) => o.disconnect())
}, [])
// Measure the chronology stream height so the constellation graph can match it
useEffect(() => {
const el = chronologyRef.current
if (!el) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setChronologyHeight(entry.contentRect.height)
}
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const handlePaletteClose = useCallback(() => {
setCommandPaletteOpen(false)
}, [])
const handleSearchClick = useCallback(() => {
setCommandPaletteOpen(true)
}, [])
const scrollToSection = useCallback((tileId: string) => {
const tileEl = document.querySelector(`[data-tile-id="${tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [])
// Constellation graph handlers
const handleRoleClick = useCallback(
(roleId: string) => {
const consultation = careerConsultationsById.get(roleId)
if (consultation) {
openPanel({ type: 'career-role', consultation })
}
},
[careerConsultationsById, openPanel],
)
const handleSkillClick = useCallback(
(skillId: string) => {
const skill = skills.find((s) => s.id === skillId)
if (skill) {
openPanel({ type: 'skill', skill })
}
},
[openPanel],
)
const handleNodeHighlight = useCallback((id: string | null) => {
if (isTabletOrBelow) return
setHighlightedNodeId(id)
setGlobalFocusId(id)
}, [isTabletOrBelow])
const handleNodeHover = useCallback((id: string | null) => {
if (isTabletOrBelow) return
const nodeType = id ? nodeTypeById.get(id) : null
setHighlightedRoleId(nodeType !== 'skill' ? id : null)
setGlobalFocusId(id)
}, [isTabletOrBelow, nodeTypeById])
// Global Ctrl+K listener to open command palette
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setCommandPaletteOpen(prev => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Handle palette actions (scroll to tile, expand item, open link, download)
const handlePaletteAction = useCallback((action: PaletteAction) => {
switch (action.type) {
case 'scroll': {
scrollToSection(action.tileId)
break
}
case 'expand': {
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Dispatch a custom event that the tile can listen for to expand the item
const expandEvent = new CustomEvent('palette-expand', {
detail: { tileId: action.tileId, itemId: action.itemId },
})
document.dispatchEvent(expandEvent)
}
break
}
case 'link': {
window.open(action.url, '_blank', 'noopener,noreferrer')
break
}
case 'download': {
// For now, open the CV file or trigger a download
// This can be wired to an actual PDF when available
window.open('/Andrew_Charlwood_CV.pdf', '_blank')
break
}
case 'panel': {
openPanel(action.panelContent)
break
}
}
}, [openPanel, scrollToSection])
return (
<div
className="font-ui"
style={{ background: 'var(--bg-dashboard)', height: '100vh', overflow: 'hidden' }}
>
<a
href="#main-content"
style={{
position: 'absolute',
top: '-48px',
left: 0,
background: 'var(--accent)',
color: '#FFFFFF',
padding: '8px 16px',
textDecoration: 'none',
zIndex: 120,
borderRadius: '0 0 4px 0',
fontSize: '14px',
fontWeight: 600,
}}
onFocus={(e) => {
e.currentTarget.style.top = '0'
}}
onBlur={(e) => {
e.currentTarget.style.top = '-48px'
}}
>
Skip to main content
</a>
<div
style={{
display: 'flex',
height: '100%',
}}
>
{!isMobileNav && (
<motion.div
initial="hidden"
animate="visible"
variants={sidebarVariants}
style={{ flexShrink: 0, height: '100%' }}
>
<Sidebar
activeSection={activeSection}
onNavigate={scrollToSection}
onSearchClick={handleSearchClick}
/>
</motion.div>
)}
<motion.main
id="main-content"
initial="hidden"
animate="visible"
variants={contentVariants}
aria-label="Dashboard content"
className="dashboard-main pmr-scrollbar p-3 xs:p-5 pb-10 md:p-7 md:pb-12 lg:px-8 lg:pt-7 lg:pb-12"
style={{
flex: 1,
overflowY: 'auto',
paddingBottom: isMobileNav ? 'calc(56px + env(safe-area-inset-bottom) + 16px)' : undefined,
}}
>
{isMobileNav && <MobileOverviewHeader onSearchClick={handleSearchClick} />}
<div className="dashboard-grid">
{/* PatientSummaryTile — full width (includes Latest Results subsection) */}
<div ref={patientSummaryRef}>
<PatientSummaryTile />
</div>
{/* Patient Pathway — parent section with constellation graph + subsections */}
<ParentSection title="Patient Pathway" tileId="patient-pathway">
<div className="pathway-columns">
<div ref={chronologyRef} className="chronology-stream" data-tile-id="section-experience">
<div className="chronology-item">
<LastConsultationCard highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
</div>
<div className="chronology-item">
<TimelineInterventionsSubsection onNodeHighlight={handleNodeHighlight} highlightedRoleId={highlightedRoleId} focusRelatedIds={focusRelatedIds} />
</div>
</div>
<div ref={constellationWrapperRef} className="pathway-graph-sticky">
<Suspense fallback={null}>
<CareerConstellation
onRoleClick={handleRoleClick}
onSkillClick={handleSkillClick}
onNodeHover={handleNodeHover}
highlightedNodeId={highlightedNodeId}
containerHeight={chronologyHeight}
animationReady={constellationReady}
globalFocusActive={globalFocusId !== null}
/>
</Suspense>
</div>
</div>
<div data-tile-id="section-skills" style={{ marginTop: '22px' }}>
<Suspense fallback={null}>
<RepeatMedicationsSubsection onNodeHighlight={handleNodeHighlight} focusRelatedIds={focusRelatedIds} />
</Suspense>
</div>
</ParentSection>
</div>
</motion.main>
</div>
{/* Command palette overlay */}
<Suspense fallback={null}>
<CommandPalette
isOpen={commandPaletteOpen}
onClose={handlePaletteClose}
onAction={handlePaletteAction}
/>
</Suspense>
{/* Detail panel */}
<Suspense fallback={null}>
<DetailPanel />
</Suspense>
{/* Floating chat widget */}
<Suspense fallback={null}>
<ChatWidget onAction={handlePaletteAction} />
</Suspense>
{/* Mobile bottom navigation */}
<MobileBottomNav
activeSection={activeSection}
onNavigate={scrollToSection}
/>
</div>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { DetailPanelContent } from '@/types/pmr'
import type { CardHeaderProps } from './Card'
import { KPIDetail } from './detail/KPIDetail'
import { ConsultationDetail } from './detail/ConsultationDetail'
import { SkillDetail } from './detail/SkillDetail'
import { SkillsAllDetail } from './detail/SkillsAllDetail'
import { EducationDetail } from './detail/EducationDetail'
import { ProjectDetail } from './detail/ProjectDetail'
import { DOT_COLORS } from '@/lib/theme-colors'
// Width mapping from content type
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
kpi: 'narrow',
skill: 'narrow',
'skills-all': 'narrow',
consultation: 'wide',
project: 'wide',
education: 'narrow',
'career-role': 'wide',
}
// Title mapping from content data
function getPanelTitle(content: DetailPanelContent): string {
switch (content.type) {
case 'kpi':
return content.kpi.label
case 'skill':
return content.skill.name
case 'skills-all':
return 'All Medications'
case 'consultation':
return content.consultation.role
case 'project':
return content.investigation.name
case 'education':
return content.document.title
case 'career-role':
return content.consultation.role
}
}
// Dot color mapping from content type
function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
switch (content.type) {
case 'kpi':
return 'teal'
case 'skill':
case 'skills-all':
return 'amber'
case 'consultation':
case 'career-role':
return 'teal'
case 'project':
return 'amber'
case 'education':
return 'purple'
}
}
export function DetailPanel() {
const { content, closePanel, isOpen, isClosing } = useDetailPanel()
const panelRef = useRef<HTMLDivElement>(null)
const titleId = 'detail-panel-title'
// Focus trap when open
useFocusTrap(panelRef, isOpen)
// Close on Escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePanel()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, closePanel])
if ((!isOpen && !isClosing) || !content) return null
const width = widthMap[content.type]
const title = getPanelTitle(content)
const dotColor = getDotColor(content)
const dotColorValue = DOT_COLORS[dotColor]
return (
<>
{/* Backdrop */}
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'var(--backdrop-bg)',
backdropFilter: 'blur(var(--backdrop-blur))',
zIndex: 1000,
animation: 'backdrop-fade-in 150ms ease-out',
opacity: isClosing ? 0 : 1,
transition: 'opacity 200ms ease-out',
}}
onClick={closePanel}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="detail-panel"
data-width={width}
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
backgroundColor: 'var(--surface)',
boxShadow: 'var(--shadow-lg)',
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
animation: isClosing ? 'panel-slide-out 250ms ease-in forwards' : 'panel-slide-in 250ms ease-out',
}}
>
{/* Header */}
<div
data-panel-header=""
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '20px 24px',
borderBottom: '1px solid var(--border-light)',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: dotColorValue,
flexShrink: 0,
}}
aria-hidden="true"
/>
<h2
id={titleId}
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
}}
>
{title}
</h2>
</div>
<button
onClick={closePanel}
aria-label="Close panel"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '44px',
height: '44px',
border: 'none',
background: 'transparent',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
color: 'var(--text-secondary)',
transition: 'background-color 150ms, color 150ms',
}}
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)'
}}
>
<X size={20} />
</button>
</div>
{/* Body (scrollable) */}
<div
data-panel-body=""
style={{
flex: 1,
overflowY: 'auto',
padding: '24px',
}}
>
{/* Render content based on type */}
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
{(content.type === 'consultation' || content.type === 'career-role') && (
<ConsultationDetail consultation={content.consultation} />
)}
{content.type === 'skill' && <SkillDetail skill={content.skill} />}
{content.type === 'skills-all' && <SkillsAllDetail category={content.category} />}
{content.type === 'education' && <EducationDetail document={content.document} />}
{content.type === 'project' && <ProjectDetail investigation={content.investigation} />}
</div>
</div>
</>
)
}
-345
View File
@@ -1,345 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface ECGAnimationProps {
onComplete: () => void
}
interface Point {
x: number
y: number
}
interface Beat {
startTime: number
widthPx: number
amplitude: number
startWX: number
}
interface LetterLayout {
char: string
startX: number
endX: number
centerX: number
}
const ECG_LETTERS: Record<string, Point[]> = {
A: [{x:0,y:0},{x:0.48,y:1},{x:0.53,y:0.42},{x:0.6,y:0.42},{x:1,y:0}],
N: [{x:0,y:0},{x:0.12,y:1},{x:0.72,y:0},{x:0.88,y:1},{x:1,y:0}],
D: [{x:0,y:0},{x:0.1,y:1},{x:0.5,y:1},{x:0.85,y:0.55},{x:1,y:0}],
R: [{x:0,y:0},{x:0.1,y:1},{x:0.35,y:1},{x:0.5,y:0.6},{x:0.55,y:0.45},{x:1,y:0}],
E: [{x:0,y:0},{x:0.1,y:1},{x:0.4,y:1},{x:0.45,y:0.5},{x:0.65,y:0.5},{x:0.7,y:0},{x:1,y:0}],
W: [{x:0,y:0},{x:0.05,y:1},{x:0.27,y:0},{x:0.5,y:0.65},{x:0.73,y:0},{x:0.95,y:1},{x:1,y:0}],
C: [{x:0,y:0},{x:0.08,y:0.6},{x:0.18,y:1},{x:0.6,y:1},{x:0.8,y:0.5},{x:0.95,y:0.1},{x:1,y:0}],
H: [{x:0,y:0},{x:0.1,y:1},{x:0.18,y:0.5},{x:0.82,y:0.5},{x:0.9,y:1},{x:1,y:0}],
L: [{x:0,y:0},{x:0.12,y:1},{x:0.3,y:1},{x:0.38,y:0},{x:1,y:0}],
O: [{x:0,y:0},{x:0.2,y:0.85},{x:0.35,y:1},{x:0.65,y:1},{x:0.8,y:0.85},{x:1,y:0}],
}
const ECG_TEXT = 'ANDREW CHARLWOOD'
function generateHeartbeatPoints(amplitude: number): Point[] {
const points: Point[] = []
const steps = 200
for (let i = 0; i <= steps; i++) {
const t = i / steps
let y = 0
if (t >= 0.05 && t < 0.2) { y = 0.12 * Math.sin(((t - 0.05) / 0.15) * Math.PI) }
else if (t >= 0.25 && t < 0.32) { y = -0.1 * Math.sin(((t - 0.25) / 0.07) * Math.PI) }
else if (t >= 0.32 && t < 0.42) { y = 1.0 * Math.sin(((t - 0.32) / 0.1) * Math.PI) }
else if (t >= 0.42 && t < 0.5) { y = -0.25 * Math.sin(((t - 0.42) / 0.08) * Math.PI) }
else if (t >= 0.55 && t < 0.75) { y = 0.2 * Math.sin(((t - 0.55) / 0.2) * Math.PI) }
points.push({ x: t, y: y * amplitude })
}
return points
}
function interpolateLetterY(points: Point[], t: number): number {
if (t <= points[0].x) return points[0].y
if (t >= points[points.length - 1].x) return points[points.length - 1].y
for (let i = 0; i < points.length - 1; i++) {
if (t >= points[i].x && t <= points[i + 1].x) {
const seg = (t - points[i].x) / (points[i + 1].x - points[i].x)
return points[i].y + (points[i + 1].y - points[i].y) * seg
}
}
return 0
}
function ecgGetTextWidth(lw: number, lg: number, sw: number): number {
const chars = ECG_TEXT.replace(/ /g, '').length
const spaces = ECG_TEXT.split(' ').length - 1
return chars * (lw + lg) - lg + spaces * sw
}
function ecgLayoutText(offsetX: number, lw: number, lg: number, sw: number): LetterLayout[] {
const layout: LetterLayout[] = []
let cursor = offsetX
for (let i = 0; i < ECG_TEXT.length; i++) {
const ch = ECG_TEXT[i]
if (ch === ' ') { cursor += sw; continue }
layout.push({ char: ch, startX: cursor, endX: cursor + lw, centerX: cursor + lw / 2 })
cursor += lw + lg
}
return layout
}
export function ECGAnimation({ onComplete }: ECGAnimationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
const startTsRef = useRef<number | null>(null)
const bgTransitionedRef = useRef(false)
const completedRef = useRef(false)
const finishAnimation = useCallback(() => {
if (completedRef.current) return
completedRef.current = true
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
onComplete()
}, [onComplete])
useEffect(() => {
const canvas = canvasRef.current
const container = containerRef.current
if (!canvas || !container) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const vw = window.innerWidth
const vh = window.innerHeight
const dpr = window.devicePixelRatio || 1
canvas.width = vw * dpr
canvas.height = vh * dpr
ctx.scale(dpr, dpr)
const scale = Math.min(1.2, Math.max(0.35, vw / 1400))
const LETTER_W = 72 * scale
const LETTER_G = 10 * scale
const SPACE_W = 30 * scale
const TRACE_SPEED = 450 * scale
const FLAT_GAP = 0.4
const HOLD_TIME = 0.75
const EXIT_TIME = 0.8
const baselineY = vh * 0.5
const ecgMaxDefl = vh * 0.25
const textMaxDefl = vh * 0.08
const lineColor = '#00ff41'
const beats: Beat[] = [
{ startTime: 0.5, widthPx: 60 * scale, amplitude: 0.3, startWX: 0 },
{ startTime: 1.2, widthPx: 90 * scale, amplitude: 0.55, startWX: 0 },
{ startTime: 2.0, widthPx: 120 * scale, amplitude: 0.85, startWX: 0 },
{ startTime: 2.8, widthPx: 140 * scale, amplitude: 1.0, startWX: 0 },
]
beats.forEach((b) => { b.startWX = b.startTime * TRACE_SPEED })
const lastBeat = beats[beats.length - 1]
const lastBeatEndWX = lastBeat.startWX + lastBeat.widthPx
const textStartWX = lastBeatEndWX + FLAT_GAP * TRACE_SPEED
const totalTextW = ecgGetTextWidth(LETTER_W, LETTER_G, SPACE_W)
const textEndWX = textStartWX + totalTextW
const textLayout = ecgLayoutText(textStartWX, LETTER_W, LETTER_G, SPACE_W)
const fontSize = Math.round(textMaxDefl / 0.715)
const headScreenRatio = 0.75
const finalHeadSX = (vw - totalTextW) / 2 + totalTextW
const textEndTime = textEndWX / TRACE_SPEED
const holdEndTime = textEndTime + HOLD_TIME
const exitEndTime = holdEndTime + EXIT_TIME
const getYAtX = (wx: number): number => {
for (let i = 0; i < beats.length; i++) {
const b = beats[i]
if (wx >= b.startWX && wx <= b.startWX + b.widthPx) {
const prog = (wx - b.startWX) / b.widthPx
const pts = generateHeartbeatPoints(b.amplitude)
const idx = Math.min(Math.floor(prog * (pts.length - 1)), pts.length - 1)
return baselineY - pts[idx].y * ecgMaxDefl
}
}
for (let j = 0; j < textLayout.length; j++) {
const item = textLayout[j]
if (wx >= item.startX && wx <= item.endX) {
const t = (wx - item.startX) / (item.endX - item.startX)
const ld = ECG_LETTERS[item.char]
if (ld) return baselineY - interpolateLetterY(ld, t) * textMaxDefl
}
}
return baselineY
}
const animate = (timestamp: number) => {
if (!startTsRef.current) startTsRef.current = timestamp
const elapsed = (timestamp - startTsRef.current) / 1000
if (elapsed >= exitEndTime) {
finishAnimation()
return
}
ctx.clearRect(0, 0, vw, vh)
let headWX = elapsed * TRACE_SPEED
const isExitPhase = elapsed >= holdEndTime
if (isExitPhase) {
headWX = textEndWX + (elapsed - holdEndTime) * TRACE_SPEED * 1.5
}
let headSX: number
let viewOff: number
const headSXEcg = headScreenRatio * vw
if (headWX <= textStartWX) {
viewOff = Math.max(0, headWX - headSXEcg)
headSX = headWX - viewOff
} else if (headWX >= textEndWX || isExitPhase) {
viewOff = textEndWX - finalHeadSX
headSX = headWX - viewOff
} else {
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
viewOff = headWX - headSX
}
const fadeAlpha = isExitPhase ? Math.max(0, 1 - (elapsed - holdEndTime) / EXIT_TIME) : 1
if (!bgTransitionedRef.current && elapsed >= textEndTime - 0.3) {
bgTransitionedRef.current = true
container.style.transition = 'background 1200ms ease-out'
container.style.background = '#FFFFFF'
}
ctx.save()
ctx.globalAlpha = fadeAlpha
const traceStart = Math.max(0, Math.floor(viewOff))
const traceEnd = Math.min(Math.ceil(isExitPhase ? textEndWX : headWX), Math.ceil(viewOff + vw))
if (traceEnd > traceStart) {
ctx.beginPath()
ctx.strokeStyle = 'rgba(0, 255, 65, 0.25)'
ctx.lineWidth = 6
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.shadowColor = lineColor
ctx.shadowBlur = 14
for (let wx = traceStart; wx <= traceEnd; wx++) {
const sx = wx - viewOff
const sy = getYAtX(wx)
if (wx === traceStart) ctx.moveTo(sx, sy)
else ctx.lineTo(sx, sy)
}
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2
ctx.shadowBlur = 4
for (let wx = traceStart; wx <= traceEnd; wx++) {
const sx = wx - viewOff
const sy = getYAtX(wx)
if (wx === traceStart) ctx.moveTo(sx, sy)
else ctx.lineTo(sx, sy)
}
ctx.stroke()
}
if (isExitPhase) {
const exitStartSX = textEndWX - viewOff
const exitEndSX = headWX - viewOff
ctx.beginPath()
ctx.strokeStyle = lineColor
ctx.lineWidth = 2
ctx.shadowBlur = 8
ctx.moveTo(exitStartSX, baselineY)
ctx.lineTo(exitEndSX, baselineY)
ctx.stroke()
}
ctx.shadowColor = lineColor
ctx.shadowBlur = 8
ctx.font = `bold ${fontSize}px Arial, Helvetica, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.lineWidth = 1.5 * scale
ctx.strokeStyle = lineColor
for (let k = 0; k < textLayout.length; k++) {
const item = textLayout[k]
const letterProgress = (headWX - item.startX) / (item.endX - item.startX)
if (letterProgress > 0.3) {
const alpha = Math.min(1, (letterProgress - 0.3) * 1.43)
ctx.globalAlpha = fadeAlpha * alpha
const lsx = item.centerX - viewOff
ctx.strokeText(item.char, lsx, baselineY)
}
}
ctx.globalAlpha = fadeAlpha
ctx.shadowBlur = 0
if (headSX >= -20 && headSX <= vw + 20) {
const headY = isExitPhase ? baselineY : getYAtX(headWX)
const grad = ctx.createRadialGradient(headSX, headY, 0, headSX, headY, 20 * scale)
grad.addColorStop(0, 'rgba(255,255,255,0.8)')
grad.addColorStop(0.3, 'rgba(0,255,65,0.6)')
grad.addColorStop(1, 'rgba(0,255,65,0)')
ctx.fillStyle = grad
ctx.beginPath()
ctx.arc(headSX, headY, 20 * scale, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = lineColor
ctx.beginPath()
ctx.arc(headSX, headY, 3, 0, Math.PI * 2)
ctx.fill()
}
ctx.restore()
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
for (let sly = 0; sly < vh; sly += 4) {
ctx.fillRect(0, sly + 2, vw, 2)
}
const vig = ctx.createRadialGradient(vw / 2, vh / 2, vh * 0.3, vw / 2, vh / 2, vh * 0.85)
vig.addColorStop(0, 'rgba(0,0,0,0)')
vig.addColorStop(1, 'rgba(0,0,0,0.4)')
ctx.fillStyle = vig
ctx.fillRect(0, 0, vw, vh)
animationRef.current = requestAnimationFrame(animate)
}
animationRef.current = requestAnimationFrame(animate)
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [finishAnimation])
return (
<AnimatePresence>
<motion.div
ref={containerRef}
className="fixed inset-0 z-50 bg-black"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
<canvas
ref={canvasRef}
className="w-full h-full"
/>
</motion.div>
</AnimatePresence>
)
}
-86
View File
@@ -1,86 +0,0 @@
import { motion } from 'framer-motion'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Education as EducationType } from '@/types'
const educationData: EducationType[] = [
{
degree: 'MPharm (Hons) Pharmacy',
institution: 'University of East Anglia',
period: '2011 — 2015',
detail: 'Upper Second-Class Honours (2:1)',
},
{
degree: 'Mary Seacole Leadership Programme',
institution: 'NHS Leadership Academy',
period: '2018',
detail: 'National healthcare leadership development programme.',
},
]
const EducationCard = ({
education,
delay,
isVisible,
}: {
education: EducationType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-shadow hover:shadow-md hover:-translate-y-0.5"
>
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-teal to-coral" />
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
{education.degree}
</h3>
<p className="text-sm text-teal mt-0.5">{education.institution}</p>
<p className="text-[13px] text-muted mt-0.5">{education.period}</p>
<p className="text-sm text-text mt-1.5 leading-relaxed">
{education.detail}
</p>
</motion.div>
)
}
export function Education() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="education" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Education
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{educationData.map((education, index) => (
<EducationCard
key={education.degree}
education={education}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
<motion.p
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="text-[13px] text-muted text-center mt-5"
>
A-Levels: Mathematics (A*), Chemistry (B), Politics (C)
</motion.p>
</section>
)
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
interface ExpandableCardShellProps {
isExpanded: boolean
isHighlighted: boolean
isDimmedByFocus?: boolean
accentColor: string
onToggle: () => void
ariaLabel: string
headerPadding?: string
className?: string
dataTileId?: string
onMouseEnter?: () => void
onMouseLeave?: () => void
renderHeader: () => React.ReactNode
renderBody: () => React.ReactNode
}
export function ExpandableCardShell({
isExpanded,
isHighlighted,
isDimmedByFocus = false,
accentColor,
onToggle,
ariaLabel,
headerPadding = '12px 14px',
className,
dataTileId,
onMouseEnter,
onMouseLeave,
renderHeader,
renderBody,
}: ExpandableCardShellProps) {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
if (e.key === 'Escape' && isExpanded) {
e.preventDefault()
onToggle()
}
},
[onToggle, isExpanded],
)
return (
<div
data-tile-id={dataTileId}
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
opacity: isDimmedByFocus ? 0.25 : 1,
transition: 'opacity 150ms ease-out',
}}
>
<div
style={{
background: isHighlighted ? hexToRgba(accentColor, 0.03) : 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${isExpanded || isHighlighted ? hexToRgba(accentColor, 0.2) : 'var(--border-light)'}`,
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
overflow: 'hidden',
}}
>
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={handleKeyDown}
aria-expanded={isExpanded}
aria-label={ariaLabel}
style={{
display: 'flex',
gap: '10px',
padding: headerPadding,
cursor: 'pointer',
minHeight: '44px',
alignItems: 'flex-start',
}}
onMouseEnter={(e) => {
if (!isExpanded) {
e.currentTarget.parentElement!.style.borderColor = hexToRgba(accentColor, 0.2)
e.currentTarget.parentElement!.style.boxShadow = 'var(--shadow-md)'
}
}}
onMouseLeave={(e) => {
if (!isExpanded) {
e.currentTarget.parentElement!.style.borderColor = 'var(--border-light)'
e.currentTarget.parentElement!.style.boxShadow = 'none'
}
}}
>
<div
aria-hidden="true"
style={{
width: '9px',
height: '9px',
borderRadius: '50%',
background: accentColor,
flexShrink: 0,
marginTop: '4px',
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
{renderHeader()}
</div>
<ChevronRight
size={14}
style={{
color: 'var(--text-tertiary)',
flexShrink: 0,
marginTop: '2px',
transform: isExpanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s ease-out',
}}
/>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={motionSafeTransition(0.2)}
style={{ overflow: 'hidden' }}
>
<div
style={{
padding: '0 12px 12px 30px',
borderTop: '1px solid var(--border-light)',
paddingTop: '12px',
borderLeft: `2px solid ${accentColor}`,
marginLeft: '12px',
}}
>
{renderBody()}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}
-164
View File
@@ -1,164 +0,0 @@
import { motion } from 'framer-motion'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Experience as ExperienceType } from '@/types'
const experiences: ExperienceType[] = [
{
role: 'Interim Head of Population Health & Data Analysis',
org: 'NHS Norfolk & Waveney ICB',
date: 'May 2025 — Nov 2025',
bullets: [
'Led team through organisational transition, maintaining delivery of £14.6M efficiency programme',
'Directed strategic priorities for population health analytics across Norfolk & Waveney (population ~1M)',
'Managed stakeholder relationships with system leaders, provider trusts, and primary care networks',
],
isCurrent: true,
},
{
role: 'Deputy Head of Population Health & Data Analysis',
org: 'NHS Norfolk & Waveney ICB',
date: 'Jul 2024 — Present',
bullets: [
'Deputised for Head of department across all operational and strategic functions',
'Oversaw £220M medicines budget and led programme of cost improvement initiatives',
'Developed Python-based switching algorithm processing 14,000 patients, delivering £2.6M savings',
'Built Blueteq automation system reducing processing time by 70%, saving 200+ hours annually',
'Created PharMetrics dashboard platform for real-time medicines expenditure tracking',
],
isCurrent: true,
},
{
role: 'High-Cost Drugs & Interface Pharmacist',
org: 'NHS Norfolk & Waveney ICB',
date: 'May 2022 — Jul 2024',
bullets: [
'Managed high-cost drugs budget across acute and community settings',
'Led NICE Technology Appraisal implementation and horizon scanning',
'Developed health economic models for biosimilar switching programmes',
'Built data pipelines for automated reporting of medicines expenditure',
],
isCurrent: false,
},
{
role: 'Pharmacy Manager',
org: 'Tesco Pharmacy',
date: 'Nov 2017 — May 2022',
bullets: [
'Managed community pharmacy delivering 3,000+ items monthly',
'Pioneered asthma screening service generating £1M+ national revenue',
'Led team of 6 through COVID-19 pandemic service delivery',
'Completed Mary Seacole NHS Leadership Programme (2018)',
],
isCurrent: false,
},
{
role: 'Duty Pharmacy Manager',
org: 'Tesco Pharmacy',
date: 'Aug 2016 — Nov 2017',
bullets: [
'Supported pharmacy manager in daily operations and clinical services',
'Delivered Medicines Use Reviews and New Medicine Service consultations',
'Maintained controlled drug compliance and clinical governance standards',
],
isCurrent: false,
},
]
const ECGDecoration = () => (
<svg
className="shrink-0 w-[200px] h-[30px] md:w-[200px] w-[120px]"
viewBox="0 0 200 30"
fill="none"
aria-hidden="true"
>
<path
d="M 0 15 L 40 15 L 50 15 C 53 15 55 12 58 12 C 61 12 63 15 66 15 L 76 15 L 80 20 L 86 2 L 92 22 L 96 15 L 106 15 C 109 15 111 11 114 11 C 117 11 120 15 123 15 L 200 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-teal opacity-30"
/>
</svg>
)
const TimelineEntry = ({
experience,
index,
isVisible,
}: {
experience: ExperienceType
index: number
isVisible: boolean
}) => {
return (
<motion.div
className="relative pl-0 md:pl-[calc(20%+32px)] mb-8 last:mb-0"
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<div
className={`absolute left-[20%] top-2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-teal bg-white z-10 hidden md:block ${
experience.isCurrent ? 'bg-teal' : ''
}`}
/>
<motion.div
className="bg-white rounded-2xl p-6 shadow-sm border-l-[3px] border-transparent hover:shadow-md hover:scale-[1.01] hover:border-l-teal/30 transition-all duration-300"
whileHover={{ scale: 1.01 }}
transition={{ duration: 0.2 }}
>
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
{experience.role}
</h3>
<p className="font-primary text-sm text-teal mt-0.5">{experience.org}</p>
<span className="inline-block px-2.5 py-0.5 mt-1.5 mb-3 bg-teal/8 rounded-full font-secondary text-xs text-teal font-medium">
{experience.date}
</span>
<ul className="space-y-1">
{experience.bullets.map((bullet, i) => (
<li
key={i}
className="relative pl-4 text-sm text-text leading-relaxed before:content-[''] before:absolute before:left-0 before:top-[10px] before:w-[5px] before:h-[5px] before:rounded-full before:bg-teal"
>
{bullet}
</li>
))}
</ul>
</motion.div>
</motion.div>
)
}
export function Experience() {
const [sectionRef, isVisible] = useScrollReveal<HTMLDivElement>({ threshold: 0.1 })
return (
<div
id="experience"
ref={sectionRef}
className="py-20 opacity-0 translate-y-6 transition-all duration-600 ease-out data-[visible=true]:opacity-100 data-[visible=true]:translate-y-0"
data-visible={isVisible}
>
<div className="flex items-center justify-center gap-4 mb-8">
<h2 className="font-primary text-2xl font-bold text-heading">Experience</h2>
<ECGDecoration />
</div>
<div className="relative">
<div className="absolute left-[20%] top-0 bottom-0 w-0.5 bg-teal/20 hidden md:block" />
<div className="space-y-0">
{experiences.map((exp, i) => (
<TimelineEntry
key={exp.role}
experience={exp}
index={i}
isVisible={isVisible}
/>
))}
</div>
</div>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
import { useCallback } from 'react'
import { motion } from 'framer-motion'
import { useActiveSection } from '@/hooks/useActiveSection'
interface NavLink {
id: string
label: string
}
const navLinks: NavLink[] = [
{ id: 'about', label: 'About' },
{ id: 'skills', label: 'Skills' },
{ id: 'experience', label: 'Experience' },
{ id: 'education', label: 'Education' },
{ id: 'projects', label: 'Projects' },
{ id: 'contact', label: 'Contact' },
]
export function FloatingNav() {
const activeSection = useActiveSection()
const scrollToSection = useCallback((sectionId: string) => {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}, [])
return (
<motion.nav
className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] max-w-[600px] w-auto bg-white rounded-full py-2 px-6 shadow-md flex items-center gap-1 border border-border overflow-x-auto scrollbar-hide"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{navLinks.map((link) => {
const isActive = activeSection === link.id
return (
<button
key={link.id}
onClick={() => scrollToSection(link.id)}
className={`
relative font-secondary text-[13px] font-medium py-1.5 px-3.5 rounded-full
transition-colors duration-300 ease-out whitespace-nowrap
${isActive
? 'text-teal font-semibold'
: 'text-muted hover:text-teal hover:bg-teal-light'
}
`}
>
{link.label}
{isActive && (
<motion.span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-teal"
layoutId="navIndicator"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
/>
)}
</button>
)
})}
</motion.nav>
)
}
-85
View File
@@ -1,85 +0,0 @@
import { motion } from 'framer-motion'
interface VitalCardProps {
value: string
label: string
valueSize?: 'default' | 'small' | 'medium'
delay?: number
}
function VitalCard({ value, label, valueSize = 'default', delay = 0 }: VitalCardProps) {
const sizeClasses = {
default: 'text-[28px]',
small: 'text-base',
medium: 'text-lg'
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="bg-card-bg rounded-2xl px-6 py-5 shadow-sm border-t-[3px] border-teal min-w-[160px] text-center transition-all duration-300 hover:shadow-md hover:-translate-y-0.5"
>
<div className={`font-primary font-bold text-heading leading-tight ${sizeClasses[valueSize]}`}>
{value}
</div>
<div className="font-secondary text-[11px] uppercase tracking-wide text-muted mt-1">
{label}
</div>
</motion.div>
)
}
export function Hero() {
return (
<section
id="about"
className="min-h-screen flex flex-col justify-center items-center text-center py-20"
>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary font-bold text-heading leading-tight"
style={{ fontSize: 'clamp(36px, 5vw, 52px)' }}
>
Andy Charlwood
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
className="text-muted text-base mt-2"
>
Deputy Head of Population Health &amp; Data Analysis
</motion.p>
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="inline-block mt-1 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium"
>
Norwich, UK
</motion.span>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mt-6 max-w-[560px] text-text text-[15px] leading-[1.8]"
>
GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. Bridging clinical pharmacy with data science to drive meaningful improvements in patient outcomes.
</motion.p>
<div className="flex gap-4 mt-10 justify-center flex-wrap">
<VitalCard value="10+" label="Years Experience" delay={0.4} />
<VitalCard value="Python/SQL/BI" label="Analytics Stack" valueSize="small" delay={0.5} />
<VitalCard value="Pop. Health" label="Focus Area" valueSize="medium" delay={0.6} />
<VitalCard value="NHS N&W" label="System" valueSize="medium" delay={0.7} />
</div>
</section>
)
}
+204
View File
@@ -0,0 +1,204 @@
import React from 'react'
import { ChevronRight } from 'lucide-react'
import { CardHeader } from './Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineConsultations } from '@/data/timeline'
import { hexToRgba } from '@/lib/utils'
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
interface LastConsultationCardProps {
highlightedRoleId?: string | null
focusRelatedIds?: Set<string> | null
}
export function LastConsultationCard({ highlightedRoleId, focusRelatedIds }: LastConsultationCardProps) {
const { openPanel } = useDetailPanel()
const consultation = timelineConsultations.find(c => c.isCurrent) ?? timelineConsultations[0]
if (!consultation) {
return null
}
const isHighlighted = highlightedRoleId === consultation.id
const isDimmed = focusRelatedIds != null && !focusRelatedIds.has(consultation.id)
const handleOpenPanel = () => {
openPanel({ type: 'consultation', consultation })
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpenPanel()
}
}
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
}
const getEmploymentType = (): string => {
if (consultation.organization.includes('ICB')) {
return 'Permanent · Full-time'
}
return 'Permanent'
}
const getBand = (): string => {
return consultation.band ?? '—'
}
const fieldLabelStyle: React.CSSProperties = {
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
marginBottom: '3px',
}
const fieldValueStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-primary)',
}
return (
<div
style={{
marginTop: '24px',
borderRadius: 'var(--radius-sm)',
border: '1px solid',
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent',
background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'transparent',
transition: 'border-color 150ms ease-out, background-color 150ms ease-out, opacity 150ms ease-out',
padding: '8px',
margin: '-8px',
opacity: isDimmed ? 0.25 : 1,
}}
>
<CardHeader dotColor="green" title="LATEST CONSULTATION" rightText="Current role" />
<div
role="button"
tabIndex={0}
onClick={handleOpenPanel}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '20px',
marginBottom: '10px',
paddingBottom: '14px',
borderBottom: '1px solid var(--border-light)',
cursor: 'pointer',
borderRadius: 'var(--radius-sm)',
padding: '8px',
margin: '-8px -8px 14px -8px',
transition: 'background-color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.04)
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
aria-label={`View full details for ${consultation.role}`}
>
<div>
<div style={fieldLabelStyle}>Date</div>
<div style={fieldValueStyle}>{formatDate(consultation.date)}</div>
</div>
<div>
<div style={fieldLabelStyle}>Organisation</div>
<div style={fieldValueStyle}>{consultation.organization}</div>
</div>
<div>
<div style={fieldLabelStyle}>Type</div>
<div style={fieldValueStyle}>{getEmploymentType()}</div>
</div>
<div>
<div style={fieldLabelStyle}>Band</div>
<div style={fieldValueStyle}>{getBand()}</div>
</div>
</div>
<div
style={{
fontSize: '15px',
fontWeight: 600,
color: consultation.orgColor ?? 'var(--accent)',
marginBottom: '12px',
}}
>
{consultation.role}
</div>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '7px',
marginBottom: '0px',
}}
>
{consultation.examination.map((bullet, index) => (
<li
key={index}
style={{
fontSize: '14px',
color: 'var(--text-primary)',
paddingLeft: '16px',
lineHeight: '1.5',
position: 'relative',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: '0',
top: '8px',
width: '5px',
height: '5px',
borderRadius: '50%',
backgroundColor: consultation.orgColor ?? 'var(--accent)',
opacity: 0.5,
}}
/>
{bullet}
</li>
))}
</ul>
<button
onClick={handleOpenPanel}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: 500,
color: consultation.orgColor ?? 'var(--accent)',
background: 'transparent',
border: 'none',
padding: '6px 0',
minHeight: '44px',
cursor: 'pointer',
transition: 'opacity 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
aria-label="View full consultation record"
>
<span>View full record</span>
<ChevronRight size={15} strokeWidth={2.5} />
</button>
</div>
)
}
+415
View File
@@ -0,0 +1,415 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { motion } from 'framer-motion'
import { CvmisLogo } from './CvmisLogo'
import { useAccessibility } from '../contexts/AccessibilityContext'
// ── Login screen timing & visual constants ──────────────────────────
const BACKDROP_BLUR_PX = 10
interface LoginScreenProps {
onComplete: () => void
}
export function LoginScreen({ onComplete }: LoginScreenProps) {
const [username, setUsername] = useState('')
const [passwordDots, setPasswordDots] = useState(0)
const [showCursor, setShowCursor] = useState(true)
const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
const [buttonPressed, setButtonPressed] = useState(false)
const [isExiting, setIsExiting] = useState(false)
const [typingComplete, setTypingComplete] = useState(false)
const [buttonHovered, setButtonHovered] = useState(false)
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
const [dotCount, setDotCount] = useState(0)
const { requestFocusAfterLogin } = useAccessibility()
const fullUsername = 'a.recruiter'
const passwordLength = 8
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Refs for interval/timeout cleanup
const usernameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const passwordIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const cursorIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutRefs = useRef<ReturnType<typeof setTimeout>[]>([])
const dotIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loginButtonRef = useRef<HTMLButtonElement>(null)
const addTimeout = useCallback((fn: () => void, delay: number) => {
const id = setTimeout(fn, delay)
timeoutRefs.current.push(id)
return id
}, [])
const canLogin = typingComplete && connectionState === 'connected'
const handleLogin = useCallback(() => {
if (!canLogin || isExiting) return
setButtonPressed(true)
addTimeout(() => {
setIsExiting(true)
addTimeout(() => {
requestFocusAfterLogin()
onComplete()
}, prefersReducedMotion ? 0 : 400)
}, 100)
}, [canLogin, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
const startLoginSequence = useCallback(() => {
if (prefersReducedMotion) {
setUsername(fullUsername)
setPasswordDots(passwordLength)
setActiveField('done')
setTypingComplete(true)
// Button is immediately available for user to click
return
}
// Username typing: 80ms per character
let usernameIndex = 0
usernameIntervalRef.current = setInterval(() => {
if (usernameIndex <= fullUsername.length) {
setUsername(fullUsername.slice(0, usernameIndex))
usernameIndex++
} else {
if (usernameIntervalRef.current) {
clearInterval(usernameIntervalRef.current)
}
setActiveField('password')
// Password dots: 60ms per dot, after 300ms pause
addTimeout(() => {
let dotCount = 0
passwordIntervalRef.current = setInterval(() => {
if (dotCount <= passwordLength) {
setPasswordDots(dotCount)
dotCount++
} else {
if (passwordIntervalRef.current) {
clearInterval(passwordIntervalRef.current)
}
setActiveField('done')
setTypingComplete(true)
// Button becomes interactive — user clicks to proceed
}
}, 40)
}, 200)
}
}, 55)
}, [prefersReducedMotion, addTimeout])
// Focus the login button when login becomes available for keyboard accessibility
useEffect(() => {
if (canLogin && loginButtonRef.current) {
loginButtonRef.current.focus()
}
}, [canLogin])
// Connection transitions to green 500ms after typing completes
useEffect(() => {
if (!typingComplete) return
const timeout = addTimeout(() => {
setConnectionState('connected')
}, prefersReducedMotion ? 0 : 500)
return () => clearTimeout(timeout)
}, [typingComplete, addTimeout, prefersReducedMotion])
// Animated trailing dots while connecting
useEffect(() => {
if (connectionState === 'connected' || prefersReducedMotion) {
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
return
}
dotIntervalRef.current = setInterval(() => {
setDotCount(prev => (prev + 1) % 4)
}, 500)
return () => {
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
}
}, [connectionState, prefersReducedMotion])
useEffect(() => {
// Cursor blink: 530ms interval
cursorIntervalRef.current = setInterval(() => {
setShowCursor(prev => !prev)
}, 530)
// Delay start to allow card entrance + logo animation to complete
// Reduced motion: logo shows instantly, so use original 400ms delay
// Full motion: 400ms card entrance + 1000ms logo animation + 100ms pause = 1500ms
const startTimeout = addTimeout(() => {
startLoginSequence()
}, prefersReducedMotion ? 400 : 600)
// Capture ref value for cleanup
const pendingTimeouts = timeoutRefs.current
return () => {
if (cursorIntervalRef.current) clearInterval(cursorIntervalRef.current)
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
if (dotIntervalRef.current) clearInterval(dotIntervalRef.current)
clearTimeout(startTimeout)
pendingTimeouts.forEach(id => clearTimeout(id))
}
}, [startLoginSequence, addTimeout, prefersReducedMotion])
const buttonBg = buttonPressed
? 'var(--accent-pressed, #085858)'
: buttonHovered && canLogin
? 'var(--accent-hover, #0A8080)'
: 'var(--accent, #0D6E6E)'
return (
<motion.div
className="fixed inset-0 flex items-center justify-center"
style={{
zIndex: 110,
}}
initial={{
backgroundColor: 'rgba(0, 0, 0, 1)',
backdropFilter: 'blur(0px)',
WebkitBackdropFilter: 'blur(0px)',
}}
animate={isExiting ? {
backgroundColor: 'rgba(240, 245, 244, 0)',
backdropFilter: 'blur(0px)',
WebkitBackdropFilter: 'blur(0px)',
} : {
backgroundColor: 'rgba(240, 245, 244, 0.7)',
backdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
WebkitBackdropFilter: `blur(${BACKDROP_BLUR_PX}px)`,
}}
transition={isExiting ? { duration: 0.6, ease: 'easeOut' } : { duration: 0.6, ease: 'easeOut' }}
role="dialog"
aria-label="Clinical system login"
aria-modal="true"
>
<motion.div
style={{
width: 'clamp(320px, 28vw, 480px)',
maxWidth: 'calc(100vw - 32px)',
padding: 'clamp(24px, 2.5vw, 40px)',
borderRadius: 'var(--radius-card, 8px)',
border: '1px solid var(--border-light, #E4EDEB)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
backgroundColor: 'var(--surface, #FFFFFF)',
}}
initial={{ opacity: 0, scale: 0.98 }}
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
transition={isExiting ? { duration: 0.4, ease: 'easeOut' } : { duration: 0.2, ease: 'easeOut' }}
>
<>
{/* Branding Header */}
<div
className="flex flex-col items-center"
style={{ marginBottom: '28px' }}
>
<div style={{ marginBottom: '12px', overflow: 'hidden' }}>
<CvmisLogo
cssHeight="clamp(160px, 18vw, 280px)"
animated={true}
/>
</div>
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: 'clamp(16px, 1.4vw, 20px)',
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
letterSpacing: '0.01em',
}}
>
CVMIS
</span>
<span
style={{
fontFamily: "var(--font-ui)",
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 400,
color: 'var(--text-tertiary, #8DA8A5)',
marginTop: '3px',
}}
>
CV Management Information System
</span>
</div>
{/* Login Form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Username Field */}
<div>
<label
style={{
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
>
Username
</label>
<div
style={{
width: '100%',
padding: '9px 11px',
fontFamily: 'var(--font-geist-mono)',
fontSize: 'clamp(13px, 1.2vw, 15px)',
backgroundColor: activeField === 'username' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'username' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
borderRadius: 'var(--radius-sm, 6px)',
color: 'var(--text-primary, #1A2B2A)',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}}
>
<span>{username}</span>
{activeField === 'username' && (
<span
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
aria-hidden="true"
>
|
</span>
)}
</div>
</div>
{/* Password Field */}
<div>
<label
style={{
display: 'block',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(12px, 1vw, 14px)',
fontWeight: 600,
color: 'var(--text-secondary, #5B7A78)',
marginBottom: '6px',
}}
>
Password
</label>
<div
style={{
width: '100%',
padding: '9px 11px',
fontFamily: 'var(--font-geist-mono)',
fontSize: 'clamp(13px, 1.2vw, 15px)',
backgroundColor: activeField === 'password' ? 'var(--surface, #FFFFFF)' : 'var(--bg-dashboard, #F0F5F4)',
border: activeField === 'password' ? '1px solid var(--accent, #0D6E6E)' : '1px solid var(--border-light, #E4EDEB)',
borderRadius: 'var(--radius-sm, 6px)',
color: 'var(--text-primary, #1A2B2A)',
letterSpacing: '0.15em',
minHeight: '38px',
display: 'flex',
alignItems: 'center',
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
}}
>
<span>{'\u2022'.repeat(passwordDots)}</span>
{activeField === 'password' && (
<span
style={{ opacity: showCursor ? 1 : 0, color: 'var(--accent, #0D6E6E)' }}
aria-hidden="true"
>
|
</span>
)}
</div>
</div>
{/* Log In Button — user clicks to proceed */}
<button
ref={loginButtonRef}
onClick={handleLogin}
disabled={!canLogin}
onMouseEnter={() => setButtonHovered(true)}
onMouseLeave={() => setButtonHovered(false)}
className={`focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus:outline-none${canLogin && !buttonPressed ? ' login-pulse-active' : ''}`}
style={{
width: '100%',
padding: '10px 16px',
fontFamily: "var(--font-ui)",
fontSize: 'clamp(14px, 1.2vw, 16px)',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: buttonBg,
border: 'none',
borderRadius: 'var(--radius-sm, 6px)',
cursor: canLogin ? 'pointer' : 'default',
opacity: canLogin ? 1 : 0.6,
transition: 'background-color 150ms, opacity 300ms',
}}
>
Log In
</button>
{/* Connection Status Indicator */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginTop: '4px',
}}
>
<span
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
boxShadow: connectionState === 'connected'
? '0 0 6px 1px rgba(5,150,105,0.4)'
: '0 0 6px 1px rgba(220,38,38,0.4)',
transition: prefersReducedMotion ? 'none' : 'background-color 300ms ease, box-shadow 300ms ease',
flexShrink: 0,
}}
/>
<span
style={{
fontFamily: 'var(--font-geist-mono)',
fontSize: '12px',
color: connectionState === 'connected' ? 'var(--success, #059669)' : 'var(--alert, #DC2626)',
transition: prefersReducedMotion ? 'none' : 'color 300ms ease',
}}
>
{connectionState === 'connected'
? 'Secure connection established, awaiting login'
: `Awaiting secure connection${'.'.repeat(dotCount)}`}
</span>
</div>
</div>
{/* Footer */}
<div
style={{
marginTop: '22px',
paddingTop: '18px',
borderTop: '1px solid var(--border-light, #E4EDEB)',
}}
>
<p
style={{
fontFamily: "var(--font-ui)",
fontSize: '11px',
color: 'var(--text-tertiary, #8DA8A5)',
textAlign: 'center',
}}
>
Secure clinical system login
</p>
</div>
</>
</motion.div>
</motion.div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { ClipboardList, UserRound, Workflow, Wrench } from 'lucide-react'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
interface MobileBottomNavProps {
activeSection: string
onNavigate: (tileId: string) => void
}
const navItems = [
{ id: 'overview', label: 'Overview', tileId: 'mobile-overview', Icon: UserRound },
{ id: 'summary', label: 'Summary', tileId: 'patient-summary', Icon: ClipboardList },
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
]
export function MobileBottomNav({ activeSection, onNavigate }: MobileBottomNavProps) {
const isMobileNav = useIsMobileNav()
if (!isMobileNav) return null
return (
<nav
aria-label="Mobile navigation"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '56px',
background: 'var(--sidebar-bg)',
borderTop: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
zIndex: 100,
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{navItems.map((item) => {
const isActive = activeSection === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onNavigate(item.tileId)}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '2px',
width: '44px',
height: '44px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: isActive ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'color 150ms',
}}
>
<item.Icon size={20} strokeWidth={isActive ? 2.4 : 2} />
<span style={{ fontSize: '10px', fontWeight: isActive ? 600 : 400 }}>{item.label}</span>
</button>
)
})}
</nav>
)
}
+260
View File
@@ -0,0 +1,260 @@
import { useState } from 'react'
import type { CSSProperties } from 'react'
import { Download, Github, Linkedin, Search, Send } from 'lucide-react'
import { CvmisLogo } from './CvmisLogo'
import { PhoneCaptcha } from './PhoneCaptcha'
import { ReferralFormModal } from './ReferralFormModal'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { getSidebarCopy } from '@/lib/profile-content'
import type { Tag } from '@/types/pmr'
interface MobileOverviewHeaderProps {
onSearchClick: () => void
}
function TagPill({ tag }: { tag: Tag }) {
const styles: Record<Tag['colorVariant'], CSSProperties> = {
teal: {
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
green: {
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
},
}
return (
<span
style={{
display: 'inline-flex',
fontSize: '12px',
fontWeight: 500,
padding: '4px 10px',
borderRadius: '4px',
lineHeight: 1.3,
...styles[tag.colorVariant],
}}
>
{tag.label}
</span>
)
}
export function MobileOverviewHeader({ onSearchClick }: MobileOverviewHeaderProps) {
const sidebarCopy = getSidebarCopy()
const [showReferralForm, setShowReferralForm] = useState(false)
return (
<div
data-tile-id="mobile-overview"
style={{
padding: '16px',
background: 'var(--sidebar-bg)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border)',
marginBottom: '16px',
}}
>
{/* Logo + Search row */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '6px', marginBottom: '12px' }}>
<CvmisLogo cssHeight="50px" />
<button
type="button"
onClick={onSearchClick}
className="sidebar-control"
aria-label={sidebarCopy.searchAriaLabel}
style={{
width: '100%',
minHeight: '44px',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 10px',
cursor: 'pointer',
}}
>
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px' }}>{sidebarCopy.searchLabel}</span>
</button>
</div>
{/* Patient info */}
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '12px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#FFFFFF',
fontSize: '16px',
fontWeight: 700,
flexShrink: 0,
}}
>
AC
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 700, color: 'var(--text-primary)' }}>
CHARLWOOD, Andrew
</div>
<div style={{ fontSize: '12px', fontFamily: 'var(--font-geist-mono)', color: 'var(--text-secondary)' }}>
{sidebarCopy.roleTitle}
</div>
</div>
</div>
<div style={{ display: 'grid', gap: '6px' }}>
{[
{ label: sidebarCopy.gphcLabel, value: patient.nhsNumber.replace(/\s/g, ''), mono: true },
{ label: sidebarCopy.educationLabel, value: patient.qualification },
{ label: sidebarCopy.locationLabel, value: patient.address },
{ label: sidebarCopy.registeredLabel, value: patient.registrationYear },
].map(({ label, value, mono }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right', fontFamily: mono ? 'var(--font-geist-mono)' : undefined, fontSize: mono ? '12px' : undefined, letterSpacing: mono ? '0.12em' : undefined }}>
{value}
</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.phoneLabel}</span>
<PhoneCaptcha phone={patient.phone} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '13px', padding: '2px 0' }}>
<span style={{ color: 'var(--text-tertiary)' }}>{sidebarCopy.emailLabel}</span>
<a
href={`mailto:${patient.email}`}
style={{ color: 'var(--accent)', fontWeight: 500, textDecoration: 'none', textAlign: 'right' }}
>
{patient.email}
</a>
</div>
</div>
</section>
{/* Tags */}
<section style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
{sidebarCopy.tagsTitle}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
{tags.map((tag) => (
<TagPill key={tag.label} tag={tag} />
))}
</div>
</section>
{/* Action buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{/* Download CV — full width */}
<a
href="/Andrew_Charlwood_CV.pdf"
target="_blank"
rel="noopener noreferrer"
aria-label="Download CV"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
letterSpacing: '0.03em',
textDecoration: 'none',
}}
>
<Download size={14} />
Download CV
</a>
{/* Three icon buttons row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
<button
type="button"
onClick={() => setShowReferralForm(true)}
aria-label="Contact patient"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
<Send size={16} />
</button>
<a
href="https://www.linkedin.com/in/andrewcharlwood/"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
}}
>
<Linkedin size={16} />
</a>
<a
href="https://github.com/andrewcharlwood"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub profile"
style={{
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
}}
>
<Github size={16} />
</a>
</div>
</div>
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import { Card } from './Card'
interface ParentSectionProps {
title: string
children: React.ReactNode
className?: string
tileId?: string
}
export function ParentSection({ title, children, className, tileId }: ParentSectionProps) {
return (
<Card full className={className} tileId={tileId}>
<h2
className="text-[1.375rem] sm:text-[1.6rem] md:text-[1.8rem] lg:text-[2.4rem]"
style={{
fontWeight: 600,
letterSpacing: '-0.02em',
color: 'var(--text-primary)',
lineHeight: 1.1,
margin: 0,
paddingBottom: '1.5rem',
}}
>
{title}
</h2>
{children}
</Card>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useState, useCallback, useRef, useEffect } from 'react'
interface PhoneCaptchaProps {
phone: string
}
function generateChallenge() {
const a = Math.floor(Math.random() * 10) + 2
const b = Math.floor(Math.random() * 8) + 1
return { question: `${a} + ${b}`, answer: a + b }
}
export function PhoneCaptcha({ phone }: PhoneCaptchaProps) {
const [state, setState] = useState<'masked' | 'challenge' | 'revealed'>('masked')
const [challenge, setChallenge] = useState(generateChallenge)
const [input, setInput] = useState('')
const [error, setError] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const maskedPhone = phone.slice(0, 2) + '\u2022\u2022\u2022 \u2022\u2022\u2022\u2022\u2022\u2022'
useEffect(() => {
if (state === 'challenge') {
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [state])
const handleRevealClick = useCallback(() => {
setChallenge(generateChallenge())
setInput('')
setError(false)
setState('challenge')
}, [])
const handleSubmit = useCallback(() => {
const parsed = parseInt(input.trim(), 10)
if (parsed === challenge.answer) {
setState('revealed')
} else {
setError(true)
setTimeout(() => {
setError(false)
setChallenge(generateChallenge())
setInput('')
}, 600)
}
}, [input, challenge.answer])
const handleDismiss = useCallback(() => {
setState('masked')
setInput('')
setError(false)
}, [])
if (state === 'revealed') {
return (
<a
href={`tel:${phone}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
{phone.replace(/(\d{5})(\d{6})/, '$1 $2')}
</a>
)
}
if (state === 'challenge') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
<span
style={{
fontSize: '11px',
color: error ? 'var(--alert, #e53935)' : 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
transition: 'color 150ms',
}}
>
{error ? 'Try again' : `${challenge.question} = ?`}
</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
ref={inputRef}
type="text"
inputMode="numeric"
autoComplete="off"
value={input}
onChange={(e) => { setInput(e.target.value); setError(false) }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit()
if (e.key === 'Escape') handleDismiss()
}}
style={{
width: '36px',
padding: '3px 4px',
fontSize: '12px',
fontFamily: 'var(--font-geist-mono)',
border: `1px solid ${error ? 'var(--alert, #e53935)' : 'var(--border)'}`,
borderRadius: '4px',
background: 'var(--surface)',
color: 'var(--text-primary)',
textAlign: 'center',
outline: 'none',
transition: 'border-color 150ms',
}}
aria-label={`Solve: ${challenge.question}`}
/>
<button
type="button"
onClick={handleSubmit}
style={{
padding: '3px 8px',
fontSize: '11px',
fontWeight: 600,
border: '1px solid var(--accent-border)',
borderRadius: '4px',
background: 'var(--accent-light)',
color: 'var(--accent)',
cursor: 'pointer',
lineHeight: 1,
}}
>
OK
</button>
</div>
</div>
)
}
return (
<button
type="button"
onClick={handleRevealClick}
style={{
color: 'var(--text-secondary)',
fontWeight: 500,
textAlign: 'right',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: 'inherit',
fontFamily: 'inherit',
}}
aria-label="Reveal phone number"
title="Click to verify and reveal"
>
{maskedPhone}
</button>
)
}
-105
View File
@@ -1,105 +0,0 @@
import { motion } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import { useScrollReveal } from '@/hooks/useScrollReveal'
import type { Project as ProjectType } from '@/types'
const projectsData: ProjectType[] = [
{
title: 'PharMetrics',
description:
'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers.',
link: 'https://medicines.charlwood.xyz/',
},
{
title: 'Patient Pathway Analysis',
description:
'Data-driven analysis of patient pathways to identify optimisation opportunities and improve clinical outcomes.',
},
{
title: 'Blueteq Generator',
description:
'Automation tool reducing high-cost drug approval processing time by 70%, saving 200+ hours annually.',
},
{
title: 'NMS Video',
description:
'Educational video resource supporting New Medicine Service consultations, improving patient engagement.',
},
]
const ProjectCard = ({
project,
delay,
isVisible,
}: {
project: ProjectType
delay: number
isVisible: boolean
}) => {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
className="group relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5"
>
<div
className="absolute inset-0 rounded-2xl p-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{
background: 'linear-gradient(135deg, #00897B, #FF6B6B)',
WebkitMask:
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}
/>
<h3 className="font-primary text-base font-semibold text-heading leading-tight">
{project.title}
</h3>
<p className="text-sm text-text leading-relaxed mt-2">
{project.description}
</p>
{project.link && (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-1.5 bg-teal text-white rounded-full text-xs font-medium font-secondary transition-all hover:bg-[#00796B] hover:-translate-y-px"
>
Visit Project
<ExternalLink size={12} />
</a>
)}
</motion.div>
)
}
export function Projects() {
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
threshold: 0.1,
})
return (
<section id="projects" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.5 }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Projects
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{projectsData.map((project, index) => (
<ProjectCard
key={project.title}
project={project}
delay={0.1 + index * 0.1}
isVisible={isVisible}
/>
))}
</div>
</section>
)
}
+433
View File
@@ -0,0 +1,433 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send } from 'lucide-react'
interface ReferralFormModalProps {
isOpen: boolean
onClose: () => void
}
interface FormData {
referringClinician: string
organisationFrom: string
presentingComplaint: string
clinicalDetails: string
contactEmail: string
}
const INITIAL_FORM: FormData = {
referringClinician: '',
organisationFrom: '',
presentingComplaint: '',
clinicalDetails: '',
contactEmail: '',
}
type SubmitStatus = 'idle' | 'submitting' | 'success' | 'error'
export function ReferralFormModal({ isOpen, onClose }: ReferralFormModalProps) {
const [form, setForm] = useState<FormData>(INITIAL_FORM)
const [status, setStatus] = useState<SubmitStatus>('idle')
const [errorMessage, setErrorMessage] = useState('')
const updateField = (field: keyof FormData, value: string) => {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('submitting')
setErrorMessage('')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.referringClinician,
organisation: form.organisationFrom,
subject: form.presentingComplaint,
message: form.clinicalDetails,
email: form.contactEmail,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.message || 'Failed to send referral')
}
setStatus('success')
setTimeout(() => {
setForm(INITIAL_FORM)
setStatus('idle')
onClose()
}, 2000)
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Failed to send referral. Please try again.')
}
}
const labelStyle: React.CSSProperties = {
display: 'block',
fontFamily: 'var(--font-geist-mono)',
fontSize: '11px',
fontWeight: 500,
color: 'var(--text-tertiary, #8DA8A5)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: '6px',
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
minHeight: '44px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
color: 'var(--text-primary, #1A2B2A)',
backgroundColor: 'var(--surface, #FFFFFF)',
border: '1px solid var(--border, #D1DDD9)',
borderRadius: 'var(--radius-sm, 6px)',
outline: 'none',
transition: 'border-color 150ms ease',
}
const readOnlyStyle: React.CSSProperties = {
...inputStyle,
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
fontStyle: 'italic',
color: 'var(--text-secondary, #5B7A78)',
cursor: 'default',
}
return (
<AnimatePresence>
{isOpen && (
<motion.div
key="referral-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(26, 43, 42, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '16px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<motion.div
key="referral-modal"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
style={{
width: '100%',
maxWidth: 'min(540px, calc(100vw - 32px))',
maxHeight: 'calc(100vh - 32px)',
overflowY: 'auto',
backgroundColor: 'var(--surface, #FFFFFF)',
borderRadius: 'var(--radius-card, 8px)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(26,43,42,0.12))',
border: '1px solid var(--border-light, #E4EDEB)',
}}
role="dialog"
aria-modal="true"
aria-labelledby="referral-form-title"
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 16px',
borderBottom: '2px solid var(--accent, #0D6E6E)',
backgroundColor: 'var(--bg-dashboard, #F0F5F4)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'var(--accent, #0D6E6E)',
flexShrink: 0,
}}
aria-hidden="true"
/>
<h2
id="referral-form-title"
style={{
fontFamily: 'var(--font-geist-mono)',
fontSize: '13px',
fontWeight: 600,
color: 'var(--accent, #0D6E6E)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
margin: 0,
}}
>
Patient Referral Form
</h2>
</div>
<button
onClick={onClose}
aria-label="Close referral form"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '44px',
height: '44px',
border: 'none',
background: 'transparent',
borderRadius: 'var(--radius-sm, 6px)',
cursor: 'pointer',
color: 'var(--text-secondary, #5B7A78)',
transition: 'background-color 150ms, color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-light, #E0F2F1)'
e.currentTarget.style.color = 'var(--accent, #0D6E6E)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = 'var(--text-secondary, #5B7A78)'
}}
>
<X size={18} />
</button>
</div>
{/* Form body */}
<form
onSubmit={handleSubmit}
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '18px' }}
>
{/* Referring Clinician */}
<div>
<label style={labelStyle} htmlFor="referringClinician">
Referring Clinician
</label>
<input
id="referringClinician"
type="text"
required
value={form.referringClinician}
onChange={(e) => updateField('referringClinician', e.target.value)}
placeholder="Your name"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Organisation Referred From */}
<div>
<label style={labelStyle} htmlFor="organisationFrom">
Organisation Referred From
</label>
<input
id="organisationFrom"
type="text"
value={form.organisationFrom}
onChange={(e) => updateField('organisationFrom', e.target.value)}
placeholder="Your organisation"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Organisation Referred To (read-only) */}
<div>
<label style={labelStyle} htmlFor="organisationTo">
Organisation Referred To
</label>
<input
id="organisationTo"
type="text"
readOnly
value="CV Managment Information System"
style={readOnlyStyle}
tabIndex={-1}
/>
</div>
{/* Receiving Clinician (read-only) */}
<div>
<label style={labelStyle} htmlFor="receivingClinician">
Receiving Clinician
</label>
<input
id="receivingClinician"
type="text"
readOnly
value="Mr A. Charlwood"
style={readOnlyStyle}
tabIndex={-1}
/>
</div>
{/* Presenting Complaint */}
<div>
<label style={labelStyle} htmlFor="presentingComplaint">
Presenting Complaint
</label>
<input
id="presentingComplaint"
type="text"
required
value={form.presentingComplaint}
onChange={(e) => updateField('presentingComplaint', e.target.value)}
placeholder="Subject / reason for referral"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Clinical Details */}
<div>
<label style={labelStyle} htmlFor="clinicalDetails">
Clinical Details
</label>
<textarea
id="clinicalDetails"
required
value={form.clinicalDetails}
onChange={(e) => updateField('clinicalDetails', e.target.value)}
placeholder="Your message..."
rows={5}
style={{
...inputStyle,
resize: 'vertical',
minHeight: '100px',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Contact Email */}
<div>
<label style={labelStyle} htmlFor="contactEmail">
Contact Email
</label>
<input
id="contactEmail"
type="email"
required
value={form.contactEmail}
onChange={(e) => updateField('contactEmail', e.target.value)}
placeholder="your.email@example.com"
style={inputStyle}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent, #0D6E6E)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border, #D1DDD9)' }}
/>
</div>
{/* Success message */}
{status === 'success' && (
<div
style={{
padding: '12px 16px',
backgroundColor: 'rgba(5, 150, 105, 0.08)',
border: '1px solid rgba(5, 150, 105, 0.2)',
borderRadius: 'var(--radius-sm, 6px)',
fontFamily: 'var(--font-ui)',
fontSize: '13px',
color: 'var(--success, #059669)',
textAlign: 'center',
}}
>
Referral sent successfully!
</div>
)}
{/* Error message */}
{status === 'error' && (
<div
style={{
padding: '12px 16px',
backgroundColor: 'rgba(220, 38, 38, 0.08)',
border: '1px solid rgba(220, 38, 38, 0.2)',
borderRadius: 'var(--radius-sm, 6px)',
fontFamily: 'var(--font-ui)',
fontSize: '13px',
color: 'var(--alert, #DC2626)',
textAlign: 'center',
}}
>
{errorMessage}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={status === 'submitting' || status === 'success'}
style={{
width: '100%',
padding: '12px 16px',
minHeight: '44px',
fontFamily: 'var(--font-ui)',
fontSize: '14px',
fontWeight: 600,
color: '#FFFFFF',
backgroundColor: status === 'submitting' || status === 'success'
? 'var(--accent-hover, #0A8080)'
: 'var(--accent, #0D6E6E)',
border: 'none',
borderRadius: 'var(--radius-sm, 6px)',
cursor: status === 'submitting' || status === 'success' ? 'default' : 'pointer',
opacity: status === 'submitting' ? 0.8 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background-color 150ms, opacity 150ms',
}}
onMouseEnter={(e) => {
if (status === 'idle' || status === 'error') {
e.currentTarget.style.backgroundColor = 'var(--accent-hover, #0A8080)'
}
}}
onMouseLeave={(e) => {
if (status === 'idle' || status === 'error') {
e.currentTarget.style.backgroundColor = 'var(--accent, #0D6E6E)'
}
}}
>
{status === 'submitting' ? (
'Sending referral...'
) : status === 'success' ? (
'Referral sent!'
) : (
<>
<Send size={16} />
Submit Referral
</>
)}
</button>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,263 @@
import React from 'react'
import type { LucideIcon } from 'lucide-react'
import {
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
Pill, Users, FileCheck, Stethoscope,
TrendingUp, Route, BookOpen, Store,
Presentation, Calculator, Banknote, Handshake, RefreshCw,
GitBranch, Workflow, UserPlus, ChevronRight,
} from 'lucide-react'
import { CardHeader } from './Card'
import { skills } from '@/data/skills'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getSkillsUICopy } from '@/lib/profile-content'
import type { SkillMedication } from '@/types/pmr'
const iconMap: Record<string, LucideIcon> = {
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
Pill, Users, FileCheck, Stethoscope,
TrendingUp, Route, BookOpen, Store,
Presentation, Calculator, Banknote, Handshake, RefreshCw,
GitBranch, Workflow, UserPlus,
}
interface SkillRowProps {
skill: SkillMedication
yearsSuffix: string
onClick: () => void
onHighlight?: (id: string | null) => void
isDimmedByFocus?: boolean
}
function SkillRow({ skill, yearsSuffix, onClick, onHighlight, isDimmedByFocus = false }: SkillRowProps) {
const IconComponent = iconMap[skill.icon]
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
minHeight: '44px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, opacity 150ms ease-out',
opacity: isDimmedByFocus ? 0.25 : 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
onHighlight?.(skill.id)
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
onHighlight?.(null)
}}
>
<div
style={{
width: '30px',
height: '30px',
borderRadius: '6px',
background: 'var(--accent-light)',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent size={15} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{skill.name}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
}}
>
{skill.frequency} · {skill.yearsOfExperience} {yearsSuffix}
</div>
</div>
<div
style={{
fontSize: '11px',
fontWeight: 500,
padding: '3px 8px',
borderRadius: '20px',
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
flexShrink: 0,
}}
>
{skill.status}
</div>
<ChevronRight
size={14}
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
/>
</div>
)
}
interface CategorySectionProps {
label: string
skills: SkillMedication[]
itemCountSuffix: string
yearsSuffix: string
onSkillClick: (skill: SkillMedication) => void
isFirst: boolean
onNodeHighlight?: (id: string | null) => void
focusRelatedIds?: Set<string> | null
}
function CategorySection({
label,
skills: categorySkills,
itemCountSuffix,
yearsSuffix,
onSkillClick,
isFirst,
onNodeHighlight,
focusRelatedIds,
}: CategorySectionProps) {
return (
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
}}
>
<span
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-tertiary)',
whiteSpace: 'nowrap',
}}
>
{label}
</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--border-light)',
}}
/>
<span
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
whiteSpace: 'nowrap',
}}
>
{categorySkills.length} {itemCountSuffix}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{categorySkills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
yearsSuffix={yearsSuffix}
onClick={() => onSkillClick(skill)}
onHighlight={onNodeHighlight}
isDimmedByFocus={focusRelatedIds != null && !focusRelatedIds.has(skill.id)}
/>
))}
</div>
</div>
)
}
interface RepeatMedicationsSubsectionProps {
onNodeHighlight?: (id: string | null) => void
focusRelatedIds?: Set<string> | null
}
const frequencyRank = (freq: string): number => {
if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2
if (freq === 'Daily') return 4
if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7
if (freq === 'Weekly') return 7
if (freq === 'Bi-monthly') return 8
return 9 // As needed
}
export function RepeatMedicationsSubsection({ onNodeHighlight, focusRelatedIds }: RepeatMedicationsSubsectionProps) {
const { openPanel } = useDetailPanel()
const skillsCopy = getSkillsUICopy()
const groupedSkills = skillsCopy.categories.map(({ id, label }) => ({
id,
label,
skills: skills
.filter((s) => s.category === id)
.sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)),
}))
const handleSkillClick = (skill: SkillMedication) => {
openPanel({ type: 'skill', skill })
}
return (
<div>
<CardHeader
dotColor="amber"
title={skillsCopy.sectionTitle}
rightText={skillsCopy.rightText}
/>
<div className="medications-grid">
{groupedSkills.map((group) => (
<CategorySection
key={group.id}
label={group.label}
skills={group.skills}
itemCountSuffix={skillsCopy.itemCountSuffix}
yearsSuffix={skillsCopy.yearsSuffix}
onSkillClick={handleSkillClick}
isFirst
onNodeHighlight={onNodeHighlight}
focusRelatedIds={focusRelatedIds}
/>
))}
</div>
</div>
)
}
+612
View File
@@ -0,0 +1,612 @@
import { useEffect, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
Download,
Github,
Linkedin,
type LucideIcon,
Menu,
Search,
Send,
UserRound,
Workflow,
Wrench,
X,
} from 'lucide-react'
import { useIsMobileNav } from '@/hooks/useIsMobileNav'
import { CvmisLogo } from './CvmisLogo'
import { PhoneCaptcha } from './PhoneCaptcha'
import { ReferralFormModal } from './ReferralFormModal'
import { patient } from '@/data/patient'
import { tags } from '@/data/tags'
import { getSidebarCopy } from '@/lib/profile-content'
import type { Tag } from '@/types/pmr'
interface SidebarProps {
activeSection: string
onNavigate: (tileId: string) => void
onSearchClick: () => void
}
interface NavSection {
id: string
label: string
tileId: string
Icon: LucideIcon
}
const navSections: NavSection[] = [
{ id: 'overview', label: 'Overview / Highlights', tileId: 'patient-summary', Icon: UserRound },
{ id: 'experience', label: 'Experience', tileId: 'section-experience', Icon: Workflow },
{ id: 'skills', label: 'Skills', tileId: 'section-skills', Icon: Wrench },
]
interface SectionTitleProps {
children: ReactNode
}
function SectionTitle({ children }: SectionTitleProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
marginBottom: '10px',
}}
>
<span>{children}</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--border-light)',
}}
/>
</div>
)
}
interface TagPillProps {
tag: Tag
}
function TagPill({ tag }: TagPillProps) {
const styles: Record<Tag['colorVariant'], CSSProperties> = {
teal: {
background: 'var(--accent-light)',
color: 'var(--accent)',
border: '1px solid var(--accent-border)',
},
amber: {
background: 'var(--amber-light)',
color: 'var(--amber)',
border: '1px solid var(--amber-border)',
},
green: {
background: 'var(--success-light)',
color: 'var(--success)',
border: '1px solid var(--success-border)',
},
}
return (
<span
style={{
display: 'inline-flex',
fontSize: '12px',
fontWeight: 500,
padding: '4px 10px',
borderRadius: '4px',
lineHeight: 1.3,
...styles[tag.colorVariant],
}}
>
{tag.label}
</span>
)
}
export default function Sidebar({ activeSection, onNavigate, onSearchClick }: SidebarProps) {
const sidebarCopy = getSidebarCopy()
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches)
const isMobileNav = useIsMobileNav()
const [isMobileExpanded, setIsMobileExpanded] = useState(false)
const [showReferralForm, setShowReferralForm] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)')
const updateDesktopState = (event: MediaQueryListEvent | MediaQueryList) => {
const desktopMode = event.matches
setIsDesktop(desktopMode)
if (desktopMode) {
setIsMobileExpanded(false)
}
}
updateDesktopState(mediaQuery)
const listener = (event: MediaQueryListEvent) => updateDesktopState(event)
mediaQuery.addEventListener('change', listener)
return () => {
mediaQuery.removeEventListener('change', listener)
}
}, [])
const isExpanded = isDesktop || isMobileExpanded
const handleNavActivate = (tileId: string) => {
onNavigate(tileId)
if (!isDesktop) {
setIsMobileExpanded(false)
}
}
if (isMobileNav) return null
return (
<>
{!isDesktop && isMobileExpanded && (
<button
type="button"
aria-label="Close sidebar navigation"
onClick={() => setIsMobileExpanded(false)}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(26,43,42,0.28)',
border: 'none',
zIndex: 108,
cursor: 'pointer',
}}
/>
)}
<aside
id="sidebar-panel"
aria-label="Sidebar"
style={{
position: isDesktop ? 'relative' : 'fixed',
top: 0,
left: 0,
bottom: 0,
height: isDesktop ? '100%' : undefined,
width: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
minWidth: isExpanded ? 'var(--sidebar-width)' : 'var(--sidebar-rail-width)',
background: 'var(--sidebar-bg)',
borderRight: '1px solid var(--border)',
overflowY: isExpanded ? 'auto' : 'hidden',
overflowX: 'hidden',
padding: isExpanded ? '6px 16px' : '12px 8px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
transition: 'width 180ms ease-out, min-width 180ms ease-out, padding 180ms ease-out',
zIndex: isDesktop ? 'auto' : 110,
}}
className={isExpanded ? 'pmr-scrollbar' : undefined}
>
{!isDesktop && (
<button
type="button"
aria-label={isExpanded ? 'Collapse sidebar navigation' : 'Expand sidebar navigation'}
aria-expanded={isExpanded}
aria-controls="sidebar-panel"
onClick={() => setIsMobileExpanded((prev) => !prev)}
className="sidebar-control"
style={{
width: '100%',
minHeight: '44px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: isExpanded ? 'space-between' : 'center',
gap: '8px',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
borderRadius: 'var(--radius-sm)',
color: 'var(--text-primary)',
padding: isExpanded ? '0 12px' : '0',
cursor: 'pointer',
}}
>
{isExpanded && <span style={{ fontSize: '12px', fontWeight: 600 }}>{sidebarCopy.menuLabel}</span>}
{isExpanded ? <X size={17} strokeWidth={2.4} /> : <Menu size={18} strokeWidth={2.4} />}
</button>
)}
{isExpanded && (
<section style={{ borderBottom: '2px solid var(--accent)', paddingBottom: '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'flex-start',
gap: '6px',
marginBottom: '4px',
width: '100%',
}}
>
<CvmisLogo cssHeight="50px" />
<button
type="button"
onClick={onSearchClick}
className="sidebar-control"
aria-label={sidebarCopy.searchAriaLabel}
style={{
width: '100%',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '0 10px',
cursor: 'pointer',
}}
>
<Search size={16} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true" />
<span style={{ flex: 1, textAlign: 'left', fontSize: '13px', padding: '5px 0' }}>
{sidebarCopy.searchLabel}
</span>
<kbd
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '2px 6px',
borderRadius: '4px',
lineHeight: 1,
}}
>
{sidebarCopy.searchShortcut}
</kbd>
</button>
</div>
<SectionTitle>{sidebarCopy.sectionTitle}</SectionTitle>
<div
style={{
width: '60px',
height: '60px',
borderRadius: '50%',
background: 'linear-gradient(135deg, var(--accent), #0A8080)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#FFFFFF',
fontSize: '20px',
fontWeight: 700,
boxShadow: '0 2px 8px rgba(13,110,110,0.25)',
marginBottom: '12px',
}}
>
AC
</div>
<div
style={{
fontSize: '17px',
fontWeight: 700,
color: 'var(--text-primary)',
letterSpacing: '-0.01em',
}}
>
CHARLWOOD, Andrew
</div>
<div
style={{
fontSize: '13px',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 400,
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{sidebarCopy.roleTitle}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '8px',
marginTop: '12px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.gphcLabel}</span>
<span
style={{
color: 'var(--text-primary)',
fontWeight: 500,
fontFamily: 'var(--font-geist-mono)',
fontSize: '12px',
letterSpacing: '0.12em',
}}
>
{patient.nhsNumber.replace(/\s/g, '')}
</span>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.educationLabel}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
{patient.qualification}
</span>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.locationLabel}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
{patient.address}
</span>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.phoneLabel}</span>
<PhoneCaptcha phone={patient.phone} />
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.emailLabel}</span>
<a
href={`mailto:${patient.email}`}
style={{
color: 'var(--accent)',
fontWeight: 500,
textDecoration: 'none',
textAlign: 'right',
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
{patient.email}
</a>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
padding: '4px 0',
}}
>
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}>{sidebarCopy.registeredLabel}</span>
<span style={{ color: 'var(--text-primary)', fontWeight: 500, textAlign: 'right' }}>
{patient.registrationYear}
</span>
</div>
</div>
<a
href="/Andrew_Charlwood_CV.pdf"
target="_blank"
rel="noopener noreferrer"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 600,
//fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
transition: 'border-color 150ms, color 150ms',
}}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>
<Download size={14} />
Download CV
</a>
</section>
)}
<section>
{isExpanded && <SectionTitle>{sidebarCopy.navigationTitle}</SectionTitle>}
<nav aria-label="Sidebar navigation" style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{navSections.map((section) => {
const isActive = activeSection === section.id
const Icon = section.Icon
return (
<button
key={section.id}
type="button"
onClick={() => handleNavActivate(section.tileId)}
aria-current={isActive ? 'page' : undefined}
aria-label={!isExpanded ? section.label : undefined}
className="sidebar-control"
style={{
minHeight: '44px',
border: '1px solid',
borderColor: isActive ? 'var(--accent-border)' : 'transparent',
background: isActive ? 'var(--accent-light)' : 'transparent',
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: isExpanded ? 'flex-start' : 'center',
gap: '10px',
padding: isExpanded ? '0 10px' : '0',
cursor: 'pointer',
transition: 'background-color 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out',
}}
>
<Icon size={17} strokeWidth={2.2} />
{isExpanded && (
<span style={{ fontSize: '14px', fontWeight: 600 }}>
{section.label}
</span>
)}
</button>
)
})}
</nav>
</section>
{isExpanded && (
<>
<section style={{ paddingTop: '4px' }}>
<SectionTitle>Contact</SectionTitle>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<button
type="button"
onClick={() => setShowReferralForm(true)}
className="sidebar-control"
style={{
width: '100%',
minHeight: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
border: '1px solid var(--accent-border)',
background: 'var(--surface)',
color: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
//fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.03em',
transition: 'border-color 150ms, color 150ms',
}}
>
<Send size={14} />
Contact patient
</button>
<div style={{ display: 'flex', gap: '6px' }}>
<a
href="https://www.linkedin.com/in/andrewcharlwood/"
target="_blank"
rel="noopener noreferrer"
className="sidebar-control"
style={{
flex: 1,
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
fontSize: '12px',
fontWeight: 500,
textDecoration: 'none',
transition: 'border-color 150ms, color 150ms',
}}
>
<Linkedin size={14} />
LinkedIn
</a>
<a
href="https://github.com/andrewcharlwood"
target="_blank"
rel="noopener noreferrer"
className="sidebar-control"
style={{
flex: 1,
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
border: '1px solid var(--border-light)',
background: 'var(--surface)',
color: 'var(--text-secondary)',
borderRadius: 'var(--radius-sm)',
fontSize: '12px',
fontWeight: 500,
textDecoration: 'none',
transition: 'border-color 150ms, color 150ms',
}}
>
<Github size={14} />
GitHub
</a>
</div>
</div>
</section>
<section style={{ paddingTop: '8px' }}>
<SectionTitle>{sidebarCopy.tagsTitle}</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '5px' }}>
{tags.map((tag) => (
<TagPill key={tag.label} tag={tag} />
))}
</div>
</section>
</>
)}
</aside>
<ReferralFormModal isOpen={showReferralForm} onClose={() => setShowReferralForm(false)} />
</>
)
}
-195
View File
@@ -1,195 +0,0 @@
import { useRef, useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import type { Skill } from '../types'
import { calculateSkillOffset } from '../lib/utils'
const GAUGE_RADIUS = 34
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
interface SkillGaugeProps {
skill: Skill
delay: number
isVisible: boolean
}
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
const [animated, setAnimated] = useState(false)
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
useEffect(() => {
if (isVisible && !animated) {
const timer = setTimeout(() => setAnimated(true), delay)
return () => clearTimeout(timer)
}
}, [isVisible, animated, delay])
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
className={`flex flex-col items-center p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
>
<svg
className="skill-gauge block"
width="80"
height="80"
viewBox="0 0 80 80"
>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke="#E2E8F0"
strokeWidth="5"
/>
<circle
cx="40"
cy="40"
r={GAUGE_RADIUS}
fill="none"
stroke={strokeColor}
strokeWidth="5"
strokeLinecap="round"
transform="rotate(-90, 40, 40)"
style={{
strokeDasharray: GAUGE_CIRCUMFERENCE,
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
}}
/>
<text
x="40"
y="40"
textAnchor="middle"
dominantBaseline="central"
fontSize="14"
fontWeight="600"
fill="#0F172A"
fontFamily="'Inter Tight', system-ui, sans-serif"
>
{skill.level}%
</text>
</svg>
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
{skill.name}
</span>
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
{skill.category}
</span>
</motion.div>
)
}
interface SkillCategoryProps {
label: string
skills: Skill[]
isVisible: boolean
baseDelay: number
}
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
return (
<div className="mb-10 last:mb-0">
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
{label}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-6">
{skills.map((skill, index) => (
<SkillGauge
key={skill.name}
skill={skill}
delay={baseDelay + index * 100}
isVisible={isVisible}
/>
))}
</div>
</div>
)
}
const skillsData: Skill[] = [
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
]
export function Skills() {
const sectionRef = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const element = sectionRef.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.unobserve(element)
}
},
{ threshold: 0.15, rootMargin: '0px' }
)
observer.observe(element)
return () => observer.disconnect()
}, [])
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
return (
<section id="skills" ref={sectionRef} className="py-20">
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary text-2xl font-bold text-heading text-center mb-8"
>
Skills &amp; Expertise
</motion.h2>
<SkillCategory
label="Technical"
skills={technicalSkills}
isVisible={isVisible}
baseDelay={200}
/>
<SkillCategory
label="Clinical"
skills={clinicalSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + 100}
/>
<SkillCategory
label="Strategic"
skills={strategicSkills}
isVisible={isVisible}
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
/>
</section>
)
}
@@ -0,0 +1,437 @@
import { useMemo, useState, useCallback } from 'react'
import { ChevronRight, ChevronDown, History } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { ExpandableCardShell } from './ExpandableCardShell'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineEntities, timelineConsultations } from '@/data/timeline'
import { documents } from '@/data/documents'
import { getExperienceEducationUICopy } from '@/lib/profile-content'
import type { TimelineEntity } from '@/types/pmr'
const timelineToDocumentId: Record<string, string> = {
'nhs-mary-seacole-2018': 'doc-mary-seacole',
'uea-mpharm-2011': 'doc-mpharm',
'highworth-alevels-2009': 'doc-alevels',
}
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
const VISIBLE_COUNT = 5
interface TimelineInterventionItemProps {
entity: TimelineEntity
isExpanded: boolean
isHighlightedFromGraph: boolean
isDimmedByFocus: boolean
isEducationAnchor: boolean
onToggle: () => void
onViewFull: () => void
onHighlight?: (id: string | null) => void
}
function TimelineInterventionItem({
entity,
isExpanded,
isHighlightedFromGraph,
isDimmedByFocus,
isEducationAnchor,
onToggle,
onViewFull,
onHighlight,
}: TimelineInterventionItemProps) {
const experienceEducationCopy = getExperienceEducationUICopy()
const isEducation = entity.kind === 'education'
const interventionLabel = isEducation ? experienceEducationCopy.educationLabel : experienceEducationCopy.employmentLabel
return (
<ExpandableCardShell
isExpanded={isExpanded}
isHighlighted={isHighlightedFromGraph}
isDimmedByFocus={isDimmedByFocus}
accentColor={entity.orgColor}
onToggle={onToggle}
ariaLabel={`${entity.title} at ${entity.organization}, ${entity.dateRange.display}. Click to ${isExpanded ? 'collapse' : 'expand'} details.`}
headerPadding="8px 8px"
className={isEducation ? 'timeline-intervention-item timeline-intervention-item--education' : 'timeline-intervention-item'}
dataTileId={isEducationAnchor ? 'section-education' : undefined}
onMouseEnter={() => onHighlight?.(entity.id)}
onMouseLeave={() => onHighlight?.(null)}
renderHeader={() => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ minWidth: 0 }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '6px',
}}
>
<span className={isEducation ? 'timeline-intervention-pill timeline-intervention-pill--education' : 'timeline-intervention-pill'}>
{interventionLabel}
</span>
{entity.dateRange.end === null && (
<span
style={{
fontSize: '9px',
fontWeight: 700,
fontFamily: 'var(--font-geist-mono)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
padding: '2px 7px',
borderRadius: '9999px',
background: 'rgba(34, 197, 94, 0.12)',
color: '#16a34a',
border: '1px solid rgba(34, 197, 94, 0.3)',
}}
>
Current
</span>
)}
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{entity.title}
</div>
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
marginTop: '2px',
}}
>
{entity.organization}
<span
style={{
fontSize: '11px',
paddingLeft: '6px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
marginTop: '3px',
}}
>
{entity.dateRange.display}
</span>
</div>
</div>
{(entity.band || entity.employmentBasis) && (
<div
style={{
display: 'flex',
flexShrink: 0,
alignItems: 'center',
gap: '5px',
}}
>
{entity.band && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '2px 6px',
borderRadius: '3px',
background: hexToRgba(entity.orgColor, 0.1),
color: entity.orgColor,
border: `1px solid ${hexToRgba(entity.orgColor, 0.25)}`,
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
Band {entity.band.toUpperCase()}
</span>
)}
{entity.employmentBasis && (
<span
title={entity.contextNote}
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '3px',
background: 'rgba(245, 158, 11, 0.1)',
color: '#b45309',
border: '1px solid rgba(245, 158, 11, 0.25)',
cursor: 'default',
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
{entity.employmentBasis}
</span>
)}
</div>
)}
</div>
)}
renderBody={() => (
<>
{entity.contextNote && (
<div
style={{
fontSize: '12px',
fontStyle: 'italic',
color: 'var(--text-tertiary)',
marginBottom: '10px',
}}
>
{entity.contextNote}
</div>
)}
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '0 0 10px 0',
display: 'flex',
flexDirection: 'column',
gap: '5px',
}}
>
{entity.details.map((detail, i) => (
<li
key={i}
style={{
fontSize: '13px',
color: 'var(--text-primary)',
lineHeight: 1.5,
paddingLeft: '12px',
position: 'relative',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: '6px',
width: '4px',
height: '4px',
borderRadius: '50%',
background: entity.orgColor,
opacity: 0.5,
}}
/>
{detail}
</li>
))}
</ul>
{!!entity.codedEntries?.length && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '10px',
}}
>
{entity.codedEntries.map((entry) => (
<span
key={entry.code}
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '4px',
background: hexToRgba(entity.orgColor, 0.08),
color: entity.orgColor,
border: `1px solid ${hexToRgba(entity.orgColor, 0.2)}`,
}}
>
{entry.code}: {entry.description}
</span>
))}
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
onViewFull()
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: 500,
color: entity.orgColor,
background: 'transparent',
border: 'none',
padding: '4px 0',
cursor: 'pointer',
fontFamily: 'inherit',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
>
{experienceEducationCopy.viewFullRecordLabel}
<ChevronRight size={12} />
</button>
</>
)}
/>
)
}
interface TimelineInterventionsSubsectionProps {
onNodeHighlight?: (id: string | null) => void
highlightedRoleId?: string | null
focusRelatedIds?: Set<string> | null
}
export function TimelineInterventionsSubsection({ onNodeHighlight, highlightedRoleId, focusRelatedIds }: TimelineInterventionsSubsectionProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const [historicalOpen, setHistoricalOpen] = useState(false)
const { openPanel } = useDetailPanel()
const visibleEntities = useMemo(() => timelineEntities.slice(0, VISIBLE_COUNT), [])
const historicalEntities = useMemo(() => timelineEntities.slice(VISIBLE_COUNT), [])
const consultationsById = useMemo(
() => new Map(timelineConsultations.map((consultation) => [consultation.id, consultation])),
[],
)
const firstEducationId = useMemo(
() => timelineEntities.find((entity) => entity.kind === 'education')?.id ?? null,
[],
)
const handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id))
}, [])
const handleViewFull = useCallback((entity: TimelineEntity) => {
if (entity.kind === 'education') {
const docId = timelineToDocumentId[entity.id]
const doc = docId ? documents.find((d) => d.id === docId) : undefined
if (doc) openPanel({ type: 'education', document: doc })
return
}
const consultation = consultationsById.get(entity.id)
if (!consultation) return
openPanel({ type: 'career-role', consultation })
}, [consultationsById, openPanel])
const historicalHasAnyFocusRelevance = focusRelatedIds !== null && focusRelatedIds !== undefined &&
historicalEntities.some((e) => focusRelatedIds.has(e.id))
const historicalDimmed = focusRelatedIds !== null && focusRelatedIds !== undefined && !historicalHasAnyFocusRelevance
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{visibleEntities.map((entity) => (
<TimelineInterventionItem
key={entity.id}
entity={entity}
isExpanded={expandedId === entity.id}
isHighlightedFromGraph={highlightedRoleId === entity.id}
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
isEducationAnchor={entity.id === firstEducationId}
onToggle={() => handleToggle(entity.id)}
onViewFull={() => handleViewFull(entity)}
onHighlight={onNodeHighlight}
/>
))}
{historicalEntities.length > 0 && (
<div
style={{
opacity: historicalDimmed ? 0.25 : 1,
transition: 'opacity 150ms ease-out',
}}
>
<div
role="button"
tabIndex={0}
onClick={() => setHistoricalOpen((prev) => !prev)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setHistoricalOpen((prev) => !prev)
}
}}
aria-expanded={historicalOpen}
aria-label={`${historicalOpen ? 'Hide' : 'Show'} ${historicalEntities.length} historical entries`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '6px 10px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(0, 137, 123, 0.2)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<History size={13} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
<span
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
flex: 1,
}}
>
{historicalOpen ? 'Hide' : 'View'} historical entries ({historicalEntities.length})
</span>
<ChevronDown
size={13}
style={{
color: 'var(--text-tertiary)',
flexShrink: 0,
transform: historicalOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s ease-out',
}}
/>
</div>
<AnimatePresence>
{historicalOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={motionSafeTransition(0.25)}
style={{ overflow: 'hidden' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '10px' }}>
{historicalEntities.map((entity) => (
<TimelineInterventionItem
key={entity.id}
entity={entity}
isExpanded={expandedId === entity.id}
isHighlightedFromGraph={highlightedRoleId === entity.id}
isDimmedByFocus={focusRelatedIds !== null && focusRelatedIds !== undefined && !focusRelatedIds.has(entity.id)}
isEducationAnchor={entity.id === firstEducationId}
onToggle={() => handleToggle(entity.id)}
onViewFull={() => handleViewFull(entity)}
onHighlight={onNodeHighlight}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
)
}
@@ -0,0 +1,96 @@
import React from 'react'
import type { ConstellationNode } from '@/types/pmr'
import { ROLE_WIDTH, ROLE_HEIGHT, MOBILE_ROLE_WIDTH } from './constants'
interface AccessibleNodeOverlayProps {
nodes: ConstellationNode[]
nodeButtonPositions: Record<string, { x: number; y: number }>
dimensions: { width: number; height: number; scaleFactor: number }
onFocus: (nodeId: string) => void
onBlur: () => void
onClick: (nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
onKeyDown: (e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => void
}
export const AccessibleNodeOverlay: React.FC<AccessibleNodeOverlayProps> = ({
nodes,
nodeButtonPositions,
dimensions,
onFocus,
onBlur,
onClick,
onKeyDown,
}) => {
const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
const isEntity = (t: string) => t === 'role' || t === 'education'
const sorted = [...nodes].sort((a, b) => {
if (isEntity(a.type) && !isEntity(b.type)) return -1
if (!isEntity(a.type) && isEntity(b.type)) return 1
if (isEntity(a.type) && isEntity(b.type)) {
return (b.startYear ?? 0) - (a.startYear ?? 0)
}
const da = domainOrder[a.domain ?? 'technical'] ?? 0
const db = domainOrder[b.domain ?? 'technical'] ?? 0
if (da !== db) return da - db
return (a.label ?? '').localeCompare(b.label ?? '')
})
const isMobileBtn = typeof window !== 'undefined' && window.innerWidth < 640
const btnSf = isMobileBtn ? 1 : dimensions.scaleFactor
return (
<div
role="group"
aria-label="Career nodes - use Tab to navigate and Enter to open details"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
>
{sorted.map(node => {
const yearRange = node.endYear
? `${node.startYear}-${node.endYear}`
: `${node.startYear}-present`
const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
const isEntityBtn = isEntity(node.type)
const buttonWidth = isEntityBtn ? (isMobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
const buttonHeight = isEntityBtn ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)
return (
<button
key={node.id}
type="button"
aria-label={
isEntityBtn
? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
: `${node.label} skill node. Press Enter to view details.`
}
style={{
position: 'absolute',
width: buttonWidth,
height: buttonHeight,
top: `${position.y}px`,
left: `${position.x}px`,
transform: 'translate(-50%, -50%)',
background: 'transparent',
border: 'none',
cursor: 'default',
pointerEvents: 'none',
padding: 0,
opacity: 0,
}}
onFocus={() => onFocus(node.id)}
onBlur={onBlur}
onClick={() => onClick(node.id, node.type)}
onKeyDown={e => onKeyDown(e, node.id, node.type)}
/>
)
})}
</div>
)
}
@@ -0,0 +1,422 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'
import * as d3 from 'd3'
import { constellationNodes } from '@/data/constellation'
import { timelineEntities } from '@/data/timeline'
import { useForceSimulation, getHeight } from '@/hooks/useForceSimulation'
import { useConstellationHighlight } from '@/hooks/useConstellationHighlight'
import { useConstellationInteraction } from '@/hooks/useConstellationInteraction'
import { useTimelineAnimation } from '@/hooks/useTimelineAnimation'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { MobileAccordion } from './MobileAccordion'
import { ConstellationLegend } from './ConstellationLegend'
import { AccessibleNodeOverlay } from './AccessibleNodeOverlay'
import { PlayPauseButton } from './PlayPauseButton'
import { FullscreenButton } from './FullscreenButton'
import { srDescription } from './screen-reader-description'
import {
MIN_HEIGHT,
SKILL_RADIUS_DEFAULT, SKILL_RADIUS_ACTIVE,
MOBILE_SKILL_RADIUS_DEFAULT, MOBILE_SKILL_RADIUS_ACTIVE,
supportsCoarsePointer, prefersReducedMotion,
} from './constants'
interface CareerConstellationProps {
onRoleClick: (id: string) => void
onSkillClick: (id: string) => void
onNodeHover?: (id: string | null) => void
highlightedNodeId?: string | null
containerHeight?: number | null
animationReady?: boolean
globalFocusActive?: boolean
}
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
const careerEntityById = new Map(timelineEntities.map(entity => [entity.id, entity]))
const CareerConstellation: React.FC<CareerConstellationProps> = ({
onRoleClick,
onSkillClick,
onNodeHover,
highlightedNodeId,
containerHeight,
animationReady = false,
globalFocusActive = false,
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
const [chartInView, setChartInView] = useState(true)
const [isFullscreen, setIsFullscreen] = useState(false)
callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }
useEffect(() => {
highlightedNodeIdRef.current = highlightedNodeId ?? null
}, [highlightedNodeId])
// Track chart visibility for play/pause button
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new IntersectionObserver(
([entry]) => setChartInView(entry.isIntersecting),
{ threshold: 0.1 },
)
observer.observe(container)
return () => observer.disconnect()
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const X_CHANGE_THRESHOLD = 0.3
const updateDimensions = (force = false) => {
const width = container.clientWidth
const viewportWidth = window.innerWidth
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
const scaleFactor = viewportWidth >= 1024
? Math.max(1, Math.min(1.6, viewportWidth / 1440))
: 1
setDimensions(prev => {
if (!force) {
const widthDelta = Math.abs(prev.width - width) / prev.width
const heightRatio = Math.max(height / prev.height, prev.height / height)
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
return prev
}
}
return { width, height, scaleFactor }
})
}
// Force update on fullscreen/orientation change so animation always restarts
requestAnimationFrame(() => updateDimensions(true))
const onOrientationChange = () => {
requestAnimationFrame(() => updateDimensions(true))
}
window.addEventListener('orientationchange', onOrientationChange)
const observer = new ResizeObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => updateDimensions(), 2000)
})
observer.observe(container)
return () => {
observer.disconnect()
window.removeEventListener('orientationchange', onOrientationChange)
if (debounceTimer) clearTimeout(debounceTimer)
}
}, [containerHeight, isFullscreen])
const toggleFullscreen = useCallback(() => {
const entering = !isFullscreen
setIsFullscreen(entering)
if (entering) {
// On portrait touch devices, request native fullscreen + lock landscape
const isPortrait = window.matchMedia('(orientation: portrait)').matches
const isTouch = window.matchMedia('(pointer: coarse)').matches
if (isPortrait && isTouch && containerRef.current?.requestFullscreen) {
const so = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise<void> }
containerRef.current.requestFullscreen()
.then(() => so.lock?.('landscape'))
.catch(() => {})
}
} else {
const so = screen.orientation as ScreenOrientation & { unlock?: () => void }
try { so.unlock?.() } catch { /* not supported */ }
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {})
}
}
}, [isFullscreen])
// ESC key to exit fullscreen
useEffect(() => {
if (!isFullscreen) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setIsFullscreen(false) }
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [isFullscreen])
// Body scroll lock while fullscreen
useEffect(() => {
if (!isFullscreen) return
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [isFullscreen])
// Sync state when native fullscreen is exited via browser controls
useEffect(() => {
const handler = () => {
if (!document.fullscreenElement && isFullscreen) {
setIsFullscreen(false)
}
}
document.addEventListener('fullscreenchange', handler)
return () => document.removeEventListener('fullscreenchange', handler)
}, [isFullscreen])
// Focus trap when fullscreen
useFocusTrap(containerRef, isFullscreen)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
const sf = isMobile ? 1 : dimensions.scaleFactor
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)
// pinnedNodeIdRef is declared later but only accessed at call-time (not during render), so empty dep arrays are correct
/* eslint-disable react-hooks/exhaustive-deps */
const resolveGraphFallback = useCallback(
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
[],
)
const resolveRoleFallback = useCallback(() => {
const hId = highlightedNodeIdRef.current
const hType = hId ? nodeById.get(hId)?.type : null
if (hId && hType && hType !== 'skill') return hId
const pId = pinnedNodeIdRef.current
const pType = pId ? nodeById.get(pId)?.type : null
if (pId && pType && pType !== 'skill') return pId
return null
}, [])
/* eslint-enable react-hooks/exhaustive-deps */
// Shared refs for hooks
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
const nodesRef = useRef<import('./types').SimNode[]>([])
const nodeSelectionRef = useRef<d3.Selection<SVGGElement, import('./types').SimNode, SVGGElement, unknown> | null>(null)
const linkSelectionRef = useRef<d3.Selection<SVGPathElement, import('./types').SimLink, SVGGElement, unknown> | null>(null)
const connectedMapRef = useRef<Map<string, Set<string>>>(new Map())
const skillRestRadiiRef = useRef<Map<string, number>>(new Map())
const visibleNodeIdsRef = useRef<Set<string>>(new Set())
const { applyGraphHighlight } = useConstellationHighlight({
nodeSelectionRef,
linkSelectionRef,
connectedMap: connectedMapRef.current,
srDefault,
srActive,
nodesRef,
skillRestRadii: skillRestRadiiRef.current,
visibleNodeIdsRef,
})
highlightGraphRef.current = applyGraphHighlight
const simOptionsRef = useRef({
resolveGraphFallback,
applyHighlight: applyGraphHighlight,
})
simOptionsRef.current = { resolveGraphFallback, applyHighlight: applyGraphHighlight }
const stableSimOptions = useMemo(() => ({
resolveGraphFallback: () => simOptionsRef.current.resolveGraphFallback(),
applyHighlight: (id: string | null) => simOptionsRef.current.applyHighlight(id),
}), [])
const sim = useForceSimulation(svgRef, dimensions, stableSimOptions)
// Sync simulation refs
useEffect(() => {
nodesRef.current = sim.nodesRef.current
nodeSelectionRef.current = sim.nodeSelectionRef.current
linkSelectionRef.current = sim.linkSelectionRef.current
if (sim.connectedMap.size > 0) connectedMapRef.current = sim.connectedMap
if (sim.skillRestRadii.size > 0) skillRestRadiiRef.current = sim.skillRestRadii
})
// Animation hook
const animation = useTimelineAnimation({
nodeSelectionRef,
linkSelectionRef,
simulationRef: sim.simulationRef,
yearIndicatorRef: sim.yearIndicatorRef,
connectorSelectionRef: sim.connectorSelectionRef,
timelineGroupRef: sim.timelineGroupRef,
skillRestRadiiRef,
srDefault,
dimensionsTrigger: dimensions.width + dimensions.height,
ready: animationReady,
})
// Sync visibleNodeIdsRef from animation hook
visibleNodeIdsRef.current = animation.visibleNodeIdsRef.current
// Interaction hook
const { pinnedNodeId, setPinnedNodeId, pinnedNodeIdRef } = useConstellationInteraction({
highlightGraphRef,
nodeSelectionRef,
svgRef,
callbacksRef,
resolveGraphFallback,
resolveRoleFallback,
dimensionsTrigger: dimensions.width + dimensions.height,
})
// External highlight sync
useEffect(() => {
if (!highlightGraphRef.current) return
highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
}, [highlightedNodeId, pinnedNodeId])
// Focus ring management
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.focus-ring').attr('stroke', 'transparent')
if (focusedNodeId) {
svg.selectAll<SVGGElement, { id: string }>('g.node')
.filter(d => d.id === focusedNodeId)
.select('.focus-ring')
.attr('stroke', 'var(--accent)')
.attr('stroke-width', 2)
}
}, [focusedNodeId])
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill' | 'education') => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
onNodeHover?.(nodeType !== 'skill' ? nodeId : resolveRoleFallback())
;(nodeType !== 'skill' ? onRoleClick : onSkillClick)(nodeId)
}, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback, setPinnedNodeId, pinnedNodeIdRef])
const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && (n.type === 'role' || n.type === 'education')) : null
const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) ?? null : null
const domainCounts = useMemo(() => {
const counts: Record<string, number> = {}
constellationNodes.filter(n => n.type === 'skill').forEach(n => {
const d = n.domain ?? 'technical'
counts[d] = (counts[d] ?? 0) + 1
})
return counts
}, [])
const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null
return (
<>
{isFullscreen && (
<div
onClick={toggleFullscreen}
style={{
position: 'fixed',
inset: 0,
zIndex: 899,
background: 'var(--backdrop-bg)',
backdropFilter: 'blur(var(--backdrop-blur))',
animation: 'backdrop-fade-in 200ms ease-out',
}}
/>
)}
<div
ref={containerRef}
{...(isFullscreen ? {
role: 'dialog',
'aria-modal': true,
'aria-label': 'Career constellation fullscreen view',
} : {})}
style={{
width: '100%',
borderRadius: isFullscreen ? 0 : 'var(--radius-sm)',
border: isFullscreen ? 'none' : '1px solid var(--border-light)',
overflow: 'hidden',
position: isFullscreen ? 'fixed' : 'relative',
...(isFullscreen ? { inset: 0, zIndex: 900, background: 'var(--surface)' } : {}),
animation: isFullscreen ? 'constellation-fullscreen-in 200ms ease-out' : undefined,
}}
>
<svg
ref={svgRef}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
role="img"
aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
className={globalFocusActive || highlightedNodeId || pinnedNodeId ? 'constellation-focus-active' : ''}
style={{
display: 'block',
width: '100%',
height: dimensions.height,
opacity: 1,
}}
/>
<ConstellationLegend isTouch={supportsCoarsePointer} domainCounts={domainCounts} />
<MobileAccordion pinnedCareerEntity={pinnedCareerEntity} show={showAccordion} />
{!prefersReducedMotion && (
<PlayPauseButton
isPlaying={animation.isPlaying}
isCompleted={animation.isCompleted}
onToggle={animation.togglePlayPause}
isMobile={isMobile}
visible={chartInView}
containerRef={containerRef}
/>
)}
<FullscreenButton
isFullscreen={isFullscreen}
onToggle={toggleFullscreen}
isMobile={isMobile}
/>
<p
style={{
position: 'absolute',
width: 1, height: 1, padding: 0, margin: -1,
overflow: 'hidden', clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap', border: 0,
}}
>
{srDescription}
</p>
<AccessibleNodeOverlay
nodes={constellationNodes}
nodeButtonPositions={sim.nodeButtonPositions}
dimensions={dimensions}
onFocus={(nodeId) => {
setFocusedNodeId(nodeId)
highlightGraphRef.current?.(nodeId)
const node = nodeById.get(nodeId)
if (node?.type !== 'skill') onNodeHover?.(nodeId)
}}
onBlur={() => {
setFocusedNodeId(null)
highlightGraphRef.current?.(resolveGraphFallback())
onNodeHover?.(resolveRoleFallback())
}}
onClick={(nodeId, nodeType) => {
setPinnedNodeId(nodeId)
pinnedNodeIdRef.current = nodeId
highlightGraphRef.current?.(nodeId)
if (nodeType !== 'skill') {
onNodeHover?.(nodeId)
onRoleClick(nodeId)
} else {
onNodeHover?.(resolveRoleFallback())
onSkillClick(nodeId)
}
}}
onKeyDown={handleNodeKeyDown}
/>
</div>
</>
)
}
export default CareerConstellation
@@ -0,0 +1,76 @@
import React from 'react'
import { supportsCoarsePointer } from './constants'
interface ConstellationLegendProps {
isTouch: boolean
domainCounts?: Record<string, number>
}
export const ConstellationLegend: React.FC<ConstellationLegendProps> = ({ isTouch, domainCounts }) => {
const items = [
{ label: 'Technical', domain: 'technical', color: 'var(--accent)' },
{ label: 'Clinical', domain: 'clinical', color: 'var(--success)' },
{ label: 'Leadership', domain: 'leadership', color: 'var(--amber)' },
]
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
padding: '8px 12px',
pointerEvents: 'none',
}}
>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-ui)',
color: 'var(--text-secondary)',
opacity: 1,
}}
>
{isTouch || supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
fontFamily: 'var(--font-geist-mono)',
fontSize: '12px',
color: 'var(--text-tertiary)',
lineHeight: '24px',
}}
>
{items.map((item, i) => (
<React.Fragment key={item.label}>
{i > 0 && (
<span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
)}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: item.color,
flexShrink: 0,
}}
/>
{item.label}{domainCounts?.[item.domain] != null ? ` (${domainCounts[item.domain]})` : ''}
</span>
</React.Fragment>
))}
</div>
</div>
)
}
@@ -0,0 +1,48 @@
import React from 'react'
import { Maximize2, Minimize2 } from 'lucide-react'
interface FullscreenButtonProps {
isFullscreen: boolean
onToggle: () => void
isMobile: boolean
}
export const FullscreenButton: React.FC<FullscreenButtonProps> = ({
isFullscreen, onToggle, isMobile,
}) => {
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
const size = isMobile ? 44 : Math.round(36 * scale)
const offset = isMobile ? 8 : Math.round(12 * scale)
const iconSize = isMobile ? 16 : Math.round(14 * scale)
const Icon = isFullscreen ? Minimize2 : Maximize2
return (
<button
onClick={onToggle}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
style={{
position: 'absolute',
right: offset,
top: offset,
width: size,
height: size,
borderRadius: '50%',
border: '1.5px solid var(--border)',
background: 'var(--surface)',
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: 0.85,
transition: 'opacity 200ms ease',
zIndex: 5,
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={e => { e.currentTarget.style.opacity = '0.85' }}
>
<Icon size={iconSize} color="var(--text-secondary)" strokeWidth={2} />
</button>
)
}
@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { TimelineEntity } from '@/types/pmr'
import { motionSafeTransition } from '@/lib/utils'
interface MobileAccordionProps {
pinnedCareerEntity: TimelineEntity | null
show: boolean
}
export const MobileAccordion: React.FC<MobileAccordionProps> = ({ pinnedCareerEntity, show }) => {
const [accordionShowMore, setAccordionShowMore] = useState(false)
useEffect(() => {
setAccordionShowMore(false)
}, [pinnedCareerEntity?.id])
return (
<AnimatePresence>
{show && pinnedCareerEntity && (
<motion.div
key={pinnedCareerEntity.id}
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={motionSafeTransition(0.2)}
style={{ overflow: 'hidden' }}
>
<div
style={{
padding: '12px 16px',
borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
fontFamily: 'var(--font-ui)',
}}
>
<div style={{ marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '2px' }}>
<span
style={{
display: 'inline-block',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
flexShrink: 0,
}}
/>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>
{pinnedCareerEntity.title}
</span>
</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
paddingLeft: '14px',
}}
>
{pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
</div>
</div>
<ul style={{ margin: 0, paddingLeft: '14px', listStyle: 'none' }}>
{(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
opacity: 0.5,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
{accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: '14px', listStyle: 'none' }}>
{(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
<li
key={i}
style={{
fontSize: '12px',
color: 'var(--text-tertiary)',
lineHeight: '1.5',
marginBottom: '4px',
display: 'flex',
gap: '8px',
}}
>
<span
style={{
display: 'inline-block',
width: '4px',
height: '4px',
borderRadius: '50%',
backgroundColor: 'var(--text-tertiary)',
opacity: 0.4,
flexShrink: 0,
marginTop: '7px',
}}
/>
{item}
</li>
))}
</ul>
)}
{pinnedCareerEntity.details.length > 3 && (
<button
type="button"
onClick={() => setAccordionShowMore(prev => !prev)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px 14px',
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
fontWeight: 500,
marginTop: '4px',
}}
>
{accordionShowMore ? 'Show less' : 'Show more'}
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,107 @@
import React, { useEffect, useRef, useState } from 'react'
interface PlayPauseButtonProps {
isPlaying: boolean
isCompleted?: boolean
onToggle: () => void
isMobile: boolean
visible?: boolean
containerRef: React.RefObject<HTMLDivElement | null>
}
export const PlayPauseButton: React.FC<PlayPauseButtonProps> = ({
isPlaying, isCompleted = false, onToggle, isMobile, visible = true, containerRef,
}) => {
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const scale = vw >= 1440 ? 1.75 : vw >= 1280 ? 1.5 : vw >= 1080 ? 1.25 : 1
const size = isMobile ? 44 : Math.round(36 * scale)
const offset = isMobile ? 8 : Math.round(12 * scale)
const btnRef = useRef<HTMLButtonElement>(null)
const [topPos, setTopPos] = useState(56)
const [scrolling, setScrolling] = useState(false)
const debounceRef = useRef(0)
useEffect(() => {
const container = containerRef.current
if (!container) return
const scrollParent = container.closest('.dashboard-main') as HTMLElement | null
if (!scrollParent) return
const margin = isMobile ? 12 : 56
const update = () => {
const cRect = container.getBoundingClientRect()
const sRect = scrollParent.getBoundingClientRect()
const visibleTop = Math.max(sRect.top, cRect.top) + margin + 50
const visibleBottom = Math.min(sRect.bottom, cRect.bottom) - size - 12
const targetY = Math.min(visibleTop, visibleBottom)
const relativeTop = targetY - cRect.top
setTopPos(Math.max(margin, relativeTop))
setScrolling(true)
clearTimeout(debounceRef.current)
debounceRef.current = window.setTimeout(() => setScrolling(false), 500)
}
scrollParent.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update, { passive: true })
update()
// Don't start hidden — clear the initial scroll trigger
setScrolling(false)
return () => {
scrollParent.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
clearTimeout(debounceRef.current)
}
}, [containerRef, isMobile, size])
const showButton = visible && !scrolling
return (
<button
ref={btnRef}
onClick={onToggle}
aria-label={isCompleted ? 'Replay animation' : isPlaying ? 'Pause animation' : 'Play animation'}
style={{
position: 'absolute',
left: offset,
top: topPos,
width: size,
height: size,
borderRadius: '50%',
border: '1.5px solid var(--border)',
background: 'var(--surface)',
boxShadow: '0 1px 4px rgba(26,43,42,0.10)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: showButton ? 0.85 : 0,
pointerEvents: showButton ? 'auto' : 'none',
transition: scrolling
? 'opacity 150ms ease, top 80ms linear'
: 'opacity 500ms ease, top 80ms linear',
zIndex: 5,
}}
onMouseEnter={e => { if (showButton) e.currentTarget.style.opacity = '1' }}
onMouseLeave={e => { if (showButton) e.currentTarget.style.opacity = '0.85' }}
>
{isCompleted ? (
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 76.398 76.398" fill="var(--text-secondary)">
<path d="M58.828,16.208l-3.686,4.735c7.944,6.182,11.908,16.191,10.345,26.123C63.121,62.112,48.954,72.432,33.908,70.06C18.863,67.69,8.547,53.522,10.912,38.477c1.146-7.289,5.063-13.694,11.028-18.037c5.207-3.79,11.433-5.613,17.776-5.252l-5.187,5.442l3.848,3.671l8.188-8.596l0.002,0.003l3.668-3.852L46.39,8.188l-0.002,0.001L37.795,0l-3.671,3.852l5.6,5.334c-7.613-0.36-15.065,1.853-21.316,6.403c-7.26,5.286-12.027,13.083-13.423,21.956c-2.879,18.313,9.676,35.558,27.989,38.442c1.763,0.277,3.514,0.411,5.245,0.411c16.254-0.001,30.591-11.85,33.195-28.4C73.317,35.911,68.494,23.73,58.828,16.208z" />
</svg>
) : isPlaying ? (
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
<rect x="2" y="1" width="4" height="12" rx="1" />
<rect x="8" y="1" width="4" height="12" rx="1" />
</svg>
) : (
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 14 14" fill="var(--text-secondary)">
<polygon points="3,1 13,7 3,13" />
</svg>
)}
</button>
)
}
+84
View File
@@ -0,0 +1,84 @@
// Sizing
export const MIN_HEIGHT = 400
export const ROLE_WIDTH = 104
export const ROLE_HEIGHT = 32
export const ROLE_RX = 16
export const SKILL_RADIUS_DEFAULT = 7
export const SKILL_RADIUS_ACTIVE = 11
export const MOBILE_ROLE_WIDTH = 80
export const MOBILE_SKILL_RADIUS_DEFAULT = 6
export const MOBILE_SKILL_RADIUS_ACTIVE = 9
export const MOBILE_LABEL_MAX_LEN = 10
// Animation / opacity
export const HIGHLIGHT_DIM_OPACITY = 0.15
export const SKILL_REST_OPACITY = 0.6
export const SKILL_ACTIVE_OPACITY = 0.9
export const LABEL_REST_OPACITY = 0.6
// Link visual params
export const LINK_BASE_WIDTH = 0.7
export const LINK_STRENGTH_WIDTH_FACTOR = 0
export const LINK_BASE_OPACITY = 0
export const LINK_STRENGTH_OPACITY_FACTOR = 0
export const LINK_REST_OPACITY = 0.3
export const LINK_REST_STRENGTH_FACTOR = 0.08
export const LINK_HIGHLIGHT_BASE_WIDTH = 1
export const LINK_HIGHLIGHT_STRENGTH_WIDTH_FACTOR = 2
export const LINK_BEZIER_VERTICAL_OFFSET = 0.15
// Role node visual params
export const ROLE_STROKE_OPACITY_DEFAULT = 1
export const ROLE_STROKE_OPACITY_ACTIVE = 1
export const ROLE_STROKE_OPACITY_CONNECTED = 0.9
export const ROLE_STROKE_WIDTH_DEFAULT = 1
export const ROLE_STROKE_WIDTH_ACTIVE = 2
export const ROLE_FILL_OPACITY_ACTIVE = 1
export const ROLE_FILL_ACTIVE = '#FFFFFF'
// Skill node visual params
export const SKILL_STROKE_WIDTH = 1
export const SKILL_STROKE_OPACITY = 0.4
export const SKILL_SIZE_ROLE_FACTOR = 0.8
export const SKILL_GLOW_STD_DEVIATION = 2.5
export const SKILL_ACTIVE_STROKE_OPACITY = 0.1
// Skill overlap offsets
export const SKILL_Y_OFFSET_STEP = 35
export const SKILL_Y_OFFSET_STEP_MOBILE = 26
export const SKILL_Y_GLOBAL_OFFSET_RATIO = -0.05
export const SKILL_Y_CENTER_BLEND = 0.55
export const SKILL_X_OVERLAP_MAX_RATIO = 1
// Timeline animation
export const ANIM_CHRONOLOGICAL_ENABLED = true
export const ANIM_ENTITY_REVEAL_MS = 2000
export const ANIM_SKILL_REVEAL_MS = 2000
export const ANIM_SKILL_STAGGER_MS = 200
export const ANIM_LINK_DRAW_MS = 600
export const ANIM_LINK_STAGGER_MS = 200
export const ANIM_REINFORCEMENT_MS = 700
export const ANIM_STEP_GAP_MS = 1000
export const ANIM_RESTART_DELAY_MS = 400
export const ANIM_SETTLE_ALPHA = 0.05
export const ANIM_MONTH_STEP_MS = 80
// Domain color map
export const DOMAIN_COLOR_MAP: Record<string, string> = {
clinical: '#059669',
technical: '#0D6E6E',
leadership: '#D97706',
}
// Entities hidden from the constellation (education + early career roles)
export const HIDDEN_ENTITY_IDS = new Set([
'pre-reg-pharmacist-2015',
'duty-pharmacy-manager-2016',
'uea-mpharm-2011',
'highworth-alevels-2009',
])
// Media queries (evaluated once at module level)
export { prefersReducedMotion } from '@/lib/utils'
export const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches
@@ -0,0 +1,25 @@
import { constellationNodes, roleSkillMappings } from '@/data/constellation'
function buildScreenReaderDescription(): string {
const entities = constellationNodes.filter(n => n.type === 'role' || n.type === 'education')
const skills = constellationNodes.filter(n => n.type === 'skill')
const entityDescriptions = entities.map(entity => {
const mapping = roleSkillMappings.find(m => m.roleId === entity.id)
const skillNames = mapping
? mapping.skillIds
.map(sid => skills.find(s => s.id === sid)?.label)
.filter(Boolean)
.join(', ')
: ''
const yearRange = entity.endYear
? `${entity.startYear}-${entity.endYear}`
: `${entity.startYear}-present`
return `${entity.label} at ${entity.organization} (${yearRange}): ${skillNames}`
})
return `Career constellation graph showing ${entities.length} roles and ${skills.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
entityDescriptions.join('. ') + '.'
}
export const srDescription = buildScreenReaderDescription()
+53
View File
@@ -0,0 +1,53 @@
import type { ConstellationNode } from '@/types/pmr'
export interface SimNode extends ConstellationNode {
x: number
y: number
vx: number
vy: number
fx?: number | null
fy?: number | null
homeX: number
homeY: number
}
export interface SimLink {
source: SimNode | string
target: SimNode | string
strength: number
}
export interface LayoutParams {
width: number
height: number
scaleFactor: number
isMobile: boolean
rw: number
rh: number
rrx: number
srDefault: number
srActive: number
topPadding: number
bottomPadding: number
sidePadding: number
timelineX: number
sf: number
}
export interface ConstellationCallbacks {
onRoleClick: (id: string) => void
onSkillClick: (id: string) => void
onNodeHover?: (id: string | null) => void
}
export type AnimationState = 'IDLE' | 'PLAYING' | 'PAUSED' | 'HOLDING' | 'RESETTING' | 'COMPLETED'
export interface AnimationStep {
entityId: string
startYear: number
startMonth: number // 0-indexed (0 = January)
skillIds: string[]
newSkillIds: string[]
reinforcedSkillIds: string[]
linkPairs: Array<{ source: string; target: string }>
}
@@ -0,0 +1,149 @@
import type { Consultation } from '@/types/pmr'
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
interface ConsultationDetailProps {
consultation: Consultation
}
export function ConsultationDetail({ consultation }: ConsultationDetailProps) {
return (
<div style={detailRootStyle}>
{/* Role header */}
<div>
<div
style={{
fontSize: '20px',
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: '1.3',
marginBottom: '4px',
}}
>
{consultation.role}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: consultation.orgColor,
marginBottom: '8px',
}}
>
{consultation.organization}
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>{consultation.duration}</span>
{consultation.isCurrent && (
<span
style={{
padding: '2px 8px',
backgroundColor: 'var(--success-light)',
color: 'var(--success)',
fontSize: '10px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Current
</span>
)}
</div>
</div>
{/* History (presenting complaint) */}
<div>
<h3 style={sectionHeadingStyle}>History</h3>
<p style={paragraphStyle}>
{consultation.history}
</p>
</div>
{/* Examination (achievements) */}
{consultation.examination && consultation.examination.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Key Achievements</h3>
<ul style={bulletListStyle}>
{consultation.examination.map((item, index) => (
<li key={index} style={bodyTextStyle}>
{item}
</li>
))}
</ul>
</div>
)}
{/* Plan (outcomes) */}
{consultation.plan && consultation.plan.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Outcomes & Impact</h3>
<ul style={bulletListStyle}>
{consultation.plan.map((item, index) => (
<li key={index} style={bodyTextStyle}>
{item}
</li>
))}
</ul>
</div>
)}
{/* Coded entries (technical environment / tags) */}
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Coded Entries</h3>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}
>
{consultation.codedEntries.map((entry, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 12px',
backgroundColor: 'var(--bg-dashboard)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
}}
>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist)',
fontWeight: 600,
color: 'var(--accent)',
}}
>
{entry.code}
</span>
<span
style={{
fontSize: '11px',
color: 'var(--text-secondary)',
}}
>
{entry.description}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { GraduationCap, Award, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react'
import type { Document } from '@/types/pmr'
import { educationExtras } from '@/data/educationExtras'
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
interface EducationDetailProps {
document: Document
}
const typeIconMap: Record<string, LucideIcon> = {
Certificate: GraduationCap,
Registration: Award,
Results: BookOpen,
Research: FlaskConical,
}
export function EducationDetail({ document }: EducationDetailProps) {
const extra = educationExtras.find((e) => e.documentId === document.id)
const Icon = typeIconMap[document.type] || GraduationCap
return (
<div style={detailRootStyle}>
{/* Header */}
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '8px',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Icon size={18} />
</div>
<div>
<div
style={{
fontSize: '20px',
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: '1.3',
}}
>
{document.title}
</div>
</div>
</div>
{document.institution && (
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: '#7C3AED',
marginBottom: '4px',
}}
>
{document.institution}
</div>
)}
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
{document.duration && <span>{document.duration}</span>}
{document.classification && (
<span
style={{
padding: '2px 8px',
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
color: '#7C3AED',
fontSize: '10px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
}}
>
{document.classification}
</span>
)}
</div>
</div>
{/* Research project (MPharm) */}
{extra?.researchDescription && (
<div>
<h3 style={sectionHeadingStyle}>Research Project</h3>
<p style={paragraphStyle}>
{extra.researchDescription}
</p>
</div>
)}
{/* OSCE score (MPharm) */}
{extra?.osceScore && (
<div>
<h3 style={sectionHeadingStyle}>OSCE Performance</h3>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 14px',
backgroundColor: 'var(--success-light)',
border: '1px solid var(--success-border)',
borderRadius: 'var(--radius-sm)',
}}
>
<span
style={{
fontSize: '20px',
fontWeight: 700,
color: 'var(--success)',
}}
>
{extra.osceScore}
</span>
<span
style={{
fontSize: '12px',
color: 'var(--text-secondary)',
}}
>
Objective Structured Clinical Examination
</span>
</div>
</div>
)}
{/* Extracurricular activities (MPharm) */}
{extra?.extracurriculars && extra.extracurriculars.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Extracurricular Activities</h3>
<ul style={bulletListStyle}>
{extra.extracurriculars.map((activity, index) => (
<li key={index} style={bodyTextStyle}>
{activity}
</li>
))}
</ul>
</div>
)}
{/* Programme detail (Mary Seacole) */}
{extra?.programmeDetail && (
<div>
<h3 style={sectionHeadingStyle}>Programme Overview</h3>
<p style={paragraphStyle}>
{extra.programmeDetail}
</p>
</div>
)}
{/* Notes */}
{document.notes && (
<div>
<h3 style={sectionHeadingStyle}>Notes</h3>
<p
style={{
...paragraphStyle,
color: 'var(--text-secondary)',
fontStyle: 'italic',
}}
>
{document.notes}
</p>
</div>
)}
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import type { KPI } from '@/types/pmr'
import { KPI_COLORS } from '@/lib/theme-colors'
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
interface KPIDetailProps {
kpi: KPI
}
export function KPIDetail({ kpi }: KPIDetailProps) {
// If story exists, render rich content; otherwise fallback to explanation
if (!kpi.story) {
return (
<div
style={{
fontFamily: 'var(--font-ui)',
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}}
>
<div
style={{
fontSize: '32px',
fontWeight: 700,
color: KPI_COLORS[kpi.colorVariant],
marginBottom: '16px',
}}
>
{kpi.value}
</div>
<p>{kpi.explanation}</p>
</div>
)
}
const { context, role, outcomes, period } = kpi.story
return (
<div style={detailRootStyle}>
{/* Headline number */}
<div>
<div
style={{
fontSize: '48px',
fontWeight: 700,
color: KPI_COLORS[kpi.colorVariant],
lineHeight: '1',
marginBottom: '8px',
}}
>
{kpi.value}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
}}
>
{kpi.label}
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
marginTop: '2px',
}}
>
{kpi.sub}
</div>
</div>
{/* Period badge (if present) */}
{period && (
<div
style={{
display: 'inline-block',
padding: '4px 10px',
backgroundColor: 'var(--accent-light)',
color: 'var(--accent)',
fontSize: '11px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
fontFamily: 'var(--font-geist)',
alignSelf: 'flex-start',
}}
>
{period}
</div>
)}
{/* Context paragraph */}
<div>
<h3 style={sectionHeadingStyle}>Context</h3>
<p style={paragraphStyle}>{context}</p>
</div>
{/* My role paragraph */}
<div>
<h3 style={sectionHeadingStyle}>My Role</h3>
<p style={paragraphStyle}>{role}</p>
</div>
{/* Outcome bullets */}
<div>
<h3 style={sectionHeadingStyle}>Key Outcomes</h3>
<ul style={bulletListStyle}>
{outcomes.map((outcome, index) => (
<li key={index} style={bodyTextStyle}>
{outcome}
</li>
))}
</ul>
</div>
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
import { ExternalLink } from 'lucide-react'
import type { Investigation } from '@/types/pmr'
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
import { detailRootStyle, sectionHeadingStyle, bulletListStyle, bodyTextStyle, paragraphStyle } from './detail-styles'
interface ProjectDetailProps {
investigation: Investigation
}
const statusBgMap: Record<Investigation['status'], string> = {
Complete: 'rgba(5,150,105,0.08)',
Ongoing: 'rgba(217,119,6,0.08)',
Live: 'rgba(10,128,128,0.08)',
}
export function ProjectDetail({ investigation }: ProjectDetailProps) {
const statusColor = PROJECT_STATUS_COLORS[investigation.status]
const statusBg = statusBgMap[investigation.status]
return (
<div style={detailRootStyle}>
{/* Header: name + year + status */}
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
flexWrap: 'wrap',
marginBottom: '8px',
}}
>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
}}
>
{investigation.requestedYear}
</span>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
fontSize: '11px',
fontWeight: 600,
color: statusColor,
backgroundColor: statusBg,
borderRadius: 'var(--radius-sm)',
}}
>
{investigation.status}
</span>
</div>
<div
style={{
fontSize: '12px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
}}
>
{investigation.requestingClinician}
</div>
</div>
{/* Methodology */}
<div>
<h3 style={sectionHeadingStyle}>Methodology</h3>
<p style={{ ...paragraphStyle, whiteSpace: 'pre-line' }}>{investigation.methodology}</p>
</div>
{/* Tech stack tags */}
<div>
<h3 style={sectionHeadingStyle}>Tech Stack</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{investigation.techStack.map((tech) => (
<span
key={tech}
style={{
padding: '3px 10px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'var(--font-geist-mono)',
color: 'var(--accent)',
backgroundColor: 'var(--accent-light)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--accent-border)',
}}
>
{tech}
</span>
))}
</div>
</div>
{/* Domain skills */}
{investigation.skills && investigation.skills.length > 0 && (
<div>
<h3 style={sectionHeadingStyle}>Domain Skills</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{investigation.skills.map((skill) => (
<span
key={skill}
style={{
padding: '3px 10px',
fontSize: '11px',
fontWeight: 500,
fontFamily: 'var(--font-geist-mono)',
color: '#0D9488',
backgroundColor: 'rgba(13,148,136,0.08)',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(13,148,136,0.2)',
}}
>
{skill}
</span>
))}
</div>
</div>
)}
{/* Results */}
<div>
<h3 style={sectionHeadingStyle}>Results</h3>
<ul style={bulletListStyle}>
{investigation.results.map((result, index) => (
<li key={index} style={bodyTextStyle}>
{result}
</li>
))}
</ul>
</div>
{/* Action buttons */}
{(investigation.externalUrl || investigation.demoUrl) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignSelf: 'flex-start' }}>
{investigation.externalUrl && (
<a
href={investigation.externalUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-ui)',
color: 'var(--surface)',
backgroundColor: 'var(--accent)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
transition: 'background-color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--accent)'
}}
>
<ExternalLink size={14} />
View Live Project
</a>
)}
{investigation.demoUrl && (
<a
href={investigation.demoUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 600,
fontFamily: 'var(--font-ui)',
color: '#0D9488',
backgroundColor: 'transparent',
border: '1px solid #0D9488',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
transition: 'background-color 150ms, color 150ms',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(13,148,136,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<ExternalLink size={14} />
Interactive Demo
</a>
)}
</div>
)}
{/* Thumbnail */}
{investigation.thumbnail && (
<img
src={investigation.thumbnail}
alt={`${investigation.name} screenshot`}
style={{
width: '100%',
height: 'auto',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
}}
/>
)}
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import type { SkillMedication } from '@/types/pmr'
import { roleSkillMappings, constellationNodes } from '@/data/constellation'
import { detailRootStyle, sectionHeadingStyle } from './detail-styles'
interface SkillDetailProps {
skill: SkillMedication
}
// Category display names
const categoryLabels: Record<SkillMedication['category'], string> = {
Technical: 'Technical',
Clinical: 'Clinical',
Strategic: 'Strategic',
}
export function SkillDetail({ skill }: SkillDetailProps) {
// Find roles that use this skill from constellation data
const usedInRoles = roleSkillMappings
.filter((mapping) => mapping.skillIds.includes(skill.id))
.map((mapping) => {
const node = constellationNodes.find((n) => n.id === mapping.roleId && n.type === 'role')
return node
})
.filter(Boolean)
// Sort chronologically (earliest first)
.sort((a, b) => (a!.startYear ?? 0) - (b!.startYear ?? 0))
return (
<div style={detailRootStyle}>
{/* Skill header */}
<div>
<div
style={{
fontSize: '20px',
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: '1.3',
marginBottom: '8px',
}}
>
{skill.name}
</div>
{/* Medication metaphor badges */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span
style={{
padding: '3px 10px',
backgroundColor: 'var(--accent-light)',
color: 'var(--accent)',
fontSize: '11px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
fontFamily: 'var(--font-geist)',
}}
>
{skill.frequency}
</span>
<span
style={{
padding: '3px 10px',
backgroundColor:
skill.status === 'Active' ? 'var(--success-light)' : 'var(--bg-dashboard)',
color: skill.status === 'Active' ? 'var(--success)' : 'var(--text-tertiary)',
fontSize: '10px',
fontWeight: 600,
borderRadius: 'var(--radius-sm)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{skill.status}
</span>
</div>
</div>
{/* Category label */}
<div>
<span
style={{
fontSize: '11px',
fontWeight: 500,
color: 'var(--text-tertiary)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
{categoryLabels[skill.category]}
</span>
</div>
{/* Years of experience */}
<div>
<h3 style={sectionHeadingStyle}>Experience</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
<span
style={{
fontSize: '28px',
fontWeight: 700,
color: 'var(--text-primary)',
lineHeight: '1',
}}
>
{skill.yearsOfExperience}
</span>
<span
style={{
fontSize: '13px',
color: 'var(--text-secondary)',
}}
>
{skill.yearsOfExperience === 1 ? 'year' : 'years'}
</span>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
marginLeft: '4px',
}}
>
Since {skill.startYear}
</span>
</div>
</div>
{/* Used in roles */}
{usedInRoles.length > 0 && (
<div>
<h3 style={{ ...sectionHeadingStyle, marginBottom: '10px' }}>Used In</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{usedInRoles.map((node) => (
<div
key={node!.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 12px',
backgroundColor: 'var(--bg-dashboard)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
}}
>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: node!.orgColor ?? 'var(--accent)',
flexShrink: 0,
}}
aria-hidden="true"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12.5px',
fontWeight: 600,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{node!.label}
</div>
<div
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist)',
color: 'var(--text-tertiary)',
marginTop: '1px',
}}
>
{node!.organization} · {node!.startYear}
{node!.endYear === null ? 'Present' : node!.endYear !== node!.startYear ? `${node!.endYear}` : ''}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
+220
View File
@@ -0,0 +1,220 @@
import React, { useEffect, useRef } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
Pill, Users, FileCheck, Stethoscope,
TrendingUp, Route, BookOpen, Store,
Presentation, Calculator, Banknote, Handshake, RefreshCw,
GitBranch, Workflow, UserPlus, ChevronRight,
} from 'lucide-react'
import { skills } from '@/data/skills'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getSkillsUICopy } from '@/lib/profile-content'
import type { SkillMedication, SkillCategory } from '@/types/pmr'
const iconMap: Record<string, LucideIcon> = {
BarChart3, Code2, Database, LayoutDashboard, Bot, FileCode2,
Pill, Users, FileCheck, Stethoscope,
TrendingUp, Route, BookOpen, Store,
Presentation, Calculator, Banknote, Handshake, RefreshCw,
GitBranch, Workflow, UserPlus,
}
interface SkillsAllDetailProps {
category?: SkillCategory
}
export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
const { openPanel } = useDetailPanel()
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({})
const skillsCopy = getSkillsUICopy()
// Scroll to highlighted category on mount
useEffect(() => {
if (category && categoryRefs.current[category]) {
categoryRefs.current[category]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [category])
const frequencyRank = (freq: string): number => {
if (freq.includes('daily')) return freq.startsWith('4') ? 0 : freq.startsWith('3') ? 1 : freq.startsWith('1') ? 3 : 2
if (freq === 'Daily') return 4
if (freq.includes('weekly')) return freq.startsWith('2') ? 5 : freq.startsWith('1') ? 6 : 7
if (freq === 'Weekly') return 7
if (freq === 'Bi-monthly') return 8
return 9 // As needed
}
const groupedSkills = skillsCopy.categories.map(({ id, label }) => ({
id,
label,
skills: skills
.filter((s) => s.category === id)
.sort((a, b) => frequencyRank(a.frequency) - frequencyRank(b.frequency)),
}))
const handleSkillClick = (skill: SkillMedication) => {
openPanel({ type: 'skill', skill })
}
return (
<div style={{ fontFamily: 'var(--font-ui)', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{groupedSkills.map((group) => {
const isHighlighted = category === group.id
return (
<div
key={group.id}
ref={(el) => { categoryRefs.current[group.id] = el }}
>
{/* Category header — divider style */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
paddingBottom: '6px',
borderBottom: isHighlighted ? '2px solid var(--accent)' : undefined,
}}
>
<span
style={{
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: isHighlighted ? 'var(--accent)' : 'var(--text-tertiary)',
whiteSpace: 'nowrap',
}}
>
{group.label}
</span>
<div
style={{
flex: 1,
height: '1px',
background: 'var(--border-light)',
}}
/>
<span
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
whiteSpace: 'nowrap',
}}
>
{group.skills.length} {skillsCopy.itemCountSuffix}
</span>
</div>
{/* Skill rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{group.skills.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
yearsSuffix={skillsCopy.yearsSuffix}
onClick={() => handleSkillClick(skill)}
/>
))}
</div>
</div>
)
})}
</div>
)
}
interface SkillRowProps {
skill: SkillMedication
yearsSuffix: string
onClick: () => void
}
function SkillRow({ skill, yearsSuffix, onClick }: SkillRowProps) {
const IconComponent = iconMap[skill.icon]
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
background: 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}}
>
{/* Icon */}
<div
style={{
width: '26px',
height: '26px',
borderRadius: '6px',
background: 'var(--accent-light)',
color: 'var(--accent)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{IconComponent && <IconComponent size={13} />}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12.5px',
fontWeight: 600,
color: 'var(--text-primary)',
lineHeight: 1.3,
}}
>
{skill.name}
</div>
<div
style={{
fontSize: '10.5px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
}}
>
{skill.frequency} · {skill.yearsOfExperience} {yearsSuffix}
</div>
</div>
{/* Chevron */}
<ChevronRight
size={14}
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
/>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import type { CSSProperties } from 'react'
export const detailRootStyle: CSSProperties = {
fontFamily: 'var(--font-ui)',
display: 'flex',
flexDirection: 'column',
gap: '24px',
}
export const sectionHeadingStyle: CSSProperties = {
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: '8px',
}
export const bulletListStyle: CSSProperties = {
margin: 0,
paddingLeft: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
listStyleType: 'disc',
}
export const bodyTextStyle: CSSProperties = {
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
}
export const paragraphStyle: CSSProperties = {
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--text-primary)',
margin: 0,
}
+155
View File
@@ -0,0 +1,155 @@
import React from 'react'
import { FileText, ChevronRight } from 'lucide-react'
import { CardHeader } from '../Card'
import { ParentSection } from '../ParentSection'
import { kpis } from '@/data/kpis'
import type { KPI } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getLatestResultsCopy, getProfileSectionTitle, getPatientSummaryNarrative } from '@/lib/profile-content'
import { KPI_COLORS } from '@/lib/theme-colors'
import { ProjectsCarousel } from './ProjectsTile'
interface MetricCardProps {
kpi: KPI
}
function MetricCard({ kpi }: MetricCardProps) {
const { openPanel } = useDetailPanel()
const latestResultsCopy = getLatestResultsCopy()
const handleClick = () => {
openPanel({ type: 'kpi', kpi })
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openPanel({ type: 'kpi', kpi })
}
}
const buttonStyles: React.CSSProperties = {
width: '100%',
textAlign: 'left',
padding: '16px 16px 14px',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'border-color 160ms ease-out, box-shadow 160ms ease-out, transform 120ms ease-out',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 0,
}
const valueStyles: React.CSSProperties = {
fontSize: 'clamp(22px, 6vw, 30px)',
fontWeight: 700,
letterSpacing: '-0.02em',
lineHeight: 1.2,
color: KPI_COLORS[kpi.colorVariant],
}
const labelStyles: React.CSSProperties = {
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-primary)',
marginTop: '4px',
}
const subStyles: React.CSSProperties = {
fontSize: '11px',
color: 'var(--text-tertiary)',
fontFamily: 'var(--font-geist-mono)',
marginTop: '2px',
}
return (
<button
onClick={handleClick}
onKeyDown={handleKeyDown}
style={buttonStyles}
className="metric-card"
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
>
<div
style={{
position: 'absolute',
top: '12px',
right: '12px',
color: 'var(--accent)',
opacity: 0.85,
}}
aria-hidden="true"
>
<FileText size={13} />
</div>
<div style={valueStyles}>{kpi.value}</div>
<div style={labelStyles}>{kpi.label}</div>
<div style={subStyles}>{kpi.sub}</div>
<div
style={{
marginTop: '8px',
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
fontSize: '11px',
fontWeight: 600,
color: 'var(--accent)',
fontFamily: 'var(--font-geist-mono)',
}}
>
{latestResultsCopy.evidenceCta}
<ChevronRight size={12} />
</div>
</button>
)
}
export function PatientSummaryTile() {
const latestResultsCopy = getLatestResultsCopy()
const sectionTitle = getProfileSectionTitle()
const profileTextStyles: React.CSSProperties = {
fontSize: '15px',
lineHeight: '1.65',
color: 'var(--text-primary)',
}
const kpiGridStyles: React.CSSProperties = {
display: 'grid',
gap: '10px',
}
return (
<ParentSection title={sectionTitle} tileId="patient-summary">
<div style={profileTextStyles}>{getPatientSummaryNarrative()}</div>
{/* Latest Results subsection */}
<div style={{ marginTop: '28px' }}>
<div className="latest-results-header">
<CardHeader dotColor="teal" title={latestResultsCopy.title} rightText={latestResultsCopy.rightText} />
<p
style={{
margin: 0,
fontSize: '12px',
color: 'var(--text-secondary)',
fontFamily: 'var(--font-geist-mono)',
}}
>
{latestResultsCopy.helperText}
</p>
</div>
<div className="kpi-grid latest-results-grid" style={kpiGridStyles}>
{kpis.map((kpi) => (
<MetricCard key={kpi.id} kpi={kpi} />
))}
</div>
</div>
{/* Projects carousel */}
<ProjectsCarousel />
</ParentSection>
)
}
+851
View File
@@ -0,0 +1,851 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import Autoplay from 'embla-carousel-autoplay'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { investigations } from '@/data/investigations'
import { CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { Investigation } from '@/types/pmr'
interface ProjectItemProps {
project: Investigation
slideWidth: string
cardMinHeight: number
onClick: () => void
index: number
total: number
cardRef?: (el: HTMLDivElement | null) => void
onArrowKey?: (direction: -1 | 1) => void
onEscape?: () => void
isInert?: boolean
}
function ProjectItem({
project,
slideWidth,
cardMinHeight,
onClick,
index,
total,
cardRef,
onArrowKey,
onEscape,
isInert,
}: ProjectItemProps) {
const livePillLabel = project.demoUrl ? 'Live Demo' : project.externalUrl ? 'Live' : null
const [isHovered, setIsHovered] = useState(false)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
onArrowKey?.(-1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
onArrowKey?.(1)
} else if (e.key === 'Escape') {
e.preventDefault()
onEscape?.()
}
},
[onClick, onArrowKey, onEscape],
)
const maxVisibleResults = 4
return (
<div
role="group"
aria-roledescription="slide"
aria-label={`Project ${index + 1} of ${total}: ${project.name}`}
aria-hidden={isInert || undefined}
style={{
flex: `0 0 ${slideWidth}`,
minWidth: 0,
containerType: 'inline-size',
}}
>
<div
ref={cardRef}
role="button"
tabIndex={isInert ? -1 : 0}
onClick={onClick}
onKeyDown={handleKeyDown}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '10px',
background: 'var(--surface)',
border: '1px solid var(--border-light)',
borderRadius: 'var(--radius-sm)',
padding: '12px',
minHeight: `${cardMinHeight}px`,
fontSize: '13px',
color: 'var(--text-primary)',
transition: 'border-color 0.15s, box-shadow 0.15s',
cursor: 'pointer',
overflow: 'hidden',
}}
onMouseEnter={(e) => {
setIsHovered(true)
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
}}
onMouseLeave={(e) => {
setIsHovered(false)
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent-border)'
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border-light)'
e.currentTarget.style.boxShadow = 'none'
}}
>
{/* Results hover overlay */}
{project.results && project.results.length > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'rgba(20, 40, 38, 0.96)',
borderRadius: 'inherit',
display: 'flex',
flexDirection: 'column',
padding: 'clamp(10px, 4cqi, 18px) clamp(12px, 5cqi, 20px)',
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.25s ease',
pointerEvents: isHovered ? 'auto' : 'none',
}}
>
<div
style={{
fontSize: 'clamp(9px, 3.5cqi, 13px)',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 600,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'rgba(255, 255, 255, 0.45)',
marginBottom: 'clamp(6px, 3cqi, 12px)',
}}
>
Intervention Outcomes
</div>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: 'clamp(5px, 2.5cqi, 12px)',
flex: 1,
overflow: 'hidden',
}}
>
{project.results.slice(0, maxVisibleResults).map((result, i) => (
<li
key={i}
style={{
display: 'flex',
gap: 'clamp(6px, 2.5cqi, 10px)',
fontSize: 'clamp(11px, 4.5cqi, 16px)',
lineHeight: 1.4,
color: 'rgba(255, 255, 255, 0.85)',
}}
>
<span
style={{
flexShrink: 0,
width: 'clamp(4px, 1.5cqi, 7px)',
height: 'clamp(4px, 1.5cqi, 7px)',
borderRadius: '50%',
background: 'var(--accent-primary, #00897B)',
marginTop: 'clamp(4px, 2cqi, 7px)',
}}
/>
<span>{result}</span>
</li>
))}
</ul>
<div
style={{
marginTop: 'auto',
paddingTop: 'clamp(6px, 3cqi, 14px)',
fontSize: 'clamp(10px, 4cqi, 14px)',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 500,
letterSpacing: '0.02em',
color: 'var(--accent-primary, #00897B)',
display: 'flex',
alignItems: 'center',
gap: 'clamp(3px, 1.5cqi, 6px)',
}}
>
Click to view more
<span style={{ fontSize: 'clamp(12px, 4.5cqi, 16px)', lineHeight: 1 }}>&#8594;</span>
</div>
</div>
)}
<div
style={{
aspectRatio: '16 / 9',
borderRadius: '6px',
border: '1px solid var(--border-light)',
background: project.thumbnail
? undefined
: 'linear-gradient(135deg, rgba(19, 94, 94, 0.12), rgba(212, 171, 46, 0.18))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
fontFamily: 'var(--font-geist-mono)',
fontSize: '10px',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
textTransform: 'uppercase',
}}
>
{project.thumbnail ? (
<img
src={project.thumbnail}
alt={`${project.name} thumbnail`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'top',
}}
/>
) : (
'Thumbnail Pending'
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
}}
>
<span style={{ flex: 1, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
{project.name}
{livePillLabel && (
<span
className="live-pill"
style={{
fontSize: '9px',
fontFamily: 'var(--font-geist-mono)',
fontWeight: 600,
letterSpacing: '0.05em',
textTransform: 'uppercase',
padding: '2px 7px',
borderRadius: '9999px',
background: 'rgba(34, 197, 94, 0.12)',
color: '#16a34a',
border: '1px solid rgba(34, 197, 94, 0.3)',
animation: 'live-pill-pulse 2s ease-in-out infinite',
whiteSpace: 'nowrap',
lineHeight: '1.4',
}}
>
{livePillLabel}
</span>
)}
</span>
<span
style={{
fontSize: '11px',
fontFamily: 'var(--font-geist-mono)',
color: 'var(--text-tertiary)',
flexShrink: 0,
}}
>
{project.requestedYear}
</span>
</div>
{project.resultSummary && (
<div
style={{
fontSize: '13px',
fontWeight: 400,
//fontFamily: 'var(--font-geist-mono)',
color: 'var(--accent)',
letterSpacing: '-0.01em',
lineHeight: 1.3,
}}
>
{project.resultSummary}
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: '8px',
alignItems: 'flex-start',
}}
>
{project.techStack && project.techStack.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', minWidth: 0 }}>
{project.techStack.slice(0, 3).map((tech) => (
<span
key={tech}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'var(--amber-light)',
color: '#92400E',
border: '1px solid var(--amber-border)',
}}
>
{tech}
</span>
))}
{project.techStack.length > 3 && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 6px',
color: 'var(--text-tertiary)',
}}
>
+{project.techStack.length - 3}
</span>
)}
</div>
)}
{project.skills && project.skills.length > 0 && (
<div
className="skills-tags"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
justifyContent: 'flex-end',
minWidth: 0,
}}
>
{project.skills.slice(0, 2).map((skill) => (
<span
key={skill}
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '3px',
background: 'rgba(13,148,136,0.08)',
color: '#0D9488',
border: '1px solid rgba(13,148,136,0.2)',
whiteSpace: 'nowrap',
}}
>
{skill}
</span>
))}
{project.skills.length > 2 && (
<span
style={{
fontSize: '10px',
fontFamily: 'var(--font-geist-mono)',
padding: '3px 6px',
color: 'var(--text-tertiary)',
}}
>
+{project.skills.length - 2}
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}
// --- Embla slide-by-slide carousel for screens < 1024px ---
function EmblaProjectsCarousel() {
const { openPanel } = useDetailPanel()
const wrapperRef = useRef<HTMLDivElement>(null)
const [wrapperWidth, setWrapperWidth] = useState(0)
const [selectedIndex, setSelectedIndex] = useState(0)
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const update = () => {
const w = el.clientWidth
if (w > 0) setWrapperWidth(w)
}
update()
const obs = new ResizeObserver(update)
obs.observe(el)
return () => obs.disconnect()
}, [])
const slidesPerView = wrapperWidth < 480 ? 1 : 2
const slideWidth = slidesPerView === 1 ? '100%' : 'calc(50% - 6px)'
const cardMinHeight = wrapperWidth < 480 ? 148 : wrapperWidth < 640 ? 168 : 182
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const [emblaRef, emblaApi] = useEmblaCarousel(
{ loop: true, align: 'start' },
[Autoplay({ delay: 4000, stopOnInteraction: false, stopOnMouseEnter: true })],
)
useEffect(() => {
if (!emblaApi || typeof window === 'undefined') return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const sync = () => {
const autoplay = emblaApi.plugins()?.autoplay
if (!autoplay) return
if (mq.matches) autoplay.stop()
else autoplay.play()
}
sync()
mq.addEventListener('change', sync)
return () => mq.removeEventListener('change', sync)
}, [emblaApi])
useEffect(() => {
emblaApi?.reInit()
}, [emblaApi, slidesPerView])
const onSelect = useCallback(() => {
if (!emblaApi) return
setSelectedIndex(emblaApi.selectedScrollSnap())
}, [emblaApi])
useEffect(() => {
if (!emblaApi) return
const updateSnaps = () => {
setScrollSnaps(emblaApi.scrollSnapList())
setSelectedIndex(emblaApi.selectedScrollSnap())
}
updateSnaps()
emblaApi.on('select', onSelect)
emblaApi.on('reInit', updateSnaps)
return () => {
emblaApi.off('select', onSelect)
emblaApi.off('reInit', updateSnaps)
}
}, [emblaApi, onSelect])
const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => {
const nextIndex = (currentIndex + direction + investigations.length) % investigations.length
cardRefs.current.get(nextIndex)?.focus()
emblaApi?.scrollTo(nextIndex)
}, [emblaApi])
const handleEscape = useCallback(() => {
wrapperRef.current?.focus()
}, [])
return (
<div
ref={wrapperRef}
role="region"
aria-roledescription="carousel"
aria-label="Significant Interventions"
tabIndex={-1}
>
<div ref={emblaRef} style={{ overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: '12px' }}>
{investigations.map((project, i) => (
<ProjectItem
key={project.id}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
index={i}
total={investigations.length}
cardRef={(el) => {
if (el) cardRefs.current.set(i, el)
else cardRefs.current.delete(i)
}}
onArrowKey={(dir) => handleArrowKey(i, dir)}
onEscape={handleEscape}
/>
))}
</div>
</div>
{scrollSnaps.length > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '6px',
marginTop: '12px',
}}
>
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to slide ${index + 1}`}
onClick={() => emblaApi?.scrollTo(index)}
style={{
width: index === selectedIndex ? '16px' : '6px',
height: '6px',
borderRadius: '3px',
border: 'none',
padding: 0,
cursor: 'pointer',
background:
index === selectedIndex
? 'var(--accent-primary, #00897B)'
: 'var(--border-light, #d1d5db)',
transition: 'all 0.3s ease',
}}
/>
))}
</div>
)}
</div>
)
}
// --- Continuous scroll carousel for screens >= 1024px ---
function ContinuousScrollCarousel() {
const { openPanel } = useDetailPanel()
const viewportRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const firstSetRef = useRef<HTMLDivElement | null>(null)
const offsetRef = useRef(0)
const isPausedRef = useRef(false)
const [viewportWidth, setViewportWidth] = useState(1200)
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
)
const resumeTimeoutRef = useRef<number>(0)
const resumeTimestampRef = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const RESUME_DELAY_MS = 10000
const RAMP_DURATION_MS = 2000
const pauseCarousel = useCallback(() => {
isPausedRef.current = true
window.clearTimeout(resumeTimeoutRef.current)
}, [])
const scheduleResume = useCallback(() => {
window.clearTimeout(resumeTimeoutRef.current)
resumeTimeoutRef.current = window.setTimeout(() => {
isPausedRef.current = false
resumeTimestampRef.current = performance.now()
}, RESUME_DELAY_MS)
}, [])
const jumpByCards = useCallback((direction: 1 | -1) => {
const trackEl = trackRef.current
const firstSetEl = firstSetRef.current
if (!trackEl || !firstSetEl) return
const gap = 12
const cardsPerView = 4
const totalGap = (cardsPerView - 1) * gap
const cardWidth = (viewportWidth - totalGap) / cardsPerView
const jumpPx = cardWidth + gap
pauseCarousel()
// Apply CSS transition for smooth jump
if (!prefersReducedMotion) {
trackEl.style.transition = 'transform 0.4s ease'
}
// Calculate new offset
const setWidth = firstSetEl.offsetWidth
let newOffset = offsetRef.current + (direction * jumpPx)
if (setWidth > 0) {
newOffset = ((newOffset % setWidth) + setWidth) % setWidth
}
offsetRef.current = newOffset
trackEl.style.transform = `translate3d(-${newOffset}px, 0, 0)`
// Remove transition after completion
if (!prefersReducedMotion) {
const transitionEnd = () => {
trackEl.style.transition = ''
}
trackEl.addEventListener('transitionend', transitionEnd, { once: true })
}
scheduleResume()
}, [viewportWidth, prefersReducedMotion, pauseCarousel, scheduleResume])
useEffect(() => {
return () => {
window.clearTimeout(resumeTimeoutRef.current)
}
}, [])
useEffect(() => {
const viewportEl = viewportRef.current
if (!viewportEl || typeof window === 'undefined') return
const updateWidth = () => {
const nextWidth = viewportEl.clientWidth
if (nextWidth > 0) setViewportWidth(nextWidth)
}
updateWidth()
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => updateWidth())
observer.observe(viewportEl)
return () => observer.disconnect()
}
window.addEventListener('resize', updateWidth)
return () => window.removeEventListener('resize', updateWidth)
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches)
syncMotionPreference()
mediaQuery.addEventListener('change', syncMotionPreference)
return () => mediaQuery.removeEventListener('change', syncMotionPreference)
}, [])
useEffect(() => {
const trackEl = trackRef.current
const firstSetEl = firstSetRef.current
if (!trackEl || !firstSetEl || prefersReducedMotion) return
let animationFrameId = 0
let lastTime = 0
const speedPxPerSecond = 24
const tick = (timestamp: number) => {
if (!lastTime) lastTime = timestamp
const deltaSeconds = (timestamp - lastTime) / 1000
lastTime = timestamp
if (!isPausedRef.current) {
let speedMultiplier = 1
if (resumeTimestampRef.current !== null) {
const elapsed = timestamp - resumeTimestampRef.current
if (elapsed < RAMP_DURATION_MS) {
const t = elapsed / RAMP_DURATION_MS
speedMultiplier = 1 - Math.pow(1 - t, 2)
} else {
resumeTimestampRef.current = null
}
}
const setWidth = firstSetEl.offsetWidth
if (setWidth > 0) {
offsetRef.current += speedPxPerSecond * speedMultiplier * deltaSeconds
if (offsetRef.current >= setWidth) offsetRef.current -= setWidth
trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)`
}
}
animationFrameId = window.requestAnimationFrame(tick)
}
animationFrameId = window.requestAnimationFrame(tick)
return () => window.cancelAnimationFrame(animationFrameId)
}, [prefersReducedMotion, viewportWidth])
const slideWidth = useMemo(() => {
const cardsPerView = 4
const gap = 12
const totalGap = (cardsPerView - 1) * gap
const computedWidth = (viewportWidth - totalGap) / cardsPerView
return `${Math.max(computedWidth, 0)}px`
}, [viewportWidth])
const cardMinHeight = useMemo(() => {
if (viewportWidth < 1440) return 196
return 214
}, [viewportWidth])
const handleArrowKey = useCallback((currentIndex: number, direction: -1 | 1) => {
const nextIndex = (currentIndex + direction + investigations.length) % investigations.length
cardRefs.current.get(nextIndex)?.focus()
jumpByCards(direction)
}, [jumpByCards])
const handleEscape = useCallback(() => {
containerRef.current?.focus()
}, [])
const arrowStyle: React.CSSProperties = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '40px',
height: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--accent-light)',
border: '1px solid var(--accent-border)',
borderRadius: '50%',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
color: 'var(--accent)',
transition: 'opacity 150ms, background-color 150ms, border-color 150ms',
zIndex: 2,
opacity: 0.85,
padding: 0,
}
return (
<div
ref={containerRef}
role="region"
aria-roledescription="carousel"
aria-label="Significant Interventions"
tabIndex={-1}
style={{ position: 'relative' }}
>
<div
ref={viewportRef}
style={{ overflow: 'hidden' }}
onMouseEnter={() => pauseCarousel()}
onMouseLeave={() => scheduleResume()}
onFocusCapture={() => pauseCarousel()}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleResume()
}
}}
>
<div
ref={trackRef}
style={{
display: 'flex',
width: 'max-content',
willChange: 'transform',
transform: 'translate3d(0, 0, 0)',
}}
>
{[0, 1].map((setIndex) => (
<div
key={setIndex}
ref={setIndex === 0 ? firstSetRef : undefined}
aria-hidden={setIndex === 1 || undefined}
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
>
{investigations.map((project, i) => (
<ProjectItem
key={`${setIndex}-${project.id}`}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
index={i}
total={investigations.length}
isInert={setIndex === 1}
cardRef={setIndex === 0 ? (el) => {
if (el) cardRefs.current.set(i, el)
else cardRefs.current.delete(i)
} : undefined}
onArrowKey={setIndex === 0 ? (dir) => handleArrowKey(i, dir) : undefined}
onEscape={setIndex === 0 ? handleEscape : undefined}
/>
))}
</div>
))}
</div>
</div>
{/* Edge fade masks */}
<div style={{
position: 'absolute', top: 0, left: 0, bottom: 0, width: '48px',
background: 'linear-gradient(to right, var(--surface), transparent)',
pointerEvents: 'none', zIndex: 1,
}} />
<div style={{
position: 'absolute', top: 0, right: 0, bottom: 0, width: '48px',
background: 'linear-gradient(to left, var(--surface), transparent)',
pointerEvents: 'none', zIndex: 1,
}} />
{/* Left arrow */}
<button
onClick={() => jumpByCards(-1)}
aria-label="Previous project"
style={{ ...arrowStyle, left: '2px' }}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--accent)'
e.currentTarget.style.color = '#fff'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
e.currentTarget.style.background = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
>
<ChevronLeft size={20} />
</button>
{/* Right arrow */}
<button
onClick={() => jumpByCards(1)}
aria-label="Next project"
style={{ ...arrowStyle, right: '2px' }}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = 'var(--accent)'
e.currentTarget.style.color = '#fff'
e.currentTarget.style.borderColor = 'var(--accent)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.85'
e.currentTarget.style.background = 'var(--accent-light)'
e.currentTarget.style.color = 'var(--accent)'
e.currentTarget.style.borderColor = 'var(--accent-border)'
}}
>
<ChevronRight size={20} />
</button>
</div>
)
}
// --- Main export ---
export function ProjectsCarousel() {
const [isSmallScreen, setIsSmallScreen] = useState(() =>
typeof window !== 'undefined'
? window.matchMedia('(max-width: 1023px)').matches
: false,
)
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)')
const handler = () => setIsSmallScreen(mq.matches)
setIsSmallScreen(mq.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return (
<div style={{ marginTop: '28px' }}>
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
{isSmallScreen ? <EmblaProjectsCarousel /> : <ContinuousScrollCarousel />}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react'
interface AccessibilityContextValue {
expandedItemId: string | null
setExpandedItem: (id: string | null) => void
requestFocusAfterLogin: () => void
focusAfterLoginRef: React.RefObject<HTMLButtonElement | null>
focusAfterViewChangeRef: React.RefObject<HTMLHeadingElement | null>
requestFocusAfterViewChange: () => void
}
const AccessibilityContext = createContext<AccessibilityContextValue | null>(null)
export function AccessibilityProvider({ children }: { children: ReactNode }) {
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
const focusAfterLoginRef = useRef<HTMLButtonElement | null>(null)
const focusAfterViewChangeRef = useRef<HTMLHeadingElement | null>(null)
const [shouldFocusAfterLogin, setShouldFocusAfterLogin] = useState(false)
const [shouldFocusAfterViewChange, setShouldFocusAfterViewChange] = useState(false)
const setExpandedItem = useCallback((id: string | null) => {
setExpandedItemId(id)
}, [])
const requestFocusAfterLogin = useCallback(() => {
setShouldFocusAfterLogin(true)
}, [])
const requestFocusAfterViewChange = useCallback(() => {
setShouldFocusAfterViewChange(true)
}, [])
useEffect(() => {
if (shouldFocusAfterLogin && focusAfterLoginRef.current) {
focusAfterLoginRef.current.focus()
setShouldFocusAfterLogin(false)
}
}, [shouldFocusAfterLogin])
useEffect(() => {
if (shouldFocusAfterViewChange && focusAfterViewChangeRef.current) {
focusAfterViewChangeRef.current.focus()
setShouldFocusAfterViewChange(false)
}
}, [shouldFocusAfterViewChange])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && expandedItemId) {
setExpandedItemId(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [expandedItemId])
return (
<AccessibilityContext.Provider
value={{
expandedItemId,
setExpandedItem,
requestFocusAfterLogin,
focusAfterLoginRef,
focusAfterViewChangeRef,
requestFocusAfterViewChange,
}}
>
{children}
</AccessibilityContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAccessibility() {
const context = useContext(AccessibilityContext)
if (!context) {
throw new Error('useAccessibility must be used within AccessibilityProvider')
}
return context
}
+68
View File
@@ -0,0 +1,68 @@
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'
import { DetailPanelContent } from '@/types/pmr'
interface DetailPanelContextValue {
content: DetailPanelContent | null
openPanel: (content: DetailPanelContent) => void
closePanel: () => void
isOpen: boolean
isClosing: boolean
}
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
undefined
)
interface DetailPanelProviderProps {
children: ReactNode
}
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
const [content, setContent] = useState<DetailPanelContent | null>(null)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<number>(0)
const openPanel = useCallback((newContent: DetailPanelContent) => {
// If we're in the middle of closing, cancel it
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = 0
}
setIsClosing(false)
setContent(newContent)
}, [])
const closePanel = useCallback(() => {
setIsClosing(true)
closeTimerRef.current = window.setTimeout(() => {
setIsClosing(false)
setContent(null)
closeTimerRef.current = 0
}, 250) // match panel-slide-out duration
}, [])
const isOpen = content !== null
const value: DetailPanelContextValue = {
content,
openPanel,
closePanel,
isOpen,
isClosing,
}
return (
<DetailPanelContext.Provider value={value}>
{children}
</DetailPanelContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDetailPanel(): DetailPanelContextValue {
const context = useContext(DetailPanelContext)
if (!context) {
throw new Error('useDetailPanel must be used within DetailPanelProvider')
}
return context
}
+10
View File
@@ -0,0 +1,10 @@
// Module-level cache: buildConstellationData() is expensive (D3 graph construction).
// 5 consumers import from here instead of calling the builder independently.
import type { ConstellationLink, ConstellationNode, RoleSkillMapping } from '@/types/pmr'
import { buildConstellationData } from '@/data/timeline'
const constellationData = buildConstellationData()
export const roleSkillMappings: RoleSkillMapping[] = constellationData.roleSkillMappings
export const constellationNodes: ConstellationNode[] = constellationData.constellationNodes
export const constellationLinks: ConstellationLink[] = constellationData.constellationLinks

Some files were not shown because too many files have changed in this diff Show More