Compare commits

..

366 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
164 changed files with 72495 additions and 16292 deletions
@@ -1,111 +0,0 @@
# Accessibility Essentials
Accessibility enables creativity - it's a foundation, not a limitation. WCAG 2.1 AA compliance.
## Core Principles (POUR)
- **Perceivable**: Content must be perceivable (alt text, contrast, captions)
- **Operable**: UI must be keyboard/touch accessible
- **Understandable**: Clear, predictable behavior
- **Robust**: Works with assistive technologies
## Contrast Requirements
| Element | Minimum Ratio |
|---------|---------------|
| Normal text | 4.5:1 |
| Large text (18pt+) | 3:1 |
| UI components | 3:1 |
**Tools**: Chrome DevTools Accessibility tab, WebAIM Contrast Checker
## Keyboard Navigation
```tsx
// All interactive elements need focus states
<button className="focus:ring-4 focus:ring-blue-500 focus:outline-none">
Accessible
</button>
// Custom elements need tabindex and key handlers
<div
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
Custom Button
</div>
```
**Essentials:**
- Tab through entire interface
- Enter/Space activates elements
- Escape closes modals
- Visible focus indicators always
## Essential ARIA
```tsx
// Buttons without text
<button aria-label="Close dialog"><X /></button>
// Expandable elements
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
// Live regions for dynamic content
<div role="status" aria-live="polite">{statusMessage}</div>
<div role="alert" aria-live="assertive">{errorMessage}</div>
// Form errors
<input aria-invalid={hasError} aria-describedby="error-msg" />
{hasError && <p id="error-msg" role="alert">Error text</p>}
```
## Semantic HTML
```tsx
// Use semantic elements, not divs
<header><nav>...</nav></header>
<main><article><h1>...</h1></article></main>
<footer>...</footer>
// Heading hierarchy (never skip levels)
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Adequate spacing between targets
- `touch-manipulation` CSS for responsive touch
## Screen Reader Content
```tsx
// Hidden but announced
<span className="sr-only">Additional context</span>
// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
Skip to main content
</a>
```
## Quick Checklist
- [ ] Keyboard: Can tab through everything
- [ ] Focus: Visible focus indicators
- [ ] Contrast: 4.5:1 for text
- [ ] Alt text: All images have appropriate alt
- [ ] Headings: Logical h1-h6 hierarchy
- [ ] Forms: Labels associated with inputs
- [ ] Errors: Announced to screen readers
- [ ] Touch: 44px minimum targets
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
@@ -1,577 +0,0 @@
# Design System Template
Meta-framework for understanding what's fixed, project-specific, and adaptable in your design system.
## Purpose
This template helps you distinguish between:
- **Fixed Elements**: Universal rules that never change
- **Project-Specific Elements**: Filled in for each project based on brand
- **Adaptable Elements**: Context-dependent implementations
---
## I. FIXED ELEMENTS
These foundations remain consistent across all projects, regardless of brand or context.
### 1. Spacing Scale
**Fixed System:**
```
4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px, 96px
```
**Usage:**
- Margins, padding, gaps between elements
- Mathematical relationships ensure visual harmony
- Use multipliers of base unit (4px)
**Why Fixed:**
Consistent spacing creates visual rhythm regardless of brand personality.
### 2. Grid System
**Fixed Structure:**
- **12-column grid** for most layouts (divisible by 2, 3, 4, 6)
- **16-column grid** for data-heavy interfaces
- **Gutters**: 16px (mobile), 24px (tablet), 32px (desktop)
**Why Fixed:**
Grid provides structural order. Brand personality shows through color, typography, content—not grid structure.
### 3. Accessibility Standards
**Fixed Requirements:**
- **WCAG 2.1 AA** compliance minimum
- **Contrast**: 4.5:1 for normal text, 3:1 for large text
- **Touch targets**: Minimum 44×44px
- **Keyboard navigation**: All interactive elements accessible
- **Screen reader**: Semantic HTML, ARIA labels where needed
**Why Fixed:**
Accessibility is not negotiable. It's a baseline requirement for ethical, legal, and usable products.
### 4. Typography Hierarchy Logic
**Fixed Structure:**
- **Mathematical scaling**: 1.25x (major third) or 1.333x (perfect fourth)
- **Hierarchy levels**: Display → H1 → H2 → H3 → Body → Small → Caption
- **Line height**: 1.5x for body text, 1.2-1.3x for headlines
- **Line length**: 45-75 characters optimal
**Why Fixed:**
Mathematical relationships create predictable, harmonious hierarchy. Specific fonts change, but the logic doesn't.
### 5. Component Architecture
**Fixed Patterns:**
- **Button states**: Default, Hover, Active, Focus, Disabled
- **Form structure**: Label above input, error below, helper text optional
- **Modal pattern**: Overlay + centered content + close mechanism
- **Card structure**: Container → Header → Body → Footer (optional)
**Why Fixed:**
Users expect consistent component behavior. Architecture is fixed; appearance is project-specific.
### 6. Animation Timing Framework
**Fixed Physics Profiles:**
- **Lightweight** (icons, chips): 150ms
- **Standard** (cards, panels): 300ms
- **Weighty** (modals, pages): 500ms
**Fixed Easing:**
- **Ease-out**: Entrances (fast start, slow end)
- **Ease-in**: Exits (slow start, fast end)
- **Ease-in-out**: Transitions (smooth both ends)
**Why Fixed:**
Natural physics feel consistent across brands. Duration and easing create that feeling.
---
## II. PROJECT-SPECIFIC ELEMENTS
Fill in these for each project based on brand personality and purpose.
### 1. Brand Color System
**Template Structure:**
```
NEUTRALS (4-5 colors):
- Background lightest: _______ (e.g., slate-50 or warm-white)
- Surface: _______ (e.g., slate-100)
- Border/divider: _______ (e.g., slate-300)
- Text secondary: _______ (e.g., slate-600)
- Text primary: _______ (e.g., slate-900)
ACCENTS (1-3 colors):
- Primary (main CTA): _______ (e.g., teal-500)
- Secondary (alternative action): _______ (optional)
- Status colors:
- Success: _______ (green-ish)
- Warning: _______ (amber-ish)
- Error: _______ (red-ish)
- Info: _______ (blue-ish)
```
**Questions to Answer:**
- What emotion should the brand evoke? (Trust, excitement, calm, urgency)
- Warm or cool neutrals?
- Conservative or bold accents?
**Examples:**
**Project A: Fintech App**
```
Neutrals: Cool greys (slate-50 → slate-900)
Primary: Deep blue (#0A2463) trust, professionalism
Success: Muted green (#10B981)
Why: Financial products need trust, not playfulness
```
**Project B: Creative Community**
```
Neutrals: Warm greys with beige undertones
Primary: Coral (#FF6B6B) energy, creativity
Success: Teal (#06D6A0) fresh, unexpected
Why: Creative spaces should feel inviting, not corporate
```
**Project C: Healthcare Platform**
```
Neutrals: Pure greys (minimal color temperature)
Primary: Soft blue (#4A90E2) calm, clinical
Success: Medical green (#38A169)
Why: Healthcare needs clarity and calm, not distraction
```
### 2. Typography Pairing
**Template:**
```
HEADLINE FONT: _______
- Weight: _______ (e.g., Bold 700)
- Use case: H1, H2, display text
- Personality: _______ (geometric/humanist/serif/etc.)
BODY FONT: _______
- Weight: _______ (e.g., Regular 400, Medium 500)
- Use case: Paragraphs, UI text
- Personality: _______ (neutral/readable/efficient)
OPTIONAL ACCENT FONT: _______
- Weight: _______
- Use case: _______ (special headlines, callouts)
```
**Pairing Logic:**
- Serif + Sans-serif (classic, editorial)
- Geometric + Humanist (modern + warm)
- Display + System (distinctive + efficient)
**Examples:**
**Project A: Editorial Platform**
```
Headline: Playfair Display (Serif, Bold 700)
Body: Inter (Sans-serif, Regular 400)
Why: Serif headlines = trustworthy, editorial feel
```
**Project B: Tech Startup**
```
Headline: DM Sans (Sans-serif, Bold 700)
Body: DM Sans (Regular 400, Medium 500)
Why: Single-font system = modern, efficient, cohesive
```
**Project C: Luxury Brand**
```
Headline: Cormorant Garamond (Serif, Light 300)
Body: Lato (Sans-serif, Regular 400)
Why: Elegant serif + readable sans = sophisticated
```
### 3. Tone of Voice
**Template:**
```
BRAND PERSONALITY:
- Formal ↔ Casual: _______ (1-10 scale)
- Professional ↔ Friendly: _______ (1-10 scale)
- Serious ↔ Playful: _______ (1-10 scale)
- Authoritative ↔ Conversational: _______ (1-10 scale)
MICROCOPY EXAMPLES:
- Button label (submit form): _______
- Error message (invalid email): _______
- Success message (saved): _______
- Empty state: _______
ANIMATION PERSONALITY:
- Speed: _______ (quick/moderate/slow)
- Feel: _______ (precise/smooth/bouncy)
```
**Examples:**
**Project A: Banking App**
```
Personality: Formal (8), Professional (9), Serious (8)
Button: "Submit Application"
Error: "Email address format is invalid"
Success: "Application submitted successfully"
Animation: Quick (precise, efficient, no-nonsense)
```
**Project B: Social App**
```
Personality: Casual (8), Friendly (9), Playful (7)
Button: "Let's go!"
Error: "Hmm, that email doesn't look right"
Success: "Nice! You're all set 🎉"
Animation: Moderate (smooth, friendly bounce)
```
### 4. Animation Speed & Feel
**Template:**
```
SPEED PREFERENCE:
- UI interactions: _______ (100-150ms / 150-200ms / 200-300ms)
- State changes: _______ (200ms / 300ms / 400ms)
- Page transitions: _______ (300ms / 500ms / 700ms)
ANIMATION STYLE:
- Easing preference: _______ (sharp / standard / bouncy)
- Movement type: _______ (minimal / smooth / expressive)
```
**Examples:**
**Project A: Trading Platform**
```
Speed: Fast (100ms UI, 200ms states, 300ms pages)
Style: Sharp easing, minimal movement
Why: Traders need speed, not distraction
```
**Project B: Wellness App**
```
Speed: Slow (200ms UI, 400ms states, 500ms pages)
Style: Smooth easing, gentle movement
Why: Calm, relaxing experience matches brand
```
---
## III. ADAPTABLE ELEMENTS
Context-dependent implementations that vary based on use case.
### 1. Component Variations
**Button Variants:**
- **Primary**: Full background color (high emphasis)
- **Secondary**: Outline only (medium emphasis)
- **Tertiary**: Text only (low emphasis)
- **Destructive**: Red-ish (danger actions)
- **Ghost**: Minimal (navigation, toolbars)
**Adaptation Rules:**
- Primary: Main CTA, one per screen section
- Secondary: Alternative actions
- Tertiary: Less important actions, multiple allowed
- Use brand colors, but hierarchy logic is fixed
### 2. Responsive Breakpoints
**Fixed Ranges:**
- XS: 0-479px (small phones)
- SM: 480-767px (large phones)
- MD: 768-1023px (tablets)
- LG: 1024-1439px (laptops)
- XL: 1440px+ (desktop)
**Adaptable Implementations:**
**Simple Content Site:**
```
XS-SM: Single column
MD: 2 columns
LG-XL: 3 columns max
Why: Content-focused, don't overwhelm
```
**Dashboard/Data App:**
```
XS: Collapsed, cards stack
SM: Simplified sidebar
MD: Full sidebar + main content
LG-XL: Sidebar + main + right panel
Why: Data apps need more screen real estate
```
### 3. Dark Mode Palette
**Adaptation Strategy:**
Not a simple inversion. Dark mode needs adjusted contrast:
**Light Mode:**
```
Background: #FFFFFF (white)
Text: #0F172A (slate-900) → 21:1 contrast
```
**Dark Mode (Adapted):**
```
Background: #0F172A (slate-900)
Text: #E2E8F0 (slate-200) → 15.8:1 contrast (still AA, but softer)
```
**Why Adapt:**
Pure white on pure black is too harsh. Dark mode needs slightly lower contrast for eye comfort.
### 4. Loading States
**Context-Dependent:**
**Fast operations (<500ms):**
- No loading indicator (feels instant)
**Medium operations (500ms-2s):**
- Spinner or skeleton screen
**Long operations (>2s):**
- Progress bar with percentage
- Or: Skeleton + estimated time
**Interactive Operations:**
- Button shows spinner inside (don't disable, show state)
### 5. Error Handling Strategy
**Context-Dependent:**
**Form Errors:**
```
Validate: On blur (after user leaves field)
Display: Inline below field
Recovery: Clear error on fix
```
**API Errors:**
```
Transient (network): Show retry button
Permanent (404): Show helpful message + next steps
Critical (500): Contact support option
```
**Data Errors:**
```
Missing: Show empty state with action
Corrupt: Show error boundary with reload
Invalid: Highlight + explain what's wrong
```
---
## DECISION TREE
When implementing a feature, ask:
### Is this...
**FIXED?**
- Does it affect structure, accessibility, or universal UX?
- Examples: Spacing scale, grid, contrast ratios, component architecture
- **Action**: Use the fixed system, no variation
**PROJECT-SPECIFIC?**
- Does it express brand personality or purpose?
- Examples: Colors, typography, tone of voice, animation feel
- **Action**: Fill in the template for this project
**ADAPTABLE?**
- Does it depend on context, content, or use case?
- Examples: Component variants, responsive behavior, error handling
- **Action**: Choose appropriate variation based on context
---
## EXAMPLE: Implementing a "Submit" Button
### Fixed Elements (Always the same):
- Touch target: 44px minimum height
- Padding: 16px horizontal (from spacing scale)
- States: Default, Hover, Active, Focus, Disabled
- Animation: 150ms ease-out (lightweight profile)
### Project-Specific (Filled per project):
- **Project A (Bank)**: Dark blue background, white text, "Submit Application"
- **Project B (Social)**: Coral background, white text, "Let's Go!"
- **Project C (Healthcare)**: Soft blue background, white text, "Continue"
### Adaptable (Context-dependent):
- **Form context**: Primary button (full color)
- **Toolbar context**: Ghost button (text only)
- **Danger context**: Destructive variant (red-ish)
---
## VALIDATION CHECKLIST
Before finalizing a design, check:
### Fixed Elements
- [ ] Uses spacing scale (4/8/12/16/24/32/48/64/96px)
- [ ] Follows grid system (12 or 16 columns)
- [ ] Meets WCAG AA contrast (4.5:1 normal, 3:1 large)
- [ ] Touch targets ≥ 44px
- [ ] Typography follows mathematical scale
- [ ] Components follow standard architecture
### Project-Specific Elements
- [ ] Brand colors filled in and intentional
- [ ] Typography pairing chosen and justified
- [ ] Tone of voice defined and consistent
- [ ] Animation speed matches brand personality
### Adaptable Elements
- [ ] Component variants appropriate for context
- [ ] Responsive behavior fits content type
- [ ] Loading states match operation duration
- [ ] Error handling fits error type
---
## PROJECT KICKOFF TEMPLATE
Use this to start a new project:
```
PROJECT NAME: _______________________
PURPOSE: ____________________________
BRAND PERSONALITY:
- Primary emotion: _______
- Warm or cool: _______
- Formal or casual: _______
- Conservative or bold: _______
COLORS (fill the template):
- Neutral base: _______
- Primary accent: _______
- Status colors: _______ / _______ / _______
TYPOGRAPHY (fill the template):
- Headline font: _______
- Body font: _______
- Pairing rationale: _______
TONE:
- Button labels style: _______
- Error message style: _______
- Success message style: _______
ANIMATION:
- Speed preference: _______ (fast/moderate/slow)
- Feel preference: _______ (sharp/smooth/bouncy)
TARGET DEVICES:
- Primary: _______ (mobile/desktop/both)
- Secondary: _______
```
---
## MAINTAINING CONSISTENCY
### Documentation
- Keep this template updated as system evolves
- Document WHY choices were made, not just WHAT
### Communication
- Share with designers: "Here's what varies vs. what's fixed"
- Share with developers: "Here are the design tokens"
### Tooling
- Use CSS variables for project-specific values
- Use Tailwind config for spacing scale
- Use design tokens in Figma/Storybook
### Reviews
- Audit: Does new work follow fixed elements?
- Validate: Are project-specific elements intentional?
- Question: Are adaptations justified by context?
---
## EXAMPLES OF COMPLETE SYSTEMS
### System A: B2B SaaS (Conservative)
**Fixed**: Standard spacing, 12-col grid, WCAG AA, major third type scale
**Project-Specific**:
- Colors: Cool greys + corporate blue
- Typography: DM Sans (headlines + body)
- Tone: Professional, formal
- Animation: Quick, precise (150ms)
**Adaptable**:
- Dashboard gets multi-panel layout
- Forms are extensive (use progressive disclosure)
- Errors show detailed technical info
### System B: Consumer Social App (Playful)
**Fixed**: Same spacing/grid/accessibility/type logic
**Project-Specific**:
- Colors: Warm greys + vibrant coral
- Typography: Poppins (headlines) + Inter (body)
- Tone: Casual, friendly, playful
- Animation: Moderate, bouncy (200ms)
**Adaptable**:
- Mobile-first (most users on phones)
- Forms are minimal (progressive profiling)
- Errors are friendly, not technical
### System C: Healthcare Platform (Clinical)
**Fixed**: Same foundational structure
**Project-Specific**:
- Colors: Pure greys + medical blue
- Typography: System fonts (SF Pro / Segoe)
- Tone: Clear, authoritative, calm
- Animation: Slow, smooth (300ms)
**Adaptable**:
- Desktop-first (clinical use at workstations)
- Forms are complex (HIPAA compliance)
- Errors are precise with next steps
---
## KEY TAKEAWAY
**The system flexibility framework lets you:**
- Maintain consistency (fixed elements)
- Express brand personality (project-specific)
- Adapt to context (adaptable elements)
**Without this framework:**
- Designers reinvent spacing every project
- Components feel inconsistent across products
- Brand personality overrides accessibility
- Context-blind implementations feel wrong
**With this framework:**
- Speed: Start from proven foundations
- Consistency: Fixed elements guarantee it
- Flexibility: Express unique brand identity
- Context: Adapt without breaking system
@@ -1,72 +0,0 @@
# Motion Specification
Motion should surprise and delight while serving function. Animation is a creative tool.
## Easing Curves
| Easing | CSS | Use For |
|--------|-----|---------|
| **Ease-out** | `cubic-bezier(0.0, 0.0, 0.2, 1)` | Entrances, appearing |
| **Ease-in** | `cubic-bezier(0.4, 0.0, 1, 1)` | Exits, disappearing |
| **Ease-in-out** | `cubic-bezier(0.4, 0.0, 0.2, 1)` | State changes, transforms |
| **Spring** | `cubic-bezier(0.68, -0.55, 0.265, 1.55)` | Playful, attention-grabbing |
| **Linear** | `linear` | Spinners, continuous loops |
## Duration by Element Weight
| Weight | Duration | Examples |
|--------|----------|----------|
| **Lightweight** | 150ms | Icons, badges, chips |
| **Standard** | 300ms | Cards, panels, list items |
| **Weighty** | 500ms | Modals, page transitions |
## Duration by Interaction
| Interaction | Duration |
|-------------|----------|
| Button press | 100ms |
| Hover state | 150ms |
| Tooltip appear | 200ms |
| Tab switch | 250ms |
| Modal open | 300ms |
| Page transition | 400ms |
## Common Patterns
```tsx
// Hover transition (CSS)
<button className="transition-colors duration-150 ease-out hover:bg-blue-700">
// Fade + slide (Framer Motion)
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
// Stagger children
<motion.ul variants={{ visible: { transition: { staggerChildren: 0.1 } } }}>
<motion.li variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }} />
</motion.ul>
```
## Performance Rules
- Only animate `transform` and `opacity` (GPU-accelerated)
- Avoid animating `width`, `height`, `margin`, `padding`
- Keep durations under 500ms for UI interactions
- Respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
## Resources
- [Framer Motion](https://www.framer.com/motion/)
- [CSS Easing Functions](https://easings.net/)
@@ -1,90 +0,0 @@
# Responsive Design Essentials
Mobile-first approach: start with mobile, progressively enhance for larger screens.
## Breakpoints
| Range | Pixels | Devices | Strategy |
|-------|--------|---------|----------|
| **XS** | 0-479px | Small phones | Single column, stacked nav, 44px touch targets |
| **SM** | 480-767px | Large phones | Single column, bottom nav, simplified UI |
| **MD** | 768-1023px | Tablets | 2 columns possible, sidebar nav |
| **LG** | 1024-1439px | Laptops | Multi-column, full nav, desktop UI |
| **XL** | 1440px+ | Desktop | Max-width containers, multi-panel layouts |
## Tailwind Responsive
```tsx
// Mobile-first: base styles, then scale up
<div className="
w-full // mobile: full width
sm:w-1/2 // 480px+: half
md:w-1/3 // 768px+: third
lg:w-1/4 // 1024px+: quarter
">
// Responsive grid
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Responsive typography
<h1 className="text-3xl md:text-4xl lg:text-5xl">
// Show/hide by breakpoint
<div className="block md:hidden">Mobile only</div>
<div className="hidden md:block">Desktop only</div>
```
## Fluid Typography
```css
h1 { font-size: clamp(2rem, 5vw, 4rem); }
p { font-size: clamp(1rem, 2.5vw, 1.25rem); }
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Use `touch-manipulation` to prevent 300ms tap delay
- Adequate spacing between targets
```tsx
<button className="min-w-[44px] min-h-[44px] touch-manipulation">
```
## Mobile Simplification
| Desktop | Mobile |
|---------|--------|
| Full nav bar | Hamburger menu |
| Side-by-side fields | Stacked fields |
| Multi-column grid | Single column |
| Inline buttons | Fixed bottom bar |
| Data table | Collapsed cards |
| Visible sidebar | Hidden/collapsible |
## Images
```tsx
// Responsive images
<img
srcSet="image-400w.jpg 400w, image-800w.jpg 800w, image-1200w.jpg 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
/>
// Next.js
<Image src="/hero.jpg" width={1200} height={600} priority className="w-full h-auto" />
```
## Testing
Test at these widths:
- 375px (iPhone SE)
- 390px (iPhone 14)
- 768px (iPad)
- 1024px (iPad Pro)
- 1280px+ (Desktop)
## Resources
- [Tailwind Responsive](https://tailwindcss.com/docs/responsive-design)
@@ -1,718 +0,0 @@
---
name: bencium-innovative-ux-designer
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
metadata:
version: 2.0.0
---
# Innovative UX Designer
Create distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. Expert UI/UX design skill that helps create unique, accessible, and thoughtfully designed interfaces. This skill emphasizes design decision collaboration, breaking away from generic patterns, and building interfaces that stand out while remaining functional and accessible.
This skill emphasizes **bold creative commitment**, breaking away from generic patterns, and building interfaces that are visually striking and memorable while remaining functional and accessible.
## Core Philosophy
**CRITICAL: Design Thinking Protocol**
Before coding, **ASK to understand context**, then **COMMIT BOLDLY** to a distinctive direction:
### Questions to Ask First
1. **Purpose**: What problem does this interface solve? Who uses it?
2. **Tone**: What aesthetic extreme fits? (see Tone Options below)
3. **Constraints**: Technical requirements (framework, performance, accessibility)?
4. **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
### Tone Options (Pick an Extreme)
Choose a clear aesthetic direction and execute with precision:
- **Brutally minimal** - stripped to essence, bold typography, vast whitespace
- **Maximalist chaos** - layered, dense, visually rich, controlled disorder
- **Retro-futuristic** - vintage meets sci-fi, nostalgic tech aesthetics
- **Organic/natural** - soft edges, earthy colors, nature-inspired textures
- **Luxury/refined** - elegant spacing, premium typography, subtle details
- **Playful/toy-like** - bright colors, rounded shapes, delightful interactions
- **Editorial/magazine** - strong typography hierarchy, asymmetric layouts
- **Brutalist/raw** - exposed structure, harsh contrasts, intentionally rough
- **Art deco/geometric** - bold patterns, metallic accents, symmetric elegance
- **Soft/pastel** - gentle gradients, muted tones, calming atmosphere
- **Industrial/utilitarian** - functional, no-nonsense, mechanical precision
### After Getting Context
- **Commit fully** to the chosen direction - no half measures
- Present 2-3 alternative approaches with trade-offs
- Then implement with precision: production-grade, visually striking, memorable
## Foundational Design Principles
### Stand Out From Generic Patterns
**NEVER Use These AI-Generated Aesthetics:**
- **Fonts**: Inter, Roboto, Arial, system fonts as primary choice, Space Grotesk (overused by AI)
- **Colors**: Generic SaaS blue (#3B82F6), purple gradients on white backgrounds
- **Patterns**: Cookie-cutter layouts, predictable component arrangements
- **Effects**: Glass morphism, Apple design mimicry, liquid/blob backgrounds
- **Overall**: Anything that looks "Claude-generated" or machine-made
**Instead, Create Atmosphere:**
- Suggest photography, patterns, textures over flat solid colors
- Apply gradient meshes, noise textures, geometric patterns
- Use layered transparencies, dramatic shadows, decorative borders
- Consider custom cursors, grain overlays, contextual effects
- Think beyond typical patterns - you can step off the written path
**Draw Inspiration From:**
- Modern landing pages (Perplexity, Comet Browser, Dia Browser)
- Framer templates and their innovative approaches
- Leading brand design studios
- Historical design movements (Bauhaus, Otl Aicher, Braun) - but as inspiration, not imitation
- Beautiful background animations (CSS, SVG) - slow, looping, subtle
**Visual Interest Strategies:**
- Unique color pairs that aren't typical
- Animation effects that feel fresh
- Background patterns that add depth without distraction
- Typography combinations that create contrast
- Visual assets that tell a story
### Core Design Philosophy
1. **Simplicity Through Reduction**
- Identify the essential purpose and eliminate distractions
- Begin with complexity, then deliberately remove until reaching the simplest effective solution
- Every element must justify its existence
2. **Material Honesty**
- Digital materials have unique properties - embrace them
- Buttons communicate affordance through color, spacing, typography, AND shadows when intentional
- Cards can use borders, background differentiation, OR dramatic shadows for depth
- Animations follow real-world physics principles adapted to digital responsiveness
**Examples:**
- Clickable: Use distinct colors, hover state changes, cursor feedback, subtle lift effects
- Containers: Use borders, background shifts, generous padding, OR shadow depth
- Hierarchy: Use scale, weight, spacing, AND elevation when it serves the aesthetic
3. **Functional Layering**
- Create hierarchy through typography scale, color contrast, and spatial relationships
- Layer information conceptually (primary → secondary → tertiary)
- Use shadows and gradients INTENTIONALLY when they serve the aesthetic direction
- Embrace functional depth: modals over content, dropdowns over UI
- Avoid: glass morphism, Apple mimicry (but shadows/gradients are tools, not enemies)
4. **Obsessive Detail**
- Consider every pixel, interaction, and transition
- Excellence emerges from hundreds of small, intentional decisions
- Balance: Details should serve simplicity, not complexity
- When detail conflicts with clarity, clarity wins
5. **Coherent Design Language**
- Every element should visually communicate its function
- Elements should feel part of a unified system
- Nothing should feel arbitrary
6. **Invisibility of Technology**
- The best technology disappears
- Users should focus on content and goals, not on understanding the interface
### What This Means in Practice
**Color Usage:**
- Base palette: 4-5 neutral shades (backgrounds, borders, text)
- Accent palette: 1-3 bold colors (CTAs, status, emphasis)
- Neutrals are slightly desaturated, warm or cool based on brand intent
- Accents are saturated enough to create clear contrast
**Typography:**
- Headlines: Emotional, attention-grabbing, UNEXPECTED (personality over pure legibility)
- Body/UI: Functional, highly legible (clarity over expression)
- 2-3 typefaces maximum, but make them CHARACTERFUL and distinctive
- Clear mathematical scale (e.g., 1.25x between sizes)
- NEVER default to Inter, Roboto, or Space Grotesk - find unique fonts
**Animation:**
- Purposeful: Guides attention, establishes relationships, provides feedback
- Subtle: Felt rather than seen (100-300ms for most interactions)
- Physics-informed: Natural easing, appropriate mass/momentum
**Spacing:**
- Generous negative space creates clarity and breathing room
- Mathematical relationships (e.g., 4px base, 8/16/24/32/48px scale)
- Consistent application creates visual rhythm
### Design Decision Checklist
Before presenting any design, verify:
1. **Purpose**: Does every element serve a clear function?
2. **Hierarchy**: Is visual importance aligned with content importance?
3. **Consistency**: Do similar elements look and behave similarly?
4. **Accessibility**: Does it meet WCAG AA standards? (contrast, touch targets, keyboard nav)
5. **Responsiveness**: Does it work on mobile, tablet, desktop?
6. **Uniqueness**: Does this break from generic SaaS patterns?
7. **Approval**: Have I asked before implementing colors, fonts, sizes, layouts?
**Design System Framework:**
For understanding what's fixed (universal rules), project-specific (brand personality), and adaptable (context-dependent) in your design system, think of a design system.
## Visual Design Standards
### Color & Contrast
**Color System Architecture:**
Every interface needs two color roles:
1. **Base/Neutral Palette (4-5 colors):**
- Backgrounds (lightest)
- Surface colors (cards, inputs)
- Borders and dividers
- Text (darkest)
- Use slightly desaturated, warm or cool greys based on brand
2. **Accent Palette (1-3 colors):**
- Primary action (CTA buttons)
- Status indicators (success, warning, error, info)
- Focus/hover states
- Use saturated colors for clear contrast against neutrals
**Palette Structure Example:**
```
Neutrals: slate-50, slate-100, slate-300, slate-700, slate-900
Accents: teal-500 (primary), amber-500 (warning), red-500 (error)
```
**Color Application Rules:**
- **Backgrounds**: Lightest neutral (slate-50 or white)
- **Text**: Darkest neutral for primary text (slate-900), mid-tone for secondary (slate-600)
- **Buttons (primary)**: Accent color with white text
- **Buttons (secondary)**: Neutral with border and dark text
- **Status indicators**: Specific accent (green=success, red=error, amber=warning, blue=info)
- **Interactive states**:
- Hover: Darken by 10-15% or shift hue slightly
- Focus: Use ring/outline in accent color
- Disabled: Reduce opacity to 40-50% and remove hover effects
**Color Relationships:**
Choose warm or cool intentionally based on brand:
- **Warm greys** (beige/brown undertones): Organic, approachable, trustworthy
- **Cool greys** (blue undertones): Modern, tech-forward, professional
Accent colors should have clear contrast with both:
- Light backgrounds (for buttons on white)
- Dark text (if used as backgrounds for white text)
**Intentional Color Usage:**
- Every color must serve a purpose (hierarchy, function, status, or action)
- Avoid decorative colors that don't communicate meaning
- Maintain consistency: same color = same meaning throughout
**Accessibility:**
- Ensure sufficient contrast for color-blind users
- Follow WCAG 2.1 AA: minimum 4.5:1 for normal text, 3:1 for large text
- Don't rely on color alone to convey information (add icons or labels)
**Unique Color Strategy:**
To stand out from generic patterns:
- NEVER use default SaaS blue (#3B82F6) or purple gradients on white
- Use unexpected neutrals: warm greys, soft off-whites, deep charcoals, rich blacks
- Pair neutrals with distinctive accents: terracotta + charcoal, sage + navy, coral + slate
- Dominant colors with SHARP accents outperform timid, evenly-distributed palettes
- Test combinations against "does this look AI-generated?" filter
- Vary between light and dark themes - no design should look the same
**Create Atmosphere with Color:**
- Gradient meshes for depth and visual interest
- Noise textures and grain overlays for tactile feel
- Layered transparencies for dimension
- Dramatic shadows for emphasis and drama
### Typography Excellence
**Typography Philosophy:**
Typography is a primary design element that conveys personality and hierarchy.
**Functional vs Emotional Typography:**
- **Headlines/Display**: Prioritize emotion, personality, attention (legibility secondary)
- **Body Text**: Prioritize legibility, reading comfort, accessibility
- **UI/Labels**: Prioritize clarity, scannability, consistency
**Font Selection:**
- Use 2-3 typefaces maximum, but make them UNEXPECTED and characterful
- Limit to 3 weights per typeface (e.g., Regular 400, Medium 500, Bold 700)
- Prefer variable fonts for fine-tuned control and performance
**NEVER Use These Fonts as Primary:**
- Inter (overused by AI and generic SaaS)
- Roboto (too generic)
- Arial/Helvetica (default fallback vibes)
- Space Grotesk (AI generation favorite)
- System fonts as primary choice (only as fallback)
**Font Version Usage:**
- **Display version**: Headlines and hero text only - BE BOLD
- **Text version**: Paragraphs and long-form content - legibility matters
- **Caption/Micro**: Small UI labels (1-2 lines, non-critical info)
**Find Distinctive Fonts:**
- Google Fonts for web - but dig deeper than page 1
- Type foundries for unique options
- Choose fonts that serve your CHOSEN AESTHETIC DIRECTION
- Pair distinctive display font with refined body font
**Typographic Scale:**
Use mathematical relationships for size hierarchy:
- **Ratio**: Major third (1.25x) for moderate contrast, Perfect fourth (1.333x) for dramatic
- **Base size**: 16px (1rem) for body text
- **Example scale (1.25x)**:
```
xs: 0.64rem (10px)
sm: 0.8rem (13px)
base: 1rem (16px)
lg: 1.25rem (20px)
xl: 1.563rem (25px)
2xl: 1.953rem (31px)
3xl: 2.441rem (39px)
4xl: 3.052rem (49px)
5xl: 3.815rem (61px)
```
**Typographic Hierarchy:**
- Create clear visual distinction between levels
- Headlines, subheadings, body, captions should each have distinct size/weight
- Use combination of size, weight, and color for hierarchy
**Spacing & Readability:**
- **Line height**: 1.5x font size for body text (e.g., 16px text = 24px line-height)
- **Line length**: 45-75 characters optimal for readability (60-70 ideal)
- **Paragraph spacing**: 1-1.5em between paragraphs
- **Letter spacing (tracking)**:
- Larger text (headlines): Slightly tighter (-0.02em to -0.05em)
- Normal text (body): Default (0)
- Small text (captions): Slightly looser (+0.01em to +0.03em)
- General rule: As size increases, reduce tracking; as size decreases, increase tracking
**Font Pairing Logic:**
When using multiple typefaces, create contrast through:
- **Category contrast**: Serif + Sans-serif (classic, clear distinction)
- **Weight contrast**: Light + Bold (dynamic, energetic)
- **Personality contrast**: Geometric + Humanist (modern + warm)
Examples:
- Serif headlines + Sans body (editorial, trustworthy)
- Display headlines + System body (distinctive + efficient)
- Bold sans headlines + Light sans body (modern, clean)
**UI Typography:**
Specific guidance for interface elements:
- **Button text**: Semi-Bold (600), 14-16px, consistent casing (all-caps OR title case)
- **Form labels**: Regular (400), 14px, positioned above input
- **Form input text**: Regular (400), 16px minimum (prevents iOS zoom on focus)
- **Placeholder text**: Light (300) or desaturated color, same size as input
- **Error messages**: Regular (400), 12-14px, color-coded (red-ish)
**Responsive Typography:**
Scale type sizes across breakpoints:
```tsx
// Example with Tailwind
<h1 className="text-3xl md:text-4xl lg:text-5xl">
Responsive Headline
</h1>
// Or with CSS clamp (fluid)
h1 {
font-size: clamp(2rem, 5vw, 4rem);
}
```
Reduce sizes on mobile (20-30% smaller than desktop)
Reduce hierarchy levels on small screens (fewer distinct sizes)
### Layout & Spatial Design
**Compositional Balance:**
- Every screen should feel balanced
- Pay attention to visual weight and negative space
- Use generous negative space to focus attention
- Add sufficient margins and paddings for professional, spacious look
**Grid Discipline:**
- Maintain consistent underlying grid system
- Create sense of order while allowing meaningful exceptions
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
**Spatial Relationships:**
- Group related elements through proximity, alignment, and shared attributes
- Use size, color, and spacing to highlight important elements
- Guide user focus through visual hierarchy
**Attention Guidance:**
- Design interfaces that guide user attention effectively
- Avoid cluttered interfaces where elements compete
- Create clear paths through the content
## Interaction Design
**Motion Specification:**
For detailed motion specs, see MOTION-SPEC.md (easing curves, duration tables, state-specific animations, implementation patterns).
### User Experience Patterns
**Core UX Principles:**
1. **Direct Manipulation**
- Users interact directly with content, not through abstract controls
- Examples:
- Drag & drop to reorder items (not up/down buttons)
- Inline editing (click to edit, not separate form)
- Sliders for ranges (not numeric input with +/-)
- Pinch/zoom gestures on mobile (not +/- buttons)
2. **Immediate Feedback**
- Every interaction provides instantaneous visual feedback (within 100ms)
- Types of feedback:
- **Visual**: Button pressed state, hover effects, color changes
- **Haptic**: Vibration on mobile (submit, error, success)
- **Audio**: Subtle sounds for critical actions (optional, user-controlled)
- **Loading**: Skeleton screens, spinners for >300ms operations
- **Success**: Checkmarks, green highlights, toast notifications
- **Error**: Red highlights, inline error messages, shake animations
3. **Consistent Behavior**
- Similar-looking elements behave similarly
- Examples:
- **Visual consistency**: All primary buttons have same colors, sizes, hover states
- **Behavioral consistency**: All modals close via X button, ESC key, and outside click
- **Interaction consistency**: All drag targets have same hover state and drop feedback
- **Pattern consistency**: All forms validate on blur and submit
4. **Forgiveness**
- Make errors difficult, but recovery easy
- **Prevention strategies**:
- Disable invalid actions (grey out unavailable buttons)
- Validate inputs inline (before submission)
- Confirm destructive actions (delete, overwrite)
- Auto-save in background (drafts, progress)
- **Recovery strategies**:
- Undo/redo for all state changes
- Soft deletes (trash/archive before permanent delete)
- Clear error messages with actionable fixes
- Preserve user input on errors (don't clear forms)
5. **Progressive Disclosure**
- Reveal details as needed rather than overwhelming users
- Levels of disclosure:
- **Summary**: Show essential info by default (card title, price, rating)
- **Details**: Expand to show more info (description, specs, reviews)
- **Advanced**: Hide complex options behind "Advanced settings" toggle
- Examples:
- Accordion: Start collapsed, expand on click
- Search filters: Show 3-5 common filters, hide rest behind "More filters"
- Settings: Basic settings visible, advanced behind "Show advanced"
**Modern UX Patterns:**
1. **Conversational Interfaces**
Prioritize natural language interaction where appropriate:
**Four types:**
- **Pure chat**: Full conversation (AI assistants, support bots)
- **Command palette**: Text-based shortcuts (Cmd+K, search everywhere)
- **Smart search**: Natural language queries (search "meetings next week" vs filtering)
- **Form alternatives**: Conversational data collection ("What's your name?" vs form fields)
**When to use:**
- Complex searches with multiple variables
- Task guidance (wizards, onboarding)
- Contextual help
- Quick actions (command palette)
**When NOT to use:**
- Simple forms (just use inputs)
- Precise control interfaces (design tools, dashboards)
- High-frequency repetitive tasks
2. **Adaptive Layouts**
Respond to user context automatically:
- **Time-based**: Dark mode at night, light during day
- **Device-based**: Simplified UI on mobile, full features on desktop
- **Connection-based**: Reduce images/video on slow connections
- **Usage-based**: Prioritize frequent actions, hide rarely-used features
Examples:
- Auto dark/light mode based on time or system preference
- Simplified mobile navigation (hamburger menu) vs full desktop nav
- Collapsed sidebar on small screens, expanded on large
3. **Bold Visual Expression**
Aesthetic flexibility based on chosen direction:
- Shadows ALLOWED and encouraged when intentional (dramatic shadows, soft elevation)
- Gradients ALLOWED for depth, accents, backgrounds, and atmosphere
- NO glass morphism effects (this is the one banned technique)
- NO Apple design mimicry (find your own voice)
- Focus on typography, color, spacing, AND visual effects to create hierarchy
- Create atmosphere: gradient meshes, noise textures, grain overlays, dramatic lighting
**Navigation:**
- Clear structure with intuitive navigation menus
- Implement breadcrumbs for deep hierarchies (more than 2 levels)
- Use standard UI patterns to reduce learning curve (hamburger menu, tab bars)
- Ensure predictable behavior (back button works, links look clickable)
- Maintain navigation context (highlight current page, preserve scroll position)
## Styling Implementation
### Component Library & Tools
**Component Library:**
- Strongly prefer shadcn components (v4, pre-installed in `@/components/ui`)
- Import individually: `import { Button } from "@/components/ui/button";`
- Use over plain HTML elements (`<Button>` over `<button>`)
- Avoid creating custom components with names that clash with shadcn
**Styling Engine:**
- Use Tailwind utility classes exclusively
- Adhere to theme variables in `index.css` via CSS custom properties
- Map variables in `@theme` (see `tailwind.config.js`)
- Use inline styles or CSS modules only when absolutely necessary
**Icons:**
- Use `@phosphor-icons/react` for buttons and inputs
- Example: `import { Plus } from "@phosphor-icons/react"; <Plus />`
- Use color for plain icon buttons
- Don't override default `size` or `weight` unless requested
**Notifications:**
- Use `sonner` for toasts
- Example: `import { toast } from 'sonner'`
**Loading States:**
- Always add loading states, spinners, placeholder animations
- Use skeletons until content renders
### Layout Implementation
**Spacing Strategy:**
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
- Nest wrappers as needed for complex layouts
**Conditional Styling:**
- Use ternary operators or clsx/classnames utilities
- Example: `className={clsx('base-class', { 'active-class': isActive })}`
### Responsive Design
**Fluid Layouts:**
- Use relative units (%, em, rem) instead of fixed pixels
- Implement CSS Grid and Flexbox for flexible layouts
- Design mobile-first, then scale up
**Media Queries:**
- Use breakpoints based on content needs, not specific devices
- Test across range of devices and orientations
**Touch Targets:**
- Minimum 44x44 pixels for interactive elements
- Provide adequate spacing between touch targets
- Consider hover states for desktop, focus states for touch/keyboard
**Performance:**
- Optimize assets for mobile networks
- Use CSS animations over JavaScript
- Implement lazy loading for images and videos
## Accessibility Standards
**Core Requirements:**
- Follow WCAG 2.1 AA guidelines
- Ensure keyboard navigability for all interactive elements
- Minimum touch target size: 44×44px
- Use semantic HTML for screen reader compatibility
- Provide alternative text for images and non-text content
**Implementation Details:**
- Use descriptive variable and function names
- Event functions: prefix with "handle" (handleClick, handleKeyDown)
- Add accessibility attributes:
- `tabindex="0"` for custom interactive elements
- `aria-label` for buttons without text
- `role` attributes when semantic HTML isn't sufficient
- Ensure logical tab order
- Provide visible focus states
## Design Process & Testing
### Design Workflow
1. **Understand Context:**
- What problem are we solving?
- Who are the users and when will they use this?
- What are the success criteria?
2. **Explore Options:**
- Present 2-3 alternative approaches
- Explain trade-offs of each option
- Ask which direction resonates
3. **Implement Iteratively:**
- Start with structure and hierarchy
- Add visual polish progressively
- Test at each stage
4. **Validate:**
- Use playwright MCP to test visual changes
- Check across different screen sizes
- Verify accessibility
### Testing Checklist
**Visual Testing:**
- Use playwright MCP when available for automated testing
- Check responsive behavior at common breakpoints
- Verify touch targets on mobile
- Test with different content lengths (short, long, edge cases)
**Accessibility Testing:**
- Test keyboard navigation
- Verify screen reader compatibility
- Check color contrast ratios
- Ensure focus states are visible
**Cross-Device Testing:**
- Test on actual devices, not just emulators
- Check different browsers (Chrome, Firefox, Safari)
- Verify touch interactions on mobile
- Test landscape and portrait orientations
## Examples
### Example 1: Designing a Button
**Initial Question to Ask:**
```
Before I implement this button, I have a few design questions:
1. What's the primary action you want to emphasize?
2. Do you have brand colors in mind, or should I suggest options?
3. What emotion should this button convey? (urgent, calm, playful, professional)
```
**After Getting Approval, Implementation:**
```tsx
import { Button } from "@/components/ui/button";
import { ArrowRight } from "@phosphor-icons/react";
<Button
className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-lg transition-colors duration-200"
>
Get Started
<ArrowRight className="ml-2" />
</Button>
```
### Example 2: Typography Hierarchy
**Question First:**
```
For this content hierarchy, I'm thinking:
- Headline: Large, bold, attention-grabbing
- Subheading: Medium, regular weight
- Body: Smaller, optimized for reading
Should we go bold and contemporary, or subtle and refined?
```
**Implementation After Approval:**
```tsx
<div className="space-y-4">
<h1 className="text-5xl font-bold tracking-tight text-slate-900">
Headline Here
</h1>
<h2 className="text-2xl text-slate-600">
Supporting subheading
</h2>
<p className="text-base leading-relaxed text-slate-700">
Body text optimized for readability with proper line height.
</p>
</div>
```
### Example 3: Unique Color Palette Suggestion
**Presenting Options:**
```
I'd like to suggest three color directions that break from typical SaaS patterns:
1. **Warm Earth Tones**
- Base: Warm grey (#E8E2DC)
- Accent: Terracotta (#C86E4B)
- For: Organic, trustworthy feel
2. **Cool Midnight**
- Base: Deep navy (#1A2332)
- Accent: Cyan (#4ECDC4)
- For: Modern, tech-forward feel
3. **Soft Pastels**
- Base: Soft pink (#FFE5E5)
- Accent: Sage green (#9DB5A4)
- For: Calm, approachable feel
Which direction feels right for your brand?
```
## Common Patterns to Avoid
**NEVER:**
- Use Inter, Roboto, Arial, Space Grotesk as primary fonts
- Use generic SaaS blue (#3B82F6) or purple gradients on white
- Copy Apple's design language or use glass morphism
- Create cookie-cutter layouts that look AI-generated
- Skip asking about context before designing
- Converge on common choices across generations (vary everything!)
- Use animations that delay user actions
- Create cluttered interfaces where elements compete
**ALWAYS:**
- Ask about purpose, tone, constraints, differentiation FIRST
- Then commit BOLDLY to a distinctive aesthetic direction
- Use unexpected, characterful typography choices
- Create atmosphere: shadows, gradients, textures, grain (when intentional)
- Dominant colors with sharp accents (not timid, evenly-distributed palettes)
- Provide immediate feedback for interactions
- Test with real devices
- Validate accessibility (it enables creativity, not limits it)
- Remember: Claude is capable of extraordinary creative work - don't hold back!
## Version History
- v2.0.0 (2025-11-22): Creative liberation update - bold aesthetics, shadows/gradients allowed, Design Thinking protocol
- v1.0.0 (2025-10-18): Initial release with comprehensive UI/UX design guidance
## References
For additional context, see:
- **Anthropic Frontend Aesthetics Cookbook**: https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- Google Fonts: https://fonts.google.com/
- Tailwind CSS Docs: https://tailwindcss.com/docs
- Shadcn UI Components: https://ui.shadcn.com/
**Progressive Disclosure Files:**
- ACCESSIBILITY.md - Accessibility essentials (WCAG AA baseline)
- MOTION-SPEC.md - Animation timing and easing
- RESPONSIVE-DESIGN.md - Mobile-first breakpoints and patterns
@@ -1,111 +0,0 @@
# Accessibility Essentials
Accessibility enables creativity - it's a foundation, not a limitation. WCAG 2.1 AA compliance.
## Core Principles (POUR)
- **Perceivable**: Content must be perceivable (alt text, contrast, captions)
- **Operable**: UI must be keyboard/touch accessible
- **Understandable**: Clear, predictable behavior
- **Robust**: Works with assistive technologies
## Contrast Requirements
| Element | Minimum Ratio |
|---------|---------------|
| Normal text | 4.5:1 |
| Large text (18pt+) | 3:1 |
| UI components | 3:1 |
**Tools**: Chrome DevTools Accessibility tab, WebAIM Contrast Checker
## Keyboard Navigation
```tsx
// All interactive elements need focus states
<button className="focus:ring-4 focus:ring-blue-500 focus:outline-none">
Accessible
</button>
// Custom elements need tabindex and key handlers
<div
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
Custom Button
</div>
```
**Essentials:**
- Tab through entire interface
- Enter/Space activates elements
- Escape closes modals
- Visible focus indicators always
## Essential ARIA
```tsx
// Buttons without text
<button aria-label="Close dialog"><X /></button>
// Expandable elements
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
// Live regions for dynamic content
<div role="status" aria-live="polite">{statusMessage}</div>
<div role="alert" aria-live="assertive">{errorMessage}</div>
// Form errors
<input aria-invalid={hasError} aria-describedby="error-msg" />
{hasError && <p id="error-msg" role="alert">Error text</p>}
```
## Semantic HTML
```tsx
// Use semantic elements, not divs
<header><nav>...</nav></header>
<main><article><h1>...</h1></article></main>
<footer>...</footer>
// Heading hierarchy (never skip levels)
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Adequate spacing between targets
- `touch-manipulation` CSS for responsive touch
## Screen Reader Content
```tsx
// Hidden but announced
<span className="sr-only">Additional context</span>
// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
Skip to main content
</a>
```
## Quick Checklist
- [ ] Keyboard: Can tab through everything
- [ ] Focus: Visible focus indicators
- [ ] Contrast: 4.5:1 for text
- [ ] Alt text: All images have appropriate alt
- [ ] Headings: Logical h1-h6 hierarchy
- [ ] Forms: Labels associated with inputs
- [ ] Errors: Announced to screen readers
- [ ] Touch: 44px minimum targets
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
@@ -1,577 +0,0 @@
# Design System Template
Meta-framework for understanding what's fixed, project-specific, and adaptable in your design system.
## Purpose
This template helps you distinguish between:
- **Fixed Elements**: Universal rules that never change
- **Project-Specific Elements**: Filled in for each project based on brand
- **Adaptable Elements**: Context-dependent implementations
---
## I. FIXED ELEMENTS
These foundations remain consistent across all projects, regardless of brand or context.
### 1. Spacing Scale
**Fixed System:**
```
4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px, 96px
```
**Usage:**
- Margins, padding, gaps between elements
- Mathematical relationships ensure visual harmony
- Use multipliers of base unit (4px)
**Why Fixed:**
Consistent spacing creates visual rhythm regardless of brand personality.
### 2. Grid System
**Fixed Structure:**
- **12-column grid** for most layouts (divisible by 2, 3, 4, 6)
- **16-column grid** for data-heavy interfaces
- **Gutters**: 16px (mobile), 24px (tablet), 32px (desktop)
**Why Fixed:**
Grid provides structural order. Brand personality shows through color, typography, content—not grid structure.
### 3. Accessibility Standards
**Fixed Requirements:**
- **WCAG 2.1 AA** compliance minimum
- **Contrast**: 4.5:1 for normal text, 3:1 for large text
- **Touch targets**: Minimum 44×44px
- **Keyboard navigation**: All interactive elements accessible
- **Screen reader**: Semantic HTML, ARIA labels where needed
**Why Fixed:**
Accessibility is not negotiable. It's a baseline requirement for ethical, legal, and usable products.
### 4. Typography Hierarchy Logic
**Fixed Structure:**
- **Mathematical scaling**: 1.25x (major third) or 1.333x (perfect fourth)
- **Hierarchy levels**: Display → H1 → H2 → H3 → Body → Small → Caption
- **Line height**: 1.5x for body text, 1.2-1.3x for headlines
- **Line length**: 45-75 characters optimal
**Why Fixed:**
Mathematical relationships create predictable, harmonious hierarchy. Specific fonts change, but the logic doesn't.
### 5. Component Architecture
**Fixed Patterns:**
- **Button states**: Default, Hover, Active, Focus, Disabled
- **Form structure**: Label above input, error below, helper text optional
- **Modal pattern**: Overlay + centered content + close mechanism
- **Card structure**: Container → Header → Body → Footer (optional)
**Why Fixed:**
Users expect consistent component behavior. Architecture is fixed; appearance is project-specific.
### 6. Animation Timing Framework
**Fixed Physics Profiles:**
- **Lightweight** (icons, chips): 150ms
- **Standard** (cards, panels): 300ms
- **Weighty** (modals, pages): 500ms
**Fixed Easing:**
- **Ease-out**: Entrances (fast start, slow end)
- **Ease-in**: Exits (slow start, fast end)
- **Ease-in-out**: Transitions (smooth both ends)
**Why Fixed:**
Natural physics feel consistent across brands. Duration and easing create that feeling.
---
## II. PROJECT-SPECIFIC ELEMENTS
Fill in these for each project based on brand personality and purpose.
### 1. Brand Color System
**Template Structure:**
```
NEUTRALS (4-5 colors):
- Background lightest: _______ (e.g., slate-50 or warm-white)
- Surface: _______ (e.g., slate-100)
- Border/divider: _______ (e.g., slate-300)
- Text secondary: _______ (e.g., slate-600)
- Text primary: _______ (e.g., slate-900)
ACCENTS (1-3 colors):
- Primary (main CTA): _______ (e.g., teal-500)
- Secondary (alternative action): _______ (optional)
- Status colors:
- Success: _______ (green-ish)
- Warning: _______ (amber-ish)
- Error: _______ (red-ish)
- Info: _______ (blue-ish)
```
**Questions to Answer:**
- What emotion should the brand evoke? (Trust, excitement, calm, urgency)
- Warm or cool neutrals?
- Conservative or bold accents?
**Examples:**
**Project A: Fintech App**
```
Neutrals: Cool greys (slate-50 → slate-900)
Primary: Deep blue (#0A2463) trust, professionalism
Success: Muted green (#10B981)
Why: Financial products need trust, not playfulness
```
**Project B: Creative Community**
```
Neutrals: Warm greys with beige undertones
Primary: Coral (#FF6B6B) energy, creativity
Success: Teal (#06D6A0) fresh, unexpected
Why: Creative spaces should feel inviting, not corporate
```
**Project C: Healthcare Platform**
```
Neutrals: Pure greys (minimal color temperature)
Primary: Soft blue (#4A90E2) calm, clinical
Success: Medical green (#38A169)
Why: Healthcare needs clarity and calm, not distraction
```
### 2. Typography Pairing
**Template:**
```
HEADLINE FONT: _______
- Weight: _______ (e.g., Bold 700)
- Use case: H1, H2, display text
- Personality: _______ (geometric/humanist/serif/etc.)
BODY FONT: _______
- Weight: _______ (e.g., Regular 400, Medium 500)
- Use case: Paragraphs, UI text
- Personality: _______ (neutral/readable/efficient)
OPTIONAL ACCENT FONT: _______
- Weight: _______
- Use case: _______ (special headlines, callouts)
```
**Pairing Logic:**
- Serif + Sans-serif (classic, editorial)
- Geometric + Humanist (modern + warm)
- Display + System (distinctive + efficient)
**Examples:**
**Project A: Editorial Platform**
```
Headline: Playfair Display (Serif, Bold 700)
Body: Inter (Sans-serif, Regular 400)
Why: Serif headlines = trustworthy, editorial feel
```
**Project B: Tech Startup**
```
Headline: DM Sans (Sans-serif, Bold 700)
Body: DM Sans (Regular 400, Medium 500)
Why: Single-font system = modern, efficient, cohesive
```
**Project C: Luxury Brand**
```
Headline: Cormorant Garamond (Serif, Light 300)
Body: Lato (Sans-serif, Regular 400)
Why: Elegant serif + readable sans = sophisticated
```
### 3. Tone of Voice
**Template:**
```
BRAND PERSONALITY:
- Formal ↔ Casual: _______ (1-10 scale)
- Professional ↔ Friendly: _______ (1-10 scale)
- Serious ↔ Playful: _______ (1-10 scale)
- Authoritative ↔ Conversational: _______ (1-10 scale)
MICROCOPY EXAMPLES:
- Button label (submit form): _______
- Error message (invalid email): _______
- Success message (saved): _______
- Empty state: _______
ANIMATION PERSONALITY:
- Speed: _______ (quick/moderate/slow)
- Feel: _______ (precise/smooth/bouncy)
```
**Examples:**
**Project A: Banking App**
```
Personality: Formal (8), Professional (9), Serious (8)
Button: "Submit Application"
Error: "Email address format is invalid"
Success: "Application submitted successfully"
Animation: Quick (precise, efficient, no-nonsense)
```
**Project B: Social App**
```
Personality: Casual (8), Friendly (9), Playful (7)
Button: "Let's go!"
Error: "Hmm, that email doesn't look right"
Success: "Nice! You're all set 🎉"
Animation: Moderate (smooth, friendly bounce)
```
### 4. Animation Speed & Feel
**Template:**
```
SPEED PREFERENCE:
- UI interactions: _______ (100-150ms / 150-200ms / 200-300ms)
- State changes: _______ (200ms / 300ms / 400ms)
- Page transitions: _______ (300ms / 500ms / 700ms)
ANIMATION STYLE:
- Easing preference: _______ (sharp / standard / bouncy)
- Movement type: _______ (minimal / smooth / expressive)
```
**Examples:**
**Project A: Trading Platform**
```
Speed: Fast (100ms UI, 200ms states, 300ms pages)
Style: Sharp easing, minimal movement
Why: Traders need speed, not distraction
```
**Project B: Wellness App**
```
Speed: Slow (200ms UI, 400ms states, 500ms pages)
Style: Smooth easing, gentle movement
Why: Calm, relaxing experience matches brand
```
---
## III. ADAPTABLE ELEMENTS
Context-dependent implementations that vary based on use case.
### 1. Component Variations
**Button Variants:**
- **Primary**: Full background color (high emphasis)
- **Secondary**: Outline only (medium emphasis)
- **Tertiary**: Text only (low emphasis)
- **Destructive**: Red-ish (danger actions)
- **Ghost**: Minimal (navigation, toolbars)
**Adaptation Rules:**
- Primary: Main CTA, one per screen section
- Secondary: Alternative actions
- Tertiary: Less important actions, multiple allowed
- Use brand colors, but hierarchy logic is fixed
### 2. Responsive Breakpoints
**Fixed Ranges:**
- XS: 0-479px (small phones)
- SM: 480-767px (large phones)
- MD: 768-1023px (tablets)
- LG: 1024-1439px (laptops)
- XL: 1440px+ (desktop)
**Adaptable Implementations:**
**Simple Content Site:**
```
XS-SM: Single column
MD: 2 columns
LG-XL: 3 columns max
Why: Content-focused, don't overwhelm
```
**Dashboard/Data App:**
```
XS: Collapsed, cards stack
SM: Simplified sidebar
MD: Full sidebar + main content
LG-XL: Sidebar + main + right panel
Why: Data apps need more screen real estate
```
### 3. Dark Mode Palette
**Adaptation Strategy:**
Not a simple inversion. Dark mode needs adjusted contrast:
**Light Mode:**
```
Background: #FFFFFF (white)
Text: #0F172A (slate-900) → 21:1 contrast
```
**Dark Mode (Adapted):**
```
Background: #0F172A (slate-900)
Text: #E2E8F0 (slate-200) → 15.8:1 contrast (still AA, but softer)
```
**Why Adapt:**
Pure white on pure black is too harsh. Dark mode needs slightly lower contrast for eye comfort.
### 4. Loading States
**Context-Dependent:**
**Fast operations (<500ms):**
- No loading indicator (feels instant)
**Medium operations (500ms-2s):**
- Spinner or skeleton screen
**Long operations (>2s):**
- Progress bar with percentage
- Or: Skeleton + estimated time
**Interactive Operations:**
- Button shows spinner inside (don't disable, show state)
### 5. Error Handling Strategy
**Context-Dependent:**
**Form Errors:**
```
Validate: On blur (after user leaves field)
Display: Inline below field
Recovery: Clear error on fix
```
**API Errors:**
```
Transient (network): Show retry button
Permanent (404): Show helpful message + next steps
Critical (500): Contact support option
```
**Data Errors:**
```
Missing: Show empty state with action
Corrupt: Show error boundary with reload
Invalid: Highlight + explain what's wrong
```
---
## DECISION TREE
When implementing a feature, ask:
### Is this...
**FIXED?**
- Does it affect structure, accessibility, or universal UX?
- Examples: Spacing scale, grid, contrast ratios, component architecture
- **Action**: Use the fixed system, no variation
**PROJECT-SPECIFIC?**
- Does it express brand personality or purpose?
- Examples: Colors, typography, tone of voice, animation feel
- **Action**: Fill in the template for this project
**ADAPTABLE?**
- Does it depend on context, content, or use case?
- Examples: Component variants, responsive behavior, error handling
- **Action**: Choose appropriate variation based on context
---
## EXAMPLE: Implementing a "Submit" Button
### Fixed Elements (Always the same):
- Touch target: 44px minimum height
- Padding: 16px horizontal (from spacing scale)
- States: Default, Hover, Active, Focus, Disabled
- Animation: 150ms ease-out (lightweight profile)
### Project-Specific (Filled per project):
- **Project A (Bank)**: Dark blue background, white text, "Submit Application"
- **Project B (Social)**: Coral background, white text, "Let's Go!"
- **Project C (Healthcare)**: Soft blue background, white text, "Continue"
### Adaptable (Context-dependent):
- **Form context**: Primary button (full color)
- **Toolbar context**: Ghost button (text only)
- **Danger context**: Destructive variant (red-ish)
---
## VALIDATION CHECKLIST
Before finalizing a design, check:
### Fixed Elements
- [ ] Uses spacing scale (4/8/12/16/24/32/48/64/96px)
- [ ] Follows grid system (12 or 16 columns)
- [ ] Meets WCAG AA contrast (4.5:1 normal, 3:1 large)
- [ ] Touch targets ≥ 44px
- [ ] Typography follows mathematical scale
- [ ] Components follow standard architecture
### Project-Specific Elements
- [ ] Brand colors filled in and intentional
- [ ] Typography pairing chosen and justified
- [ ] Tone of voice defined and consistent
- [ ] Animation speed matches brand personality
### Adaptable Elements
- [ ] Component variants appropriate for context
- [ ] Responsive behavior fits content type
- [ ] Loading states match operation duration
- [ ] Error handling fits error type
---
## PROJECT KICKOFF TEMPLATE
Use this to start a new project:
```
PROJECT NAME: _______________________
PURPOSE: ____________________________
BRAND PERSONALITY:
- Primary emotion: _______
- Warm or cool: _______
- Formal or casual: _______
- Conservative or bold: _______
COLORS (fill the template):
- Neutral base: _______
- Primary accent: _______
- Status colors: _______ / _______ / _______
TYPOGRAPHY (fill the template):
- Headline font: _______
- Body font: _______
- Pairing rationale: _______
TONE:
- Button labels style: _______
- Error message style: _______
- Success message style: _______
ANIMATION:
- Speed preference: _______ (fast/moderate/slow)
- Feel preference: _______ (sharp/smooth/bouncy)
TARGET DEVICES:
- Primary: _______ (mobile/desktop/both)
- Secondary: _______
```
---
## MAINTAINING CONSISTENCY
### Documentation
- Keep this template updated as system evolves
- Document WHY choices were made, not just WHAT
### Communication
- Share with designers: "Here's what varies vs. what's fixed"
- Share with developers: "Here are the design tokens"
### Tooling
- Use CSS variables for project-specific values
- Use Tailwind config for spacing scale
- Use design tokens in Figma/Storybook
### Reviews
- Audit: Does new work follow fixed elements?
- Validate: Are project-specific elements intentional?
- Question: Are adaptations justified by context?
---
## EXAMPLES OF COMPLETE SYSTEMS
### System A: B2B SaaS (Conservative)
**Fixed**: Standard spacing, 12-col grid, WCAG AA, major third type scale
**Project-Specific**:
- Colors: Cool greys + corporate blue
- Typography: DM Sans (headlines + body)
- Tone: Professional, formal
- Animation: Quick, precise (150ms)
**Adaptable**:
- Dashboard gets multi-panel layout
- Forms are extensive (use progressive disclosure)
- Errors show detailed technical info
### System B: Consumer Social App (Playful)
**Fixed**: Same spacing/grid/accessibility/type logic
**Project-Specific**:
- Colors: Warm greys + vibrant coral
- Typography: Poppins (headlines) + Inter (body)
- Tone: Casual, friendly, playful
- Animation: Moderate, bouncy (200ms)
**Adaptable**:
- Mobile-first (most users on phones)
- Forms are minimal (progressive profiling)
- Errors are friendly, not technical
### System C: Healthcare Platform (Clinical)
**Fixed**: Same foundational structure
**Project-Specific**:
- Colors: Pure greys + medical blue
- Typography: System fonts (SF Pro / Segoe)
- Tone: Clear, authoritative, calm
- Animation: Slow, smooth (300ms)
**Adaptable**:
- Desktop-first (clinical use at workstations)
- Forms are complex (HIPAA compliance)
- Errors are precise with next steps
---
## KEY TAKEAWAY
**The system flexibility framework lets you:**
- Maintain consistency (fixed elements)
- Express brand personality (project-specific)
- Adapt to context (adaptable elements)
**Without this framework:**
- Designers reinvent spacing every project
- Components feel inconsistent across products
- Brand personality overrides accessibility
- Context-blind implementations feel wrong
**With this framework:**
- Speed: Start from proven foundations
- Consistency: Fixed elements guarantee it
- Flexibility: Express unique brand identity
- Context: Adapt without breaking system
@@ -1,72 +0,0 @@
# Motion Specification
Motion should surprise and delight while serving function. Animation is a creative tool.
## Easing Curves
| Easing | CSS | Use For |
|--------|-----|---------|
| **Ease-out** | `cubic-bezier(0.0, 0.0, 0.2, 1)` | Entrances, appearing |
| **Ease-in** | `cubic-bezier(0.4, 0.0, 1, 1)` | Exits, disappearing |
| **Ease-in-out** | `cubic-bezier(0.4, 0.0, 0.2, 1)` | State changes, transforms |
| **Spring** | `cubic-bezier(0.68, -0.55, 0.265, 1.55)` | Playful, attention-grabbing |
| **Linear** | `linear` | Spinners, continuous loops |
## Duration by Element Weight
| Weight | Duration | Examples |
|--------|----------|----------|
| **Lightweight** | 150ms | Icons, badges, chips |
| **Standard** | 300ms | Cards, panels, list items |
| **Weighty** | 500ms | Modals, page transitions |
## Duration by Interaction
| Interaction | Duration |
|-------------|----------|
| Button press | 100ms |
| Hover state | 150ms |
| Tooltip appear | 200ms |
| Tab switch | 250ms |
| Modal open | 300ms |
| Page transition | 400ms |
## Common Patterns
```tsx
// Hover transition (CSS)
<button className="transition-colors duration-150 ease-out hover:bg-blue-700">
// Fade + slide (Framer Motion)
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
// Stagger children
<motion.ul variants={{ visible: { transition: { staggerChildren: 0.1 } } }}>
<motion.li variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }} />
</motion.ul>
```
## Performance Rules
- Only animate `transform` and `opacity` (GPU-accelerated)
- Avoid animating `width`, `height`, `margin`, `padding`
- Keep durations under 500ms for UI interactions
- Respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
## Resources
- [Framer Motion](https://www.framer.com/motion/)
- [CSS Easing Functions](https://easings.net/)
@@ -1,90 +0,0 @@
# Responsive Design Essentials
Mobile-first approach: start with mobile, progressively enhance for larger screens.
## Breakpoints
| Range | Pixels | Devices | Strategy |
|-------|--------|---------|----------|
| **XS** | 0-479px | Small phones | Single column, stacked nav, 44px touch targets |
| **SM** | 480-767px | Large phones | Single column, bottom nav, simplified UI |
| **MD** | 768-1023px | Tablets | 2 columns possible, sidebar nav |
| **LG** | 1024-1439px | Laptops | Multi-column, full nav, desktop UI |
| **XL** | 1440px+ | Desktop | Max-width containers, multi-panel layouts |
## Tailwind Responsive
```tsx
// Mobile-first: base styles, then scale up
<div className="
w-full // mobile: full width
sm:w-1/2 // 480px+: half
md:w-1/3 // 768px+: third
lg:w-1/4 // 1024px+: quarter
">
// Responsive grid
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Responsive typography
<h1 className="text-3xl md:text-4xl lg:text-5xl">
// Show/hide by breakpoint
<div className="block md:hidden">Mobile only</div>
<div className="hidden md:block">Desktop only</div>
```
## Fluid Typography
```css
h1 { font-size: clamp(2rem, 5vw, 4rem); }
p { font-size: clamp(1rem, 2.5vw, 1.25rem); }
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Use `touch-manipulation` to prevent 300ms tap delay
- Adequate spacing between targets
```tsx
<button className="min-w-[44px] min-h-[44px] touch-manipulation">
```
## Mobile Simplification
| Desktop | Mobile |
|---------|--------|
| Full nav bar | Hamburger menu |
| Side-by-side fields | Stacked fields |
| Multi-column grid | Single column |
| Inline buttons | Fixed bottom bar |
| Data table | Collapsed cards |
| Visible sidebar | Hidden/collapsible |
## Images
```tsx
// Responsive images
<img
srcSet="image-400w.jpg 400w, image-800w.jpg 800w, image-1200w.jpg 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
/>
// Next.js
<Image src="/hero.jpg" width={1200} height={600} priority className="w-full h-auto" />
```
## Testing
Test at these widths:
- 375px (iPhone SE)
- 390px (iPhone 14)
- 768px (iPad)
- 1024px (iPad Pro)
- 1280px+ (Desktop)
## Resources
- [Tailwind Responsive](https://tailwindcss.com/docs/responsive-design)
@@ -1,718 +0,0 @@
---
name: bencium-innovative-ux-designer
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
metadata:
version: 2.0.0
---
# Innovative UX Designer
Create distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. Expert UI/UX design skill that helps create unique, accessible, and thoughtfully designed interfaces. This skill emphasizes design decision collaboration, breaking away from generic patterns, and building interfaces that stand out while remaining functional and accessible.
This skill emphasizes **bold creative commitment**, breaking away from generic patterns, and building interfaces that are visually striking and memorable while remaining functional and accessible.
## Core Philosophy
**CRITICAL: Design Thinking Protocol**
Before coding, **ASK to understand context**, then **COMMIT BOLDLY** to a distinctive direction:
### Questions to Ask First
1. **Purpose**: What problem does this interface solve? Who uses it?
2. **Tone**: What aesthetic extreme fits? (see Tone Options below)
3. **Constraints**: Technical requirements (framework, performance, accessibility)?
4. **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
### Tone Options (Pick an Extreme)
Choose a clear aesthetic direction and execute with precision:
- **Brutally minimal** - stripped to essence, bold typography, vast whitespace
- **Maximalist chaos** - layered, dense, visually rich, controlled disorder
- **Retro-futuristic** - vintage meets sci-fi, nostalgic tech aesthetics
- **Organic/natural** - soft edges, earthy colors, nature-inspired textures
- **Luxury/refined** - elegant spacing, premium typography, subtle details
- **Playful/toy-like** - bright colors, rounded shapes, delightful interactions
- **Editorial/magazine** - strong typography hierarchy, asymmetric layouts
- **Brutalist/raw** - exposed structure, harsh contrasts, intentionally rough
- **Art deco/geometric** - bold patterns, metallic accents, symmetric elegance
- **Soft/pastel** - gentle gradients, muted tones, calming atmosphere
- **Industrial/utilitarian** - functional, no-nonsense, mechanical precision
### After Getting Context
- **Commit fully** to the chosen direction - no half measures
- Present 2-3 alternative approaches with trade-offs
- Then implement with precision: production-grade, visually striking, memorable
## Foundational Design Principles
### Stand Out From Generic Patterns
**NEVER Use These AI-Generated Aesthetics:**
- **Fonts**: Inter, Roboto, Arial, system fonts as primary choice, Space Grotesk (overused by AI)
- **Colors**: Generic SaaS blue (#3B82F6), purple gradients on white backgrounds
- **Patterns**: Cookie-cutter layouts, predictable component arrangements
- **Effects**: Glass morphism, Apple design mimicry, liquid/blob backgrounds
- **Overall**: Anything that looks "Claude-generated" or machine-made
**Instead, Create Atmosphere:**
- Suggest photography, patterns, textures over flat solid colors
- Apply gradient meshes, noise textures, geometric patterns
- Use layered transparencies, dramatic shadows, decorative borders
- Consider custom cursors, grain overlays, contextual effects
- Think beyond typical patterns - you can step off the written path
**Draw Inspiration From:**
- Modern landing pages (Perplexity, Comet Browser, Dia Browser)
- Framer templates and their innovative approaches
- Leading brand design studios
- Historical design movements (Bauhaus, Otl Aicher, Braun) - but as inspiration, not imitation
- Beautiful background animations (CSS, SVG) - slow, looping, subtle
**Visual Interest Strategies:**
- Unique color pairs that aren't typical
- Animation effects that feel fresh
- Background patterns that add depth without distraction
- Typography combinations that create contrast
- Visual assets that tell a story
### Core Design Philosophy
1. **Simplicity Through Reduction**
- Identify the essential purpose and eliminate distractions
- Begin with complexity, then deliberately remove until reaching the simplest effective solution
- Every element must justify its existence
2. **Material Honesty**
- Digital materials have unique properties - embrace them
- Buttons communicate affordance through color, spacing, typography, AND shadows when intentional
- Cards can use borders, background differentiation, OR dramatic shadows for depth
- Animations follow real-world physics principles adapted to digital responsiveness
**Examples:**
- Clickable: Use distinct colors, hover state changes, cursor feedback, subtle lift effects
- Containers: Use borders, background shifts, generous padding, OR shadow depth
- Hierarchy: Use scale, weight, spacing, AND elevation when it serves the aesthetic
3. **Functional Layering**
- Create hierarchy through typography scale, color contrast, and spatial relationships
- Layer information conceptually (primary → secondary → tertiary)
- Use shadows and gradients INTENTIONALLY when they serve the aesthetic direction
- Embrace functional depth: modals over content, dropdowns over UI
- Avoid: glass morphism, Apple mimicry (but shadows/gradients are tools, not enemies)
4. **Obsessive Detail**
- Consider every pixel, interaction, and transition
- Excellence emerges from hundreds of small, intentional decisions
- Balance: Details should serve simplicity, not complexity
- When detail conflicts with clarity, clarity wins
5. **Coherent Design Language**
- Every element should visually communicate its function
- Elements should feel part of a unified system
- Nothing should feel arbitrary
6. **Invisibility of Technology**
- The best technology disappears
- Users should focus on content and goals, not on understanding the interface
### What This Means in Practice
**Color Usage:**
- Base palette: 4-5 neutral shades (backgrounds, borders, text)
- Accent palette: 1-3 bold colors (CTAs, status, emphasis)
- Neutrals are slightly desaturated, warm or cool based on brand intent
- Accents are saturated enough to create clear contrast
**Typography:**
- Headlines: Emotional, attention-grabbing, UNEXPECTED (personality over pure legibility)
- Body/UI: Functional, highly legible (clarity over expression)
- 2-3 typefaces maximum, but make them CHARACTERFUL and distinctive
- Clear mathematical scale (e.g., 1.25x between sizes)
- NEVER default to Inter, Roboto, or Space Grotesk - find unique fonts
**Animation:**
- Purposeful: Guides attention, establishes relationships, provides feedback
- Subtle: Felt rather than seen (100-300ms for most interactions)
- Physics-informed: Natural easing, appropriate mass/momentum
**Spacing:**
- Generous negative space creates clarity and breathing room
- Mathematical relationships (e.g., 4px base, 8/16/24/32/48px scale)
- Consistent application creates visual rhythm
### Design Decision Checklist
Before presenting any design, verify:
1. **Purpose**: Does every element serve a clear function?
2. **Hierarchy**: Is visual importance aligned with content importance?
3. **Consistency**: Do similar elements look and behave similarly?
4. **Accessibility**: Does it meet WCAG AA standards? (contrast, touch targets, keyboard nav)
5. **Responsiveness**: Does it work on mobile, tablet, desktop?
6. **Uniqueness**: Does this break from generic SaaS patterns?
7. **Approval**: Have I asked before implementing colors, fonts, sizes, layouts?
**Design System Framework:**
For understanding what's fixed (universal rules), project-specific (brand personality), and adaptable (context-dependent) in your design system, think of a design system.
## Visual Design Standards
### Color & Contrast
**Color System Architecture:**
Every interface needs two color roles:
1. **Base/Neutral Palette (4-5 colors):**
- Backgrounds (lightest)
- Surface colors (cards, inputs)
- Borders and dividers
- Text (darkest)
- Use slightly desaturated, warm or cool greys based on brand
2. **Accent Palette (1-3 colors):**
- Primary action (CTA buttons)
- Status indicators (success, warning, error, info)
- Focus/hover states
- Use saturated colors for clear contrast against neutrals
**Palette Structure Example:**
```
Neutrals: slate-50, slate-100, slate-300, slate-700, slate-900
Accents: teal-500 (primary), amber-500 (warning), red-500 (error)
```
**Color Application Rules:**
- **Backgrounds**: Lightest neutral (slate-50 or white)
- **Text**: Darkest neutral for primary text (slate-900), mid-tone for secondary (slate-600)
- **Buttons (primary)**: Accent color with white text
- **Buttons (secondary)**: Neutral with border and dark text
- **Status indicators**: Specific accent (green=success, red=error, amber=warning, blue=info)
- **Interactive states**:
- Hover: Darken by 10-15% or shift hue slightly
- Focus: Use ring/outline in accent color
- Disabled: Reduce opacity to 40-50% and remove hover effects
**Color Relationships:**
Choose warm or cool intentionally based on brand:
- **Warm greys** (beige/brown undertones): Organic, approachable, trustworthy
- **Cool greys** (blue undertones): Modern, tech-forward, professional
Accent colors should have clear contrast with both:
- Light backgrounds (for buttons on white)
- Dark text (if used as backgrounds for white text)
**Intentional Color Usage:**
- Every color must serve a purpose (hierarchy, function, status, or action)
- Avoid decorative colors that don't communicate meaning
- Maintain consistency: same color = same meaning throughout
**Accessibility:**
- Ensure sufficient contrast for color-blind users
- Follow WCAG 2.1 AA: minimum 4.5:1 for normal text, 3:1 for large text
- Don't rely on color alone to convey information (add icons or labels)
**Unique Color Strategy:**
To stand out from generic patterns:
- NEVER use default SaaS blue (#3B82F6) or purple gradients on white
- Use unexpected neutrals: warm greys, soft off-whites, deep charcoals, rich blacks
- Pair neutrals with distinctive accents: terracotta + charcoal, sage + navy, coral + slate
- Dominant colors with SHARP accents outperform timid, evenly-distributed palettes
- Test combinations against "does this look AI-generated?" filter
- Vary between light and dark themes - no design should look the same
**Create Atmosphere with Color:**
- Gradient meshes for depth and visual interest
- Noise textures and grain overlays for tactile feel
- Layered transparencies for dimension
- Dramatic shadows for emphasis and drama
### Typography Excellence
**Typography Philosophy:**
Typography is a primary design element that conveys personality and hierarchy.
**Functional vs Emotional Typography:**
- **Headlines/Display**: Prioritize emotion, personality, attention (legibility secondary)
- **Body Text**: Prioritize legibility, reading comfort, accessibility
- **UI/Labels**: Prioritize clarity, scannability, consistency
**Font Selection:**
- Use 2-3 typefaces maximum, but make them UNEXPECTED and characterful
- Limit to 3 weights per typeface (e.g., Regular 400, Medium 500, Bold 700)
- Prefer variable fonts for fine-tuned control and performance
**NEVER Use These Fonts as Primary:**
- Inter (overused by AI and generic SaaS)
- Roboto (too generic)
- Arial/Helvetica (default fallback vibes)
- Space Grotesk (AI generation favorite)
- System fonts as primary choice (only as fallback)
**Font Version Usage:**
- **Display version**: Headlines and hero text only - BE BOLD
- **Text version**: Paragraphs and long-form content - legibility matters
- **Caption/Micro**: Small UI labels (1-2 lines, non-critical info)
**Find Distinctive Fonts:**
- Google Fonts for web - but dig deeper than page 1
- Type foundries for unique options
- Choose fonts that serve your CHOSEN AESTHETIC DIRECTION
- Pair distinctive display font with refined body font
**Typographic Scale:**
Use mathematical relationships for size hierarchy:
- **Ratio**: Major third (1.25x) for moderate contrast, Perfect fourth (1.333x) for dramatic
- **Base size**: 16px (1rem) for body text
- **Example scale (1.25x)**:
```
xs: 0.64rem (10px)
sm: 0.8rem (13px)
base: 1rem (16px)
lg: 1.25rem (20px)
xl: 1.563rem (25px)
2xl: 1.953rem (31px)
3xl: 2.441rem (39px)
4xl: 3.052rem (49px)
5xl: 3.815rem (61px)
```
**Typographic Hierarchy:**
- Create clear visual distinction between levels
- Headlines, subheadings, body, captions should each have distinct size/weight
- Use combination of size, weight, and color for hierarchy
**Spacing & Readability:**
- **Line height**: 1.5x font size for body text (e.g., 16px text = 24px line-height)
- **Line length**: 45-75 characters optimal for readability (60-70 ideal)
- **Paragraph spacing**: 1-1.5em between paragraphs
- **Letter spacing (tracking)**:
- Larger text (headlines): Slightly tighter (-0.02em to -0.05em)
- Normal text (body): Default (0)
- Small text (captions): Slightly looser (+0.01em to +0.03em)
- General rule: As size increases, reduce tracking; as size decreases, increase tracking
**Font Pairing Logic:**
When using multiple typefaces, create contrast through:
- **Category contrast**: Serif + Sans-serif (classic, clear distinction)
- **Weight contrast**: Light + Bold (dynamic, energetic)
- **Personality contrast**: Geometric + Humanist (modern + warm)
Examples:
- Serif headlines + Sans body (editorial, trustworthy)
- Display headlines + System body (distinctive + efficient)
- Bold sans headlines + Light sans body (modern, clean)
**UI Typography:**
Specific guidance for interface elements:
- **Button text**: Semi-Bold (600), 14-16px, consistent casing (all-caps OR title case)
- **Form labels**: Regular (400), 14px, positioned above input
- **Form input text**: Regular (400), 16px minimum (prevents iOS zoom on focus)
- **Placeholder text**: Light (300) or desaturated color, same size as input
- **Error messages**: Regular (400), 12-14px, color-coded (red-ish)
**Responsive Typography:**
Scale type sizes across breakpoints:
```tsx
// Example with Tailwind
<h1 className="text-3xl md:text-4xl lg:text-5xl">
Responsive Headline
</h1>
// Or with CSS clamp (fluid)
h1 {
font-size: clamp(2rem, 5vw, 4rem);
}
```
Reduce sizes on mobile (20-30% smaller than desktop)
Reduce hierarchy levels on small screens (fewer distinct sizes)
### Layout & Spatial Design
**Compositional Balance:**
- Every screen should feel balanced
- Pay attention to visual weight and negative space
- Use generous negative space to focus attention
- Add sufficient margins and paddings for professional, spacious look
**Grid Discipline:**
- Maintain consistent underlying grid system
- Create sense of order while allowing meaningful exceptions
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
**Spatial Relationships:**
- Group related elements through proximity, alignment, and shared attributes
- Use size, color, and spacing to highlight important elements
- Guide user focus through visual hierarchy
**Attention Guidance:**
- Design interfaces that guide user attention effectively
- Avoid cluttered interfaces where elements compete
- Create clear paths through the content
## Interaction Design
**Motion Specification:**
For detailed motion specs, see MOTION-SPEC.md (easing curves, duration tables, state-specific animations, implementation patterns).
### User Experience Patterns
**Core UX Principles:**
1. **Direct Manipulation**
- Users interact directly with content, not through abstract controls
- Examples:
- Drag & drop to reorder items (not up/down buttons)
- Inline editing (click to edit, not separate form)
- Sliders for ranges (not numeric input with +/-)
- Pinch/zoom gestures on mobile (not +/- buttons)
2. **Immediate Feedback**
- Every interaction provides instantaneous visual feedback (within 100ms)
- Types of feedback:
- **Visual**: Button pressed state, hover effects, color changes
- **Haptic**: Vibration on mobile (submit, error, success)
- **Audio**: Subtle sounds for critical actions (optional, user-controlled)
- **Loading**: Skeleton screens, spinners for >300ms operations
- **Success**: Checkmarks, green highlights, toast notifications
- **Error**: Red highlights, inline error messages, shake animations
3. **Consistent Behavior**
- Similar-looking elements behave similarly
- Examples:
- **Visual consistency**: All primary buttons have same colors, sizes, hover states
- **Behavioral consistency**: All modals close via X button, ESC key, and outside click
- **Interaction consistency**: All drag targets have same hover state and drop feedback
- **Pattern consistency**: All forms validate on blur and submit
4. **Forgiveness**
- Make errors difficult, but recovery easy
- **Prevention strategies**:
- Disable invalid actions (grey out unavailable buttons)
- Validate inputs inline (before submission)
- Confirm destructive actions (delete, overwrite)
- Auto-save in background (drafts, progress)
- **Recovery strategies**:
- Undo/redo for all state changes
- Soft deletes (trash/archive before permanent delete)
- Clear error messages with actionable fixes
- Preserve user input on errors (don't clear forms)
5. **Progressive Disclosure**
- Reveal details as needed rather than overwhelming users
- Levels of disclosure:
- **Summary**: Show essential info by default (card title, price, rating)
- **Details**: Expand to show more info (description, specs, reviews)
- **Advanced**: Hide complex options behind "Advanced settings" toggle
- Examples:
- Accordion: Start collapsed, expand on click
- Search filters: Show 3-5 common filters, hide rest behind "More filters"
- Settings: Basic settings visible, advanced behind "Show advanced"
**Modern UX Patterns:**
1. **Conversational Interfaces**
Prioritize natural language interaction where appropriate:
**Four types:**
- **Pure chat**: Full conversation (AI assistants, support bots)
- **Command palette**: Text-based shortcuts (Cmd+K, search everywhere)
- **Smart search**: Natural language queries (search "meetings next week" vs filtering)
- **Form alternatives**: Conversational data collection ("What's your name?" vs form fields)
**When to use:**
- Complex searches with multiple variables
- Task guidance (wizards, onboarding)
- Contextual help
- Quick actions (command palette)
**When NOT to use:**
- Simple forms (just use inputs)
- Precise control interfaces (design tools, dashboards)
- High-frequency repetitive tasks
2. **Adaptive Layouts**
Respond to user context automatically:
- **Time-based**: Dark mode at night, light during day
- **Device-based**: Simplified UI on mobile, full features on desktop
- **Connection-based**: Reduce images/video on slow connections
- **Usage-based**: Prioritize frequent actions, hide rarely-used features
Examples:
- Auto dark/light mode based on time or system preference
- Simplified mobile navigation (hamburger menu) vs full desktop nav
- Collapsed sidebar on small screens, expanded on large
3. **Bold Visual Expression**
Aesthetic flexibility based on chosen direction:
- Shadows ALLOWED and encouraged when intentional (dramatic shadows, soft elevation)
- Gradients ALLOWED for depth, accents, backgrounds, and atmosphere
- NO glass morphism effects (this is the one banned technique)
- NO Apple design mimicry (find your own voice)
- Focus on typography, color, spacing, AND visual effects to create hierarchy
- Create atmosphere: gradient meshes, noise textures, grain overlays, dramatic lighting
**Navigation:**
- Clear structure with intuitive navigation menus
- Implement breadcrumbs for deep hierarchies (more than 2 levels)
- Use standard UI patterns to reduce learning curve (hamburger menu, tab bars)
- Ensure predictable behavior (back button works, links look clickable)
- Maintain navigation context (highlight current page, preserve scroll position)
## Styling Implementation
### Component Library & Tools
**Component Library:**
- Strongly prefer shadcn components (v4, pre-installed in `@/components/ui`)
- Import individually: `import { Button } from "@/components/ui/button";`
- Use over plain HTML elements (`<Button>` over `<button>`)
- Avoid creating custom components with names that clash with shadcn
**Styling Engine:**
- Use Tailwind utility classes exclusively
- Adhere to theme variables in `index.css` via CSS custom properties
- Map variables in `@theme` (see `tailwind.config.js`)
- Use inline styles or CSS modules only when absolutely necessary
**Icons:**
- Use `@phosphor-icons/react` for buttons and inputs
- Example: `import { Plus } from "@phosphor-icons/react"; <Plus />`
- Use color for plain icon buttons
- Don't override default `size` or `weight` unless requested
**Notifications:**
- Use `sonner` for toasts
- Example: `import { toast } from 'sonner'`
**Loading States:**
- Always add loading states, spinners, placeholder animations
- Use skeletons until content renders
### Layout Implementation
**Spacing Strategy:**
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
- Nest wrappers as needed for complex layouts
**Conditional Styling:**
- Use ternary operators or clsx/classnames utilities
- Example: `className={clsx('base-class', { 'active-class': isActive })}`
### Responsive Design
**Fluid Layouts:**
- Use relative units (%, em, rem) instead of fixed pixels
- Implement CSS Grid and Flexbox for flexible layouts
- Design mobile-first, then scale up
**Media Queries:**
- Use breakpoints based on content needs, not specific devices
- Test across range of devices and orientations
**Touch Targets:**
- Minimum 44x44 pixels for interactive elements
- Provide adequate spacing between touch targets
- Consider hover states for desktop, focus states for touch/keyboard
**Performance:**
- Optimize assets for mobile networks
- Use CSS animations over JavaScript
- Implement lazy loading for images and videos
## Accessibility Standards
**Core Requirements:**
- Follow WCAG 2.1 AA guidelines
- Ensure keyboard navigability for all interactive elements
- Minimum touch target size: 44×44px
- Use semantic HTML for screen reader compatibility
- Provide alternative text for images and non-text content
**Implementation Details:**
- Use descriptive variable and function names
- Event functions: prefix with "handle" (handleClick, handleKeyDown)
- Add accessibility attributes:
- `tabindex="0"` for custom interactive elements
- `aria-label` for buttons without text
- `role` attributes when semantic HTML isn't sufficient
- Ensure logical tab order
- Provide visible focus states
## Design Process & Testing
### Design Workflow
1. **Understand Context:**
- What problem are we solving?
- Who are the users and when will they use this?
- What are the success criteria?
2. **Explore Options:**
- Present 2-3 alternative approaches
- Explain trade-offs of each option
- Ask which direction resonates
3. **Implement Iteratively:**
- Start with structure and hierarchy
- Add visual polish progressively
- Test at each stage
4. **Validate:**
- Use playwright MCP to test visual changes
- Check across different screen sizes
- Verify accessibility
### Testing Checklist
**Visual Testing:**
- Use playwright MCP when available for automated testing
- Check responsive behavior at common breakpoints
- Verify touch targets on mobile
- Test with different content lengths (short, long, edge cases)
**Accessibility Testing:**
- Test keyboard navigation
- Verify screen reader compatibility
- Check color contrast ratios
- Ensure focus states are visible
**Cross-Device Testing:**
- Test on actual devices, not just emulators
- Check different browsers (Chrome, Firefox, Safari)
- Verify touch interactions on mobile
- Test landscape and portrait orientations
## Examples
### Example 1: Designing a Button
**Initial Question to Ask:**
```
Before I implement this button, I have a few design questions:
1. What's the primary action you want to emphasize?
2. Do you have brand colors in mind, or should I suggest options?
3. What emotion should this button convey? (urgent, calm, playful, professional)
```
**After Getting Approval, Implementation:**
```tsx
import { Button } from "@/components/ui/button";
import { ArrowRight } from "@phosphor-icons/react";
<Button
className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-lg transition-colors duration-200"
>
Get Started
<ArrowRight className="ml-2" />
</Button>
```
### Example 2: Typography Hierarchy
**Question First:**
```
For this content hierarchy, I'm thinking:
- Headline: Large, bold, attention-grabbing
- Subheading: Medium, regular weight
- Body: Smaller, optimized for reading
Should we go bold and contemporary, or subtle and refined?
```
**Implementation After Approval:**
```tsx
<div className="space-y-4">
<h1 className="text-5xl font-bold tracking-tight text-slate-900">
Headline Here
</h1>
<h2 className="text-2xl text-slate-600">
Supporting subheading
</h2>
<p className="text-base leading-relaxed text-slate-700">
Body text optimized for readability with proper line height.
</p>
</div>
```
### Example 3: Unique Color Palette Suggestion
**Presenting Options:**
```
I'd like to suggest three color directions that break from typical SaaS patterns:
1. **Warm Earth Tones**
- Base: Warm grey (#E8E2DC)
- Accent: Terracotta (#C86E4B)
- For: Organic, trustworthy feel
2. **Cool Midnight**
- Base: Deep navy (#1A2332)
- Accent: Cyan (#4ECDC4)
- For: Modern, tech-forward feel
3. **Soft Pastels**
- Base: Soft pink (#FFE5E5)
- Accent: Sage green (#9DB5A4)
- For: Calm, approachable feel
Which direction feels right for your brand?
```
## Common Patterns to Avoid
**NEVER:**
- Use Inter, Roboto, Arial, Space Grotesk as primary fonts
- Use generic SaaS blue (#3B82F6) or purple gradients on white
- Copy Apple's design language or use glass morphism
- Create cookie-cutter layouts that look AI-generated
- Skip asking about context before designing
- Converge on common choices across generations (vary everything!)
- Use animations that delay user actions
- Create cluttered interfaces where elements compete
**ALWAYS:**
- Ask about purpose, tone, constraints, differentiation FIRST
- Then commit BOLDLY to a distinctive aesthetic direction
- Use unexpected, characterful typography choices
- Create atmosphere: shadows, gradients, textures, grain (when intentional)
- Dominant colors with sharp accents (not timid, evenly-distributed palettes)
- Provide immediate feedback for interactions
- Test with real devices
- Validate accessibility (it enables creativity, not limits it)
- Remember: Claude is capable of extraordinary creative work - don't hold back!
## Version History
- v2.0.0 (2025-11-22): Creative liberation update - bold aesthetics, shadows/gradients allowed, Design Thinking protocol
- v1.0.0 (2025-10-18): Initial release with comprehensive UI/UX design guidance
## References
For additional context, see:
- **Anthropic Frontend Aesthetics Cookbook**: https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- Google Fonts: https://fonts.google.com/
- Tailwind CSS Docs: https://tailwindcss.com/docs
- Shadcn UI Components: https://ui.shadcn.com/
**Progressive Disclosure Files:**
- ACCESSIBILITY.md - Accessibility essentials (WCAG AA baseline)
- MOTION-SPEC.md - Animation timing and easing
- RESPONSIVE-DESIGN.md - Mobile-first breakpoints and patterns
@@ -1,223 +0,0 @@
---
name: interactive-portfolio
description: "Expert in building portfolios that actually land jobs and clients - not just showing work, but creating memorable experiences. Covers developer portfolios, designer portfolios, creative portfolios, and portfolios that convert visitors into opportunities. Use when: portfolio, personal website, showcase work, developer portfolio, designer portfolio."
source: vibeship-spawner-skills (Apache 2.0)
---
# Interactive Portfolio
**Role**: Portfolio Experience Designer
You know a portfolio isn't a resume - it's a first impression that needs
to convert. You balance creativity with usability. You understand that
hiring managers spend 30 seconds on each portfolio. You make those 30
seconds count. You help people stand out without being gimmicky.
## Capabilities
- Portfolio architecture
- Project showcase design
- Interactive case studies
- Personal branding for devs/designers
- Contact conversion
- Portfolio performance
- Work presentation
- Testimonial integration
## Patterns
### Portfolio Architecture
Structure that works for portfolios
**When to use**: When planning portfolio structure
```javascript
## Portfolio Architecture
### The 30-Second Test
In 30 seconds, visitors should know:
1. Who you are
2. What you do
3. Your best work
4. How to contact you
### Essential Sections
| Section | Purpose | Priority |
|---------|---------|----------|
| Hero | Hook + identity | Critical |
| Work/Projects | Prove skills | Critical |
| About | Personality + story | Important |
| Contact | Convert interest | Critical |
| Testimonials | Social proof | Nice to have |
| Blog/Writing | Thought leadership | Optional |
### Navigation Patterns
```
Option 1: Single page scroll
- Best for: Designers, creatives
- Works well with animations
- Mobile friendly
Option 2: Multi-page
- Best for: Lots of projects
- Individual case study pages
- Better for SEO
Option 3: Hybrid
- Main sections on one page
- Detailed case studies separate
- Best of both worlds
```
### Hero Section Formula
```
[Your name]
[What you do in one line]
[One line that differentiates you]
[CTA: View Work / Contact]
```
```
### Project Showcase
How to present work effectively
**When to use**: When building project sections
```javascript
## Project Showcase
### Project Card Elements
| Element | Purpose |
|---------|---------|
| Thumbnail | Visual hook |
| Title | What it is |
| One-liner | What you did |
| Tech/tags | Quick scan |
| Results | Proof of impact |
### Case Study Structure
```
1. Hero image/video
2. Project overview (2-3 sentences)
3. The challenge
4. Your role
5. Process highlights
6. Key decisions
7. Results/impact
8. Learnings (optional)
9. Links (live, GitHub, etc.)
```
### Showing Impact
| Instead of | Write |
|------------|-------|
| "Built a website" | "Increased conversions 40%" |
| "Designed UI" | "Reduced user drop-off 25%" |
| "Developed features" | "Shipped to 50K users" |
### Visual Presentation
- Device mockups for web/mobile
- Before/after comparisons
- Process artifacts (wireframes, etc.)
- Video walkthroughs for complex work
- Hover effects for engagement
```
### Developer Portfolio Specifics
What works for dev portfolios
**When to use**: When building developer portfolio
```javascript
## Developer Portfolio
### What Hiring Managers Look For
1. Code quality (GitHub link)
2. Real projects (not just tutorials)
3. Problem-solving ability
4. Communication skills
5. Technical depth
### Must-Haves
- GitHub profile link (cleaned up)
- Live project links
- Tech stack for each project
- Your specific contribution (for team projects)
### Project Selection
| Include | Avoid |
|---------|-------|
| Real problems solved | Tutorial clones |
| Side projects with users | Incomplete projects |
| Open source contributions | "Coming soon" |
| Technical challenges | Basic CRUD apps |
### Technical Showcase
```javascript
// Show code snippets that demonstrate:
- Clean architecture decisions
- Performance optimizations
- Clever solutions
- Testing approach
```
### Blog/Writing
- Technical deep dives
- Problem-solving stories
- Learning journeys
- Shows communication skills
```
## Anti-Patterns
### ❌ Template Portfolio
**Why bad**: Looks like everyone else.
No memorable impression.
Doesn't show creativity.
Easy to forget.
**Instead**: Add personal touches.
Custom design elements.
Unique project presentations.
Your voice in the copy.
### ❌ All Style No Substance
**Why bad**: Fancy animations, weak projects.
Style over substance.
Hiring managers see through it.
No proof of skills.
**Instead**: Projects first, style second.
Real work with real impact.
Quality over quantity.
Depth over breadth.
### ❌ Resume Website
**Why bad**: Boring, forgettable.
Doesn't use the medium.
No personality.
Lists instead of stories.
**Instead**: Show, don't tell.
Visual case studies.
Interactive elements.
Personality throughout.
## ⚠️ Sharp Edges
| Issue | Severity | Solution |
|-------|----------|----------|
| Portfolio more complex than your actual work | medium | ## Right-Sizing Your Portfolio |
| Portfolio looks great on desktop, broken on mobile | high | ## Mobile-First Portfolio |
| Visitors don't know what to do next | medium | ## Portfolio CTAs |
| Portfolio shows old or irrelevant work | medium | ## Portfolio Freshness |
## Related Skills
Works well with: `scroll-experience`, `3d-web-experience`, `landing-page-design`, `personal-branding`
-14
View File
@@ -1,14 +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\")",
"Bash(npx skills find:*)",
"WebSearch",
"Bash(ls \"C:\\\\Users\\\\Andy\\\\Ralph Local\\\\Tasks\\\\New CV website\\\\designs\"\" 2>nul || echo \"Directory does not exist \")"
]
}
}
@@ -1,111 +0,0 @@
# Accessibility Essentials
Accessibility enables creativity - it's a foundation, not a limitation. WCAG 2.1 AA compliance.
## Core Principles (POUR)
- **Perceivable**: Content must be perceivable (alt text, contrast, captions)
- **Operable**: UI must be keyboard/touch accessible
- **Understandable**: Clear, predictable behavior
- **Robust**: Works with assistive technologies
## Contrast Requirements
| Element | Minimum Ratio |
|---------|---------------|
| Normal text | 4.5:1 |
| Large text (18pt+) | 3:1 |
| UI components | 3:1 |
**Tools**: Chrome DevTools Accessibility tab, WebAIM Contrast Checker
## Keyboard Navigation
```tsx
// All interactive elements need focus states
<button className="focus:ring-4 focus:ring-blue-500 focus:outline-none">
Accessible
</button>
// Custom elements need tabindex and key handlers
<div
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
Custom Button
</div>
```
**Essentials:**
- Tab through entire interface
- Enter/Space activates elements
- Escape closes modals
- Visible focus indicators always
## Essential ARIA
```tsx
// Buttons without text
<button aria-label="Close dialog"><X /></button>
// Expandable elements
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
// Live regions for dynamic content
<div role="status" aria-live="polite">{statusMessage}</div>
<div role="alert" aria-live="assertive">{errorMessage}</div>
// Form errors
<input aria-invalid={hasError} aria-describedby="error-msg" />
{hasError && <p id="error-msg" role="alert">Error text</p>}
```
## Semantic HTML
```tsx
// Use semantic elements, not divs
<header><nav>...</nav></header>
<main><article><h1>...</h1></article></main>
<footer>...</footer>
// Heading hierarchy (never skip levels)
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Adequate spacing between targets
- `touch-manipulation` CSS for responsive touch
## Screen Reader Content
```tsx
// Hidden but announced
<span className="sr-only">Additional context</span>
// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
Skip to main content
</a>
```
## Quick Checklist
- [ ] Keyboard: Can tab through everything
- [ ] Focus: Visible focus indicators
- [ ] Contrast: 4.5:1 for text
- [ ] Alt text: All images have appropriate alt
- [ ] Headings: Logical h1-h6 hierarchy
- [ ] Forms: Labels associated with inputs
- [ ] Errors: Announced to screen readers
- [ ] Touch: 44px minimum targets
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
@@ -1,577 +0,0 @@
# Design System Template
Meta-framework for understanding what's fixed, project-specific, and adaptable in your design system.
## Purpose
This template helps you distinguish between:
- **Fixed Elements**: Universal rules that never change
- **Project-Specific Elements**: Filled in for each project based on brand
- **Adaptable Elements**: Context-dependent implementations
---
## I. FIXED ELEMENTS
These foundations remain consistent across all projects, regardless of brand or context.
### 1. Spacing Scale
**Fixed System:**
```
4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px, 96px
```
**Usage:**
- Margins, padding, gaps between elements
- Mathematical relationships ensure visual harmony
- Use multipliers of base unit (4px)
**Why Fixed:**
Consistent spacing creates visual rhythm regardless of brand personality.
### 2. Grid System
**Fixed Structure:**
- **12-column grid** for most layouts (divisible by 2, 3, 4, 6)
- **16-column grid** for data-heavy interfaces
- **Gutters**: 16px (mobile), 24px (tablet), 32px (desktop)
**Why Fixed:**
Grid provides structural order. Brand personality shows through color, typography, content—not grid structure.
### 3. Accessibility Standards
**Fixed Requirements:**
- **WCAG 2.1 AA** compliance minimum
- **Contrast**: 4.5:1 for normal text, 3:1 for large text
- **Touch targets**: Minimum 44×44px
- **Keyboard navigation**: All interactive elements accessible
- **Screen reader**: Semantic HTML, ARIA labels where needed
**Why Fixed:**
Accessibility is not negotiable. It's a baseline requirement for ethical, legal, and usable products.
### 4. Typography Hierarchy Logic
**Fixed Structure:**
- **Mathematical scaling**: 1.25x (major third) or 1.333x (perfect fourth)
- **Hierarchy levels**: Display → H1 → H2 → H3 → Body → Small → Caption
- **Line height**: 1.5x for body text, 1.2-1.3x for headlines
- **Line length**: 45-75 characters optimal
**Why Fixed:**
Mathematical relationships create predictable, harmonious hierarchy. Specific fonts change, but the logic doesn't.
### 5. Component Architecture
**Fixed Patterns:**
- **Button states**: Default, Hover, Active, Focus, Disabled
- **Form structure**: Label above input, error below, helper text optional
- **Modal pattern**: Overlay + centered content + close mechanism
- **Card structure**: Container → Header → Body → Footer (optional)
**Why Fixed:**
Users expect consistent component behavior. Architecture is fixed; appearance is project-specific.
### 6. Animation Timing Framework
**Fixed Physics Profiles:**
- **Lightweight** (icons, chips): 150ms
- **Standard** (cards, panels): 300ms
- **Weighty** (modals, pages): 500ms
**Fixed Easing:**
- **Ease-out**: Entrances (fast start, slow end)
- **Ease-in**: Exits (slow start, fast end)
- **Ease-in-out**: Transitions (smooth both ends)
**Why Fixed:**
Natural physics feel consistent across brands. Duration and easing create that feeling.
---
## II. PROJECT-SPECIFIC ELEMENTS
Fill in these for each project based on brand personality and purpose.
### 1. Brand Color System
**Template Structure:**
```
NEUTRALS (4-5 colors):
- Background lightest: _______ (e.g., slate-50 or warm-white)
- Surface: _______ (e.g., slate-100)
- Border/divider: _______ (e.g., slate-300)
- Text secondary: _______ (e.g., slate-600)
- Text primary: _______ (e.g., slate-900)
ACCENTS (1-3 colors):
- Primary (main CTA): _______ (e.g., teal-500)
- Secondary (alternative action): _______ (optional)
- Status colors:
- Success: _______ (green-ish)
- Warning: _______ (amber-ish)
- Error: _______ (red-ish)
- Info: _______ (blue-ish)
```
**Questions to Answer:**
- What emotion should the brand evoke? (Trust, excitement, calm, urgency)
- Warm or cool neutrals?
- Conservative or bold accents?
**Examples:**
**Project A: Fintech App**
```
Neutrals: Cool greys (slate-50 → slate-900)
Primary: Deep blue (#0A2463) trust, professionalism
Success: Muted green (#10B981)
Why: Financial products need trust, not playfulness
```
**Project B: Creative Community**
```
Neutrals: Warm greys with beige undertones
Primary: Coral (#FF6B6B) energy, creativity
Success: Teal (#06D6A0) fresh, unexpected
Why: Creative spaces should feel inviting, not corporate
```
**Project C: Healthcare Platform**
```
Neutrals: Pure greys (minimal color temperature)
Primary: Soft blue (#4A90E2) calm, clinical
Success: Medical green (#38A169)
Why: Healthcare needs clarity and calm, not distraction
```
### 2. Typography Pairing
**Template:**
```
HEADLINE FONT: _______
- Weight: _______ (e.g., Bold 700)
- Use case: H1, H2, display text
- Personality: _______ (geometric/humanist/serif/etc.)
BODY FONT: _______
- Weight: _______ (e.g., Regular 400, Medium 500)
- Use case: Paragraphs, UI text
- Personality: _______ (neutral/readable/efficient)
OPTIONAL ACCENT FONT: _______
- Weight: _______
- Use case: _______ (special headlines, callouts)
```
**Pairing Logic:**
- Serif + Sans-serif (classic, editorial)
- Geometric + Humanist (modern + warm)
- Display + System (distinctive + efficient)
**Examples:**
**Project A: Editorial Platform**
```
Headline: Playfair Display (Serif, Bold 700)
Body: Inter (Sans-serif, Regular 400)
Why: Serif headlines = trustworthy, editorial feel
```
**Project B: Tech Startup**
```
Headline: DM Sans (Sans-serif, Bold 700)
Body: DM Sans (Regular 400, Medium 500)
Why: Single-font system = modern, efficient, cohesive
```
**Project C: Luxury Brand**
```
Headline: Cormorant Garamond (Serif, Light 300)
Body: Lato (Sans-serif, Regular 400)
Why: Elegant serif + readable sans = sophisticated
```
### 3. Tone of Voice
**Template:**
```
BRAND PERSONALITY:
- Formal ↔ Casual: _______ (1-10 scale)
- Professional ↔ Friendly: _______ (1-10 scale)
- Serious ↔ Playful: _______ (1-10 scale)
- Authoritative ↔ Conversational: _______ (1-10 scale)
MICROCOPY EXAMPLES:
- Button label (submit form): _______
- Error message (invalid email): _______
- Success message (saved): _______
- Empty state: _______
ANIMATION PERSONALITY:
- Speed: _______ (quick/moderate/slow)
- Feel: _______ (precise/smooth/bouncy)
```
**Examples:**
**Project A: Banking App**
```
Personality: Formal (8), Professional (9), Serious (8)
Button: "Submit Application"
Error: "Email address format is invalid"
Success: "Application submitted successfully"
Animation: Quick (precise, efficient, no-nonsense)
```
**Project B: Social App**
```
Personality: Casual (8), Friendly (9), Playful (7)
Button: "Let's go!"
Error: "Hmm, that email doesn't look right"
Success: "Nice! You're all set 🎉"
Animation: Moderate (smooth, friendly bounce)
```
### 4. Animation Speed & Feel
**Template:**
```
SPEED PREFERENCE:
- UI interactions: _______ (100-150ms / 150-200ms / 200-300ms)
- State changes: _______ (200ms / 300ms / 400ms)
- Page transitions: _______ (300ms / 500ms / 700ms)
ANIMATION STYLE:
- Easing preference: _______ (sharp / standard / bouncy)
- Movement type: _______ (minimal / smooth / expressive)
```
**Examples:**
**Project A: Trading Platform**
```
Speed: Fast (100ms UI, 200ms states, 300ms pages)
Style: Sharp easing, minimal movement
Why: Traders need speed, not distraction
```
**Project B: Wellness App**
```
Speed: Slow (200ms UI, 400ms states, 500ms pages)
Style: Smooth easing, gentle movement
Why: Calm, relaxing experience matches brand
```
---
## III. ADAPTABLE ELEMENTS
Context-dependent implementations that vary based on use case.
### 1. Component Variations
**Button Variants:**
- **Primary**: Full background color (high emphasis)
- **Secondary**: Outline only (medium emphasis)
- **Tertiary**: Text only (low emphasis)
- **Destructive**: Red-ish (danger actions)
- **Ghost**: Minimal (navigation, toolbars)
**Adaptation Rules:**
- Primary: Main CTA, one per screen section
- Secondary: Alternative actions
- Tertiary: Less important actions, multiple allowed
- Use brand colors, but hierarchy logic is fixed
### 2. Responsive Breakpoints
**Fixed Ranges:**
- XS: 0-479px (small phones)
- SM: 480-767px (large phones)
- MD: 768-1023px (tablets)
- LG: 1024-1439px (laptops)
- XL: 1440px+ (desktop)
**Adaptable Implementations:**
**Simple Content Site:**
```
XS-SM: Single column
MD: 2 columns
LG-XL: 3 columns max
Why: Content-focused, don't overwhelm
```
**Dashboard/Data App:**
```
XS: Collapsed, cards stack
SM: Simplified sidebar
MD: Full sidebar + main content
LG-XL: Sidebar + main + right panel
Why: Data apps need more screen real estate
```
### 3. Dark Mode Palette
**Adaptation Strategy:**
Not a simple inversion. Dark mode needs adjusted contrast:
**Light Mode:**
```
Background: #FFFFFF (white)
Text: #0F172A (slate-900) → 21:1 contrast
```
**Dark Mode (Adapted):**
```
Background: #0F172A (slate-900)
Text: #E2E8F0 (slate-200) → 15.8:1 contrast (still AA, but softer)
```
**Why Adapt:**
Pure white on pure black is too harsh. Dark mode needs slightly lower contrast for eye comfort.
### 4. Loading States
**Context-Dependent:**
**Fast operations (<500ms):**
- No loading indicator (feels instant)
**Medium operations (500ms-2s):**
- Spinner or skeleton screen
**Long operations (>2s):**
- Progress bar with percentage
- Or: Skeleton + estimated time
**Interactive Operations:**
- Button shows spinner inside (don't disable, show state)
### 5. Error Handling Strategy
**Context-Dependent:**
**Form Errors:**
```
Validate: On blur (after user leaves field)
Display: Inline below field
Recovery: Clear error on fix
```
**API Errors:**
```
Transient (network): Show retry button
Permanent (404): Show helpful message + next steps
Critical (500): Contact support option
```
**Data Errors:**
```
Missing: Show empty state with action
Corrupt: Show error boundary with reload
Invalid: Highlight + explain what's wrong
```
---
## DECISION TREE
When implementing a feature, ask:
### Is this...
**FIXED?**
- Does it affect structure, accessibility, or universal UX?
- Examples: Spacing scale, grid, contrast ratios, component architecture
- **Action**: Use the fixed system, no variation
**PROJECT-SPECIFIC?**
- Does it express brand personality or purpose?
- Examples: Colors, typography, tone of voice, animation feel
- **Action**: Fill in the template for this project
**ADAPTABLE?**
- Does it depend on context, content, or use case?
- Examples: Component variants, responsive behavior, error handling
- **Action**: Choose appropriate variation based on context
---
## EXAMPLE: Implementing a "Submit" Button
### Fixed Elements (Always the same):
- Touch target: 44px minimum height
- Padding: 16px horizontal (from spacing scale)
- States: Default, Hover, Active, Focus, Disabled
- Animation: 150ms ease-out (lightweight profile)
### Project-Specific (Filled per project):
- **Project A (Bank)**: Dark blue background, white text, "Submit Application"
- **Project B (Social)**: Coral background, white text, "Let's Go!"
- **Project C (Healthcare)**: Soft blue background, white text, "Continue"
### Adaptable (Context-dependent):
- **Form context**: Primary button (full color)
- **Toolbar context**: Ghost button (text only)
- **Danger context**: Destructive variant (red-ish)
---
## VALIDATION CHECKLIST
Before finalizing a design, check:
### Fixed Elements
- [ ] Uses spacing scale (4/8/12/16/24/32/48/64/96px)
- [ ] Follows grid system (12 or 16 columns)
- [ ] Meets WCAG AA contrast (4.5:1 normal, 3:1 large)
- [ ] Touch targets ≥ 44px
- [ ] Typography follows mathematical scale
- [ ] Components follow standard architecture
### Project-Specific Elements
- [ ] Brand colors filled in and intentional
- [ ] Typography pairing chosen and justified
- [ ] Tone of voice defined and consistent
- [ ] Animation speed matches brand personality
### Adaptable Elements
- [ ] Component variants appropriate for context
- [ ] Responsive behavior fits content type
- [ ] Loading states match operation duration
- [ ] Error handling fits error type
---
## PROJECT KICKOFF TEMPLATE
Use this to start a new project:
```
PROJECT NAME: _______________________
PURPOSE: ____________________________
BRAND PERSONALITY:
- Primary emotion: _______
- Warm or cool: _______
- Formal or casual: _______
- Conservative or bold: _______
COLORS (fill the template):
- Neutral base: _______
- Primary accent: _______
- Status colors: _______ / _______ / _______
TYPOGRAPHY (fill the template):
- Headline font: _______
- Body font: _______
- Pairing rationale: _______
TONE:
- Button labels style: _______
- Error message style: _______
- Success message style: _______
ANIMATION:
- Speed preference: _______ (fast/moderate/slow)
- Feel preference: _______ (sharp/smooth/bouncy)
TARGET DEVICES:
- Primary: _______ (mobile/desktop/both)
- Secondary: _______
```
---
## MAINTAINING CONSISTENCY
### Documentation
- Keep this template updated as system evolves
- Document WHY choices were made, not just WHAT
### Communication
- Share with designers: "Here's what varies vs. what's fixed"
- Share with developers: "Here are the design tokens"
### Tooling
- Use CSS variables for project-specific values
- Use Tailwind config for spacing scale
- Use design tokens in Figma/Storybook
### Reviews
- Audit: Does new work follow fixed elements?
- Validate: Are project-specific elements intentional?
- Question: Are adaptations justified by context?
---
## EXAMPLES OF COMPLETE SYSTEMS
### System A: B2B SaaS (Conservative)
**Fixed**: Standard spacing, 12-col grid, WCAG AA, major third type scale
**Project-Specific**:
- Colors: Cool greys + corporate blue
- Typography: DM Sans (headlines + body)
- Tone: Professional, formal
- Animation: Quick, precise (150ms)
**Adaptable**:
- Dashboard gets multi-panel layout
- Forms are extensive (use progressive disclosure)
- Errors show detailed technical info
### System B: Consumer Social App (Playful)
**Fixed**: Same spacing/grid/accessibility/type logic
**Project-Specific**:
- Colors: Warm greys + vibrant coral
- Typography: Poppins (headlines) + Inter (body)
- Tone: Casual, friendly, playful
- Animation: Moderate, bouncy (200ms)
**Adaptable**:
- Mobile-first (most users on phones)
- Forms are minimal (progressive profiling)
- Errors are friendly, not technical
### System C: Healthcare Platform (Clinical)
**Fixed**: Same foundational structure
**Project-Specific**:
- Colors: Pure greys + medical blue
- Typography: System fonts (SF Pro / Segoe)
- Tone: Clear, authoritative, calm
- Animation: Slow, smooth (300ms)
**Adaptable**:
- Desktop-first (clinical use at workstations)
- Forms are complex (HIPAA compliance)
- Errors are precise with next steps
---
## KEY TAKEAWAY
**The system flexibility framework lets you:**
- Maintain consistency (fixed elements)
- Express brand personality (project-specific)
- Adapt to context (adaptable elements)
**Without this framework:**
- Designers reinvent spacing every project
- Components feel inconsistent across products
- Brand personality overrides accessibility
- Context-blind implementations feel wrong
**With this framework:**
- Speed: Start from proven foundations
- Consistency: Fixed elements guarantee it
- Flexibility: Express unique brand identity
- Context: Adapt without breaking system
@@ -1,72 +0,0 @@
# Motion Specification
Motion should surprise and delight while serving function. Animation is a creative tool.
## Easing Curves
| Easing | CSS | Use For |
|--------|-----|---------|
| **Ease-out** | `cubic-bezier(0.0, 0.0, 0.2, 1)` | Entrances, appearing |
| **Ease-in** | `cubic-bezier(0.4, 0.0, 1, 1)` | Exits, disappearing |
| **Ease-in-out** | `cubic-bezier(0.4, 0.0, 0.2, 1)` | State changes, transforms |
| **Spring** | `cubic-bezier(0.68, -0.55, 0.265, 1.55)` | Playful, attention-grabbing |
| **Linear** | `linear` | Spinners, continuous loops |
## Duration by Element Weight
| Weight | Duration | Examples |
|--------|----------|----------|
| **Lightweight** | 150ms | Icons, badges, chips |
| **Standard** | 300ms | Cards, panels, list items |
| **Weighty** | 500ms | Modals, page transitions |
## Duration by Interaction
| Interaction | Duration |
|-------------|----------|
| Button press | 100ms |
| Hover state | 150ms |
| Tooltip appear | 200ms |
| Tab switch | 250ms |
| Modal open | 300ms |
| Page transition | 400ms |
## Common Patterns
```tsx
// Hover transition (CSS)
<button className="transition-colors duration-150 ease-out hover:bg-blue-700">
// Fade + slide (Framer Motion)
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
// Stagger children
<motion.ul variants={{ visible: { transition: { staggerChildren: 0.1 } } }}>
<motion.li variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }} />
</motion.ul>
```
## Performance Rules
- Only animate `transform` and `opacity` (GPU-accelerated)
- Avoid animating `width`, `height`, `margin`, `padding`
- Keep durations under 500ms for UI interactions
- Respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
## Resources
- [Framer Motion](https://www.framer.com/motion/)
- [CSS Easing Functions](https://easings.net/)
@@ -1,90 +0,0 @@
# Responsive Design Essentials
Mobile-first approach: start with mobile, progressively enhance for larger screens.
## Breakpoints
| Range | Pixels | Devices | Strategy |
|-------|--------|---------|----------|
| **XS** | 0-479px | Small phones | Single column, stacked nav, 44px touch targets |
| **SM** | 480-767px | Large phones | Single column, bottom nav, simplified UI |
| **MD** | 768-1023px | Tablets | 2 columns possible, sidebar nav |
| **LG** | 1024-1439px | Laptops | Multi-column, full nav, desktop UI |
| **XL** | 1440px+ | Desktop | Max-width containers, multi-panel layouts |
## Tailwind Responsive
```tsx
// Mobile-first: base styles, then scale up
<div className="
w-full // mobile: full width
sm:w-1/2 // 480px+: half
md:w-1/3 // 768px+: third
lg:w-1/4 // 1024px+: quarter
">
// Responsive grid
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Responsive typography
<h1 className="text-3xl md:text-4xl lg:text-5xl">
// Show/hide by breakpoint
<div className="block md:hidden">Mobile only</div>
<div className="hidden md:block">Desktop only</div>
```
## Fluid Typography
```css
h1 { font-size: clamp(2rem, 5vw, 4rem); }
p { font-size: clamp(1rem, 2.5vw, 1.25rem); }
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Use `touch-manipulation` to prevent 300ms tap delay
- Adequate spacing between targets
```tsx
<button className="min-w-[44px] min-h-[44px] touch-manipulation">
```
## Mobile Simplification
| Desktop | Mobile |
|---------|--------|
| Full nav bar | Hamburger menu |
| Side-by-side fields | Stacked fields |
| Multi-column grid | Single column |
| Inline buttons | Fixed bottom bar |
| Data table | Collapsed cards |
| Visible sidebar | Hidden/collapsible |
## Images
```tsx
// Responsive images
<img
srcSet="image-400w.jpg 400w, image-800w.jpg 800w, image-1200w.jpg 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
/>
// Next.js
<Image src="/hero.jpg" width={1200} height={600} priority className="w-full h-auto" />
```
## Testing
Test at these widths:
- 375px (iPhone SE)
- 390px (iPhone 14)
- 768px (iPad)
- 1024px (iPad Pro)
- 1280px+ (Desktop)
## Resources
- [Tailwind Responsive](https://tailwindcss.com/docs/responsive-design)
@@ -1,718 +0,0 @@
---
name: bencium-innovative-ux-designer
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
metadata:
version: 2.0.0
---
# Innovative UX Designer
Create distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. Expert UI/UX design skill that helps create unique, accessible, and thoughtfully designed interfaces. This skill emphasizes design decision collaboration, breaking away from generic patterns, and building interfaces that stand out while remaining functional and accessible.
This skill emphasizes **bold creative commitment**, breaking away from generic patterns, and building interfaces that are visually striking and memorable while remaining functional and accessible.
## Core Philosophy
**CRITICAL: Design Thinking Protocol**
Before coding, **ASK to understand context**, then **COMMIT BOLDLY** to a distinctive direction:
### Questions to Ask First
1. **Purpose**: What problem does this interface solve? Who uses it?
2. **Tone**: What aesthetic extreme fits? (see Tone Options below)
3. **Constraints**: Technical requirements (framework, performance, accessibility)?
4. **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
### Tone Options (Pick an Extreme)
Choose a clear aesthetic direction and execute with precision:
- **Brutally minimal** - stripped to essence, bold typography, vast whitespace
- **Maximalist chaos** - layered, dense, visually rich, controlled disorder
- **Retro-futuristic** - vintage meets sci-fi, nostalgic tech aesthetics
- **Organic/natural** - soft edges, earthy colors, nature-inspired textures
- **Luxury/refined** - elegant spacing, premium typography, subtle details
- **Playful/toy-like** - bright colors, rounded shapes, delightful interactions
- **Editorial/magazine** - strong typography hierarchy, asymmetric layouts
- **Brutalist/raw** - exposed structure, harsh contrasts, intentionally rough
- **Art deco/geometric** - bold patterns, metallic accents, symmetric elegance
- **Soft/pastel** - gentle gradients, muted tones, calming atmosphere
- **Industrial/utilitarian** - functional, no-nonsense, mechanical precision
### After Getting Context
- **Commit fully** to the chosen direction - no half measures
- Present 2-3 alternative approaches with trade-offs
- Then implement with precision: production-grade, visually striking, memorable
## Foundational Design Principles
### Stand Out From Generic Patterns
**NEVER Use These AI-Generated Aesthetics:**
- **Fonts**: Inter, Roboto, Arial, system fonts as primary choice, Space Grotesk (overused by AI)
- **Colors**: Generic SaaS blue (#3B82F6), purple gradients on white backgrounds
- **Patterns**: Cookie-cutter layouts, predictable component arrangements
- **Effects**: Glass morphism, Apple design mimicry, liquid/blob backgrounds
- **Overall**: Anything that looks "Claude-generated" or machine-made
**Instead, Create Atmosphere:**
- Suggest photography, patterns, textures over flat solid colors
- Apply gradient meshes, noise textures, geometric patterns
- Use layered transparencies, dramatic shadows, decorative borders
- Consider custom cursors, grain overlays, contextual effects
- Think beyond typical patterns - you can step off the written path
**Draw Inspiration From:**
- Modern landing pages (Perplexity, Comet Browser, Dia Browser)
- Framer templates and their innovative approaches
- Leading brand design studios
- Historical design movements (Bauhaus, Otl Aicher, Braun) - but as inspiration, not imitation
- Beautiful background animations (CSS, SVG) - slow, looping, subtle
**Visual Interest Strategies:**
- Unique color pairs that aren't typical
- Animation effects that feel fresh
- Background patterns that add depth without distraction
- Typography combinations that create contrast
- Visual assets that tell a story
### Core Design Philosophy
1. **Simplicity Through Reduction**
- Identify the essential purpose and eliminate distractions
- Begin with complexity, then deliberately remove until reaching the simplest effective solution
- Every element must justify its existence
2. **Material Honesty**
- Digital materials have unique properties - embrace them
- Buttons communicate affordance through color, spacing, typography, AND shadows when intentional
- Cards can use borders, background differentiation, OR dramatic shadows for depth
- Animations follow real-world physics principles adapted to digital responsiveness
**Examples:**
- Clickable: Use distinct colors, hover state changes, cursor feedback, subtle lift effects
- Containers: Use borders, background shifts, generous padding, OR shadow depth
- Hierarchy: Use scale, weight, spacing, AND elevation when it serves the aesthetic
3. **Functional Layering**
- Create hierarchy through typography scale, color contrast, and spatial relationships
- Layer information conceptually (primary → secondary → tertiary)
- Use shadows and gradients INTENTIONALLY when they serve the aesthetic direction
- Embrace functional depth: modals over content, dropdowns over UI
- Avoid: glass morphism, Apple mimicry (but shadows/gradients are tools, not enemies)
4. **Obsessive Detail**
- Consider every pixel, interaction, and transition
- Excellence emerges from hundreds of small, intentional decisions
- Balance: Details should serve simplicity, not complexity
- When detail conflicts with clarity, clarity wins
5. **Coherent Design Language**
- Every element should visually communicate its function
- Elements should feel part of a unified system
- Nothing should feel arbitrary
6. **Invisibility of Technology**
- The best technology disappears
- Users should focus on content and goals, not on understanding the interface
### What This Means in Practice
**Color Usage:**
- Base palette: 4-5 neutral shades (backgrounds, borders, text)
- Accent palette: 1-3 bold colors (CTAs, status, emphasis)
- Neutrals are slightly desaturated, warm or cool based on brand intent
- Accents are saturated enough to create clear contrast
**Typography:**
- Headlines: Emotional, attention-grabbing, UNEXPECTED (personality over pure legibility)
- Body/UI: Functional, highly legible (clarity over expression)
- 2-3 typefaces maximum, but make them CHARACTERFUL and distinctive
- Clear mathematical scale (e.g., 1.25x between sizes)
- NEVER default to Inter, Roboto, or Space Grotesk - find unique fonts
**Animation:**
- Purposeful: Guides attention, establishes relationships, provides feedback
- Subtle: Felt rather than seen (100-300ms for most interactions)
- Physics-informed: Natural easing, appropriate mass/momentum
**Spacing:**
- Generous negative space creates clarity and breathing room
- Mathematical relationships (e.g., 4px base, 8/16/24/32/48px scale)
- Consistent application creates visual rhythm
### Design Decision Checklist
Before presenting any design, verify:
1. **Purpose**: Does every element serve a clear function?
2. **Hierarchy**: Is visual importance aligned with content importance?
3. **Consistency**: Do similar elements look and behave similarly?
4. **Accessibility**: Does it meet WCAG AA standards? (contrast, touch targets, keyboard nav)
5. **Responsiveness**: Does it work on mobile, tablet, desktop?
6. **Uniqueness**: Does this break from generic SaaS patterns?
7. **Approval**: Have I asked before implementing colors, fonts, sizes, layouts?
**Design System Framework:**
For understanding what's fixed (universal rules), project-specific (brand personality), and adaptable (context-dependent) in your design system, think of a design system.
## Visual Design Standards
### Color & Contrast
**Color System Architecture:**
Every interface needs two color roles:
1. **Base/Neutral Palette (4-5 colors):**
- Backgrounds (lightest)
- Surface colors (cards, inputs)
- Borders and dividers
- Text (darkest)
- Use slightly desaturated, warm or cool greys based on brand
2. **Accent Palette (1-3 colors):**
- Primary action (CTA buttons)
- Status indicators (success, warning, error, info)
- Focus/hover states
- Use saturated colors for clear contrast against neutrals
**Palette Structure Example:**
```
Neutrals: slate-50, slate-100, slate-300, slate-700, slate-900
Accents: teal-500 (primary), amber-500 (warning), red-500 (error)
```
**Color Application Rules:**
- **Backgrounds**: Lightest neutral (slate-50 or white)
- **Text**: Darkest neutral for primary text (slate-900), mid-tone for secondary (slate-600)
- **Buttons (primary)**: Accent color with white text
- **Buttons (secondary)**: Neutral with border and dark text
- **Status indicators**: Specific accent (green=success, red=error, amber=warning, blue=info)
- **Interactive states**:
- Hover: Darken by 10-15% or shift hue slightly
- Focus: Use ring/outline in accent color
- Disabled: Reduce opacity to 40-50% and remove hover effects
**Color Relationships:**
Choose warm or cool intentionally based on brand:
- **Warm greys** (beige/brown undertones): Organic, approachable, trustworthy
- **Cool greys** (blue undertones): Modern, tech-forward, professional
Accent colors should have clear contrast with both:
- Light backgrounds (for buttons on white)
- Dark text (if used as backgrounds for white text)
**Intentional Color Usage:**
- Every color must serve a purpose (hierarchy, function, status, or action)
- Avoid decorative colors that don't communicate meaning
- Maintain consistency: same color = same meaning throughout
**Accessibility:**
- Ensure sufficient contrast for color-blind users
- Follow WCAG 2.1 AA: minimum 4.5:1 for normal text, 3:1 for large text
- Don't rely on color alone to convey information (add icons or labels)
**Unique Color Strategy:**
To stand out from generic patterns:
- NEVER use default SaaS blue (#3B82F6) or purple gradients on white
- Use unexpected neutrals: warm greys, soft off-whites, deep charcoals, rich blacks
- Pair neutrals with distinctive accents: terracotta + charcoal, sage + navy, coral + slate
- Dominant colors with SHARP accents outperform timid, evenly-distributed palettes
- Test combinations against "does this look AI-generated?" filter
- Vary between light and dark themes - no design should look the same
**Create Atmosphere with Color:**
- Gradient meshes for depth and visual interest
- Noise textures and grain overlays for tactile feel
- Layered transparencies for dimension
- Dramatic shadows for emphasis and drama
### Typography Excellence
**Typography Philosophy:**
Typography is a primary design element that conveys personality and hierarchy.
**Functional vs Emotional Typography:**
- **Headlines/Display**: Prioritize emotion, personality, attention (legibility secondary)
- **Body Text**: Prioritize legibility, reading comfort, accessibility
- **UI/Labels**: Prioritize clarity, scannability, consistency
**Font Selection:**
- Use 2-3 typefaces maximum, but make them UNEXPECTED and characterful
- Limit to 3 weights per typeface (e.g., Regular 400, Medium 500, Bold 700)
- Prefer variable fonts for fine-tuned control and performance
**NEVER Use These Fonts as Primary:**
- Inter (overused by AI and generic SaaS)
- Roboto (too generic)
- Arial/Helvetica (default fallback vibes)
- Space Grotesk (AI generation favorite)
- System fonts as primary choice (only as fallback)
**Font Version Usage:**
- **Display version**: Headlines and hero text only - BE BOLD
- **Text version**: Paragraphs and long-form content - legibility matters
- **Caption/Micro**: Small UI labels (1-2 lines, non-critical info)
**Find Distinctive Fonts:**
- Google Fonts for web - but dig deeper than page 1
- Type foundries for unique options
- Choose fonts that serve your CHOSEN AESTHETIC DIRECTION
- Pair distinctive display font with refined body font
**Typographic Scale:**
Use mathematical relationships for size hierarchy:
- **Ratio**: Major third (1.25x) for moderate contrast, Perfect fourth (1.333x) for dramatic
- **Base size**: 16px (1rem) for body text
- **Example scale (1.25x)**:
```
xs: 0.64rem (10px)
sm: 0.8rem (13px)
base: 1rem (16px)
lg: 1.25rem (20px)
xl: 1.563rem (25px)
2xl: 1.953rem (31px)
3xl: 2.441rem (39px)
4xl: 3.052rem (49px)
5xl: 3.815rem (61px)
```
**Typographic Hierarchy:**
- Create clear visual distinction between levels
- Headlines, subheadings, body, captions should each have distinct size/weight
- Use combination of size, weight, and color for hierarchy
**Spacing & Readability:**
- **Line height**: 1.5x font size for body text (e.g., 16px text = 24px line-height)
- **Line length**: 45-75 characters optimal for readability (60-70 ideal)
- **Paragraph spacing**: 1-1.5em between paragraphs
- **Letter spacing (tracking)**:
- Larger text (headlines): Slightly tighter (-0.02em to -0.05em)
- Normal text (body): Default (0)
- Small text (captions): Slightly looser (+0.01em to +0.03em)
- General rule: As size increases, reduce tracking; as size decreases, increase tracking
**Font Pairing Logic:**
When using multiple typefaces, create contrast through:
- **Category contrast**: Serif + Sans-serif (classic, clear distinction)
- **Weight contrast**: Light + Bold (dynamic, energetic)
- **Personality contrast**: Geometric + Humanist (modern + warm)
Examples:
- Serif headlines + Sans body (editorial, trustworthy)
- Display headlines + System body (distinctive + efficient)
- Bold sans headlines + Light sans body (modern, clean)
**UI Typography:**
Specific guidance for interface elements:
- **Button text**: Semi-Bold (600), 14-16px, consistent casing (all-caps OR title case)
- **Form labels**: Regular (400), 14px, positioned above input
- **Form input text**: Regular (400), 16px minimum (prevents iOS zoom on focus)
- **Placeholder text**: Light (300) or desaturated color, same size as input
- **Error messages**: Regular (400), 12-14px, color-coded (red-ish)
**Responsive Typography:**
Scale type sizes across breakpoints:
```tsx
// Example with Tailwind
<h1 className="text-3xl md:text-4xl lg:text-5xl">
Responsive Headline
</h1>
// Or with CSS clamp (fluid)
h1 {
font-size: clamp(2rem, 5vw, 4rem);
}
```
Reduce sizes on mobile (20-30% smaller than desktop)
Reduce hierarchy levels on small screens (fewer distinct sizes)
### Layout & Spatial Design
**Compositional Balance:**
- Every screen should feel balanced
- Pay attention to visual weight and negative space
- Use generous negative space to focus attention
- Add sufficient margins and paddings for professional, spacious look
**Grid Discipline:**
- Maintain consistent underlying grid system
- Create sense of order while allowing meaningful exceptions
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
**Spatial Relationships:**
- Group related elements through proximity, alignment, and shared attributes
- Use size, color, and spacing to highlight important elements
- Guide user focus through visual hierarchy
**Attention Guidance:**
- Design interfaces that guide user attention effectively
- Avoid cluttered interfaces where elements compete
- Create clear paths through the content
## Interaction Design
**Motion Specification:**
For detailed motion specs, see MOTION-SPEC.md (easing curves, duration tables, state-specific animations, implementation patterns).
### User Experience Patterns
**Core UX Principles:**
1. **Direct Manipulation**
- Users interact directly with content, not through abstract controls
- Examples:
- Drag & drop to reorder items (not up/down buttons)
- Inline editing (click to edit, not separate form)
- Sliders for ranges (not numeric input with +/-)
- Pinch/zoom gestures on mobile (not +/- buttons)
2. **Immediate Feedback**
- Every interaction provides instantaneous visual feedback (within 100ms)
- Types of feedback:
- **Visual**: Button pressed state, hover effects, color changes
- **Haptic**: Vibration on mobile (submit, error, success)
- **Audio**: Subtle sounds for critical actions (optional, user-controlled)
- **Loading**: Skeleton screens, spinners for >300ms operations
- **Success**: Checkmarks, green highlights, toast notifications
- **Error**: Red highlights, inline error messages, shake animations
3. **Consistent Behavior**
- Similar-looking elements behave similarly
- Examples:
- **Visual consistency**: All primary buttons have same colors, sizes, hover states
- **Behavioral consistency**: All modals close via X button, ESC key, and outside click
- **Interaction consistency**: All drag targets have same hover state and drop feedback
- **Pattern consistency**: All forms validate on blur and submit
4. **Forgiveness**
- Make errors difficult, but recovery easy
- **Prevention strategies**:
- Disable invalid actions (grey out unavailable buttons)
- Validate inputs inline (before submission)
- Confirm destructive actions (delete, overwrite)
- Auto-save in background (drafts, progress)
- **Recovery strategies**:
- Undo/redo for all state changes
- Soft deletes (trash/archive before permanent delete)
- Clear error messages with actionable fixes
- Preserve user input on errors (don't clear forms)
5. **Progressive Disclosure**
- Reveal details as needed rather than overwhelming users
- Levels of disclosure:
- **Summary**: Show essential info by default (card title, price, rating)
- **Details**: Expand to show more info (description, specs, reviews)
- **Advanced**: Hide complex options behind "Advanced settings" toggle
- Examples:
- Accordion: Start collapsed, expand on click
- Search filters: Show 3-5 common filters, hide rest behind "More filters"
- Settings: Basic settings visible, advanced behind "Show advanced"
**Modern UX Patterns:**
1. **Conversational Interfaces**
Prioritize natural language interaction where appropriate:
**Four types:**
- **Pure chat**: Full conversation (AI assistants, support bots)
- **Command palette**: Text-based shortcuts (Cmd+K, search everywhere)
- **Smart search**: Natural language queries (search "meetings next week" vs filtering)
- **Form alternatives**: Conversational data collection ("What's your name?" vs form fields)
**When to use:**
- Complex searches with multiple variables
- Task guidance (wizards, onboarding)
- Contextual help
- Quick actions (command palette)
**When NOT to use:**
- Simple forms (just use inputs)
- Precise control interfaces (design tools, dashboards)
- High-frequency repetitive tasks
2. **Adaptive Layouts**
Respond to user context automatically:
- **Time-based**: Dark mode at night, light during day
- **Device-based**: Simplified UI on mobile, full features on desktop
- **Connection-based**: Reduce images/video on slow connections
- **Usage-based**: Prioritize frequent actions, hide rarely-used features
Examples:
- Auto dark/light mode based on time or system preference
- Simplified mobile navigation (hamburger menu) vs full desktop nav
- Collapsed sidebar on small screens, expanded on large
3. **Bold Visual Expression**
Aesthetic flexibility based on chosen direction:
- Shadows ALLOWED and encouraged when intentional (dramatic shadows, soft elevation)
- Gradients ALLOWED for depth, accents, backgrounds, and atmosphere
- NO glass morphism effects (this is the one banned technique)
- NO Apple design mimicry (find your own voice)
- Focus on typography, color, spacing, AND visual effects to create hierarchy
- Create atmosphere: gradient meshes, noise textures, grain overlays, dramatic lighting
**Navigation:**
- Clear structure with intuitive navigation menus
- Implement breadcrumbs for deep hierarchies (more than 2 levels)
- Use standard UI patterns to reduce learning curve (hamburger menu, tab bars)
- Ensure predictable behavior (back button works, links look clickable)
- Maintain navigation context (highlight current page, preserve scroll position)
## Styling Implementation
### Component Library & Tools
**Component Library:**
- Strongly prefer shadcn components (v4, pre-installed in `@/components/ui`)
- Import individually: `import { Button } from "@/components/ui/button";`
- Use over plain HTML elements (`<Button>` over `<button>`)
- Avoid creating custom components with names that clash with shadcn
**Styling Engine:**
- Use Tailwind utility classes exclusively
- Adhere to theme variables in `index.css` via CSS custom properties
- Map variables in `@theme` (see `tailwind.config.js`)
- Use inline styles or CSS modules only when absolutely necessary
**Icons:**
- Use `@phosphor-icons/react` for buttons and inputs
- Example: `import { Plus } from "@phosphor-icons/react"; <Plus />`
- Use color for plain icon buttons
- Don't override default `size` or `weight` unless requested
**Notifications:**
- Use `sonner` for toasts
- Example: `import { toast } from 'sonner'`
**Loading States:**
- Always add loading states, spinners, placeholder animations
- Use skeletons until content renders
### Layout Implementation
**Spacing Strategy:**
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
- Nest wrappers as needed for complex layouts
**Conditional Styling:**
- Use ternary operators or clsx/classnames utilities
- Example: `className={clsx('base-class', { 'active-class': isActive })}`
### Responsive Design
**Fluid Layouts:**
- Use relative units (%, em, rem) instead of fixed pixels
- Implement CSS Grid and Flexbox for flexible layouts
- Design mobile-first, then scale up
**Media Queries:**
- Use breakpoints based on content needs, not specific devices
- Test across range of devices and orientations
**Touch Targets:**
- Minimum 44x44 pixels for interactive elements
- Provide adequate spacing between touch targets
- Consider hover states for desktop, focus states for touch/keyboard
**Performance:**
- Optimize assets for mobile networks
- Use CSS animations over JavaScript
- Implement lazy loading for images and videos
## Accessibility Standards
**Core Requirements:**
- Follow WCAG 2.1 AA guidelines
- Ensure keyboard navigability for all interactive elements
- Minimum touch target size: 44×44px
- Use semantic HTML for screen reader compatibility
- Provide alternative text for images and non-text content
**Implementation Details:**
- Use descriptive variable and function names
- Event functions: prefix with "handle" (handleClick, handleKeyDown)
- Add accessibility attributes:
- `tabindex="0"` for custom interactive elements
- `aria-label` for buttons without text
- `role` attributes when semantic HTML isn't sufficient
- Ensure logical tab order
- Provide visible focus states
## Design Process & Testing
### Design Workflow
1. **Understand Context:**
- What problem are we solving?
- Who are the users and when will they use this?
- What are the success criteria?
2. **Explore Options:**
- Present 2-3 alternative approaches
- Explain trade-offs of each option
- Ask which direction resonates
3. **Implement Iteratively:**
- Start with structure and hierarchy
- Add visual polish progressively
- Test at each stage
4. **Validate:**
- Use playwright MCP to test visual changes
- Check across different screen sizes
- Verify accessibility
### Testing Checklist
**Visual Testing:**
- Use playwright MCP when available for automated testing
- Check responsive behavior at common breakpoints
- Verify touch targets on mobile
- Test with different content lengths (short, long, edge cases)
**Accessibility Testing:**
- Test keyboard navigation
- Verify screen reader compatibility
- Check color contrast ratios
- Ensure focus states are visible
**Cross-Device Testing:**
- Test on actual devices, not just emulators
- Check different browsers (Chrome, Firefox, Safari)
- Verify touch interactions on mobile
- Test landscape and portrait orientations
## Examples
### Example 1: Designing a Button
**Initial Question to Ask:**
```
Before I implement this button, I have a few design questions:
1. What's the primary action you want to emphasize?
2. Do you have brand colors in mind, or should I suggest options?
3. What emotion should this button convey? (urgent, calm, playful, professional)
```
**After Getting Approval, Implementation:**
```tsx
import { Button } from "@/components/ui/button";
import { ArrowRight } from "@phosphor-icons/react";
<Button
className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-lg transition-colors duration-200"
>
Get Started
<ArrowRight className="ml-2" />
</Button>
```
### Example 2: Typography Hierarchy
**Question First:**
```
For this content hierarchy, I'm thinking:
- Headline: Large, bold, attention-grabbing
- Subheading: Medium, regular weight
- Body: Smaller, optimized for reading
Should we go bold and contemporary, or subtle and refined?
```
**Implementation After Approval:**
```tsx
<div className="space-y-4">
<h1 className="text-5xl font-bold tracking-tight text-slate-900">
Headline Here
</h1>
<h2 className="text-2xl text-slate-600">
Supporting subheading
</h2>
<p className="text-base leading-relaxed text-slate-700">
Body text optimized for readability with proper line height.
</p>
</div>
```
### Example 3: Unique Color Palette Suggestion
**Presenting Options:**
```
I'd like to suggest three color directions that break from typical SaaS patterns:
1. **Warm Earth Tones**
- Base: Warm grey (#E8E2DC)
- Accent: Terracotta (#C86E4B)
- For: Organic, trustworthy feel
2. **Cool Midnight**
- Base: Deep navy (#1A2332)
- Accent: Cyan (#4ECDC4)
- For: Modern, tech-forward feel
3. **Soft Pastels**
- Base: Soft pink (#FFE5E5)
- Accent: Sage green (#9DB5A4)
- For: Calm, approachable feel
Which direction feels right for your brand?
```
## Common Patterns to Avoid
**NEVER:**
- Use Inter, Roboto, Arial, Space Grotesk as primary fonts
- Use generic SaaS blue (#3B82F6) or purple gradients on white
- Copy Apple's design language or use glass morphism
- Create cookie-cutter layouts that look AI-generated
- Skip asking about context before designing
- Converge on common choices across generations (vary everything!)
- Use animations that delay user actions
- Create cluttered interfaces where elements compete
**ALWAYS:**
- Ask about purpose, tone, constraints, differentiation FIRST
- Then commit BOLDLY to a distinctive aesthetic direction
- Use unexpected, characterful typography choices
- Create atmosphere: shadows, gradients, textures, grain (when intentional)
- Dominant colors with sharp accents (not timid, evenly-distributed palettes)
- Provide immediate feedback for interactions
- Test with real devices
- Validate accessibility (it enables creativity, not limits it)
- Remember: Claude is capable of extraordinary creative work - don't hold back!
## Version History
- v2.0.0 (2025-11-22): Creative liberation update - bold aesthetics, shadows/gradients allowed, Design Thinking protocol
- v1.0.0 (2025-10-18): Initial release with comprehensive UI/UX design guidance
## References
For additional context, see:
- **Anthropic Frontend Aesthetics Cookbook**: https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- Google Fonts: https://fonts.google.com/
- Tailwind CSS Docs: https://tailwindcss.com/docs
- Shadcn UI Components: https://ui.shadcn.com/
**Progressive Disclosure Files:**
- ACCESSIBILITY.md - Accessibility essentials (WCAG AA baseline)
- MOTION-SPEC.md - Animation timing and easing
- RESPONSIVE-DESIGN.md - Mobile-first breakpoints and patterns
@@ -1,223 +0,0 @@
---
name: interactive-portfolio
description: "Expert in building portfolios that actually land jobs and clients - not just showing work, but creating memorable experiences. Covers developer portfolios, designer portfolios, creative portfolios, and portfolios that convert visitors into opportunities. Use when: portfolio, personal website, showcase work, developer portfolio, designer portfolio."
source: vibeship-spawner-skills (Apache 2.0)
---
# Interactive Portfolio
**Role**: Portfolio Experience Designer
You know a portfolio isn't a resume - it's a first impression that needs
to convert. You balance creativity with usability. You understand that
hiring managers spend 30 seconds on each portfolio. You make those 30
seconds count. You help people stand out without being gimmicky.
## Capabilities
- Portfolio architecture
- Project showcase design
- Interactive case studies
- Personal branding for devs/designers
- Contact conversion
- Portfolio performance
- Work presentation
- Testimonial integration
## Patterns
### Portfolio Architecture
Structure that works for portfolios
**When to use**: When planning portfolio structure
```javascript
## Portfolio Architecture
### The 30-Second Test
In 30 seconds, visitors should know:
1. Who you are
2. What you do
3. Your best work
4. How to contact you
### Essential Sections
| Section | Purpose | Priority |
|---------|---------|----------|
| Hero | Hook + identity | Critical |
| Work/Projects | Prove skills | Critical |
| About | Personality + story | Important |
| Contact | Convert interest | Critical |
| Testimonials | Social proof | Nice to have |
| Blog/Writing | Thought leadership | Optional |
### Navigation Patterns
```
Option 1: Single page scroll
- Best for: Designers, creatives
- Works well with animations
- Mobile friendly
Option 2: Multi-page
- Best for: Lots of projects
- Individual case study pages
- Better for SEO
Option 3: Hybrid
- Main sections on one page
- Detailed case studies separate
- Best of both worlds
```
### Hero Section Formula
```
[Your name]
[What you do in one line]
[One line that differentiates you]
[CTA: View Work / Contact]
```
```
### Project Showcase
How to present work effectively
**When to use**: When building project sections
```javascript
## Project Showcase
### Project Card Elements
| Element | Purpose |
|---------|---------|
| Thumbnail | Visual hook |
| Title | What it is |
| One-liner | What you did |
| Tech/tags | Quick scan |
| Results | Proof of impact |
### Case Study Structure
```
1. Hero image/video
2. Project overview (2-3 sentences)
3. The challenge
4. Your role
5. Process highlights
6. Key decisions
7. Results/impact
8. Learnings (optional)
9. Links (live, GitHub, etc.)
```
### Showing Impact
| Instead of | Write |
|------------|-------|
| "Built a website" | "Increased conversions 40%" |
| "Designed UI" | "Reduced user drop-off 25%" |
| "Developed features" | "Shipped to 50K users" |
### Visual Presentation
- Device mockups for web/mobile
- Before/after comparisons
- Process artifacts (wireframes, etc.)
- Video walkthroughs for complex work
- Hover effects for engagement
```
### Developer Portfolio Specifics
What works for dev portfolios
**When to use**: When building developer portfolio
```javascript
## Developer Portfolio
### What Hiring Managers Look For
1. Code quality (GitHub link)
2. Real projects (not just tutorials)
3. Problem-solving ability
4. Communication skills
5. Technical depth
### Must-Haves
- GitHub profile link (cleaned up)
- Live project links
- Tech stack for each project
- Your specific contribution (for team projects)
### Project Selection
| Include | Avoid |
|---------|-------|
| Real problems solved | Tutorial clones |
| Side projects with users | Incomplete projects |
| Open source contributions | "Coming soon" |
| Technical challenges | Basic CRUD apps |
### Technical Showcase
```javascript
// Show code snippets that demonstrate:
- Clean architecture decisions
- Performance optimizations
- Clever solutions
- Testing approach
```
### Blog/Writing
- Technical deep dives
- Problem-solving stories
- Learning journeys
- Shows communication skills
```
## Anti-Patterns
### ❌ Template Portfolio
**Why bad**: Looks like everyone else.
No memorable impression.
Doesn't show creativity.
Easy to forget.
**Instead**: Add personal touches.
Custom design elements.
Unique project presentations.
Your voice in the copy.
### ❌ All Style No Substance
**Why bad**: Fancy animations, weak projects.
Style over substance.
Hiring managers see through it.
No proof of skills.
**Instead**: Projects first, style second.
Real work with real impact.
Quality over quantity.
Depth over breadth.
### ❌ Resume Website
**Why bad**: Boring, forgettable.
Doesn't use the medium.
No personality.
Lists instead of stories.
**Instead**: Show, don't tell.
Visual case studies.
Interactive elements.
Personality throughout.
## ⚠️ Sharp Edges
| Issue | Severity | Solution |
|-------|----------|----------|
| Portfolio more complex than your actual work | medium | ## Right-Sizing Your Portfolio |
| Portfolio looks great on desktop, broken on mobile | high | ## Mobile-First Portfolio |
| Visitors don't know what to do next | medium | ## Portfolio CTAs |
| Portfolio shows old or irrelevant work | medium | ## Portfolio Freshness |
## Related Skills
Works well with: `scroll-experience`, `3d-web-experience`, `landing-page-design`, `personal-branding`
+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
-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"
}
}
-72
View File
@@ -1,72 +0,0 @@
# AGENTS.md
This file provides guidance to AI agents (OpenCode, Claude Code, etc.) when working with code in this repository.
## Project Overview
Interactive CV/portfolio website for Andy Charlwood with a distinctive loading experience: terminal boot sequence → ECG canvas animation with name tracing. Built as a React SPA with TypeScript and Vite.
## Commands
- `npm run dev` — Start dev server (localhost:5173)
- `npm run build` — TypeScript compile + Vite production build
- `npm run typecheck` — TypeScript type checking only (`tsc --noEmit`)
- `npm run lint` — ESLint
- `npm run preview` — Preview production build
No test framework is configured.
## Architecture
### Loading UI Flow
`App.tsx` manages a `Phase` state (`'boot'``'ecg'`). Each phase renders exclusively:
1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic
2. **ECGAnimation** — Canvas-based heartbeat animation (~5-6s) with letter tracing, background transitions from black to white
Total boot-to-ECG completion time must be ≤10 seconds.
### Key Patterns
- **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → letter tracing → exit.
### Path Aliases
`@/` maps to `./src/` (configured in both `vite.config.ts` and `tsconfig.json`).
### Styling
Tailwind CSS with custom design tokens in `tailwind.config.js`:
- **Colors**: teal `#00897B` (primary), coral `#FF6B6B` (accent), ECG palette (green/cyan/dim)
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono/terminal)
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
### Type System
All data types live in `src/types/index.ts`. Strict TypeScript — no `any` types. One component per file with typed props interfaces.
## Guardrails
- Boot sequence text and colors must match `References/concept.html` exactly (CLINICAL TERMINAL v3.2.1 format).
- ECG animation timing/amplitudes/color transitions must match the concept reference.
- When writing components with visual styling or animations, invoke the `frontend-design` skill first.
## Available Skills
This project has access to the following agent skills in `.agents/skills/`:
- **frontend-design** — Use for any visual styling or animation work
## Project Structure
```
src/
├── components/ # One component per file (PascalCase)
├── hooks/ # Custom hooks (camelCase, use* prefix)
├── lib/ # Utility functions
├── types/ # TypeScript interfaces
├── App.tsx # Phase manager (root component)
└── index.css # Global styles + Tailwind directives
Ralph/ # Implementation plan, guardrails, progress tracking
References/ # Source content (concept.html, ECGVideo/)
```
+45 -56
View File
@@ -2,74 +2,63 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Interactive CV/portfolio website for Andy Charlwood with a distinctive three-phase loading experience: terminal boot sequence → ECG canvas animation → main content. Built as a React SPA with TypeScript and Vite.
## Commands
- `npm run dev` — Start dev server (localhost:5173)
- `npm run build` — TypeScript compile + Vite production build
- `npm run typecheck` — TypeScript type checking only (`tsc --noEmit`)
- `npm run lint` — ESLint
- `npm run preview` — Preview production build
```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)
```
No test framework is configured.
**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
### Three-Phase UI Flow
**Interactive CV/portfolio** with a PMR (patient medical record) interface aesthetic. Three-phase UX: terminal boot → ECG heartbeat → dashboard.
`App.tsx` manages a `Phase` state (`'boot'``'ecg'``'content'`). Each phase renders exclusively:
### App lifecycle (`src/App.tsx`)
Phase orchestrator managing: BootSequence → ECGAnimation → LoginScreen → DashboardLayout
1. **BootSequence** — Terminal typing animation (~4s), green-on-black aesthetic
2. **ECGAnimation** — Canvas-based heartbeat animation (~5-6s) with letter tracing, background transitions from black to white
3. **Content** — FloatingNav + all CV sections (Hero, Skills, Experience, Education, Projects, Contact, Footer)
### 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.)
Total boot-to-content time must be ≤10 seconds.
### Key subsystems
### Key Patterns
| 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 |
- **Scroll reveals**: `useScrollReveal` hook wraps IntersectionObserver with trigger-once semantics. Used by every content section. Never use scroll event listeners.
- **Active nav tracking**: `useActiveSection` hook tracks which section is in viewport for FloatingNav highlighting.
- **Staggered animations**: Components use index-based delays (`baseDelay + index * 100`) with Framer Motion.
- **SVG skill circles**: `Skills.tsx` uses `strokeDashoffset = circumference * (1 - level / 100)` with `-90deg` rotation to start from 12 o'clock.
- **Canvas ECG**: `ECGAnimation.tsx` does imperative canvas drawing with requestAnimationFrame — flatline → 3 heartbeats (40px→60px→100px) → letter tracing → exit.
### 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.
### Path Aliases
## Conventions
`@/` maps to `./src/` (configured in both `vite.config.ts` and `tsconfig.json`).
- **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
### Styling
## Design tokens
Tailwind CSS with custom design tokens in `tailwind.config.js`:
- **Colors**: teal `#00897B` (primary), coral `#FF6B6B` (accent), ECG palette (green/cyan/dim)
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono/terminal)
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
- Inline styles only for dynamic values that Tailwind can't express (e.g., computed `strokeDashoffset`).
### Type System
All data types live in `src/types/index.ts`. Strict TypeScript — no `any` types. One component per file with typed props interfaces.
## Guardrails
- Boot sequence text and colors must match `References/concept.html` exactly (CLINICAL TERMINAL v3.2.1 format).
- ECG animation timing/amplitudes/color transitions must match the concept reference.
- CV content sourced from `References/CV_v4.md` — roles, dates, and achievement numbers must be accurate.
- Icons via `lucide-react`, not unicode symbols.
- When writing components with visual styling or animations, invoke the `/frontend-design` skill first.
## Project Structure
```
src/
├── components/ # One component per file (PascalCase)
├── hooks/ # Custom hooks (camelCase, use* prefix)
├── lib/ # Utility functions
├── types/ # TypeScript interfaces
├── App.tsx # Phase manager (root component)
└── index.css # Global styles + Tailwind directives
Ralph/ # Implementation plan, guardrails, progress tracking
References/ # Source content (concept.html, CV_v4.md, ECGVideo/)
```
- **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.
+49 -38
View File
@@ -1,70 +1,81 @@
# Andy Charlwood - Interactive CV
A distinctive interactive portfolio website featuring a three-phase cinematic loading experience: terminal boot sequence → ECG heartbeat animation → main content. Built with React, TypeScript, and Vite.
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
- **Three-Phase Loading Experience**: Terminal boot (~4s) → ECG animation (~5-6s) → content reveal
- **Interactive Sections**: Hero, Skills, Experience, Education, Projects, Contact
- **Smooth Animations**: Framer Motion for scroll reveals and staggered transitions
- **SVG Skill Visualization**: Circular progress indicators for skill levels
- **Floating Navigation**: Active section tracking as you scroll
- **Responsive Design**: Tailwind CSS with custom breakpoints
- **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
- **Framework**: React 18 + TypeScript
- **Build Tool**: Vite 6
- **Styling**: Tailwind CSS 3
- **Animations**: Framer Motion + Canvas API
- **Icons**: Lucide React
- **Linting**: ESLint 9
| 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
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
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` | Start dev server (localhost:5173) |
| `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)
├── hooks/ # Custom hooks (use* prefix)
├── lib/ # Utility functions
── types/ # TypeScript interfaces
├── App.tsx # Phase manager (root component)
── index.css # Global styles + Tailwind
├── 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`
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono)
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
## License
Private - All rights reserved.
- **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.
- [x] **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.
- [x] **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.
- [x] **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.
- [x] **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.
-263
View File
@@ -1,263 +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
### Iteration 8 — Task 9: Build Education, Projects, Contact sections
- **Completed**: Task 9 - Build Education, Projects, Contact sections
- **Files created**:
- `src/components/Education.tsx` - Education cards with gradient top border
- `src/components/Projects.tsx` - Project cards with gradient border hover effect
- `src/components/Contact.tsx` - Contact grid with Lucide icons
- **Files modified**:
- `src/App.tsx` - Replaced placeholder sections with actual components
- **Design decisions**:
- **Education**: 2-column grid with gradient top border (teal→coral), hover elevation effect
- **Projects**: 2x2 grid with gradient border hover effect using CSS mask technique
- **Contact**: 4-column grid (2x2 on mobile), Lucide icons (Phone, Mail, Linkedin, MapPin)
- Framer Motion for staggered entry animations (100ms delay per card)
- useScrollReveal hook for scroll-triggered visibility
- ExternalLink icon from Lucide for project links
- Contact links use teal color with hover transition
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- CSS gradient border hover effect uses mask-composite: exclude technique
- Lucide icons are tree-shakable - import only what's needed
- Contact items with href conditionally render as anchor tags
### Iteration 9 — Task 10: Build Footer component and main App.tsx
- **Completed**: Task 10 - Build Footer component and main App.tsx
- **Files created**:
- `src/components/Footer.tsx` - Footer with decorative ECG waveform SVG
- **Files modified**:
- `src/App.tsx` - Added Footer import and component to content phase
- **Design decisions**:
- ECG waveform SVG matches concept.html: 120x20 viewBox with PQRST pattern
- Framer Motion for scroll-triggered entrance (opacity 0→1, y 16→0)
- Teal stroke at 30% opacity for subtle branding
- Font-secondary for text-xs muted attribution
- Footer placed outside main element as per semantic HTML
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- App.tsx already had three-phase orchestration working correctly
- Footer scroll animation uses whileInView with once:true and margin:'-50px'
### Iteration 10 — Task 11: Implement scroll animations and responsive design
- **Completed**: Task 11 - Implement scroll animations and responsive design
- **Files modified**:
- `tailwind.config.js` - Added custom 'xs' screen at 480px for mobile breakpoint
- `src/App.tsx` - Added responsive padding (px-5 xs:px-6 md:px-8)
- `src/components/FloatingNav.tsx` - Responsive width and font/padding on mobile
- `src/components/Hero.tsx` - Responsive section padding, vitals grid, title font size
- `src/components/Skills.tsx` - Responsive grid (2→3→auto-fit), gauge size, padding
- `src/components/Experience.tsx` - Responsive card padding, ECG decoration size
- `src/components/Education.tsx` - Responsive section padding
- `src/components/Projects.tsx` - Responsive grid (1 col at tablet, 2 cols at desktop)
- `src/components/Contact.tsx` - Responsive section padding
- `src/components/Footer.tsx` - Responsive padding
- **Design decisions**:
- Added 'xs' breakpoint at 480px to match concept.html mobile breakpoint
- Scroll-reveal animations standardized to opacity 0→1, translateY 24px→0 across all sections
- Responsive patterns from concept.html:
- 768px (md): 2-col grids, smaller nav padding, vitals 2-col grid
- 480px (xs): 1-col grids, smaller fonts, smaller gauges (64px), reduced padding
- Main container uses px-5 xs:px-6 md:px-8 for responsive horizontal padding
- Section padding uses py-12 xs:py-16 md:py-20 for consistent vertical rhythm
- Skills grid: 2 cols mobile, 3 cols tablet, auto-fit desktop
- Hero vitals: stacked mobile, 2-col tablet, flex row desktop
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
- **Learnings**:
- Tailwind custom screens allow precise breakpoint matching to design specs
- Using w-16 h-16 xs:w-20 xs:h-20 for SVG gauges maintains aspect ratio while scaling
- Grid-based responsive layouts more reliable than flex-wrap for consistent card sizing
### Iteration 11 — Task 12: Final integration, testing, and polish
- **Completed**: Task 12 - Final integration, testing, and polish
- **Quality checks verified**:
- `npm run typecheck` ✓ - No TypeScript errors
- `npm run lint` ✓ - No ESLint errors
- `npm run build` ✓ - Production build completes (290KB JS, 18KB CSS gzipped to 94KB/4.5KB)
- **CV content accuracy verified** against CV_v4.md:
- Hero: Name, title, location, summary all match
- Experience: 5 roles in correct order with accurate dates and bullet points
- Education: MPharm UEA, Mary Seacole Programme with correct details
- Skills: 18 skills across Technical/Clinical/Strategic categories
- Projects: 4 projects with descriptions and PharMetrics link
- Contact: Phone, email, LinkedIn, location all accurate
- **All 12 tasks completed** - React conversion finished
- **Learnings**:
- Production build size is reasonable at ~94KB gzipped for JS
- All components properly typed with TypeScript strict mode
- IntersectionObserver hooks cleanup correctly on unmount
-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
-522
View File
@@ -1,522 +0,0 @@
# Design 1: The Compression
> A scroll-driven storytelling experience in 3 acts that ENACTS Andy's core skill — compressing raw data chaos into clean insight.
---
## Overview
The Compression is a scrollytelling portfolio that transforms the act of reading a CV into an emotional experience. The page is structured as a three-act narrative controlled entirely by scroll position. The user doesn't just learn that Andy compresses months of manual analysis into 3 days — they FEEL it.
**Act 1 "The Raw Data"** overwhelms the user with a wall of simulated prescribing data — drug names, BNF codes, costs, patient IDs — scrolling upward in green monospaced text on black. It's deliberately uncomfortable. This is the problem Andy solves every day.
**Act 2 "The Algorithm"** transforms the chaos in real-time as the user scrolls. Data lines cluster, group, sort, and collapse. Career cards appear during the transformation, each representing a stage in Andy's growing capability. The transformation becomes more sophisticated as roles progress from pharmacy management to population health analytics.
**Act 3 "The Insight"** delivers the payoff: clean, minimal output. Key numbers as beautiful data cards. Skills as animated gauges. Education and projects in calm, white-space-rich layout. The emotional contrast with Act 1 is the entire point.
The scroll position is the playback head. Fast scrollers get the highlights. Slow scrollers get the full show. Scrolling backward reverses everything. The user controls the pace of revelation — exactly how Andy controls the pace of a stakeholder presentation.
### Why This Design
Scroll-driven storytelling achieves 400% higher engagement than static content. But more importantly, this design doesn't just DESCRIBE Andy's value proposition — it DEMONSTRATES it. By the time a recruiter reaches Act 3, they've viscerally experienced what it feels like to have raw data compressed into clean insight. That's Andy's pitch, made physical.
---
## ECG Transition
**Starting frame:** Andy's name, neon green (#00FF41), on pure black. Static.
### Sequence (2.2 seconds total)
1. **Destabilize** (400ms): The neon green letterforms of Andy's name begin to flicker — not uniformly, but character-by-character, as if each letter is a data point losing coherence. Individual pixels at the edges of the letters start detaching, drifting 1-2px from their positions. The name is becoming unstable.
2. **Decompose** (600ms): The letters break apart completely. Each character disintegrates into a small cluster of monospaced character fragments — not random pixels, but recognizable text fragments: drug names, BNF codes, cost figures, patient IDs. The fragments scatter outward from each letter's position, decelerating with spring physics. The green shifts from neon (#00FF41) to a dimmer data-green (#3a6b45) as fragments spread.
3. **Grid snap** (500ms): The scattered fragments snap into grid positions — monospaced rows, left-aligned, filling the viewport. They're now readable as lines of simulated prescribing data. The grid formation happens with a satisfying staccato rhythm, rows snapping into place from top to bottom with 20ms stagger. The name "ANDY CHARLWOOD" dissolves last, its characters reassembling into a header row at the top of the data wall: `PATIENT_DATASET // CHARLWOOD.A // NORFOLK_ICB`.
4. **Data wall live** (200ms): The data wall begins scrolling upward automatically for a brief moment (2-3 rows), establishing the scrolling data aesthetic. Then it pauses, waiting for the user's scroll input. The background has remained black throughout — no seam between the intro and Act 1. The transition IS Act 1 beginning.
### Why This Transition Works
There is no seam. The neon green name from the ECG intro literally decomposes into the raw data that forms Act 1's visual foundation. The user's eye follows a continuous transformation: name → fragments → data rows. The emotional shift is from "that was a cool animation" to "wait, what is all this data?" — which is exactly the disorientation Act 1 is designed to create.
---
## Visual System
### Color Journey (Scroll-Driven)
The entire page's color palette transitions continuously as the user scrolls, creating an unmistakable sense of progression:
| Scroll Position | Background | Text Primary | Accent | Emotional Register |
|----------------|------------|-------------|--------|-------------------|
| 0% (Act 1 start) | Black #000000 | ECG green #00FF41 | — | Overwhelm, clinical |
| 15% (Act 1 mid) | Black #000000 | Dim green #3a6b45 | — | Dense, relentless |
| 30% (Act 2 start) | Charcoal #1e293b | Dim green → slate #94a3b8 | Teal #00897B | Transformation beginning |
| 50% (Act 2 mid) | Slate #334155 | Light slate #e2e8f0 | Teal #00897B | Organization, clarity |
| 70% (Act 3 start) | Light gray #f8fafc | Charcoal #1e293b | Teal #00897B | Relief, clean |
| 100% (Act 3 end) | White #FFFFFF | Dark #0f172a | Cyan accent #00D4AA | Confidence, resolution |
The background transition is implemented as a continuous CSS custom property (`--bg-progress`) mapped to scroll position, interpolating between color stops. No hard cuts — the eye never perceives a boundary between acts.
### Typography
Three typefaces, each with a clear role in the narrative:
- **IBM Plex Mono 400** — The data voice. Used for all raw data text in Act 1, metric numbers throughout, code snippets, and the header row. Set at 13px/1.6 in the data wall, 16px/1.4 for inline metrics. This is the typeface of the problem.
- **Space Grotesk 500, 700** — The heading voice. Used for section headings, role titles, and the name in the hero. Set at 32-48px for section headings, 24px for role titles. Weight 700 for primary headings, 500 for subheadings. This is the typeface of structure.
- **IBM Plex Sans 400, 450** — The body voice. Used for all descriptive text, bullet points, and the profile summary. Set at 16px/1.7 for body text, 14px/1.6 for secondary text. Weight 450 (slightly heavier than regular) for body text to maintain readability against busy backgrounds. This is the typeface of insight.
### Texture and Ambient Elements
- **Dot grid**: A faint grid of dots at 3% opacity, visible from Act 2 onward. Grid spacing 24px. The grid represents structure emerging from chaos — it's not visible in Act 1 (there is no structure yet) but gradually appears as the data organizes. Mouse proximity brightens the nearest grid intersection to 15% opacity within a 60px radius, creating a subtle "spotlight" effect.
- **Gradient glows**: Behind key data cards and metric numbers in Act 3, soft radial gradients (teal at 8-10% opacity) provide visual warmth and draw the eye. These are 200-300px diameter, centered on each element, and breathe (subtle scale oscillation at 4s period).
- **Data traces**: Thin horizontal lines (1px, 5% opacity) span the full viewport width behind content in Acts 2-3, suggesting the remnants of the data wall's grid structure. Content sits on these traces like data on a chart.
### Motion Principles
- **Easing**: All animations use `cubic-bezier(0.16, 1, 0.3, 1)` — a custom ease-out that starts fast and decelerates smoothly. This gives everything a confident, decisive feel, matching the "compression" metaphor (fast analysis, clean output).
- **Scroll-driven**: Every animation is mapped to scroll position via normalized 0-1 progress values. No time-based animations in the main content (except ambient loops like the gradient glow breathing). The user IS the timeline.
- **Number rendering**: Metric numbers render digit-by-digit at 30ms per digit when counting up. The count rate is tied to scroll velocity — scroll faster, numbers count faster. This creates a visceral connection between user effort and data processing.
- **SVG path drawing**: All drawn lines (timeline paths, skill bar fills, education path) animate via `stroke-dashoffset` mapped to scroll progress. The drawing direction always follows the data flow direction (left-to-right or top-to-bottom).
- **GPU compositing**: All transforms use translate3d, opacity, or scale exclusively. No animations trigger layout or paint (no width/height/margin animations). This ensures 60fps on mid-range devices.
---
## Section-by-Section Design
### Act 1: The Raw Data
**Scroll range:** 0% - 25% of total scroll depth.
**What the user sees:** A full-viewport wall of monospaced green text on black — simulated prescribing data. Rows contain realistic-looking drug names, BNF codes, practice codes, cost figures, and patient counts. The data scrolls upward at a rate proportional to the user's scroll, creating a "Matrix" effect but with real pharmaceutical data terminology.
**Data wall composition:**
```
BNF 0407010H0 MORPHINE SULFATE M/R PJ68043 £14.82 x120 NORFOLK_ICB
BNF 0212000Y0 ATORVASTATIN D81024 £2.16 x890 NORFOLK_ICB
BNF 0601022B0 METFORMIN HCL PJ68043 £1.04 x445 NORFOLK_ICB
BNF 0205051R0 RAMIPRIL D81024 £1.89 x670 NORFOLK_ICB
...
```
The data is generated procedurally (not hardcoded) from arrays of real BNF codes, drug names, practice codes, and cost ranges. Each row is unique but plausible. Approximately 200-300 rows are generated, with only ~30 visible at any time.
**Header row** (persistent at top): `PATIENT_DATASET // CHARLWOOD.A // NORFOLK_ICB` in brighter green (#00FF41), with a subtle underline. This is the remnant of Andy's name from the ECG transition.
**Scroll behavior:** As the user scrolls, the data wall scrolls upward. The scroll rate is 1.5x the user's scroll speed, creating a slight acceleration that enhances the overwhelming feeling. At 15% scroll, some rows begin to dim (opacity dropping to 30%), creating depth — foreground rows are bright, background rows are faded.
**Emotional intent:** Discomfort. Information overload. "How does anyone make sense of this?" This is the state of prescribing data before Andy touches it.
**Ambient detail:** A faint scan line sweeps downward across the data wall every 8 seconds (very subtle, 2% opacity). A tiny blinking cursor sits at the bottom-right of the data wall, suggesting a terminal awaiting input.
### Act 2: The Algorithm
**Scroll range:** 25% - 60% of total scroll depth.
**What the user sees:** The raw data begins to transform. This is the core of the experience — a choreographed sequence of data manipulations that correspond to Andy's career progression.
**Transformation sequence (mapped to scroll progress within Act 2):**
**Phase 1 — Sorting (0-20% of Act 2):** Data rows rearrange. Rows with similar BNF codes cluster together. The movement is animated — rows slide vertically to their new positions, creating a satisfying cascade of shifting text. Some rows highlight in teal (#00897B) as they're "selected" by the algorithm. A label appears at screen edge: `SORTING BY BNF_CODE...`
Simultaneously, the first career card slides in from the right: **Pharmacy Manager, Tesco PLC (2017-2022)**. It's a card with a dark background (#1e293b), rounded corners, and a teal left border. The card contains the role title, date range, and 2-3 key bullets. It appears alongside the sorting transformation, contextualizing it: Andy's first role involved identifying patterns (the asthma screening process adopted nationally).
**Phase 2 — Grouping (20-45% of Act 2):** Sorted rows collapse into groups. 10 individual rows of the same drug compress into a single summary row showing the drug name, total cost, and patient count. The compression animation is physical — rows accordion inward, stacking on top of each other until only the summary remains. The data wall is visibly shrinking. More whitespace appears between groups.
The second career card slides in: **High-Cost Drugs & Interface Pharmacist, NHS ICB (2022-2024)**. The role's key achievement — the Blueteq automation (70% form reduction, 200 hours saved) — is visualized as a mini-animation within the card: a stack of form icons compresses to 30% of its original height.
**Phase 3 — Analysis (45-70% of Act 2):** Grouped data transforms into structured visualizations. Cost figures align into bar segments. Patient counts form columns. The monospaced text is giving way to geometric shapes — rectangles, lines, circles. The background has lightened to slate. The data wall is no longer recognizable as raw text — it's becoming a dashboard.
The third career card slides in: **Deputy Head, Population Health & Data Analysis (2024-Present)**. The £220M budget management and the switching algorithm achievements appear. Key metric: `14,000 patients identified` counts up from zero as the user scrolls past.
**Phase 4 — Compression (70-100% of Act 2):** This is the signature moment. All remaining data elements — the bars, columns, shapes — physically compress toward the center of the screen. They funnel through a narrow "processing" zone (visualized as two converging lines forming a V-shape or funnel). On the other side, clean data cards emerge, fully formed. The funnel animation is tied directly to scroll — scroll backward and everything reverses, data expanding back out of the funnel.
The fourth career card slides in: **Interim Head, Population Health & Data Analysis (2025)**. The £14.6M efficiency programme headline. This number counts up dramatically: `£14,600,000` digit by digit, each digit appearing with a micro-flash of teal light.
**Background transition:** Throughout Act 2, the background continuously transitions from black (#000000) through charcoal (#1e293b) to slate (#334155). The text color shifts from dim green (#3a6b45) to light slate (#e2e8f0). By the end of Act 2, the page no longer looks like a terminal — it looks like a modern dashboard.
### Act 3: The Insight
**Scroll range:** 60% - 100% of total scroll depth.
**What the user sees:** Clean, beautiful, minimal content. Maximum whitespace. The emotional relief after Acts 1-2 makes this content feel earned and precious. This is "normal" portfolio layout elevated by contrast.
**Background:** Continues transitioning from slate (#334155) → light gray (#f8fafc) → white (#FFFFFF). By the Skills section, the background is fully white.
#### Hero (60-65% scroll)
Andy's name is already visible (persistent header from Act 1). As Act 3 begins, the profile summary text types itself character-by-character synchronized to scroll position. Stop scrolling = stop typing. Resume scrolling = resume typing. The text appears in IBM Plex Sans 450, 18px, charcoal (#1e293b). A thin teal line (#00897B) underscores the summary once complete.
Below the summary, three "impact pills" fade in with stagger: `£14.6M Efficiency Programme` | `1.2M Population Served` | `£220M Budget Managed`. Each pill has a teal border and a subtle gradient glow.
#### Skills (65-75% scroll)
Skills are displayed as horizontal bar charts that draw themselves left-to-right, synchronized to scroll position. The scroll-to-progress mapping means each bar fills as the user scrolls through the skills section.
**Layout:** Two columns on desktop, single column on mobile. Each row contains:
- Skill name (IBM Plex Sans 450, 15px, left-aligned)
- Horizontal bar (height 8px, rounded ends)
- Proficiency percentage (IBM Plex Mono 400, 14px, right-aligned, counts up as bar fills)
**Bar fill gradient:** Each bar fills with a gradient that shifts from cool blue (#60a5fa) at 0% to teal (#00897B) at 50% to warm cyan (#00D4AA) at 100%. The gradient position corresponds to the proficiency level, so higher-skilled bars are warmer-colored.
**Skill categories** are separated by subtle headings (Space Grotesk 500, 13px, uppercase, tracking 0.1em, slate #64748b):
- TECHNICAL: Python, SQL, Power BI, JavaScript/TypeScript, Algorithm Design, Data Pipelines
- HEALTHCARE: Medicines Optimisation, Population Health, NICE Implementation, Health Economics
- LEADERSHIP: Budget Management, Stakeholder Engagement, Team Development, Change Management
**Interaction:** Hovering a skill bar causes it to brighten slightly and the percentage number to pulse. The nearest dot-grid intersections brighten. A tooltip with a one-line description fades in after 300ms hover dwell.
#### Experience (75-85% scroll)
Experience entries are displayed as timeline cards that "assemble" as the user scrolls past each one's trigger point. The assembly is sequential and scroll-driven:
1. **Title draws** (first 20% of card's scroll range): The role title types itself in Space Grotesk 700, 22px, teal (#00897B).
2. **Company slides in** (20-35%): The company name and date range slide in from the left, IBM Plex Sans 400, 15px, slate (#64748b).
3. **Context line fades** (35-50%): The one-line role context fades in.
4. **Bullets sequence** (50-100%): Each bullet point fades in from below with a 100ms stagger. Key metrics within bullets (£14.6M, 14,000, 200 hours, £2.6M, £1M, 50%) count up from zero as they appear, with the count rate tied to scroll velocity.
**Timeline visual:** A thin vertical line (2px, teal at 20% opacity) connects the cards. Small nodes (8px circles) mark each role. As the user scrolls past a node, it fills with solid teal and emits a subtle radial pulse animation.
**Card layout:** Each card has generous padding (32px), a very subtle left border (3px, teal at 40% opacity), and sits on a barely-visible card surface (#f8fafc on white background). On hover, the card surface becomes #f1f5f9 and the left border reaches full teal opacity.
**Achievement highlights:** Key achievements within each role have metric numbers displayed in IBM Plex Mono 700, teal (#00897B), with a faint gradient glow behind them. These are the numbers that counted up from zero — they remain vivid and prominent.
Note: The career cards from Act 2 are NOT repeated here. Act 2 showed the career in the context of transformation. Act 3's Experience section provides the complete, detailed content. However, if the user scrolls back to Act 2, the career cards there are still visible and interactive. The two views complement each other — Act 2 is the narrative, Act 3 is the reference.
#### Education (85-92% scroll)
A winding SVG path draws itself as the user scrolls, connecting education milestones. The path is a gentle S-curve that moves top-to-bottom, with milestone nodes positioned along it.
**Path drawing:** The SVG `<path>` has a `stroke-dasharray` equal to its total length and a `stroke-dashoffset` that transitions from total length (invisible) to 0 (fully drawn) mapped to scroll progress. The stroke is 2px, teal (#00897B) at 40% opacity, with a brighter 4px glow version behind it at 15% opacity.
**Milestone nodes** (positioned along the path):
1. **A-Levels (2009-2011)**: Mathematics A*, Chemistry B, Politics C. Highworth Grammar School. Node icon: a small graduation cap SVG.
2. **MPharm (2011-2015)**: University of East Anglia, 2:1 Honours. Node icon: a flask/molecule SVG. The research project branches off as a sidebar annotation (a short branching path from the main line): "Drug delivery and cocrystals: 75.1% (Distinction)."
3. **GPhC Registration (2016)**: General Pharmaceutical Council. Node icon: a shield/badge SVG.
4. **Mary Seacole Programme (2018)**: NHS Leadership Academy, 78%. Node icon: a leadership/star SVG.
Each node starts as an empty circle (2px border, no fill). As the drawn path reaches the node, it fills with solid teal and a label card fades in beside it. The branch for the research project draws after the MPharm node fills.
#### Projects (92-97% scroll)
Each project occupies approximately one-third of a viewport height. As the user scrolls INTO a project, its visualization builds in real-time:
**Project 1 — Switching Algorithm:**
A network of small dots (representing patients) appears scattered randomly. As the user scrolls, the dots route through a funnel visualization (two converging lines). On the output side, they emerge organized into groups. A counter shows: `14,000 patients identified → £2.6M annual savings`. The funnel is the algorithm. The dots are the patients. The counter ties it to impact.
**Project 2 — Blueteq Automation:**
A stack of form icons (representing prior approval forms) appears on the left. As the user scrolls, 70% of the forms slide off-screen (fade out to the left), leaving 30% remaining. A counter shows: `70% reduction | 200 hours saved | 7-8 hrs/week ongoing`. The visual is simple and devastating — most of the work just disappears.
**Project 3 — Sankey Chart Tool:**
An actual mini Sankey diagram draws itself as the user scrolls. Colored flows move from left-side nodes (drug categories) through middle nodes (treatment stages) to right-side nodes (outcomes). The flows animate with a flowing particle effect along the paths. This is a working visualization of what Andy built.
**Project 4 — Controlled Drug Monitoring:**
A timeline visualization showing a patient's morphine equivalent exposure over time. A line chart draws itself left-to-right with scroll, with a horizontal threshold line marking "high risk." When the drawn line crosses the threshold, it changes color from teal to coral (#FF6B6B) and pulses. Counter: `Population-scale patient safety analysis`.
#### Contact (97-100% scroll)
The scroll reaches "the end of the data." A summary card appears, pulling together the key numbers from the entire page into a single impact statement:
```
£14.6M efficiency programme identified
14,000 patients flagged by algorithm
£2.6M annual savings on target
1.2 million population served
```
Each number is displayed in IBM Plex Mono 700, 28px, teal, with a gentle gradient glow. They appear with staggered fade-in as the user scrolls to the final section.
Below the summary, the contact form slides up as the final "output" of the data pipeline. The form has a minimal design: Name, Email, Message fields with clean borders, a teal submit button, and contact details (email, phone, location) displayed alongside.
A subtle callback to Act 1: the form's background has a barely-visible (1% opacity) pattern of the raw data text from the data wall, visible only on close inspection. The data is still there — it's just been compressed into clean insight.
---
## Interactions and Micro-interactions
### The Living Grid (Ambient)
A faint dot grid (3% opacity, 24px spacing) covers the viewport from Act 2 onward. This grid is interactive:
- **Mouse proximity**: The nearest grid intersection to the cursor brightens to 15% opacity, with 2-3 adjacent intersections at 8% opacity. Creates a subtle "spotlight" effect as the user moves their mouse. Radius ~60px.
- **Scroll activity**: When the user is actively scrolling, grid intersections along the scroll direction briefly flash (5% → 10% → 5% over 200ms), creating a cascading "data processing" ripple.
- **Section transitions**: When crossing from one section to another, a horizontal wave of grid brightening sweeps across the viewport (left to right, 400ms), marking the boundary.
Implementation: CSS custom properties for grid opacity, updated via requestAnimationFrame tied to mouse position and scroll events. The grid is a repeating CSS background pattern, not individual DOM elements.
### Number Count-ups
Every significant metric in the document counts up from zero to its final value:
- Count rate is proportional to scroll velocity (faster scroll = faster count)
- Numbers render digit-by-digit at 30ms per digit for large numbers (e.g., £14,600,000 takes ~270ms at base rate)
- A brief teal flash illuminates each digit as it appears
- Once fully counted, numbers hold their final value permanently (no re-counting on re-scroll)
- Scrolling backward past a number's trigger point smoothly counts it back down to zero
Implementation: Custom `useScrollCountUp` hook. Accepts target number, scroll range (start/end percentage), and formatting options. Returns the current display value based on scroll position. Uses `useTransform` from Framer Motion to map scroll progress to number value.
### Card Assembly Animations
Experience and project cards build themselves as the user scrolls:
- Each card has 4-6 sub-elements that animate sequentially
- The sequence is tied to scroll progress within the card's trigger range
- Easing is `cubic-bezier(0.16, 1, 0.3, 1)` for all movements
- Elements animate in from consistent directions: titles type-in, subtitles slide from left, body text fades from below, metrics scale up from zero
- Scrolling backward reverses the assembly — elements retreat in reverse order
### Data Wall Interactions (Act 1)
The data wall is primarily passive (scroll-driven), but has two subtle interactive layers:
- **Row highlighting**: The row nearest to the viewport center has slightly brighter text (50% → 70% opacity). Adjacent rows are progressively dimmer. This creates a "focused row" effect that tracks with scroll.
- **Mouse hover**: Hovering over a specific data row highlights it in brighter green and displays a tiny tooltip: "1 of 247,000 prescribing records" (or similar contextual text). This reinforces that each row represents real data.
### Scroll Progress Indicator
A thin progress bar sits at the top of the viewport (2px height, full width):
- **Color**: Transitions through the same color journey as the page (green → teal → cyan)
- **Width**: Maps directly to scroll percentage (0% = left edge, 100% = full width)
- **Act markers**: Three small notches at 25%, 60%, and 100% mark the act boundaries
- **Label**: A tiny "Act 1/3", "Act 2/3", "Act 3/3" label sits above the progress bar, updating at act boundaries
---
## Navigation
### Persistent Header
A minimal header sits at the top of the viewport with `position: fixed`:
- **Content**: Andy's name (Space Grotesk 700, 16px) on the left, act indicator on the right
- **Appearance**: Transparent in Act 1 (text in green), transitions to a subtle frosted-glass background (`backdrop-filter: blur(12px)`, white at 80% opacity) in Act 3
- **Act navigation**: Three dots in the header represent the three acts. The active act's dot is filled teal. Clicking a dot smooth-scrolls to that act's start position.
### Skip to Content
For users who want to bypass the narrative experience:
- A "Skip to CV →" link appears at bottom-right during Acts 1-2 (IBM Plex Sans 400, 14px, teal)
- Clicking it smooth-scrolls directly to Act 3 (the clean CV content)
- The link disappears once the user reaches Act 3
### Section Navigation (Act 3)
Within Act 3, a floating side navigation appears (similar to the existing FloatingNav):
- Small dots aligned vertically on the right edge
- Each dot corresponds to a section: Skills, Experience, Education, Projects, Contact
- Active section dot is filled teal, others are outlined
- Clicking a dot smooth-scrolls to that section
- Dots only appear when Act 3 is active
### Keyboard Navigation
- Arrow Up/Down: Scroll by section
- 1/2/3: Jump to Act 1/2/3
- Escape: Skip to Act 3 (same as "Skip to CV")
- Tab: Focuses interactive elements in DOM order
---
## Responsive Strategy
### Desktop (>1024px)
The full experience: data wall with 80-character rows, wide career cards alongside the transformation, two-column skill bars, generous whitespace in Act 3. The dot-grid ambient effect is active. Mouse interactions (hover, proximity) are fully enabled. Data wall shows ~30 visible rows at a time.
### Tablet (768px - 1024px)
Simplified data wall with 50-character rows (truncated BNF data). Career cards in Act 2 appear below the transformation area rather than alongside. Single-column skill bars. The dot-grid effect is reduced to major intersections only (48px spacing). Data wall shows ~25 visible rows.
### Mobile (<768px)
The scroll-driven narrative is preserved — this is scroll's native strength. Key adaptations:
- **Data wall**: 30-character rows, ~20 visible at a time. Fewer data fields per row (drug name + cost only). The overwhelming effect is maintained through density rather than width.
- **Act 2 transformation**: Simplified grouping animations (rows collapse in place rather than rearranging). Career cards appear in-flow, not overlaid.
- **Act 3**: Single-column layout throughout. Skill bars are full-width. Timeline cards are full-width with left border. Projects stack vertically with reduced visualization complexity (Sankey chart becomes a simplified flow, funnel is a simple before/after).
- **Ambient effects**: Dot-grid disabled. Gradient glows reduced to 5% opacity. Scroll progress bar and act indicators remain.
- **Touch**: All scroll-driven animations work identically with touch scroll. Hover interactions (grid brightening, card hover states) are disabled.
### Ultra-wide (>1440px)
Content is capped at 1200px max-width. The data wall extends to full viewport width (data rows span the entire screen). The extra horizontal space enhances the "wall of data" effect in Act 1.
---
## Technical Implementation
### Scroll Engine
The scroll system is the backbone of the entire experience. It maps a single scroll position to multiple parallel animation timelines.
```
Architecture:
- Total scroll depth: ~4x viewport height (tuned for comfortable scroll pace)
- Framer Motion useScroll() provides scrollYProgress (0 to 1)
- useTransform() maps scrollYProgress ranges to individual animation values
- Each section registers its scroll range via a config object:
{ start: 0.6, end: 0.75, ... } → Skills section occupies 60-75% of scroll
- Within each section, sub-animations are further mapped to the section's 0-1 range
```
### Data Wall Generation
The Act 1 data wall is procedurally generated at mount time:
```
Data arrays:
- ~50 real BNF codes (from public BNF data)
- ~80 drug names (generic names, publicly available)
- ~20 practice codes (anonymized format: PJ68xxx, D81xxx)
- Cost ranges (£0.50 - £200.00, realistic distributions)
- Patient counts (x50 - x2000)
Generation:
- 250-300 rows generated by randomly combining array elements
- Each row is a pre-formatted string matching fixed-width columns
- Rows are memoized (React.useMemo) — no re-generation on scroll
- Only ~30 rows are rendered at any time (virtualized list)
```
### Scroll-Driven Background
The background color transitions via CSS custom properties:
```
Implementation:
- A single --scroll-progress CSS variable (0 to 1) updated via requestAnimationFrame
- Background uses a multi-stop gradient positioned by --scroll-progress
- Gradient stops correspond to act boundaries
- The gradient is applied to a fixed, full-viewport background div
- No JavaScript per-frame color calculation — the browser interpolates
```
### Number Counter Hook
```
useScrollCountUp(target, scrollRange, options):
- target: final number (e.g., 14600000)
- scrollRange: { start: 0.78, end: 0.82 } — scroll range where count happens
- options: { prefix: '£', separator: ',', digits: true }
- Returns: formatted string of current value based on scroll position
- Uses Framer Motion useTransform to map scroll → number
- digit-by-digit mode: each digit position updates independently at 30ms intervals
```
### SVG Path Drawing
Education path and project visualizations use SVG stroke animation:
```
Implementation:
- SVG path has stroke-dasharray = path.getTotalLength()
- stroke-dashoffset transitions from totalLength (hidden) to 0 (visible)
- Offset value is mapped to scroll progress via useTransform
- A second, thicker, blurred path behind creates the glow effect
- Both paths update simultaneously for consistent glow
```
### Performance Budget
- **Target**: 60fps throughout on mid-range devices (4-core CPU, integrated GPU)
- **DOM elements**: <200 in Act 1, <400 in Act 3. Data wall uses virtualization.
- **Canvas**: No canvas used — all effects are CSS/SVG. This simplifies the rendering pipeline.
- **Composited properties only**: All animations use transform (translate3d) or opacity. No width, height, margin, padding, top, left animations.
- **will-change**: Applied to elements that animate frequently (data wall rows, card elements, background div)
- **IntersectionObserver**: Used to disable off-screen animations. Sections outside the viewport don't compute scroll mappings.
- **Bundle**: Framer Motion tree-shaken to ~30kb gzip. No D3 dependency. Total JS budget: <80kb gzip.
### Reduced Motion
When `prefers-reduced-motion: reduce` is active:
- Data wall shows a static screenshot-like snapshot (no scrolling data)
- Act structure is removed — all content displays as a standard scrolling page
- Section reveals use simple opacity fades (200ms) instead of assembly animations
- Number counters display final values immediately (no count-up)
- SVG paths are fully drawn (no progressive draw)
- Dot-grid ambient effect is disabled
- Progress bar remains functional for navigation
---
## Accessibility
### ARIA Structure
```html
<main aria-label="Andy Charlwood - Portfolio">
<section aria-label="Act 1: Raw Data Visualization" role="region">
<div aria-hidden="true" aria-description="Decorative visualization of raw prescribing data">
<!-- Data wall (purely decorative) -->
</div>
</section>
<section aria-label="Act 2: Data Transformation" role="region">
<!-- Transformation visuals (aria-hidden) + Career cards (accessible) -->
</section>
<section aria-label="Professional Profile" role="region">
<!-- Hero, Skills, Experience, Education, Projects, Contact -->
<!-- Each subsection has its own landmark heading -->
</section>
</main>
```
### Screen Reader Experience
Screen readers skip Acts 1-2 decorative content entirely and receive a clean, structured CV:
1. Andy Charlwood — Profile summary
2. Core Skills (structured list)
3. Professional Experience (chronological, with full role details)
4. Education and Registration
5. Projects (with outcomes and metrics)
6. Contact information
This is the same content as Act 3, in standard semantic HTML with proper heading hierarchy (h1 → h2 → h3).
### Keyboard Navigation
- **Tab order**: Follows logical CV structure regardless of visual act position
- **Skip links**: "Skip to main content" bypasses all decorative elements
- **Act navigation**: Number keys 1-3 jump to acts, clearly labeled in focus order
- **Focus indicators**: All interactive elements have visible focus rings (2px solid teal, 2px offset)
### Color Contrast
- Act 1: Green (#00FF41) on black (#000000) = contrast ratio 10.5:1 (AAA)
- Act 2: Light slate (#e2e8f0) on slate (#334155) = contrast ratio 7.2:1 (AAA)
- Act 3: Dark (#0f172a) on white (#FFFFFF) = contrast ratio 17.1:1 (AAA)
- Teal accent (#00897B) on white (#FFFFFF) = contrast ratio 4.56:1 (AA for normal text, AAA for large text)
### Scroll Depth
Total scroll depth is capped at approximately 4 viewport heights. This is comfortable for the narrative while not exhausting for keyboard/switch users. The "Skip to CV" shortcut is always available.
---
## What Makes This Special
1. **It ENACTS the value proposition.** The user doesn't read "I compress months of analysis into 3 days" — they experience overwhelming data being compressed into clean insight. The medium IS the message.
2. **The emotional arc is engineered.** Act 1 creates discomfort. Act 2 provides relief through transformation. Act 3 delivers resolution. This is the same emotional structure as a great presentation, a compelling film, or a satisfying algorithm — start with the problem, show the process, deliver the result.
3. **Scroll is the perfect input.** Everyone knows how to scroll. The engagement model is proven (400% higher than static). Fast scrollers get the highlights, slow scrollers get the full experience. It works perfectly on mobile where scroll is native. There's no learning curve, no instructions needed.
4. **The signature moment — The Compression funnel** — is share-worthy. Watching data physically compress through a funnel into clean output, controlled by your scroll, is viscerally satisfying. It's the moment someone takes a screen recording.
5. **It respects the recruiter's time.** The "Skip to CV" button is always available. A recruiter in a hurry can jump straight to Act 3 and get a clean, professional CV. A recruiter with time gets the full narrative experience. Two audiences, one site.
6. **The data is authentic.** The Act 1 data wall uses real BNF codes and drug names. The transformation sequence reflects actual data processing operations (sort → group → aggregate → visualize). Andy's domain expertise is woven into the visual DNA of the site, not just its text content.
-624
View File
@@ -1,624 +0,0 @@
# Design 2: The Dashboard
## Overview
Andy's CV presented as a live operational dashboard — the kind of analytical interface he builds for the NHS, now turned on himself. The medium IS the message.
This is not a scrolling portfolio with dashboard "styling." It is a fundamentally different navigation paradigm: **tab-switching views** instead of vertical scroll. Each tab is a self-contained viewport with its own optimized layout — bento grids of metric cards, filterable skill panels, an interactive horizontal timeline, a project portfolio with status badges. The user navigates Andy's career the same way Andy navigates the data systems he builds: by switching views, drilling into detail, and reading quantitative signals at a glance.
This is the most data-dense of all six designs. It is designed for recruiters, hiring managers, and technical leads who appreciate information density and are comfortable with complex interfaces. It rewards exploration and communicates Andy's analytical mindset before a single word of content is read.
**Key characteristics:**
- Tab-based view switching replaces scroll-based navigation entirely
- High information density with multiple data points visible simultaneously
- Metric cards with large numbers as the primary content unit
- Adaptive light/dark mode respecting system preference
- Persistent status bar providing ambient context
- Quantitative achievements lead — numbers, not prose
---
## ECG Transition
**Starting point:** "ANDREW CHARLWOOD" is on screen in neon green (`#00ff41`) on black. The heartbeat trace is complete. The name is fully formed and glowing.
**Then...**
### Phase 1: The Name Dims, the Edges Pulse (400ms)
The neon green letters hold for a beat, then begin to dim — not disappearing, but reducing to approximately 30% opacity. They remain visible as ghosted characters. Simultaneously, the remnant flatline portions of the heartbeat trace (to the left and right of the name) start pulsing with small, rhythmic blips, as if the heartbeat hasn't stopped — it has migrated to the periphery.
### Phase 2: Multi-Channel Ignition (800ms)
Two additional horizontal traces draw themselves simultaneously across the full viewport width:
- **Upper trace** at ~30% viewport height in teal (`#00897B`): draws a steady, regular pulse pattern — the rhythm of structured data
- **Lower trace** at ~70% viewport height in coral (`#FF6B6B`): draws a slower, more organic waveform — the rhythm of clinical observation
For approximately one second, the screen displays three horizontal traces — teal on top, ghosted green name in the middle, coral on the bottom. The visual effect is a multi-channel patient monitor displaying three simultaneous vital signs. This is a deliberately surprising beat: the user expects the animation to end, and instead it multiplies, signaling that this is a data-rich environment.
### Phase 3: Simultaneous Flatline (200ms)
All three traces flatline at once. A synchronized moment of pure stillness. Three horizontal lines on black. The name is still faintly visible. This 200ms pause is deliberate silence — a beat of tension before the transformation.
### Phase 4: Grid Materialization (400ms)
From the flatline positions, a grid structure fades in. The three horizontal flatlines become the top edges of bento-grid rows. Vertical dividers descend from the top trace line downward, intersecting the middle and bottom traces, dividing the screen into a grid of cells (4 columns x 3 rows on desktop, adapting to viewport). The verticals draw downward over 400ms, staggered left-to-right at 80ms intervals. They use a dim teal (`rgba(0, 137, 123, 0.2)`).
The background simultaneously shifts from pure black to deep navy (`#0A1628`). The scanline overlay shifts from black to `rgba(10, 22, 40, 0.03)` — subtle dark-blue scanlines that become part of the dashboard texture rather than disappearing.
### Phase 5: Content Cascade (500ms)
The "ANDREW CHARLWOOD" text slides to the top-left corner, scales down, and transitions from ghosted green to clean white. It becomes the dashboard title. The tab bar materializes beside it — each tab label fading in with 80ms stagger. "Overview" receives an active-state underline that draws itself in teal from left to right.
Each grid cell brightens individually with staggered timing (50ms per cell, top-left to bottom-right). As each cell activates, its KPI value fades in: "10+ years", "14,000 patients", "14.6M", "220M budget", and so on. The cascade reveal takes approximately 500ms for all cells.
The status bar slides up from the bottom edge of the viewport (the coral trace line becomes the status bar's top border).
### Phase 6: Final State
Deep navy dashboard (`#0A1628`) with bento grid of KPI cards, tab bar at top, status bar at bottom. The three ECG traces have literally become the structural lines of the dashboard layout. The heartbeat didn't end — it crystallized into information architecture.
**Total transition duration:** ~3 seconds
**Why this works:** The metaphor is precise. Andy takes raw clinical signals (vital signs, prescribing data) and transforms them into organized, actionable dashboards. The transition demonstrates this competency visually. The multi-channel moment is memorable, and the grid materialization provides a satisfying structural resolution.
---
## Visual System: Systematic Clarity
### Color Palette
**Adaptive mode** — the dashboard respects `prefers-color-scheme` and provides a manual toggle (persisted to `localStorage`).
**Light mode:**
- Background: cool white `#FAFAFA`
- Surface/cards: `#FFFFFF`
- Borders: `#E4E4E7` (zinc-200)
- Text primary: `#09090B` (zinc-950)
- Text secondary: `#71717A` (zinc-500)
**Dark mode:**
- Background: rich black `#09090B`
- Surface/cards: `#18181B` (zinc-900)
- Borders: `#27272A` (zinc-800)
- Text primary: `#FAFAFA` (zinc-50)
- Text secondary: `#A1A1AA` (zinc-400)
**Accent colors (consistent across modes):**
- Primary blue: `#2563EB` — the dominant interactive color. Used for active tab underlines, primary buttons, link states, and chart elements.
- Emerald: `#10B981` — health/active states. Used for "current" role indicators, active skills, live project badges, and positive metrics.
- Amber: `#F59E0B` — highlights and notable achievements. Used for standout numbers, awards, and attention-drawing callouts.
- Coral: `#FF6B6B` — inherited from the site's accent palette. Used sparingly for clinical-domain tagging in capabilities view.
- Teal: `#00897B` — inherited from the site's primary palette. Used for data/technical-domain tagging and hover states.
**Full zinc neutral scale** for all grays, ensuring consistent, harmonious neutral tones across both modes.
### Typography
**Single-family system** — Inter for all text, Geist Mono for numbers and data values.
- **Dashboard title / Hero name:** Inter 600, 48px, tracking `-0.025em`
- **Tab labels:** Inter 500, 14px, tracking `0.01em`, uppercase
- **Section headings (within tabs):** Inter 600, 24px, tracking `-0.015em`
- **Card KPI values:** Geist Mono 600, 48-72px (varies by card size), tracking `-0.02em`
- **Card labels:** Inter 500, 14px, zinc-500
- **Body text (bullets, descriptions):** Inter 400, 15px, line-height 1.7
- **Status bar text:** Inter 400, 13px
- **Timestamps/dates:** Geist Mono 400, 13px
Hierarchy is established through size, weight, and tracking only — no decorative font variations. Tight negative tracking at large sizes keeps the typographic texture dense and professional.
### Spacing and Grid
- **Grid system:** CSS Grid, 12-column, 24px gap
- **Max content width:** 1120px, centered with `auto` margins
- **Card internal padding:** 24px
- **Border radius:** 8px for small elements (badges, inputs), 12px for cards, 16px for containers/tab panels
- **Section spacing within tabs:** 32px between card groups
- **Consistent 8px base unit** — all spacing values are multiples of 8
### Motion
- **Primary easing:** `cubic-bezier(0.32, 0.72, 0, 1)` (Vercel easing) — fast entry, gentle settle
- **Reveal animation:** Elements enter with `opacity: 0, translateY: 8px, filter: blur(4px)` and resolve to `opacity: 1, translateY: 0, filter: blur(0)` over 300ms
- **Stagger interval:** 40ms between sequential elements
- **Spring parameters:** `{ stiffness: 300, damping: 30 }` for layout animations (card reflow, panel resize)
- **Tab crossfade:** 150ms fade out, 150ms fade in, with the incoming view's elements staggering in using the reveal animation
- **Number countup:** Metric card values animate from 0 to target over 800ms using `ease-out` timing, triggered on tab entry
- **Hover:** Cards lift 2px (`translateY: -2px`) with border color transitioning to blue-500 over 150ms
### Material and Surface Treatment
Clean, flat surfaces with precise borders defining all edges. This is not a skeuomorphic or glassmorphic design — it is systematic and structural.
- **Light mode:** Shadows are barely perceptible (`0 1px 2px rgba(0,0,0,0.04)`), used only on cards. Borders are the primary spatial separator.
- **Dark mode:** No shadows. Borders and subtle background-color differentiation define hierarchy.
- **No gradients on surfaces.** Gradients are reserved exclusively for the ECG transition animation and the occasional data visualization element.
- **Borders define everything:** card edges, tab underlines, status bar top edge, grid cell boundaries.
### Signature Visual: The Status Dot
Every section, skill, and experience item has a **6px colored dot** positioned consistently at the top-left of its container:
- **Emerald dot:** Current/active items — current role, current projects, skills actively in use
- **Blue dot:** Completed items — past roles, completed education, shipped projects
- **Amber dot:** Notable achievements — items with standout metrics (the 14.6M programme, the asthma screening revenue, the switching algorithm)
In the navigation tab bar, the active tab's dot **pulses subtly** (opacity oscillation between 0.6 and 1.0, 2s cycle) to indicate the current view. This pulse is the only continuously animated element in the resting state — everything else is still until interacted with, reinforcing the "precision instrument" feel.
---
## Section-by-Section Design
### Tab Bar (Persistent Navigation Chrome)
Fixed at the top of the viewport. Full width. Contains:
- **Left region:** "Andy Charlwood" in Inter 600, 18px. Below (or beside on wider screens): "Population Health & Data Analysis" in Inter 400, 13px, zinc-500.
- **Center region:** Tab labels — "Overview", "Capabilities", "Timeline", "Portfolio", "Connect". Each is a button with Inter 500, 14px, uppercase, tracking `0.01em`. Active tab has a 2px teal underline and slightly bolder weight. Inactive tabs are zinc-500 with hover-to-zinc-300 transition.
- **Right region:** Theme toggle (sun/moon icon, 20px), and a small "Download CV" link styled as a subtle outlined button.
The tab bar has a bottom border (`1px solid zinc-200` light / `zinc-800` dark). Background matches the page background with a `backdrop-filter: blur(12px)` for slight transparency when content scrolls behind it (relevant for tabs with scrollable content).
**Tab bar height:** 56px desktop, 48px mobile (when it becomes bottom nav).
---
### Tab 1: Overview
The landing view after the ECG transition. This is a **bento grid** — a CSS Grid with items of varying column spans, creating an asymmetric but balanced layout.
**Grid structure (desktop, 4 columns):**
```
[ Name & Title Card (2 cols) ] [ Profile Summary (2 cols) ]
[ Years Exp (1) ] [ Budget (1) ] [ Patients (1) ] [ Savings (1) ]
[ Tech Stack Card (2 cols) ] [ Current Focus (2 cols) ]
[ Location + GPhC (1 col) ] [ Leadership (1 col) ] [ Education Highlight (2 cols) ]
```
**Card types in Overview:**
1. **Name & Title Card** (2-col span): Andy Charlwood in Inter 600 48px. "Deputy Head, Population Health & Data Analysis" below. "NHS Norfolk & Waveney ICB" in teal. Emerald status dot (current role).
2. **Profile Summary Card** (2-col span): The CV profile text, but condensed to 2-3 sentences. Inter 400, 15px, line-height 1.7. This is the only prose-heavy card.
3. **Metric Cards** (1-col span each):
- "10+" in Geist Mono 72px, "Years Experience" label below, blue dot
- "220M" in Geist Mono 64px with "GBP" prefix in 24px, "Prescribing Budget" label, amber dot
- "14,000" in Geist Mono 56px, "Patients Identified" label, emerald dot
- "14.6M" in Geist Mono 64px with "GBP" prefix in 24px, "Efficiency Programme" label, amber dot
4. **Tech Stack Card** (2-col span): Horizontal row of technology badges: Python, SQL, Power BI, JS/TS, each as a pill with icon. Teal-tinted background on hover. This card serves as a quick-reference for technical keywords that ATS systems and recruiters scan for.
5. **Current Focus Card** (2-col span): 2-3 bullet points about current work direction, drawn from the most recent role. Emerald dot.
6. **Location + GPhC Card** (1-col): "Norwich, UK" with a subtle map pin icon. "GPhC Registered Pharmacist" with registration number. "Since August 2016" in Geist Mono.
7. **Leadership Card** (1-col): "Mary Seacole Programme" with "NHS Leadership Academy" below. "78%" score in Geist Mono. Blue dot (completed).
8. **Education Highlight Card** (2-col): "MPharm 2:1 Honours" in large type. "University of East Anglia, 2011-2015". "Research: 75.1% Distinction" as a highlighted callout with amber dot.
All cards have 12px border-radius, 24px internal padding, and the standard border treatment. On hover, cards lift 2px and the border transitions to blue-500.
**Click behavior:** Clicking a metric card reveals an expanded state (the card grows to fill 2 columns, pushing others down) showing contextual detail — e.g., clicking "14,000 Patients" expands to show a brief description of the switching algorithm and a link to the Portfolio tab.
---
### Tab 2: Capabilities
A two-panel layout for exploring skills.
**Left panel (sidebar, ~280px fixed width):**
A vertical list of skill categories styled as selectable list items:
- "Technical" (8 skills)
- "Clinical" (6 skills)
- "Strategic" (4 skills)
Each category shows its name, skill count, and a small bar chart preview (a thin horizontal bar showing relative skill level average for that category). The active category has a blue left border (3px) and slightly elevated background.
**Right panel (fluid width):**
Displays the selected category's skills as gauge visualizations.
Each skill is rendered as a card containing:
- Skill name in Inter 500, 16px
- Circular SVG gauge (same pattern as current implementation: `strokeDashoffset = circumference * (1 - level / 100)`, rotated -90deg to start from 12 o'clock)
- Percentage in Geist Mono 600, 24px, centered in the gauge
- Category-specific color: teal for Technical, coral for Clinical, blue for Strategic
- A status dot: emerald for skills actively used in current role, blue for all others
Skills are arranged in a responsive grid: 4 columns on desktop within the right panel, 3 on tablet, 2 on mobile.
**Gauge animation:** When switching categories, the gauges animate from 0 to their target value over 800ms with `ease-out` timing. This countup triggers every time a category is selected (not just on first view), reinforcing the "live data" feel.
**Interaction detail:** Hovering a skill gauge shows a tooltip with a one-line description of how Andy uses that skill (e.g., "Python: Built switching algorithms, controlled drug monitoring, data pipeline automation").
---
### Tab 3: Timeline
An interactive chronological view of Andy's career.
**Desktop layout — Horizontal timeline:**
A horizontal scrollable container with CSS scroll-snap. The X-axis represents years (2011-2026), with year markers at regular intervals. The timeline has two tracks:
**Track 1 (upper, primary):** Professional experience entries. Each entry is a card positioned at its start date, with width proportional to duration. Cards contain:
- Role title in Inter 600, 16px
- Organization in Inter 400, 14px, teal
- Date range in Geist Mono 400, 13px
- Status dot: emerald for current roles, blue for past
Cards are stacked vertically when roles overlap (e.g., Deputy Head and Interim Head at ICB).
**Track 2 (lower, secondary):** Education and professional development milestones. Rendered as smaller markers/pills:
- "MPharm, UEA" (2011-2015, spanning 4 years)
- "Mary Seacole Programme" (2018, point marker)
- "GPhC Registration" (2016, point marker)
**Timeline chrome:**
- A thin horizontal axis line in zinc-300 with year tick marks
- The "present" marker (2026) has a pulsing emerald dot
- A subtle gradient fade at the left edge indicates more content to scroll
**Expand interaction:** Clicking any experience card expands it downward to reveal the full bullet points for that role. The timeline adjusts layout smoothly (spring animation, 300ms). Only one card can be expanded at a time — expanding a new card collapses the previous one.
**Keyboard navigation:** Left/right arrow keys scroll the timeline by one year. Enter/Space expands the focused card.
---
### Tab 4: Portfolio
A card grid displaying Andy's projects with status metadata.
**Grid:** 2 columns on desktop, 1 on mobile. Each project card contains:
- Project title in Inter 600, 18px
- Description in Inter 400, 15px, 2-3 lines
- **Status badge** styled like a deployment indicator:
- "Live" — emerald background, white text (for PharMetrics)
- "Internal" — blue background, white text (for Blueteq Generator, CD Monitoring)
- "Complete" — zinc-500 background, white text (for NMS Video)
- Tech tags: small pills showing technologies used (Python, Power BI, etc.)
- Impact metric: a single standout number for each project, displayed in Geist Mono
- PharMetrics: "Real-time tracking"
- Switching Algorithm: "14,000 patients / 2.6M savings"
- Blueteq Generator: "70% reduction / 200hrs saved"
- CD Monitoring: "Population-scale safety"
- Sankey Analysis: "Patient pathway visualization"
- External link button (for PharMetrics)
**Hover preview:** On desktop, hovering a project card for 500ms shows an expanded preview with additional context — the full description and a technical implementation note. This preview slides out from the card's right edge (200ms, spring animation).
**Project data (from CV):**
1. **PharMetrics** — Real-time medicines expenditure dashboard for NHS decision-makers. Status: Live. Tech: Power BI, SQL. Impact: Real-time tracking across 220M budget.
2. **Switching Algorithm** — Python-based algorithm identifying patients on expensive drugs suitable for cost-effective alternatives. Status: Internal. Tech: Python, SQL. Impact: 14,000 patients identified, 2.6M annual savings.
3. **Blueteq Generator** — Automation tool for high-cost drug prior approval form creation. Status: Internal. Tech: Python. Impact: 70% reduction in forms, 200+ hours saved.
4. **Controlled Drug Monitoring** — System calculating oral morphine equivalents across all opioid prescriptions at population scale. Status: Internal. Tech: Python, SQL. Impact: Population-scale patient safety analysis.
5. **Sankey Chart Analysis** — Tool visualizing patient journeys through high-cost drug pathways. Status: Internal. Tech: Python. Impact: Trust-level compliance auditing.
6. **Patient Pathway Analysis** — Data-driven analysis of patient pathways to identify optimization opportunities. Status: Internal. Tech: Python, SQL. Impact: Clinical outcome improvements.
---
### Tab 5: Connect
Contact information and a simple message form.
**Layout:** Centered single column within the tab panel, max-width 600px. Clean and minimal — this tab has the lowest information density by design, creating visual breathing room after the data-heavy other tabs.
**Content:**
- "Get in Touch" heading, Inter 600, 32px
- Email: andy@charlwood.xyz as a clickable link, styled with the blue accent
- Location: Norwich, UK with a subtle map pin icon
- LinkedIn / GitHub links as icon buttons with labels
**Optional contact form:**
- Name input
- Email input
- Message textarea
- Submit button in blue accent, full-width
All form inputs use 12px border-radius, zinc-200 borders (light) / zinc-700 borders (dark), 16px internal padding. Focus state adds a blue border and subtle blue glow (`box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15)`).
---
### The Status Bar (Persistent Bottom Chrome)
Fixed at the bottom of the viewport, full width, 36px height.
**Content (left to right):**
- "Last updated: Feb 2026" in Geist Mono 400, 12px
- Vertical separator (1px, zinc-600)
- "Status: Open to opportunities" with a pulsing emerald dot
- Vertical separator
- "Norwich, UK" with a pin icon
- **Right-aligned:** "GPhC Registered" with a subtle badge
**Styling:**
- Light mode: `#F4F4F5` background (zinc-100), zinc-300 top border, zinc-600 text
- Dark mode: `#18181B` background (zinc-900), zinc-800 top border, zinc-400 text
The status bar provides ambient information that's always available regardless of which tab the user is viewing. It communicates "this person is available and current" without requiring the user to navigate to a contact page.
---
## Interactions and Micro-interactions
### Tab Switching
- Clicking a new tab triggers a crossfade: the current tab panel fades out (150ms, ease-out), then the new panel fades in (150ms, ease-in) with its child elements staggering via the reveal animation (40ms intervals).
- The active tab underline slides to the new tab position using a `layoutId` animation (Framer Motion), creating a smooth indicator transition rather than a discrete jump.
### Metric Card Countup
- When a metric card enters the viewport (on tab switch or initial load), its number value animates from 0 to the target over 800ms using `ease-out` timing.
- The "GBP" prefix and labels appear instantly — only the number animates.
- If the user switches away from a tab and returns, the countup replays, reinforcing the "live data refresh" metaphor.
### Card Hover States
- All cards: `translateY: -2px` lift, border color transition to `blue-500`, 150ms duration.
- Metric cards in Overview: the number subtly increases size by 2% on hover (a data-zoom effect).
- Project cards in Portfolio: the status badge pulses once on hover.
### Skill Gauge Interaction
- Category selection in Capabilities triggers all gauge animations simultaneously with 40ms stagger.
- Individual gauge hover: the gauge ring thickens from strokeWidth 5 to 7, and a tooltip appears.
### Timeline Card Expansion
- Click triggers a spring layout animation: the card's height expands to reveal bullet points. Other cards shift downward smoothly.
- The expanded card receives a left blue border (3px) and a slightly elevated shadow.
- A second click collapses the card.
- Only one card can be expanded at a time.
### Theme Toggle
- Clicking the sun/moon icon in the tab bar triggers a smooth crossfade of all color values (200ms). CSS custom properties handle the color swap, so no React re-render is needed for the transition.
- The icon itself rotates 180 degrees during the toggle (sun rotates out, moon rotates in).
### Status Dot Pulse
- The active tab's status dot and the "Open to opportunities" status bar dot share the same pulse animation: opacity oscillates between 0.6 and 1.0 on a 2-second cycle using `animation: pulse 2s ease-in-out infinite`.
- All other dots are static.
---
## Navigation
### Primary Navigation: Tab Bar
The tab bar is the only navigation mechanism. There is no scroll-based section jumping, no sidebar, no hamburger menu. This is a deliberate constraint: the dashboard metaphor demands that users switch views, not scroll through a document.
**Tab list:**
| Tab | Label | Keyboard | URL Hash |
|-----|-------|----------|----------|
| 1 | Overview | `1` or `Alt+1` | `#overview` |
| 2 | Capabilities | `2` or `Alt+2` | `#capabilities` |
| 3 | Timeline | `3` or `Alt+3` | `#timeline` |
| 4 | Portfolio | `4` or `Alt+4` | `#portfolio` |
| 5 | Connect | `5` or `Alt+5` | `#connect` |
**URL hash routing:** Each tab updates the URL hash on activation. On page load, the app reads the hash and activates the corresponding tab (defaulting to Overview if no hash or unrecognized hash). This enables direct linking to specific tabs — a recruiter can share `charlwood.xyz/#portfolio` to send someone directly to the projects view.
**Tab state persistence:** Within a session, each tab preserves its internal state. If the user expands a timeline card, switches to Portfolio, and returns to Timeline, the card is still expanded. This state is managed via React context (not URL), so it resets on page reload.
### Secondary Navigation: Within-Tab Interactions
- **Overview:** Card click expands for detail. No further navigation depth.
- **Capabilities:** Category sidebar acts as sub-navigation. Click a category to filter the skill display.
- **Timeline:** Horizontal scroll (mouse wheel, touch swipe, or arrow keys) navigates chronologically. Card click expands.
- **Portfolio:** Card click/hover reveals additional detail. External links navigate away.
- **Connect:** No navigation — static content.
---
## Responsive Strategy
### Desktop (>1024px)
The full dashboard experience. Multi-column bento grids, side-by-side capability panels, horizontal timeline, and the persistent tab bar + status bar chrome.
- Tab bar: horizontal, centered tabs with full text labels
- Overview: 4-column bento grid
- Capabilities: sidebar (280px) + skill grid (4 columns)
- Timeline: horizontal scroll with snap points
- Portfolio: 2-column card grid
- Status bar: full-width with all metadata items
### Tablet (768-1024px)
Dashboard bar becomes horizontally scrollable tabs (same visual style, but container scrolls if tabs exceed width). This prevents cramped labels.
- Overview: 2-column grid. Metric cards stack into 2x2 blocks. Larger cards remain 2-col span.
- Capabilities: Filter panel collapses to a horizontal selector (dropdown or scrollable pill bar) above the skill grid. Skills display in 3 columns.
- Timeline: Switches from horizontal to **vertical**. Entries stack chronologically top-to-bottom. Education items interleave with experience items in date order. Year markers appear as horizontal dividers.
- Portfolio: Remains 2-column or shifts to single column depending on card content.
- Status bar: Remains persistent at bottom, but "GPhC Registered" badge moves to a second line or hides behind a chevron.
### Mobile (<768px)
The dashboard bar transforms into a **bottom navigation** with 5 icon buttons (matching the 5 tabs). Each icon is from Lucide:
- Overview: `LayoutDashboard`
- Capabilities: `Gauge`
- Timeline: `Clock`
- Portfolio: `FolderOpen`
- Connect: `Mail`
The active tab has a teal dot above its icon and the label displayed below.
- Tab bar moves to bottom, 56px height, with safe area padding for devices with home indicators
- The top of the viewport shows the current tab title + theme toggle only
- Overview: Single-column stack. All metric cards are full-width. Name card at top, metrics below, then supporting cards.
- Capabilities: Category selector as a horizontal scrollable pill bar at top. Skills display in 2 columns below.
- Timeline: Vertical single-column. Full-width cards. Year markers as sticky section headers.
- Portfolio: Single-column card stack. Status badges are prominent.
- Connect: Full-width form, generous touch targets (48px minimum).
- Status bar: Moves to the top of each view as a collapsible banner (tap to expand). Shows only "Open to opportunities" by default with a chevron to reveal full metadata.
### Breakpoint Summary
| Element | Desktop (>1024) | Tablet (768-1024) | Mobile (<768) |
|---------|-----------------|-------------------|---------------|
| Tab bar | Top, horizontal | Top, scrollable | Bottom, icons |
| Status bar | Bottom, full | Bottom, condensed | Top, collapsible |
| Overview grid | 4 columns | 2 columns | 1 column |
| Capabilities | Sidebar + grid | Dropdown + grid | Pills + grid |
| Timeline | Horizontal scroll | Vertical stack | Vertical stack |
| Portfolio | 2 columns | 2 columns | 1 column |
| Card padding | 24px | 20px | 16px |
| Grid gap | 24px | 20px | 16px |
---
## Technical Implementation
### Component Architecture
```
App.tsx
BootSequence.tsx
ECGAnimation.tsx (modified exit: multi-trace → grid → cascade)
Dashboard.tsx (replaces current content phase)
DashboardTabBar.tsx
TabButton.tsx
DashboardContent.tsx (renders active tab panel)
OverviewTab.tsx
BentoGrid.tsx
MetricCard.tsx
ProfileCard.tsx
TechStackCard.tsx
CapabilitiesTab.tsx
CategorySidebar.tsx
SkillGaugeGrid.tsx
SkillGauge.tsx
TimelineTab.tsx
TimelineTrack.tsx
TimelineEntry.tsx
TimelineMilestone.tsx
PortfolioTab.tsx
ProjectCard.tsx
StatusBadge.tsx
ConnectTab.tsx
ContactForm.tsx
StatusBar.tsx
ThemeToggle.tsx
```
### State Management
- **Active tab:** React `useState` in `Dashboard.tsx`. Updated on tab click. Synced to URL hash via `useEffect` (writes on change, reads on mount).
- **Tab internal state:** React context (`DashboardContext`) holding: expanded timeline entry ID, selected skill category, expanded overview card ID. This context is not reset on tab switch, enabling state preservation.
- **Theme:** `useState` initialized from `localStorage`, falling back to `prefers-color-scheme` media query. Toggle writes to `localStorage` and applies a `data-theme="dark"` attribute to the document root. All colors reference CSS custom properties.
### CSS Strategy
- Tailwind CSS for utility classes, consistent with the existing project setup
- CSS custom properties for theme-aware colors (defined in `index.css` under `:root` and `[data-theme="dark"]` selectors)
- CSS Grid for bento layouts with explicit `grid-template-columns` and `grid-column: span N` on cards
- CSS `scroll-snap-type: x mandatory` for horizontal timeline on desktop
- `backdrop-filter: blur(12px)` on tab bar for the subtle transparency effect
- `@media (prefers-color-scheme: dark)` as the fallback when no manual toggle has been used
### Tab Transition Implementation
```
Tab switch flow:
1. User clicks new tab
2. Current tab panel: animate out (opacity 1→0, 150ms)
3. Update active tab state
4. New tab panel mounts
5. New tab panel: staggered reveal (each child: opacity 0→1, y 8→0, blur 4→0, 300ms, 40ms stagger)
6. If tab has countup elements (metric cards, skill gauges), countups trigger after reveal
```
Using Framer Motion's `AnimatePresence` with `mode="wait"` to manage the tab panel crossfade. Each tab panel is wrapped in a `motion.div` with `key={activeTab}` to trigger exit/enter animations.
### Performance Considerations
- **Tab panels:** Only the active tab renders its full content. Inactive tabs are unmounted (not hidden with `display: none`) to keep DOM light. State is preserved in context, not in DOM.
- **Metric countups:** Use `requestAnimationFrame`-based animation, not CSS — this allows precise easing control and avoids layout thrashing.
- **Timeline scroll:** Horizontal scrolling uses CSS-native scroll-snap, not JavaScript-controlled positioning.
- **Images:** If project screenshots are added later, use `loading="lazy"` and serve WebP with `<picture>` fallback.
- **Gauge SVGs:** Pre-computed `strokeDashoffset` values stored as constants. No recalculation on render.
### ECG Transition Modifications
The existing `ECGAnimation.tsx` needs modifications for the multi-trace and grid materialization:
1. After the name is complete (current `holdEndTime`), instead of the simple exit phase, the canvas draws two additional traces (teal and coral) at 30% and 70% viewport height.
2. The `bgTransitionedRef` logic changes: background transitions to `#0A1628` instead of `#FFFFFF`.
3. A new phase is added after the multi-trace flatline: vertical grid lines are drawn on the canvas, followed by content-cell placeholder rectangles.
4. The canvas fade-out timing is adjusted to overlap with the React dashboard mount, so the grid drawn on canvas aligns pixel-perfectly with the CSS Grid rendered by React.
5. The `onComplete` callback fires after the grid materialization, triggering the phase switch from `'ecg'` to `'content'`.
---
## Accessibility
### Keyboard Navigation
The tab-based interface maps naturally to the ARIA tabs pattern:
- `Tab` moves focus between the tab bar and the active tab panel
- `ArrowLeft` / `ArrowRight` moves between tabs when the tab bar is focused
- `Enter` / `Space` activates a focused tab
- Within the active panel, `Tab` navigates through interactive elements in document order
- In Timeline tab: `ArrowLeft` / `ArrowRight` scrolls the timeline by one year; `Enter` / `Space` expands the focused timeline entry
- Number keys `1`-`5` activate tabs directly (when tab bar is focused)
### ARIA Roles and Labels
- Tab bar: `role="tablist"`, each tab `role="tab"` with `aria-selected`, each panel `role="tabpanel"` with `aria-labelledby`
- Metric cards: `aria-label` with full context, e.g., `aria-label="14,000 patients identified for cost-effective switching through Python-based algorithm"`
- Skill gauges: `aria-valuenow`, `aria-valuemin="0"`, `aria-valuemax="100"`, `role="progressbar"`, `aria-label="Python proficiency: 90 percent"`
- Status bar: `aria-live="polite"` region, so dynamic updates (if any) are announced
- Timeline entries: `role="article"` with expandable content using `aria-expanded`
- Status dots: `aria-hidden="true"` (decorative; the semantic information is in adjacent text)
### Color and Contrast
- All text meets WCAG 2.1 AA contrast requirements in both light and dark modes
- The zinc neutral scale is specifically chosen for reliable contrast ratios
- Status dots are never the sole indicator of state — they always accompany text labels
- Focus indicators: 2px blue outline with 2px offset, visible in both themes
- The theme toggle is not required to use the site — both themes meet accessibility standards independently
### Motion and Preferences
- All animations respect `prefers-reduced-motion`. When reduced motion is preferred:
- Tab crossfades become instant switches (no animation)
- Metric countups display final values immediately
- Gauge animations are disabled; gauges render at their target values
- Card hover lifts are disabled
- Status dot pulse is disabled
- ECG transition skips to final state after a brief hold
### Screen Reader Experience
The tab-based navigation provides a clear, navigable structure for screen readers:
1. User encounters the tab bar with 5 clearly labeled tabs
2. Activating a tab announces the panel label
3. Within each panel, content is structured with headings (`h2` for section titles, `h3` for individual entries)
4. Metric cards read as: "[Value] [Label]. [Additional context from aria-label]"
5. The status bar is announced on page load and when content changes
---
## What Makes This Special
**The medium IS the message.** By presenting his CV as a dashboard, Andy demonstrates his analytical mindset through the navigation itself. A recruiter doesn't just read about Andy's ability to create data systems — they experience one. The information architecture of the site is itself a portfolio piece.
**Numbers lead.** Every other CV website puts prose first and numbers second. This design inverts that: the first thing you see is a grid of metric cards with large Geist Mono numbers. "14,000 patients." "14.6M programme." "220M budget." These numbers are more compelling than any paragraph of self-description, and presenting them in a dashboard context makes them feel quantitative and verifiable rather than resume-inflated.
**The density is the point.** Most portfolio sites are spacious, scrolling single-column affairs with generous whitespace. This design deliberately goes the other direction: high density, multiple data points visible simultaneously, information that rewards careful reading. This says "I am comfortable with complexity" in a way that minimal designs cannot.
**The ECG transition earns its keep.** The multi-trace multiplication and grid materialization aren't just visually interesting — they tell a story. Raw clinical signals (vital signs) transform into organized, structured data (dashboard grid). This is literally what Andy does: he takes messy prescribing data and turns it into actionable analytics. The transition is a 3-second visual metaphor for his career.
**Adaptive theming signals engineering maturity.** Supporting both light and dark modes with a manual toggle and `prefers-color-scheme` respect is a technical detail that fellow developers and technical recruiters will notice and appreciate. It signals awareness of modern frontend standards.
**The status bar adds ambient context.** "Open to opportunities" is visible on every single tab view without requiring the user to navigate to a contact page. It's a constant, low-key signal — like a system indicator light — that communicates availability without being pushy. This is a detail borrowed from actual operational dashboards, where system status is always visible.
**Tab persistence respects the user's exploration.** Preserving expanded state across tab switches communicates respect for the user's time and attention. It says: "I built this thoughtfully." It's a subtle UX detail that most portfolio sites don't consider, because most portfolio sites don't have this level of navigational complexity to manage.
-511
View File
@@ -1,511 +0,0 @@
# Design 3: The Observatory
## Overview
A non-linear, spatial interface where the site does not scroll -- it is an interactive constellation. Glowing nodes arranged in a force-directed graph represent sections of Andy's career. Click a node to zoom in. Navigation is spatial, not linear. The most visually distinctive and architecturally ambitious of all 6 designs.
The core insight: a traditional CV is a list. A constellation is a map. Lists impose a reading order. Maps invite exploration. By presenting Andy's career as an interconnected constellation rather than a sequential document, visitors build their own mental model of how clinical expertise, technical skill, and strategic leadership connect -- and they remember it, because they built it themselves.
This design draws from three disciplines: knowledge-graph visualization (Obsidian, Neo4j Browser), environmental storytelling in game design (where narrative is discovered through spatial exploration rather than linear delivery), and the force-directed graph layouts used in data science to reveal hidden structure in complex datasets. It applies all three to the problem of self-presentation.
---
## ECG Transition
**Starting point:** "ANDREW CHARLWOOD" is on screen in neon green (#00ff41) strokes on a black (#000) background. The ECG trace that drew it is still visible. The drawing head has stopped.
**Then:**
The letterforms begin to **contract inward** toward the center of the name. Each letter stretches and thins -- like light near a gravitational singularity -- as it compresses toward a single convergence point at screen center. The neon green shifts through cyan (#00E5FF) to bright white (#FFFFFF) as the letters converge, mimicking the blueshift of light under gravitational compression.
All letters collapse into a single luminous point. A beat of stillness (200ms).
The point **pulses** -- a sonar ring of soft cyan (#00D4AA) radiates outward from center. As this ring passes across the viewport, constellation nodes **blink into existence in its wake**, each one appearing with a brief flash and then settling into a soft glow. The ring reaches the viewport edges and fades.
Simultaneously, the black background shifts imperceptibly to deep navy (#0A0E1A). The luminous center point fades and Andy's name re-renders as clean DOM text (Space Grotesk, 700 weight, soft white #ECECF0) at center screen. His role title fades in below at smaller size.
The 5-6 constellation nodes that appeared during the sonar ring now animate to their orbital positions around the name using spring physics -- they overshoot slightly, oscillate, and settle. Each node has a faint label that appears on hover proximity.
**Duration:** ~2.4 seconds.
**Color journey:** Black (#000) --> Deep Navy (#0A0E1A). ECG green (#00ff41) --> Cyan (#00E5FF) --> White (#FFF) at convergence --> Warm amber (#D4874D) node glows + Electric cyan (#00D4AA) active states in the constellation.
**The message:** "What was a single heartbeat line has become a universe of interconnected points. Welcome to the observatory."
---
## Visual System
### Color Palette
| Token | Value | Usage |
|---|---|---|
| `--bg-deep` | `#0A0E1A` | Primary background (deep navy-black) |
| `--bg-gradient` | `radial-gradient(ellipse at center, #0F1428 0%, #0A0E1A 70%)` | Subtle depth at center |
| `--amber` | `#D4874D` | Primary accent -- node highlights, connection lines, active indicators |
| `--amber-glow` | `rgba(212, 135, 77, 0.15)` | Ambient glow around active nodes |
| `--cyan` | `#00D4AA` | Active/hover states, sonar pulses, interactive feedback |
| `--cyan-glow` | `rgba(0, 212, 170, 0.12)` | Hover glow |
| `--text-primary` | `#ECECF0` | Headings, labels, primary text |
| `--text-secondary` | `#8B8FA3` | Descriptions, body text |
| `--text-dim` | `#4A4E63` | Tertiary labels, metadata |
| `--grid-line` | `#1A1F2E` | Faint structural lines (used sparingly) |
| `--node-border` | `#2A2F42` | Inactive node borders |
| `--card-bg` | `rgba(15, 20, 40, 0.85)` | Detail panel backgrounds (translucent) |
| `--card-border` | `rgba(212, 135, 77, 0.2)` | Detail panel border glow |
### Background Treatment
No grid for this design. The dark space should feel open, organic, and expansive -- not systematic. Three layers create depth:
1. **Base:** Flat deep navy (#0A0E1A)
2. **Depth gradient:** Subtle radial gradient, lighter at center (#0F1428), fading to base at edges. Creates a sense of looking into space.
3. **Star particles:** Very low density (30-50 particles across the viewport), tiny (1-2px), faintly glowing white at 10-20% opacity. Drift slowly. These are purely atmospheric -- they do not carry content or respond to interaction. They simply make the space feel alive.
### Typography
| Role | Font | Weight | Size |
|---|---|---|---|
| Display (name) | Space Grotesk | 700 | `clamp(2rem, 4vw, 3.5rem)` |
| Section headings | Space Grotesk | 500 | `clamp(1.25rem, 2.5vw, 1.75rem)` |
| Body text | IBM Plex Sans | 400 | 15px / 1.7 line-height |
| Subheadings | IBM Plex Sans | 500 | 14px |
| Data labels / stats | IBM Plex Mono | 400 | 13px, uppercase, 0.05em tracking |
| Node labels | Space Grotesk | 500 | 13px |
**Font loading strategy:** Space Grotesk and IBM Plex Sans loaded via Google Fonts with `display=swap`. IBM Plex Mono loaded with `display=optional` (falls back to system mono if slow to load -- acceptable for data labels).
### Motion
- **Spring physics** for all node movement: `mass: 1, stiffness: 120, damping: 14` (Framer Motion spring config). This creates a responsive, organic feel -- nodes overshoot and settle rather than moving linearly.
- **Zoom transitions:** `cubic-bezier(0.16, 1, 0.3, 1)` -- fast departure, gentle arrival. Duration 600ms.
- **Hover effects:** 150ms ease-out for color/glow changes.
- **Connection line reveals:** `stroke-dasharray` animation, 800ms per line with 100ms stagger between lines.
- **Sonar pulse on interaction:** Radial ring emanating from clicked node, 400ms, opacity 0.3 --> 0.
### Signature Visual: Connection Lines
The constellation's defining feature is the connection web that reveals relationships between career elements. After visiting 3+ nodes, a "View Connections" toggle appears.
- Lines are SVG `<path>` elements drawn between node center points.
- **Line thickness** encodes relationship strength: strong connections (Python --> switching algorithm --> 2.6M savings) use 2px lines; weaker thematic connections use 0.75px lines.
- **Line color:** Warm amber (#D4874D) at 40% opacity, brightening to 80% on hover.
- **Line style:** Slightly curved (quadratic bezier with a subtle arc), not straight. This creates a more organic, constellation-like appearance.
- **Interaction:** Hovering a connection line shows a tooltip explaining the relationship. Example: "Python skills --> Built switching algorithm --> 14,000 patients identified, 2.6M annual savings."
- **Animation:** Lines draw themselves using `stroke-dashoffset` animation when first revealed.
---
## Section-by-Section Design
### Hub View (Default State)
The hub is the home state -- what visitors see after the ECG transition completes. Andy's name sits at center in Space Grotesk 700, with his role title below in IBM Plex Sans. Around the name, 5-6 constellation nodes orbit at varying distances:
**Node positions (approximate, adjusted by force-directed layout):**
| Node | Orbital Distance | Glow Color | Icon Concept |
|---|---|---|---|
| Skills | Close orbit (top-right) | Amber | Hexagonal skill web |
| Experience | Close orbit (left) | Amber | Timeline/pulse line |
| Education | Mid orbit (bottom-left) | Cyan | Academic cap / book |
| Projects | Mid orbit (bottom-right) | Cyan | Code brackets / diagram |
| Contact | Outer orbit (top-left) | Amber | Signal / connection |
Each node is a 48-64px circle with:
- A soft glow (`box-shadow: 0 0 20px var(--amber-glow)`)
- A thin border (`1px solid var(--node-border)`, transitioning to `--amber` on hover)
- A small icon or symbol at center (Lucide icons, 20px)
- A label that appears on hover or cursor proximity (within 100px), fading in at 200ms
**Gravitational attraction:** As the cursor moves near a node (within 120px), the node is gently pulled toward the cursor by 4-8px. This creates a subtle sense of magnetic interaction without disrupting the layout. The pull uses spring physics with high damping (damping: 20) to prevent oscillation.
**Ambient animation:** Nodes drift very slowly in micro-orbits (2-3px movement radius, 8-12 second cycle). This keeps the constellation feeling alive without being distracting.
### Skills Node (Zoomed In)
**Zoom transition:** Clicking the Skills node triggers a smooth pan+zoom. The clicked node expands to fill ~70% of the viewport width. Other nodes animate to the periphery (scaled down to 24px, still visible, still clickable). Duration: 600ms.
**Internal layout:** A radial skill diagram. Skills orbit a center point at distances proportional to their proficiency level (higher proficiency = closer to center, representing mastery as gravitational pull).
Three concentric rings (barely visible, #1A1F2E at 30% opacity) mark proficiency zones: Expert (inner), Proficient (mid), Competent (outer).
**Skill categories** are color-coded:
- Technical skills: Amber (#D4874D) nodes
- Clinical skills: Cyan (#00D4AA) nodes
- Strategic skills: Soft white (#ECECF0) nodes with amber border
Each skill is a small node (32-40px) with the skill name below it. Hovering a skill:
1. Expands the node slightly (scale 1.15)
2. Shows a tooltip with proficiency percentage and a one-line description
3. Highlights all related skills with pulsing connection lines
**Interaction:** The radial diagram can be slowly rotated by click-and-drag (Framer Motion `drag` with `dragElastic: 0.1`, constrained to rotation). This serves no functional purpose -- it simply makes the diagram feel tactile and explorable.
**Skill data:**
Technical: Python (90%), SQL (88%), Power BI (92%), JS/TS (70%), Data Analysis (95%), Dashboard Dev (88%), Algorithm Design (82%), Data Pipelines (80%)
Clinical: Medicines Optimisation (95%), Pop. Health Analytics (90%), NICE TA (85%), Health Economics (80%), Clinical Pathways (82%), CD Assurance (88%)
Strategic: Budget Mgmt (90%), Stakeholder Engagement (88%), Pharma Negotiation (85%), Team Development (82%)
### Experience Node (Zoomed In)
**Internal layout:** A vertical timeline within the expanded node, scrollable if content exceeds the viewport. The timeline line runs vertically at 20% from the left edge, with timeline dots and cards to the right.
Each role card contains:
- Role title (Space Grotesk, 500, `--text-primary`)
- Organisation (IBM Plex Sans, 400, `--cyan`)
- Date range (IBM Plex Mono, 400, `--text-dim`, in a pill badge with `--amber` background at 10% opacity)
- Expandable bullet points (collapsed by default, showing first 2 bullets with "Show more" toggle)
**Color-coding per employer era:**
- NHS Norfolk & Waveney ICB roles: Left border amber (#D4874D)
- Tesco Pharmacy roles: Left border cyan (#00D4AA)
This creates instant visual distinction between the "data/analytics" era and the "clinical pharmacy" era.
**Background shift:** The expanded node's background subtly shifts warm (#0F1220) during ICB roles and cooler (#0A1018) during Tesco roles. The shift is barely perceptible but creates an atmospheric distinction.
**Role data (from CV_v4.md):**
1. **Interim Head, Population Health & Data Analysis** -- NHS Norfolk & Waveney ICB -- May-Nov 2025
- Identified and prioritised a 14.6M efficiency programme through comprehensive data analysis; achieved over-target performance by October 2025
- Built Python-based switching algorithm compressing months of manual analysis into 3 days, identifying 14,000 patients and 2.6M in annual savings
- Automated incentive scheme analysis; achieved 50% reduction in targeted prescribing within first two months
- Presented strategy and financial position to Chief Medical Officer on bimonthly basis
- Led transformation from practice-level data to patient-level SQL analytics
2. **Deputy Head, Population Health & Data Analysis** -- NHS Norfolk & Waveney ICB -- Jul 2024-Present
- Managed 220M prescribing budget with sophisticated forecasting models
- Collaborated with ICB data engineering team to create comprehensive medicines data table
- Led financial scenario modelling for system-wide DOAC switching programme
- Led renegotiation of pharmaceutical rebate terms ahead of patent expiry
- Supported commissioning of tirzepatide (NICE TA1026) including financial projections
- Developed Python-based controlled drug monitoring system
- Educated colleagues on data interpretation and analytics best practices
3. **High-Cost Drugs & Interface Pharmacist** -- NHS Norfolk & Waveney ICB -- May 2022-Jul 2024
- Wrote most of the system's high-cost drug pathways spanning rheumatology, ophthalmology, dermatology, gastroenterology, neurology, and migraine
- Developed software automating Blueteq prior approval form creation: 70% reduction in forms, 200 hours immediate savings
- Integrated Blueteq data with secondary care activity databases
- Created Python-based Sankey chart analysis tool visualising patient journeys
4. **Pharmacy Manager** -- Tesco PLC -- Nov 2017-May 2022
- Identified and shared asthma screening process adopted nationally across ~300 branches, enabling ~1M in revenue
- Led creation of national induction training plan and eLearning modules
- Supervised two staff members through NVQ3 qualifications
### Education Node (Zoomed In)
**Internal layout:** A horizontal path with interactive milestone markers. The path is a subtle line running left-to-right across the expanded node, with milestone nodes along it.
**Milestones:**
1. **A-Levels** (2009-2011) -- Highworth Grammar School. Mathematics (A*), Chemistry (B), Politics (C). Node shows as a small marker.
2. **MPharm (Hons) Pharmacy** (2011-2015) -- University of East Anglia. Upper Second-Class Honours (2:1). This is the primary milestone -- larger node. Clicking opens a detail panel with the research project information.
3. **Research Project** -- Drug delivery and cocrystals: 75.1% (Distinction). This sub-node opens a mini-visualization: simple SVG polyhedra representing cocrystal structures, rotatable by mouse drag. The polyhedra are wireframe-style in cyan (#00D4AA) on the dark background, gently rotating when idle. This is a small touch of delight that also subtly demonstrates technical capability (interactive 3D in the browser).
4. **Mary Seacole Programme** (2018) -- NHS Leadership Academy. 78%. Change management, healthcare leadership, system-level thinking.
5. **GPhC Registration** (August 2016-Present) -- Persistent certification. Shown as a badge rather than a path node.
### Projects Node (Zoomed In)
**Internal layout:** Each project is a sub-cluster -- a mini-constellation of technology + outcome nodes. The cluster is interactive: clicking zooms into it.
**Projects:**
1. **PharMetrics** -- Real-time medicines expenditure dashboard. Sub-nodes: "Power BI" (tech), "NHS Decision-Makers" (audience), "Actionable Analytics" (outcome). Link: medicines.charlwood.xyz
2. **Switching Algorithm** -- Python-based patient identification system. Sub-nodes: "Python" (tech), "14,000 Patients" (scale), "2.6M Savings" (outcome), "3 Days vs Months" (efficiency).
3. **Blueteq Generator** -- Automation tool for high-cost drug approvals. Sub-nodes: "Automation" (tech), "70% Reduction" (efficiency), "200+ Hours Saved" (outcome).
4. **Sankey Chart Tool** -- Patient journey visualization. Sub-nodes: "Python" (tech), "Data Visualization" (method), "Pathway Compliance" (outcome).
5. **Controlled Drug Monitor** -- Population-level opioid exposure tracking. Sub-nodes: "Python + SQL" (tech), "Patient Safety" (purpose), "Population Scale" (scope).
Each project cluster has connection lines back to the Skills and Experience nodes, showing provenance.
### Contact Node (Zoomed In)
**Internal layout:** A clean, centered panel with contact information. The copy reads: "Ready to connect another node to the network."
**Contact methods:**
- Email: andy@charlwood.xyz (clickable mailto link)
- Phone: 07795553088
- LinkedIn: linkedin.com/in/andrewcharlwood (opens in new tab)
- Location: Norwich, UK
Each contact method is a horizontal row with a Lucide icon (Mail, Phone, Linkedin, MapPin) in cyan, label in dim text, and value in primary text. Hovering a row highlights it with a subtle amber glow.
---
## Interactions & Micro-interactions
### Node Hover
1. Cursor enters 120px proximity zone: node begins gravitational pull toward cursor (4-8px, spring physics)
2. Cursor enters node bounds: border transitions from `--node-border` to `--amber` (150ms). Glow intensifies. Label fades in below node (200ms fade).
3. Cursor exits: all effects reverse with matching timing.
### Node Click (Zoom In)
1. Clicked node scales up with spring animation (mass: 1, stiffness: 100, damping: 12) from ~56px to ~70% viewport width
2. Other nodes simultaneously scale down to 24px and drift to viewport periphery (spring, 600ms)
3. Background subtly darkens by 10% to create focus
4. Clicked node's internal content fades in with 200ms delay, 400ms duration
5. A subtle "zoom out" icon (Lucide Minimize2) appears top-left of expanded node
### Zoom Out
1. Triggered by: clicking zoom-out button, pressing Escape, or clicking any peripheral node
2. If clicking a peripheral node: that node zooms in while the current one zooms out (seamless swap, ~700ms)
3. If zooming out to hub: expanded node contracts, peripheral nodes return to orbital positions (spring physics, ~600ms). Internal content fades out before contraction begins.
### Connection Line Reveal
1. Triggered after visiting 3+ unique nodes. A floating "View Connections" pill button fades in at bottom-center.
2. Clicking the toggle: connection lines draw themselves between related nodes using `stroke-dashoffset` animation. Each line takes 600ms. Lines stagger by 100ms.
3. Hovering a connection line: the line brightens to 80% opacity, thickens by 0.5px, and a tooltip appears at the midpoint explaining the relationship.
4. Clicking the toggle again: lines retract (reverse `stroke-dashoffset`) and the button returns to "View Connections."
### Sonar Pulse
When any interactive action occurs (node click, lens switch, connection toggle), a subtle sonar ring (cyan, 20% opacity) radiates from the point of interaction. Duration: 400ms. Radius: 80px. This provides visual feedback that ties every interaction back to the ECG intro's sonar moment.
### Ambient Drift
All nodes in the hub view drift in micro-orbits: 2-3px movement radius, 8-12 second cycle, using sine-wave interpolation. The drift directions are randomized per node. This keeps the constellation alive without being distracting. The drift pauses during zoom transitions to prevent visual conflict.
---
## Navigation
### The Lens System
A floating toolbar anchored to the bottom-center of the viewport (above the connection toggle, if visible). Contains 3 lens buttons:
| Lens | Icon | Effect |
|---|---|---|
| **The Numbers** | Hash (#) | All nodes dim except those containing quantitative achievements. Amber-highlighted stat cards float above the dimmed constellation showing: 14.6M, 14,000, 220M, 2.6M, 200 hrs, 1M. Each card links to its source node. |
| **The Journey** | Clock / Timeline | Nodes rearrange from orbital positions into a horizontal chronological timeline. Spring animation. Leftmost = A-Levels (2009), rightmost = current role (2025). Nodes are spaced proportionally to duration. This is the traditional fallback view -- familiar and scannable. |
| **The Stack** | Layers | Nodes regroup by technical capability. Three vertical columns: "Clinical," "Technical," "Strategic." Within each column, relevant content from Experience, Skills, and Projects is aggregated. Shows Andy's capabilities cross-cut across all roles. |
Clicking any lens animates the constellation into the new arrangement. Clicking the same lens again returns to the default hub view. Only one lens can be active at a time.
**Lens transitions:** Nodes move to their new positions using spring physics (600ms). Content within nodes fades out during transition and fades back in once settled (200ms fade).
### Keyboard Navigation
- **Tab:** Cycles through nodes in logical order (Skills, Experience, Education, Projects, Contact)
- **Enter / Space:** Zooms into focused node
- **Escape:** Zooms out to hub view
- **Arrow keys:** When in hub view, moves focus between adjacent nodes (proximity-based adjacency)
- **L key:** Cycles through lenses (None --> Numbers --> Journey --> Stack --> None)
- **C key:** Toggles connection lines (after 3+ nodes visited)
### Focus Indicators
Keyboard-focused nodes receive a visible focus ring: 2px solid cyan (#00D4AA) with 4px offset. The focus ring pulses gently (opacity 0.7 --> 1.0, 1.5s cycle) to distinguish it from hover states.
---
## Responsive Strategy
### Desktop (> 1024px)
Full spatial constellation experience. All features enabled:
- Force-directed node layout with gravitational cursor interaction
- Click-to-zoom node expansion
- Drag to rearrange nodes
- Connection lines with hover tooltips
- Full lens system
- Keyboard navigation
### Tablet (768px - 1024px)
Simplified constellation:
- Fewer ambient particles (15-20 instead of 30-50)
- No gravitational cursor pull (touch interfaces lack persistent cursor position)
- Tap to zoom into nodes
- Detail views render as full-screen overlays (sliding up from bottom, 90vh height) rather than inline expansion
- Connection lines are shown as a static overlay rather than animated reveal
- Lens toolbar moves to top of screen as a horizontal pill selector
### Mobile (< 768px)
The constellation transforms into a **vertical card stack**:
- Each card represents one constellation node. Cards are stacked vertically with 16px gap.
- Each card shows: icon, section title, one-line preview (e.g., "Python, SQL, Power BI + 15 more skills")
- Tapping a card expands it to show full section content (accordion-style, one expanded at a time)
- The lens toolbar becomes a horizontal pill selector at top of screen, sticky on scroll
- "The Journey" lens on mobile presents a standard vertical timeline
- "The Numbers" lens shows a simple stat card grid (2 columns)
- "The Stack" lens shows tabbed category view
- Background: solid deep navy. No particles, no gradient (performance).
- Connection lines: not shown on mobile. Instead, a "Related" section at the bottom of each expanded card lists connected items as text links.
### Touch Interaction
- **Tap node / card:** Zoom in (desktop/tablet) or expand (mobile)
- **Pinch-to-zoom:** Not supported (avoids conflict with browser zoom). Zoom is click/tap only.
- **Swipe:** On mobile, swipe horizontally between lens views. Swipe down to collapse an expanded card.
- **Long-press:** Not used (avoids confusion with system long-press behaviors).
---
## Technical Implementation
### Force-Directed Layout
**Library:** `d3-force` (lightweight -- only the force simulation module, not all of D3). ~15KB gzipped.
**Configuration:**
```
forceSimulation(nodes)
.force('charge', forceManyBody().strength(-200))
.force('center', forceCenter(viewportWidth / 2, viewportHeight / 2))
.force('collision', forceCollide().radius(80))
.force('radial', forceRadial(orbitDistance, cx, cy).strength(0.3))
```
Nodes are initialized with target orbital positions. The simulation runs for ~100 ticks on mount to reach equilibrium, then continues running at low alpha for ambient drift.
**Performance:** The simulation runs on `requestAnimationFrame` but only when nodes are moving (alpha > 0.001). When the constellation is at rest, the simulation pauses entirely. On resize, the simulation restarts with updated center coordinates.
### Zoom Transitions
**Library:** Framer Motion `AnimatePresence` with `layoutId` for seamless zoom.
Each node has a `layoutId` matching its section key (e.g., `layoutId="skills"`). When the node expands, its `layout` animation triggers automatically. The detail content uses `AnimatePresence` for mount/unmount transitions.
```tsx
<motion.div layoutId={nodeId} layout="position" transition={{ type: "spring", stiffness: 120, damping: 14 }}>
{isExpanded ? <DetailView /> : <NodeIcon />}
</motion.div>
```
### Connection Lines
SVG `<path>` elements rendered in a fixed-position SVG overlay that spans the viewport. Paths are quadratic bezier curves between node center positions:
```
M startX startY Q controlX controlY endX endY
```
The control point is offset perpendicular to the line midpoint, creating a gentle arc. The offset direction alternates for adjacent lines to prevent overlap.
**Animation:** `stroke-dasharray` set to total path length. `stroke-dashoffset` animated from total length to 0 (line drawing effect). Duration: 600ms with `ease-out` timing.
### Star Particles
A single `<canvas>` element behind all content. 30-50 particles initialized with random positions and slow drift velocities. Rendered with `requestAnimationFrame`. Each particle is a 1-2px circle with 10-20% opacity.
The canvas pauses rendering when the tab is not visible (`document.visibilityState`). On mobile, the canvas is not created (particles disabled for performance).
### Detail Panel Scrolling
Zoomed-in node content that exceeds the viewport height uses `overflow-y: auto` with custom scrollbar styling (thin, amber-colored on WebKit browsers). The scroll container is the expanded node's inner content area, not the page body. `body` overflow is set to `hidden` when any node is expanded to prevent background scrolling.
### State Management
React `useState` for:
- `activeNode: string | null` -- which node is expanded (null = hub view)
- `activeLens: 'numbers' | 'journey' | 'stack' | null` -- current lens
- `visitedNodes: Set<string>` -- tracks which nodes have been viewed (for connection toggle threshold)
- `showConnections: boolean` -- connection lines visibility
No external state management library needed. State is simple and localized.
### Data Structure
```tsx
interface ConstellationNode {
id: string;
label: string;
icon: LucideIcon;
orbitDistance: number; // relative to center, 0-1
orbitAngle: number; // radians
glowColor: 'amber' | 'cyan';
content: React.ReactNode; // rendered when expanded
}
interface Connection {
from: string; // node id
to: string; // node id
strength: number; // 0-1, maps to line thickness
label: string; // tooltip text
}
```
---
## Accessibility
### Screen Reader Experience
The DOM order follows a logical reading sequence regardless of visual layout:
1. Skip-to-content link (hidden, keyboard-accessible)
2. Andy Charlwood -- name and role title
3. Navigation: lens buttons + node list
4. Skills section content
5. Experience section content
6. Education section content
7. Projects section content
8. Contact section content
The constellation visual is a progressive enhancement. Screen readers traverse the underlying DOM in document order, encountering all content as standard sections with headings.
### ARIA Attributes
- Each constellation node: `role="button"`, `aria-label="View [Section Name]"`, `aria-expanded="true|false"`
- Expanded node detail panel: `role="region"`, `aria-label="[Section Name] details"`
- Lens buttons: `role="radio"` within a `role="radiogroup"` with `aria-label="View mode"`
- Connection toggle: `aria-pressed="true|false"`, `aria-label="Show career connections"`
### Keyboard Navigation
Full keyboard support as detailed in the Navigation section. Tab order matches DOM order. Focus indicators are visible and high-contrast (cyan on dark navy exceeds WCAG AAA contrast).
### Motion Preferences
When `prefers-reduced-motion: reduce` is detected:
- Constellation renders in static positions (no ambient drift, no spring physics)
- Node expansion uses opacity fade (200ms) instead of layout animation
- No sonar pulses
- No connection line drawing animation (lines appear immediately)
- No gravitational cursor pull
- Star particles are static (no drift)
- Lens transitions use crossfade instead of spatial rearrangement
### Color Contrast
All text meets WCAG AA contrast against the dark background:
- `--text-primary` (#ECECF0) on `--bg-deep` (#0A0E1A): contrast ratio 14.2:1 (AAA)
- `--text-secondary` (#8B8FA3) on `--bg-deep` (#0A0E1A): contrast ratio 5.8:1 (AA)
- `--amber` (#D4874D) on `--bg-deep` (#0A0E1A): contrast ratio 5.1:1 (AA)
- `--cyan` (#00D4AA) on `--bg-deep` (#0A0E1A): contrast ratio 8.3:1 (AAA)
### First-Time Visitor Onboarding
On first visit (checked via `localStorage`), a brief animated tour plays:
1. A pulsing ring highlights the center name (0.5s)
2. An arrow animates from center to a node with tooltip: "Click a node to explore" (1s)
3. The tooltip fades, and the constellation becomes interactive (0.5s)
Total: 2 seconds. Dismissible by clicking anywhere. Does not replay on subsequent visits.
### The "Journey" Lens as Fallback
The Journey lens rearranges the constellation into a standard horizontal timeline -- the most familiar CV layout pattern. This serves as a cognitive fallback for visitors who find the spatial navigation confusing. It is always accessible from the lens toolbar and via the L keyboard shortcut.
---
## What Makes This Special
This is the most **distinctive** of all 6 designs. No other CV site navigates like this.
The constellation creates a mental map of Andy's career where everything is visible at once -- reducing cognitive load while increasing exploration curiosity. Visitors do not need to remember what is "below the fold" because nothing is below the fold. The entire career is laid out in space, available at a glance.
The **connection web** is the signature feature. It shows not just WHAT Andy has done but HOW it all connects. The Python skill node connects to the switching algorithm project, which connects to the 14,000 patients identified, which connects to the 2.6M savings figure, which connects to the 220M budget he manages. Career coherence -- the idea that every role and skill builds on the last -- is visualized as a literal knowledge graph.
The lens system adds intellectual depth. Three different lenses on the same data demonstrate analytical thinking -- the ability to view information from multiple angles. This is exactly what Andy does professionally: take the same prescribing dataset and extract different insights depending on the question being asked.
Finally, the ECG-to-constellation transition is narratively powerful. A single heartbeat line becomes a universe of interconnected points. One signal becomes many. This mirrors Andy's career trajectory: from individual clinical interactions (one pharmacist, one patient) to population-level analytics (one analyst, one million patients).
-724
View File
@@ -1,724 +0,0 @@
# Design 4: The Dosage
## Overview
The user controls how much information they see. A pharmaceutical dosage metaphor -- self-titrate your information intake. Combined with a Cmd+K command palette for power users. The most accessible, recruiter-friendly, and fastest-to-relevant-content of all 6 designs.
The core insight: most CVs and portfolios assume the visitor wants to see everything, in the order the author chose. This assumption wastes time. A hiring manager scanning 30 CVs wants key numbers in 5 seconds. A thorough reviewer wants the full picture. A curious peer wants to deep-dive into specific projects. These are three different users with three different "doses" of information needed.
The Dosage design lets each visitor self-prescribe. Every piece of content exists at three depth levels (headline, summary, detail), and the visitor controls which level they see. The pharmaceutical metaphor is not cosmetic -- it reflects Andy's background as a pharmacist and his professional understanding that the right amount of the right information at the right time is what matters.
Layered on top is a command palette (Cmd+K) borrowed from developer tools and productivity apps (Linear, Raycast, VS Code). This signals technical sophistication while providing a power-user shortcut to any piece of content.
---
## ECG Transition
**Starting point:** "ANDREW CHARLWOOD" is on screen in neon green (#00ff41) strokes on a black (#000) background. The ECG trace that drew it is still visible. The drawing head has stopped.
**Then:**
The neon green name begins a smooth **color shift**: green (#00ff41) transitions to teal (#0D7377) over 600ms. Simultaneously, the rough ECG-traced letterforms **morph** into clean Plus Jakarta Sans (later replaced by DM Sans in the final render) typography. The imprecise, hand-drawn quality of the ECG strokes straightens and refines -- serifs sharpen, curves smooth, letter spacing normalizes. This morphing happens over 1 second, overlapping with the color shift.
As the name refines, it **rises** from center-screen toward upper-third position (approximately 28vh from top). The movement follows `cubic-bezier(0.22, 0.68, 0, 1.00)` -- fast departure, gentle settle. Duration: 800ms.
Below where the name was positioned, a single horizontal line appears. This is the midline of the ECG trace -- the flatline that connected the letter strokes -- left behind as the name lifted away. The line transitions from neon green to teal (#0D7377) and **extends** smoothly to span the full viewport width. Duration: 600ms, starting 200ms after the name begins rising.
The black background brightens to warm white (#F8F6F3) during the name rise. The transition uses `ease-out` timing over 1 second.
Below the teal line, the subtitle "Deputy Head of Population Health & Data Analysis" fades in (300ms, 400ms delay after line extends). Then the prompt "What would you like to know?" fades in (300ms, 200ms delay after subtitle). Then the five choice buttons stagger in from below, 60ms apart, each with a subtle `translateY(12px)` to `translateY(0)` entrance.
The teal line persists as a permanent UI element throughout the entire experience -- a visual heartbeat-monitor flatline that doubles as a pharmaceutical Rx signature line. When the visitor clicks any choice button, this line **pulses once**: a brief flash of neon green (#00ff41) glow that travels along the line's length left-to-right in 300ms, then fades. This callback to the ECG origin happens on every major interaction, creating continuity.
**Duration:** ~1.5 seconds total. Deliberately calm.
**Color journey:** Black (#000) --> Warm White (#F8F6F3). ECG green (#00ff41) --> Teal (#0D7377). The warm white has a faint warm undertone (not clinical pure white) that creates an approachable, paper-like feel.
**The message:** "The dramatic part is over. Now it is about you."
---
## Visual System
### Color Palette
| Token | Value | Usage |
|---|---|---|
| `--bg-warm` | `#F8F6F3` | Primary background -- warm off-white |
| `--bg-cream` | `#F0EDE8` | Card surfaces, elevated elements |
| `--teal` | `#0D7377` | Primary accent -- links, interactive elements, Rx line |
| `--teal-light` | `rgba(13, 115, 119, 0.08)` | Hover backgrounds, subtle tints |
| `--teal-medium` | `rgba(13, 115, 119, 0.15)` | Active states, progress fills |
| `--amber` | `#D4874D` | Secondary accent -- highlights, warmth |
| `--amber-light` | `rgba(212, 135, 77, 0.1)` | Amber tinted backgrounds |
| `--coral` | `#E8735A` | CTA buttons, urgent emphasis |
| `--text-heading` | `#1A1A2E` | Dark headings |
| `--text-body` | `#3D3D56` | Body text |
| `--text-muted` | `#8B8B9E` | Labels, metadata, tertiary text |
| `--border` | `#E2DED8` | Warm gray borders, dividers |
| `--ecg-green` | `#00ff41` | ECG callback pulses only |
### Background Treatment
The primary background is warm off-white (#F8F6F3) -- deliberately NOT pure white. A faint **paper grain texture** at 2% opacity overlays the background, created via a subtle CSS noise pattern. This creates a tactile, printed-document quality without being heavy-handed.
```css
background-image: url("data:image/svg+xml,..."); /* tiny repeating noise SVG */
background-size: 200px 200px;
opacity: 0.02;
```
The grain is purely cosmetic and does not affect readability.
### Typography
| Role | Font | Weight | Size | Notes |
|---|---|---|---|---|
| Display (name) | DM Sans | 700 | `clamp(2.5rem, 5vw, 4rem)` | Geometric, slightly rounded, approachable |
| Section headings | DM Sans | 700 | `clamp(1.5rem, 3vw, 2rem)` | |
| Subheadings | DM Sans | 500 | 1.125rem (18px) | |
| Body text | Inter | 400-450 | 15px / 1.7 line-height | `font-feature-settings: 'cv01', 'cv02', 'ss03'` for refined character shapes |
| Labels / metadata | Inter | 500 | 13px, uppercase, 0.05em tracking | |
| Data / statistics | JetBrains Mono | 400 | 14px | Used for numbers, percentages, code-like content |
| Large statistics | JetBrains Mono | 700 | `clamp(2rem, 4vw, 3.5rem)` | The "big numbers" in The Numbers view |
**Type scale:** Modular ratio 1.25 (Major Third). Steps: 0.875rem, 1rem, 1.25rem, 1.5625rem, 1.953rem, 2.441rem, 3.052rem.
**Font loading:** DM Sans and Inter from Google Fonts with `display=swap`. JetBrains Mono with `display=optional` (acceptable fallback to system mono for data labels).
### Spacing System
- **Base unit:** 4px
- **Scale:** 4, 8, 12, 16, 24, 32, 48, 64, 80, 120px
- **Section spacing:** 120px between major sections
- **Card padding:** 24px (mobile: 16px)
- **Grid:** 12-column grid, content centered in 8 columns (max-width: 720px for text content, 960px for card grids)
- **Viewport padding:** 32px sides (tablet: 24px, mobile: 16px)
### Motion
| Property | Value | Usage |
|---|---|---|
| Primary easing | `cubic-bezier(0.22, 0.68, 0, 1.00)` | Fast start, gentle settle. All entrance animations. |
| Exit easing | `cubic-bezier(0.4, 0, 0.2, 1)` | Standard Material-style exit. |
| Micro-interaction duration | 150-200ms | Hover effects, button presses, color transitions |
| Content transition duration | 300-500ms max | View switches, panel openings |
| Hard limit | 500ms | No animation exceeds this. Respect the visitor's time. |
| Stagger delay | 60ms | Between siblings in a list (buttons, cards, stat items) |
| Rx line pulse | 300ms | Left-to-right green flash on major interactions |
### Material & Depth
Flat design with subtle depth. No heavy drop shadows.
- **Cards:** 1px solid `--border` (#E2DED8). Background `--bg-cream` (#F0EDE8). No border-radius greater than 12px.
- **Hover state:** Background lightens to white (#FFFFFF). Border transitions to `--teal-light`. Subtle `box-shadow: 0 2px 8px rgba(0,0,0,0.04)`.
- **Active/pressed:** Background shifts to `--teal-light`. Scale 0.98 (20ms spring).
- **Elevated elements** (command palette, tooltips): `box-shadow: 0 8px 30px rgba(0,0,0,0.08)`. Background white with 1px `--border`.
### Signature Visual: The Measure Bar
Every major statistic has a thin horizontal progress bar beneath it. This is the design's recurring visual motif.
- Height: 3px
- Background track: `--border` (#E2DED8)
- Fill: `--teal` (#0D7377)
- Fill width: proportional to the stat's magnitude relative to a contextual maximum
- Animation: fills from 0% to target width on IntersectionObserver trigger, using `cubic-bezier(0.22, 0.68, 0, 1.00)`, 800ms duration
- Stagger: 100ms between adjacent Measure Bars
Examples:
- "14.6M" efficiency programme: Measure Bar fills to 100% (it IS the maximum in context)
- "2.6M" savings: Measure Bar fills to ~18% (relative to 14.6M)
- "14,000" patients: full width in its own context group
- "200 hours" saved: Measure Bar fills to contextual proportion
The Measure Bar is a quiet, persistent design element that gives every number a physical weight. Numbers alone are abstract; a bar makes them visceral.
---
## Section-by-Section Design
### Hero / Landing Page
After the ECG transition completes, the visitor sees:
**Top section (above the Rx line):**
- Andy's name in DM Sans 700, `--text-heading` color, centered
- Role title: "Deputy Head of Population Health & Data Analysis" in Inter 400, `--text-muted`, centered, below name
**The Rx line:** Full-width horizontal line, 2px, `--teal`. Persistent throughout the experience.
**Below the Rx line:**
- Prompt: "What would you like to know?" in DM Sans 500, `--text-heading`, centered
- 5 choice buttons in a horizontal row (wrapping to 2 rows on mobile):
| Button | Label | Icon (Lucide) |
|---|---|---|
| 1 | The Numbers | Hash |
| 2 | The Journey | Clock |
| 3 | The Skills | Layers |
| 4 | The Impact | Zap |
| 5 | Everything | List |
**Button styling:**
- Pill-shaped: `border-radius: 999px`
- Border: 1px solid `--border`
- Background: `--bg-cream`
- Text: DM Sans 500, 14px, `--text-body`
- Icon: 16px, `--teal`, left of label
- Hover: background white, border `--teal-light`, icon `--amber`
- Active: background `--teal-light`, text `--teal`
The buttons stagger in from below (60ms apart) during the ECG transition.
### The Numbers View
Triggered by clicking "The Numbers" button. The button gains an active state (teal background, white text). The Rx line pulses green. Below the prompt area, content fades in:
**Layout:** A centered column of large statistics, each one a self-contained card.
Each stat card contains:
1. **The number:** JetBrains Mono 700, `clamp(2rem, 4vw, 3.5rem)`, `--text-heading`
2. **The context:** One line of Inter 400, 15px, `--text-body`
3. **The Measure Bar:** 3px tall, `--teal` fill, animated
4. **"Tell me more" link:** Inter 500, 13px, `--teal`, with ChevronRight icon. Clicking expands to the Summary depth.
**Statistics displayed:**
| Number | Context | Source |
|---|---|---|
| 14.6M | Efficiency programme identified through data analysis | Interim Head role |
| 14,000 | Patients identified by Python switching algorithm | Interim Head role |
| 220M | Prescribing budget managed with forecasting models | Deputy Head role |
| 2.6M | Annual savings from automated switching analysis | Interim Head role |
| 200+ hrs | Saved annually by Blueteq automation system | High-Cost Drugs role |
| ~1M | Revenue enabled by asthma screening process adopted nationally | Tesco role |
**Depth levels for each stat:**
- **Headline** (default): The number + one-line context + Measure Bar
- **Summary** (first "tell me more" click): 2-3 sentence expansion explaining methodology and impact. "Tell me more" changes to "Full detail."
- **Detail** (second click): Full bullet points from the relevant role, tools used, timeline. A "Collapse" link returns to Headline level.
### The Journey View
Triggered by clicking "The Journey" button. Content below the prompt:
**Layout:** A horizontal timeline running left-to-right across the full content width.
**Timeline structure:**
- Horizontal line: 2px, `--border`, full width
- Timeline dots: 12px circles at each role position
- Current role dot: filled `--teal`
- Past role dots: filled `--bg-cream` with 2px `--teal` border
**Role positions (left to right, spaced proportionally by date):**
1. Duty Pharmacy Manager (Aug 2016 - Nov 2017)
2. Pharmacy Manager (Nov 2017 - May 2022)
3. High-Cost Drugs & Interface Pharmacist (May 2022 - Jul 2024)
4. Deputy Head, Population Health & Data Analysis (Jul 2024 - Present)
5. Interim Head, Population Health & Data Analysis (May 2025 - Nov 2025)
Each dot has a label below: role title (DM Sans 500, 13px, `--text-body`). Organisation name appears on hover in `--text-muted`.
**Depth levels:**
- **Headline** (default): Timeline with role titles only. Compact. Scannable in 3 seconds.
- **Summary** (click a dot): A card expands below the timeline showing the role title, organisation, date range, and first 2 bullet points. Only one card open at a time (accordion).
- **Detail** (click "Full detail" in expanded card): All bullet points for that role appear. Tools/technologies mentioned are highlighted as inline teal badges.
**Employer era color-coding:**
- NHS ICB roles: timeline dots and cards have a teal left border
- Tesco roles: timeline dots and cards have an amber left border
### The Skills View
Triggered by clicking "The Skills" button.
**Layout:** Three category cards stacked vertically.
**Categories:**
1. **Technical** -- Python, SQL, Power BI, JS/TS, Data Analysis, Dashboard Dev, Algorithm Design, Data Pipelines
2. **Clinical** -- Medicines Optimisation, Pop. Health Analytics, NICE TA, Health Economics, Clinical Pathways, CD Assurance
3. **Strategic** -- Budget Mgmt, Stakeholder Engagement, Pharma Negotiation, Team Development
Each category card:
- Header: Category name in DM Sans 700, with count badge ("8 skills", "6 skills", "4 skills")
- Collapsed state: Header + top 3 skills shown as pill badges with proficiency percentages
- Expanded state (click header): All skills visible as a grid. Each skill shows:
- Name (DM Sans 500, 14px)
- Proficiency (JetBrains Mono 400, 13px, `--teal`)
- SVG circular gauge (64px diameter, `strokeDashoffset = circumference * (1 - level / 100)`, teal for Technical/Strategic, coral for Clinical)
- The gauge animates when revealed (1s ease-out with 80ms stagger between skills)
### The Impact View
Triggered by clicking "The Impact" button.
**Layout:** Project cards in a 2-column grid (single column on mobile).
**Projects:**
1. **PharMetrics**
- One-line: "Real-time medicines expenditure dashboard for NHS decision-makers"
- Outcome badge: "Live Project" in teal
- Link: medicines.charlwood.xyz
- Tech badges: Power BI, SQL
2. **Switching Algorithm**
- One-line: "Python algorithm identifying 14,000 patients for cost-effective alternatives"
- Outcome badge: "2.6M savings" in teal
- Stat with Measure Bar: "Compressed months of analysis into 3 days"
- Tech badges: Python, SQL
3. **Blueteq Generator**
- One-line: "Automated prior approval form creation for high-cost drugs"
- Outcome badge: "200+ hrs/year saved" in teal
- Stat: "70% reduction in required forms"
- Tech badges: Python, Automation
4. **Sankey Chart Tool**
- One-line: "Patient journey visualization for pathway compliance auditing"
- Tech badges: Python, Data Visualization
5. **Controlled Drug Monitor**
- One-line: "Population-scale opioid exposure tracking for patient safety"
- Tech badges: Python, SQL
Each card has three depth levels:
- **Headline** (default): Title + one-line description + outcome badge
- **Summary** (click): 2-3 sentence methodology description + tech badges
- **Detail** (click again): Full description from CV, related role context, connection to other projects
### Everything View
Triggered by clicking "Everything" button. This renders the complete CV in a traditional single-scroll layout:
1. Hero section with name, title, summary paragraph
2. Vitals row (key stats as cards with Measure Bars)
3. Skills section with all three categories expanded
4. Experience section as vertical timeline with all bullet points
5. Education section with MPharm and Mary Seacole cards + A-Level note
6. Projects section as card grid
7. Contact section
8. Footer with Rx line callback
This is the fallback for visitors who want a conventional CV experience. It is also the view that search engines and screen readers encounter (full content in DOM regardless of which button is clicked; the button views filter visibility, they do not remove content from DOM).
---
## Interactions & Micro-interactions
### Choice Button Selection
1. User clicks a choice button
2. The Rx line pulses: a neon green (#00ff41) glow travels left-to-right along the line (300ms, ease-out)
3. The clicked button transitions to active state (teal background, white text, 150ms)
4. Previously active button returns to default state (150ms)
5. Content below the prompt area crossfades: old content fades out (200ms), new content fades in from below with `translateY(12px)` (300ms, 60ms stagger for child elements)
### Depth Expansion
1. User clicks "Tell me more" or an expandable element
2. The element smoothly expands: `max-height` transition from current to target (300ms, `cubic-bezier(0.22, 0.68, 0, 1.00)`)
3. New content fades in during expansion (opacity 0 to 1, 200ms, 100ms delay)
4. The "Tell me more" text changes to "Full detail" (if going from Headline to Summary) or "Collapse" (if at Detail level)
5. Chevron icon rotates 90 degrees (150ms)
### Depth Collapse
1. User clicks "Collapse"
2. Content fades out (150ms)
3. Element contracts (300ms, matching expansion easing)
4. Returns to Headline depth. "Tell me more" reappears.
### Command Palette Open
1. User presses Cmd+K (or clicks the search icon in the side rail)
2. Background dims with a 40% black overlay (200ms fade)
3. Palette container slides down from top with subtle `translateY(-8px)` to `translateY(0)` (250ms, spring)
4. Input field auto-focuses. Cursor blinks.
5. Placeholder text: "Search skills, roles, projects, or actions..."
### Command Palette Search
1. User types. Results appear in real-time (fuzzy matching via fuse.js)
2. Results grouped by section: "Experience", "Skills", "Projects", "Actions"
3. Each result: icon (section-colored) + title + breadcrumb (e.g., "Experience > Deputy Head > Python algorithm")
4. Arrow keys navigate results. Active result has teal background highlight.
5. Enter selects: navigates to the relevant content, expanding it to Detail depth. The corresponding choice button activates.
6. Escape closes the palette (200ms fade + slide up)
### Command Palette Actions
Beyond content search, the palette surfaces actions:
- "Download CV as PDF" -- generates and downloads a formatted PDF
- "Email Andy" -- opens mailto:andy@charlwood.xyz
- "View PharMetrics" -- opens medicines.charlwood.xyz in new tab
- "LinkedIn" -- opens linkedin.com/in/andrewcharlwood in new tab
Actions appear in an "Actions" group at the bottom of results, marked with a subtle lightning bolt icon.
### Rx Line Pulse
Triggered on every major interaction (button click, depth change, command palette selection). The pulse is a neon green (#00ff41) glow that:
1. Appears at the left edge of the line
2. Travels rightward across the full viewport width (300ms, ease-out)
3. Fades from 60% opacity to 0% as it travels
4. The teal (#0D7377) base line is always visible -- the pulse is a highlight overlay
This is the design's heartbeat callback. It ties every interaction back to the ECG origin without being heavy-handed.
### Measure Bar Animation
1. IntersectionObserver detects the stat entering the viewport (threshold: 0.3)
2. The 3px bar fill animates from width 0% to target width
3. Duration: 800ms, easing: `cubic-bezier(0.22, 0.68, 0, 1.00)`
4. Stagger: 100ms between adjacent Measure Bars
5. Trigger-once: bars do not re-animate on subsequent views
---
## Navigation
### The Side Rail
A persistent minimal sidebar rail on the left edge of the viewport. Width: 48px. Background: transparent (does not occlude content).
**Contents (top to bottom):**
- Search icon (Lucide Search, 20px) -- triggers command palette
- Divider line (1px, 16px wide, `--border`)
- 5 section icons matching the choice buttons:
- Hash (The Numbers)
- Clock (The Journey)
- Layers (The Skills)
- Zap (The Impact)
- List (Everything)
- Spacer (flex-grow)
- Dose meter (bottom)
Each icon: 20px, `--text-muted` color. Active icon: `--teal`. Hover: `--amber`.
**"Seen" indicators:** After a visitor has viewed a section (clicked the corresponding button), a 4px teal dot appears below that section's icon. This creates a subtle completeness signal without being gamified.
Clicking any icon triggers the same behavior as clicking the corresponding choice button (Rx line pulse, content crossfade, button active state update).
### The Dose Meter
Positioned at the bottom of the side rail. A vertical bar, 4px wide, 48px tall.
- Background track: `--border`
- Fill: `--teal`, growing upward
- Fill height: proportional to the percentage of total content elements the visitor has viewed (seen section / total sections, plus expanded items / total expandable items)
No label, no percentage. Just a quiet fill. If the visitor has seen everything, the bar is full and gains a subtle amber glow.
**Disable:** A tiny settings gear icon (12px, `--text-dim`) appears on hover near the dose meter. Clicking it toggles the meter off (it fades out and the gear icon shows a strikethrough state). Preference stored in `localStorage`.
### Keyboard Shortcuts
| Key | Action |
|---|---|
| `Cmd+K` / `Ctrl+K` | Open command palette |
| `Escape` | Close command palette / collapse expanded content |
| `1-5` | Switch to view 1-5 (Numbers, Journey, Skills, Impact, Everything) |
| `Tab` | Navigate between interactive elements in DOM order |
| `Enter` / `Space` | Activate focused button / expand focused content |
| `?` | Show keyboard shortcut overlay (dismissible) |
---
## Responsive Strategy
### Desktop (> 1024px)
Full experience:
- Side rail visible on left edge
- Choice buttons in single horizontal row
- Content in 8-column centered grid (max-width 960px)
- Command palette as floating overlay (max-width 640px, centered)
- 2-column grid for project cards
- Horizontal timeline for Journey view
- Dose meter in side rail
### Tablet (768px - 1024px)
- Side rail collapses to a **bottom tab bar** (5 icons + search, horizontal, 56px height, anchored to bottom)
- Content fills full width minus 24px padding each side
- Choice buttons wrap to 2 rows if needed
- Command palette becomes full-screen overlay (slides up from bottom)
- Project cards in single column
- Horizontal timeline becomes scrollable (horizontal overflow with subtle scroll indicators)
- Dose meter moves to the right side of the bottom tab bar as a horizontal bar
### Mobile (< 768px)
- **Bottom tab bar:** 5 section icons + search icon. Same as tablet but more compact (48px height). Icons 18px. Active icon has teal dot below.
- **Choice buttons:** Stack vertically, full width minus 32px padding. Larger touch targets (48px height minimum).
- **Content:** Single column, 16px padding.
- **Command palette:** Full-screen overlay. Input at top. Results scrollable below.
- **The Journey timeline:** Converts from horizontal to **vertical** timeline. Roles stack vertically with timeline line on the left. More natural for vertical scrolling.
- **Project cards:** Single column, full width.
- **Skill gauges:** Grid of 2 columns instead of 3.
- **Dose meter:** Hidden on mobile (the bottom tab bar's "seen" dots provide equivalent information).
- **Rx line:** Still visible, but at reduced width (viewport width minus 32px, centered). Pulse animation still fires.
- **Depth expansion:** Touch-friendly. "Tell me more" links have 44px minimum touch target. Expansion uses the full screen width.
The progressive disclosure mechanic is **inherently mobile-friendly** because it shows less content by default. Mobile users benefit most from the dosage model -- they are the most likely to want just "The Numbers" rather than scrolling through everything.
---
## Technical Implementation
### Choice Button State Management
```tsx
type ViewMode = 'numbers' | 'journey' | 'skills' | 'impact' | 'everything';
const [activeView, setActiveView] = useState<ViewMode | null>(null);
const [visitedViews, setVisitedViews] = useState<Set<ViewMode>>(new Set());
```
Switching views triggers `AnimatePresence` for crossfade transitions. The DOM always contains all content (for SEO/accessibility); views are toggled via `display` or `visibility` with animated wrappers.
### Three-Depth System
A reusable `DepthContent` component manages the three levels:
```tsx
interface DepthContentProps {
headline: React.ReactNode;
summary: React.ReactNode;
detail: React.ReactNode;
id: string; // unique identifier for dose tracking
}
const DepthContent: React.FC<DepthContentProps> = ({ headline, summary, detail, id }) => {
const [depth, setDepth] = useState<1 | 2 | 3>(1);
const { trackView } = useDoseMeter();
useEffect(() => {
trackView(id, depth);
}, [depth]);
return (
<div>
{headline}
<AnimatePresence mode="wait">
{depth >= 2 && <motion.div key="summary" ...>{summary}</motion.div>}
{depth >= 3 && <motion.div key="detail" ...>{detail}</motion.div>}
</AnimatePresence>
<DepthToggle currentDepth={depth} onToggle={setDepth} />
</div>
);
};
```
### Command Palette
**Library:** Headless UI `Combobox` for the input + listbox pattern. `fuse.js` for fuzzy search (~6KB gzipped).
**Search index:** Built at app initialization from all CV content. Each searchable item has:
- `title`: display name
- `section`: which view it belongs to
- `content`: searchable text (role descriptions, skill names, project details)
- `action`: what happens when selected (navigate to view, expand to depth, open URL)
```tsx
const searchIndex = new Fuse(allContent, {
keys: ['title', 'content'],
threshold: 0.4,
includeScore: true,
});
```
Results are grouped by section and capped at 8 results per group.
### Side Rail Active Tracking
The side rail uses the `useActiveSection` hook pattern:
```tsx
const useActiveView = () => {
const [activeView, setActiveView] = useState<ViewMode | null>(null);
// Tracks which button was last clicked
// Side rail icons reflect this state
return { activeView, setActiveView };
};
```
For the "Everything" view, IntersectionObserver tracks which section is in the viewport and updates the side rail's active icon accordingly.
### Dose Meter
A custom hook tracks content exploration:
```tsx
const useDoseMeter = () => {
const [viewedItems, setViewedItems] = useState<Map<string, number>>(new Map());
// key: content item ID, value: max depth viewed (1, 2, or 3)
const totalItems = TOTAL_CONTENT_ITEMS; // constant
const totalDepthPoints = totalItems * 3; // max possible
const currentPoints = Array.from(viewedItems.values()).reduce((sum, d) => sum + d, 0);
const percentage = currentPoints / totalDepthPoints;
return { percentage, trackView, viewedItems };
};
```
The meter fill is a CSS custom property (`--dose-fill`) animated via transition on the bar element.
### Rx Line Pulse
The pulse is a CSS pseudo-element on the line container:
```css
.rx-line::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg, transparent 0%, #00ff41 50%, transparent 100%);
opacity: 0;
transform: translateX(-100%);
}
.rx-line.pulsing::after {
animation: rxPulse 300ms ease-out forwards;
}
@keyframes rxPulse {
0% { opacity: 0.6; transform: translateX(-100%); }
100% { opacity: 0; transform: translateX(100%); }
}
```
The `.pulsing` class is added via React state and removed after the animation completes (300ms timeout).
### Measure Bar Animation
Each Measure Bar is a simple div with CSS transition:
```css
.measure-bar-fill {
height: 3px;
width: 0%;
background: var(--teal);
transition: width 800ms cubic-bezier(0.22, 0.68, 0, 1.00);
}
.measure-bar-fill.visible {
width: var(--target-width);
}
```
The `--target-width` is set via inline style from the data. The `.visible` class is toggled by IntersectionObserver (trigger-once).
### Performance Budget
- **Fonts:** DM Sans (700, 500) + Inter (400, 450, 500) + JetBrains Mono (400, 700) = ~120KB total
- **fuse.js:** ~6KB gzipped
- **Framer Motion:** tree-shaken to AnimatePresence + motion div = ~30KB gzipped
- **Headless UI Combobox:** ~8KB gzipped
- **Total JS bundle (above framework):** ~44KB gzipped
- **No canvas rendering.** All visuals are DOM/CSS. This is the lightest design of all 6.
---
## Accessibility
This is the **most accessible** of all 6 designs.
### Full Content Always in DOM
Regardless of which choice button is active, all CV content exists in the DOM in logical order. The view buttons toggle `visibility` and `aria-hidden`, not `display: none` or DOM removal. This means:
- Search engines index the full CV content
- Screen readers can traverse all content
- The "Everything" button simply makes everything visible -- it does not load additional content
### Progressive Disclosure Patterns
All expand/collapse interactions use standard WAI-ARIA patterns:
- Expandable items: `aria-expanded="true|false"` on the trigger
- Content panels: `aria-hidden` mirrors expanded state
- Role: `aria-controls` links trigger to its content panel
- State change announced: trigger's `aria-expanded` update is announced by screen readers
### Command Palette
- Fully keyboard navigable: arrow keys, Enter, Escape
- `role="combobox"` with `aria-haspopup="listbox"`
- Results: `role="listbox"` with `role="option"` children
- `aria-activedescendant` tracks the currently highlighted result
- `aria-label="Search CV content and actions"`
### Side Rail
- `role="navigation"`, `aria-label="Section navigation"`
- Each icon: `role="button"`, `aria-label="View [section name]"`, `aria-pressed="true|false"`
- Dose meter: `role="progressbar"`, `aria-valuenow`, `aria-valuemin="0"`, `aria-valuemax="100"`, `aria-label="Content exploration progress"`
### Focus Management
- When a choice button is clicked, focus moves to the first content element in the new view
- When the command palette opens, focus moves to the search input
- When the command palette closes, focus returns to the element that opened it
- When content expands, focus moves to the newly revealed content
- Skip-to-content link is the first focusable element
### Motion Preferences
When `prefers-reduced-motion: reduce` is detected:
- Measure Bars show their final width immediately (no animation)
- No Rx line pulse animation
- View transitions use instant swap instead of crossfade
- Depth expansions use instant show/hide instead of animated expansion
- Choice button stagger is removed (all appear simultaneously)
- The ECG transition morph is simplified to a crossfade (green name fades out, clean name fades in)
### Color Contrast
All text meets WCAG AA on the warm white background:
- `--text-heading` (#1A1A2E) on `--bg-warm` (#F8F6F3): contrast ratio 14.8:1 (AAA)
- `--text-body` (#3D3D56) on `--bg-warm` (#F8F6F3): contrast ratio 8.2:1 (AAA)
- `--text-muted` (#8B8B9E) on `--bg-warm` (#F8F6F3): contrast ratio 3.5:1 (AA for large text)
- `--teal` (#0D7377) on `--bg-warm` (#F8F6F3): contrast ratio 5.4:1 (AA)
- `--coral` (#E8735A) on `--bg-warm` (#F8F6F3): contrast ratio 3.1:1 (AA for large text; used only for CTA buttons with white text overlay)
Button text contrast:
- White (#FFFFFF) on `--teal` (#0D7377): contrast ratio 5.4:1 (AA)
- White (#FFFFFF) on `--coral` (#E8735A): contrast ratio 3.3:1 (AA for large text, 18px+)
### Touch Targets
All interactive elements have minimum 44px touch targets on mobile:
- Choice buttons: 48px height
- "Tell me more" links: 44px tap area (padded beyond visible text)
- Side rail / bottom tab icons: 44px tap area
- Command palette results: 48px row height
---
## What Makes This Special
The Dosage respects the visitor's time more than any other design. It answers the question every CV visitor has but never gets to ask: "What do you want to know?"
A busy recruiter clicks "The Numbers" and sees Andy's quantitative impact in 5 seconds flat. A thorough hiring manager clicks "Everything" and reads the full CV. A curious peer clicks "The Impact" and deep-dives into the switching algorithm project through three depth levels. A power user hits Cmd+K and searches for "Python" to find every mention across all sections instantly.
The pharmaceutical dosage metaphor is elegant without being heavy-handed. It is not costume design -- it is a genuine UX pattern. The concept of dose-response (the right amount of the right thing at the right time) is literally how pharmacists think, and it is literally how good information architecture should work. Andy's professional worldview IS the site's UX philosophy.
The Rx line -- the persistent teal horizontal line with its green pulse callback -- is the design's signature. It is simultaneously:
- A remnant of the ECG animation (narrative continuity)
- A pharmaceutical prescription line (career metaphor)
- A progress indicator (interaction feedback)
- A visual anchor (layout stability)
One element, four meanings. That is efficient design.
The command palette signals technical sophistication to the right audience (developers, tech-adjacent roles) without alienating non-technical visitors (it is optional, triggered only by keyboard shortcut or an unobtrusive search icon). It says: "Andy knows how power users think, because he builds tools for them."
And the dose meter -- that quiet little bar in the corner -- does something subtle and important. It tells the visitor: "There is more here if you want it." It creates gentle curiosity without pressure. It makes thoroughness feel rewarding rather than obligatory. Most CV sites give you everything and hope you read it. This one gives you control and trusts you to find what matters.
-851
View File
@@ -1,851 +0,0 @@
# Design 5: The Depth Stack
> Content exists at different depths. The surface shows the overview; clicking reveals progressively deeper layers. Push/pop navigation like iOS, but applied to a CV. The most mature, executive-grade design of all six. Luxury in restraint.
---
## Overview
The Depth Stack treats Andy's CV as a layered document rather than a scrolling page. The surface layer presents a spacious, editorial overview with headline information. Each section can be "pushed into" to reveal progressively richer detail -- role summaries become full achievement breakdowns, skill categories expand into proficiency grids, project titles open into case studies.
This z-axis navigation model borrows from iOS push/pop transitions and applies it to career storytelling. The result feels immediately familiar on mobile (it maps to native navigation patterns) and strikingly distinctive on desktop (where the visible stack edges create a sense of explorable depth).
The visual language is Refined Editorial: Fraunces serif headings on pure white, copper accents threading through every section, generous whitespace that says "my work speaks for itself." Every design decision communicates seniority and substance -- someone managing a nine-figure budget should have a site that feels commensurate.
**Andy reads as:** Senior executive with depth of experience worth exploring.
---
## ECG Transition
Starting frame: Andy's name is on screen, neon green (`#00FF41`), on a black background. The ECG heartbeat has completed. The name glows.
### Beat 1: The Fermata (400ms)
Nothing happens. The name sits, glowing. This pause is deliberate -- a held breath, a fermata in music. The viewer has been watching fast-paced animation for approximately 10 seconds. The stillness is the first signal that this design values a different tempo. It says: "slow down, pay attention differently now."
### Beat 2: The Color Drain (800ms)
The neon green begins to drain from the letters, like ink being absorbed by paper. The color shifts through a desaturated green-gray, then through a warm neutral, and arrives at copper (`#B87333`). The transition is unhurried -- 800ms for a color shift is luxuriously slow in web animation terms. The glow disappears entirely. What was electric and luminous is now matte and material. The name looks *engraved* rather than projected.
Color keyframes:
```
0ms: #00FF41 (neon green, full glow, blur radius 8px)
200ms: #66CC77 (desaturated green, glow dimming, blur 4px)
400ms: #99AA88 (green-gray, glow gone, blur 0)
600ms: #B89977 (warm neutral, matte)
800ms: #B87333 (copper, fully matte, no glow)
```
### Beat 3: The Copper Thread Extends (600ms, overlapping)
Starting at the 600ms mark of Beat 2 (so the line appears as the name reaches its warm neutral phase), a single horizontal line -- thin, copper, 1.5px -- extends from the left edge of the name toward both viewport margins simultaneously. It moves at a measured pace, reaching full viewport width in approximately 600ms. This is the birth of the Copper Thread, the site's visual signature. The line passes through the name's baseline, anchoring it.
The line draws using a CSS `scaleX` transform from 0 to 1, centered on the name's left edge, eased with `cubic-bezier(0.25, 0.1, 0.25, 1)`. The line is a real DOM element (`<div>`) positioned to match the canvas baseline, creating the handoff point.
### Beat 4: The Curtain Rise (1000ms)
The white (`#FFFFFF`) enters not as a uniform fade but as a curtain rise: the lower portion of the viewport begins turning white, with the boundary rising smoothly upward. The boundary between black above and white below is a soft gradient (40px of blending, not a hard edge).
Implementation: a CSS `linear-gradient` animated via CSS custom properties or `requestAnimationFrame`:
```css
background: linear-gradient(
to top,
#FFFFFF var(--curtain-progress),
#000000 calc(var(--curtain-progress) + 40px)
);
```
The copper line remains stationary at its position as the white rises past it. Below the copper line, on the now-white background, the hero subtitle and intro text begin fading in via pure opacity (no translation, no movement -- just materialization). Above the copper line, still against black, Andy's name in copper holds steady.
When the white boundary reaches the name, the remaining black dissolves over 400ms. Andy's name transitions from copper to the primary text color -- deep navy (`#1A2B4A`) -- as the background behind it turns white. The canvas hands off to the DOM: the Fraunces heading element appears at matched coordinates, the canvas fades out. The name may drift subtly upward into its final hero position, but only 20-30px -- almost imperceptible.
### Final State
The page is fully white with the copper thread line. Below it, content is already partially visible. Above it, Andy's name in Fraunces serif sits with authority. The editorial layout has begun. The breadcrumb bar fades in at the top over 300ms.
**Total transition duration: approximately 2400ms.** Deliberately the slowest of all six designs. But it never feels slow because every beat has purpose and the viewer is watching something transform, not waiting for something to load.
**Emotional arc:** Electric --> still --> refined --> authoritative. The animation's raw energy is distilled into the most minimal design element possible (one line, one color). Less is more, stated as literal visual principle.
### Reduced Motion Fallback
If `prefers-reduced-motion: reduce` is set, the entire transition collapses to a simple 400ms opacity crossfade: black background fades to white, neon green name fades to navy Fraunces heading. The copper thread line appears immediately at full width. No curtain rise, no color drain, no drift. The creative transition is an enhancement, not a requirement.
---
## Visual System
### Color Palette
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Pure white | `#FFFFFF` | Page background, primary layer surfaces |
| Surface | Cool light gray | `#F5F5F7` | Recessed areas, secondary layer backgrounds, detail sheet backgrounds |
| Primary text | True black | `#111111` | Body text, maximum authority and contrast |
| Secondary text | Cool gray | `#6E6E73` | Metadata, dates, labels, breadcrumb inactive segments |
| Primary accent | Deep navy | `#1A2B4A` | Headings (Fraunces), nav active state, primary interactive elements |
| Secondary accent | Copper/bronze | `#B87333` | The Copper Thread, achievement callout borders, link underlines, hover states, key numerals |
| Tertiary | Sage green | `#7A9E7E` | Healthcare context nods -- used very sparingly (1-2 instances per viewport maximum). NHS role indicators, health-related skill tags |
| Highlight | Pale blue | `#E8F0FE` | Text selection color, inline emphasis backgrounds, breadcrumb hover |
| Border | Light cool gray | `#D2D2D7` | Structural dividers, card edges (non-copper) |
| Layer shadow | Warm black | `rgba(26, 43, 74, 0.08)` | Stack edge shadows only (the one exception to the "no shadows" rule) |
**Color psychology:** Navy and copper together read as institutional excellence -- think university crests, financial institutions, executive stationery. This palette says "I am senior, accomplished, and comfortable in my authority." The sage green whispers "healthcare" without shouting it. The warm off-black shadow color ensures even the stack-depth shadows feel intentional rather than default.
**Color application rules:**
- Copper appears in only three contexts: the Thread lines, achievement border accents, and link/hover states. Never as backgrounds. Never as large areas of fill.
- Sage green is reserved for healthcare-specific callouts. If a section has no clinical relevance, sage green does not appear.
- Navy is used exclusively for headings and primary interactive elements. Body text is true black, not navy.
### Typography System
**Heading typeface: Fraunces** (Google Fonts, variable font)
- Optical size axis (`opsz`): 9-144. At display sizes (48px+), the letterforms become more graceful with higher stroke contrast. At text sizes, they simplify for readability.
- Weight axis (`wght`): 600 for section headings, 700-800 for the hero name.
- `font-feature-settings: 'ss01'` for the alternate glyph set (softer terminals).
- This is NOT a newspaper serif. Fraunces has warmth, personality, and a slight quirkiness in its soft serifs that prevents stuffiness. It's distinctive without being heavy.
**Body typeface: Plus Jakarta Sans** (Google Fonts)
- Weights: 400 (body), 500 (emphasis/labels), 600 (bold body, card titles).
- Slightly rounded terminals give it warmth that pairs well with Fraunces without competing.
- Alternative: Source Sans 3 for a more neutral, technical feel.
**Monospace typeface: Source Code Pro** (Google Fonts)
- Weight: 400 only.
- Used sparingly -- key statistics, dates in the timeline, budget figures. Never for running text.
- The restraint in mono usage distinguishes this from more technical-feeling designs.
**Type Scale (modular ratio 1.333 -- Perfect Fourth):**
```
Display: clamp(3rem, 6vw, 5rem) -- Hero name in Fraunces 800
H1: clamp(2.25rem, 4vw, 3.375rem) -- Section titles in Fraunces 600
H2: clamp(1.5rem, 2.5vw, 2.25rem) -- Subsection titles in Fraunces 600
H3: 1.25rem -- Card/item titles in Plus Jakarta Sans 600
Body: 1.0625rem (17px) -- Base reading size, Plus Jakarta Sans 400
Body-lg: 1.1875rem (19px) -- Pull quotes, lead paragraphs
Small: 0.875rem (14px) -- Metadata, dates, labels
Mono: 0.875rem (14px) -- Statistics, budget figures
```
**Line heights:**
```
Display/H1: 1.1 (tight)
H2: 1.2
H3: 1.3
Body: 1.65 (generous for reading comfort at 680px column width)
Small: 1.5
```
**Letter spacing:**
```
Display: -0.02em (tightened for visual cohesion at large sizes)
H1: -0.015em
H2-Body: 0 (default)
Small/Meta: 0.01em (slightly open for legibility at small sizes)
Mono: 0.02em (open for numeral clarity)
```
**Weight philosophy:** Only three weights visible at any given time in any given viewport. Hierarchy comes through typeface contrast (Fraunces vs Plus Jakarta Sans), size, and color -- not through bold proliferation. Body text stays at 400. The contrast between ornate Fraunces headings and clean Plus Jakarta Sans body text creates sophisticated tension that carries the design.
### Spacing and Layout Rhythm
**Base unit:** 8px. All spacing is multiples of 8.
**Section spacing:** 160px (20 base units) between major sections. This is the most generous spacing of all six designs. The whitespace is a design element, not wasted space. It signals: "there is no hurry here."
**Primary content column:** Single column, max-width 680px -- the typographically optimal reading width for 17px body text. This creates a strong editorial centerline. Content never spreads to fill wide viewports; it holds its narrow column with confidence.
**Pull quote / stat breakouts:** Key achievements and large statistics can break out to 800-900px width, creating typographic moments that punctuate the rhythm. These are the only elements that exceed the 680px column.
**Horizontal rules:** Thin 1px lines in `#D2D2D7` between subsections within a layer. Classic editorial device. On layer transitions, the copper thread line replaces these at the section boundary.
**Card internal spacing:**
```
Card padding: 32px (4 units)
Card gap: 24px (3 units)
Content group gap: 16px (2 units)
Related item gap: 8px (1 unit)
```
**Vertical rhythm within a section:**
```
Section title to first content: 48px
Between content groups: 32px
Between items within a group: 16px
Stat number to stat label: 8px
```
### Motion Design Language
**Primary easing:** `cubic-bezier(0.25, 0.1, 0.25, 1)` -- close to CSS `ease`, but slightly more gentle on the deceleration. Nothing about this design should feel urgent or flashy.
**Layer transition easing:** `cubic-bezier(0.32, 0.72, 0, 1.05)` -- a slight overshoot (1.05) on layer push creates a subtle spring feel that adds physicality without being playful. Duration: 300ms.
**Duration philosophy:**
```
Micro-interactions (hover, focus): 200ms
Content reveals (opacity): 600-800ms
Layer push/pop: 300ms
Detail sheet enter: 350ms
Detail sheet exit: 250ms (exits are always faster than enters)
Copper thread line draw: 400ms per section
Stagger between items: 80ms
```
**What moves:**
- Layer transitions: translateX + scale + blur (the z-axis push/pop).
- Content reveals: Pure opacity fade. No translateY, no translateX, no scale. Just opacity 0 to 1 over 600ms. This design *trusts its content* to be interesting without needing to slide into frame.
- The copper thread line: draws left-to-right via `scaleX` when a new section enters.
- Link underlines: draw left-to-right on hover.
- Large statistics: static. No counting animations. The number "14,000" is more powerful when it appears fully formed than when it counts up from zero.
**What does NOT move:**
- Text once revealed. No parallax. No scroll-linked animations.
- Navigation elements. The breadcrumb updates its text, but doesn't animate position.
- Images (if any). They appear via opacity fade and stay put.
- The page itself. No scroll hijacking, no momentum effects.
**Scroll reveals:** Content within a layer fades in when it enters the viewport (IntersectionObserver at 15% threshold). Trigger once -- never re-animate on scroll back. Stagger delay: 80ms between sibling elements. This is slower than other designs (which use 40-60ms) because the editorial pacing rewards patience.
### Material and Texture
**Primary approach: Pure flat.** No box shadows on cards. No gradients. No glassmorphism. No neumorphism. No blur effects on static elements. Depth comes entirely from typography scale, spacing, and the z-axis layer system.
**The one shadow exception:** Stack edge shadows. When layers are pushed back, the background layer's right edge casts a subtle shadow (`box-shadow: -4px 0 16px rgba(26, 43, 74, 0.08)`) to create the illusion of physical stacking. This is the only shadow in the entire design. Its rarity makes it meaningful.
**One texture element:** A very subtle halftone dot pattern at 1.5% opacity applied to `#F5F5F7` surface areas (detail sheets, secondary panels). This nods to print editorial heritage -- the kind of texture you'd see at 10x magnification on a high-quality magazine page. It's imperceptible consciously but adds tactile warmth subliminally.
Implementation:
```css
.surface-texture {
background-image: radial-gradient(circle, #111111 0.5px, transparent 0.5px);
background-size: 12px 12px;
opacity: 0.015;
}
```
**Photography treatment:** If Andy adds a headshot or project screenshots, they should be desaturated to 60-70% and given a subtle duotone wash (navy + copper). No full-color photos breaking the palette. This maintains the editorial cohesion.
### The Copper Thread (Visual Signature)
The Copper Thread is a 1.5px horizontal line in `#B87333` that appears as a consistent visual motif:
1. **Section dividers:** At the top of each major section, the copper line runs the full width of the content column (680px, or breakout width if applicable). It draws itself left-to-right when the section enters the viewport, taking 400ms.
2. **Achievement callout borders:** Key achievements (stats, awards, notable outcomes) have a 2px copper left-border, creating a pull-quote-like emphasis within the flow.
3. **Link underlines:** Interactive text links show a copper underline that draws left-to-right on hover (200ms `scaleX` transition). The underline is 1.5px, matching the thread weight.
4. **Breadcrumb separator:** The `/` in the breadcrumb path is rendered in copper, visually connecting the navigation to the design signature.
**Rules:**
- The copper line is always 1.5px. Never thicker, never thinner.
- It appears only in the horizontal orientation (never vertical, except as the achievement left-border).
- Its color is always `#B87333`. Never lighter, never darker, never transparent.
- This consistency is the point. One color, one weight, used everywhere -- it becomes the site's visual DNA.
---
## The Z-Axis Navigation Model
### Layer Architecture
Content exists at three depth levels:
| Level | Name | Contains | How to reach | How to exit |
|-------|------|----------|-------------|-------------|
| 0 | Overview | Hero, section summaries, headline stats | Default state / breadcrumb root | N/A (base layer) |
| 1 | Section | Full section content (roles, skills, projects) | Click section from Overview | Back button, Escape, swipe right, breadcrumb |
| 2 | Detail | Deep content (role achievements, project case study, skill breakdown) | Click item from Section layer | Back button, Escape, swipe right, drag-dismiss (sheets), breadcrumb |
### Push Transition (Entering Deeper)
When the user clicks a section or item to go deeper:
1. The current layer scales to 95% and shifts left 20px (`transform: scale(0.95) translateX(-20px)`).
2. A 4px blur is applied to the receding layer (`filter: blur(4px)`).
3. The receding layer's opacity reduces to 0.4.
4. Simultaneously, the new layer slides in from the right edge of the viewport (`translateX(100%) --> translateX(0)`).
5. The new layer's content fades in via opacity as it arrives.
Easing: `cubic-bezier(0.32, 0.72, 0, 1.05)` (slight spring overshoot).
Duration: 300ms.
The receding layer remains partially visible as a "stack edge" on the left side -- the user can see they're one level deeper.
### Pop Transition (Going Back)
Triggered by: browser back button, Escape key, swipe right (mobile), or clicking a breadcrumb ancestor.
1. The current (top) layer slides out to the right (`translateX(0) --> translateX(100%)`).
2. Simultaneously, the background layer scales back up to 100%, shifts back to center, deblurs, and restores full opacity.
3. The background layer's scroll position is preserved -- it returns exactly where the user left it.
Easing: `cubic-bezier(0.32, 0.72, 0, 1)` (no overshoot on pop -- it should feel like settling back, not bouncing).
Duration: 250ms (exits are faster than enters).
### Detail Sheets (Level 2 Alternative)
The deepest level of content (project case studies, detailed role descriptions, full skill breakdowns) can also be presented as bottom sheets rather than full push layers. This is preferred for content that is a "deep dive" rather than a lateral navigation.
**Sheet enter:** Slides up from the bottom of the viewport, covering 85% of viewport height. Background darkens to `rgba(0, 0, 0, 0.08)` -- barely perceptible, just enough to establish the overlay. Duration: 350ms, eased with `cubic-bezier(0.32, 0.72, 0, 1)`.
**Sheet dismiss:** Drag downward past 30% of sheet height to dismiss (with momentum -- a fast flick also dismisses). Or press Escape. Or click the darkened background. The sheet slides back down, background un-darkens. Duration: 250ms.
**Sheet styling:** `#F5F5F7` background (the surface color), `border-radius: 16px 16px 0 0` on top corners. A small drag handle indicator (32px wide, 4px tall, `#D2D2D7`, `border-radius: 2px`) centered at the top. Content inside follows the same 680px column and typography rules.
### Stacked Edges (Visual Depth Cue)
When the user is at Level 1 or Level 2, the background layers create visible "stack edges" on the left side of the viewport:
- Level 1: The Overview layer is visible as a 20px sliver on the left, slightly blurred and dimmed.
- Level 2: Both the Overview and Section layers are visible as stacked slivers (Overview at ~12px peek, Section at ~20px peek), creating a visual "deck" effect.
The stack edges cast the design's only shadows: `box-shadow: -4px 0 16px rgba(26, 43, 74, 0.08)`. This subtle depth cue tells the user "there is content behind this that you can return to."
---
## Breadcrumb Navigation
### Structure
A persistent top bar, fixed to the viewport top, `height: 56px`, background `#FFFFFF` with a 1px bottom border in `#D2D2D7`. Contains:
**Left side:** Site title -- "Andy Charlwood" in Plus Jakarta Sans 500, `#1A2B4A`. Always visible. Clicking returns to the Overview (Level 0), popping all layers.
**Right side:** Breadcrumb trail, updating per depth level:
```
Level 0: (no breadcrumb -- just the name)
Level 1: Andy Charlwood / Experience
Level 2: Andy Charlwood / Experience / NHS Norfolk & Waveney ICB
```
The `/` separator is rendered in copper (`#B87333`), connecting the breadcrumb to the Copper Thread signature.
Inactive breadcrumb segments are in `#6E6E73` (secondary text color). The current (active) segment is in `#1A2B4A` (primary navy). Hovering an inactive segment shows the pale blue highlight (`#E8F0FE`) background and a copper underline draws in.
Clicking any breadcrumb segment pops back to that level. If clicking "Experience" from Level 2, the detail layer pops and the user returns to the Experience section layer.
### Section Picker
Below the breadcrumb bar, a horizontal row of section labels acts as the primary navigation between sections at Level 1. Visible only when at Level 0 or Level 1.
```
Overview | Experience | Skills | Education | Projects | Contact
```
Labels in Plus Jakarta Sans 400, `#6E6E73`. Active section in `#1A2B4A` with a copper underline (2px, drawn left-to-right on activation). Horizontal scroll on mobile with fade-out indicators at edges.
Clicking a section from the Overview pushes to that section (Level 1). Clicking a different section while already at Level 1 does a lateral slide (current section exits left, new section enters from right, 250ms).
---
## Section-by-Section Design
### Overview (Level 0 -- Base Layer)
The landing state after the ECG transition completes. Maximum whitespace, minimum content. This layer exists to intrigue, not to inform exhaustively.
**Layout:**
```
[Breadcrumb bar - name only, no trail]
[Section picker - horizontal labels]
[160px spacing]
Andy Charlwood
Deputy Head of Population Health
& Data Analysis
[48px spacing]
NHS Norfolk & Waveney ICB
[80px spacing]
-------- copper thread line --------
[48px spacing]
[Stat] [Stat] [Stat]
14,000 GBP220M GBP2.6M
patients budget savings
identified managed annual
[80px spacing]
A pharmacist turned data analyst who
transforms healthcare operations through
Python-powered intelligence.
[160px spacing]
[Section cards - minimal, clickable]
Experience > Skills > Education >
Projects > Contact >
```
**Hero name:** Fraunces 800, navy `#1A2B4A`, `clamp(3rem, 6vw, 5rem)`. This is the name that transitioned from the ECG canvas.
**Title:** Plus Jakarta Sans 400, `#6E6E73`, `1.25rem`. Understated.
**Headline stats:** Three key numbers in Source Code Pro 400, copper `#B87333`, `clamp(2rem, 4vw, 3rem)`. Labels beneath in Plus Jakarta Sans 400, `#6E6E73`, `0.875rem`. Stats are separated by 48px and centered as a row. No animated counting -- the numbers appear fully formed.
**Lead paragraph:** Plus Jakarta Sans 400, `#111111`, `1.1875rem` (body-lg). Maximum 2-3 sentences. Centered on the content column.
**Section cards:** Minimal rectangles with section name in Plus Jakarta Sans 500, `#1A2B4A`, a right-pointing chevron (`lucide-react` `ChevronRight`) in `#6E6E73`, and a copper left-border (2px). On hover, the chevron shifts right 4px and turns copper. Clicking pushes to that section.
### Experience (Level 1)
Pushed from the Overview. Shows all roles with summary information, inviting deeper exploration.
**Layout per role:**
```
-------- copper thread line --------
NHS Norfolk & Waveney ICB
Deputy Head / Interim Head of Population Health & Data Analysis
Aug 2024 -- Present
Built Python-based algorithms that compressed months of manual analysis
into 3 days. Managing a GBP220M prescribing budget.
[View achievements -->]
-------- 1px gray divider --------
NHS Norfolk & Waveney ICB
Senior Prescribing Data Analyst
Oct 2021 -- Aug 2024
...
```
**Role title:** Fraunces 600, `#1A2B4A`, `clamp(1.5rem, 2.5vw, 2.25rem)`.
**Organization:** Plus Jakarta Sans 500, `#111111`, `1.25rem`.
**Dates:** Source Code Pro 400, `#6E6E73`, `0.875rem`.
**Summary:** Plus Jakarta Sans 400, `#111111`, `1.0625rem`. 2-3 sentences maximum.
**"View achievements" link:** Plus Jakarta Sans 500, copper `#B87333`, with copper underline drawing on hover. Clicking pushes to the role detail (Level 2).
Roles separated by 1px `#D2D2D7` dividers. Copper thread at the very top of the section only.
### Experience Detail (Level 2 -- Detail Sheet)
Opened from a specific role. Slides up as a bottom sheet covering 85% viewport.
**Contents:**
- Role title (Fraunces 600) and organization (Plus Jakarta Sans 500) at the top.
- Dates in Source Code Pro.
- Full achievement bullets with quantified outcomes. Each bullet has a copper left-border if it includes a number.
- Methodology notes (what tools, what approach).
- "Key Impact" callout box: a `#F5F5F7` background card with a copper top-border, containing the single most impressive stat from that role in large Source Code Pro copper numerals.
### Skills (Level 1)
**Layout:**
```
-------- copper thread line --------
Technical Skills
Python SQL Power BI
Advanced Advanced Advanced
JavaScript/TS Algorithm Design Data Pipelines
Intermediate Advanced Advanced
-------- 1px gray divider --------
Leadership & Management
Team Leadership Budget Management Stakeholder Engagement
NHS Leadership ... ...
Academy
[Click any skill category for detailed breakdown]
```
At Level 1, skills are displayed as category groups with skill names and proficiency labels. No progress bars, no percentage circles -- this editorial design communicates proficiency through language ("Advanced," "Intermediate"), not charts.
**Skill names:** Plus Jakarta Sans 500, `#111111`.
**Proficiency labels:** Plus Jakarta Sans 400, `#6E6E73`.
**Category titles:** Fraunces 600, `#1A2B4A`.
Clicking a category pushes to a detail sheet showing:
- Full skill list with context (where each skill was applied, in which role).
- Related projects that demonstrate the skill.
- Certifications or training related to the category.
### Education (Level 1)
Two milestones, presented with editorial generosity.
```
-------- copper thread line --------
MPharm (Hons) Pharmacy
University of East Anglia, 2009 -- 2013
2:1 Classification
Research project: Drug delivery and pharmaceutical cocrystals
Final project grade: 75.1% (Distinction)
[View detail -->]
-------- 1px gray divider --------
NHS Leadership Academy
Mary Seacole Programme
2023
[View detail -->]
```
**Detail sheet for MPharm:** Full research project description, module highlights, committee involvement, grades.
**Detail sheet for Mary Seacole:** Programme overview, leadership competencies developed, application to current role.
### Projects (Level 1)
Project cards in a 2-column grid (breaking the single-column rule for visual variety and because project cards benefit from browsable density).
Each card:
```
[Project Title -- Fraunces 600, navy]
[One-line description -- Plus Jakarta Sans 400, #111111]
[Tech stack tags -- Source Code Pro 400, #6E6E73, 0.75rem]
[-->]
```
Card background: `#FFFFFF` with 1px `#D2D2D7` border. Copper left-border (2px). On hover: border shifts to `#B87333` on all sides (200ms transition).
Cards are max-width 320px in the 2-column layout. Gap: 24px.
Clicking a card opens a detail sheet with:
- Full project description and problem statement.
- Technical approach and architecture.
- Screenshots (desaturated, duotoned).
- Quantified outcomes.
- Links to live demos or repositories (if applicable).
**Projects to feature:**
- Controlled drug monitoring system
- DOAC switching dashboard
- Sankey chart analysis tool
- Python algorithms for prescribing analysis
- Population health data pipeline
### Contact (Level 1)
No drill-down needed. Clean, single-layer presentation.
```
-------- copper thread line --------
Get In Touch
[Email address -- copper link]
[LinkedIn -- copper link]
[Location: Norwich, UK -- #6E6E73]
[Optional: simple contact form with name, email, message fields]
```
Form inputs: 1px `#D2D2D7` border, Plus Jakarta Sans 400, `#111111`. Focus state: border shifts to `#B87333` (copper). Submit button: `#1A2B4A` background, white text, Plus Jakarta Sans 500. Hover: background shifts to `#B87333`.
---
## Interactions and Micro-interactions
### Hover States
- **Text links:** Copper underline draws left-to-right (200ms `scaleX` from `transform-origin: left`). Underline is 1.5px to match the Thread.
- **Cards/clickable areas:** Border color transitions to copper (200ms). No shadow appears. No scale change.
- **Section picker labels:** Pale blue (`#E8F0FE`) background fades in. Copper underline draws in.
- **Breadcrumb segments:** Same pale blue background + copper underline.
- **Chevron arrows:** Shift right 4px, color transitions from gray to copper (200ms).
### Focus States
- **Interactive elements:** 2px outline in `#2563EB` (accessible blue) with 2px offset. This departs from the copper palette for accessibility contrast requirements.
- **Form inputs:** Border shifts to copper on focus. Label floats above and reduces size.
### Active/Click States
- **Buttons:** Scale to 0.98 for 100ms, then release. Subtle physical feedback.
- **Cards:** Background briefly shifts to `#F5F5F7` for 150ms before the push transition begins.
### Loading States
- If any layer requires async content loading, a single copper dot pulses (opacity 0.3 to 1.0, 800ms cycle) at the center of the content area. No spinners, no skeleton screens. A single dot, pulsing patiently.
### Scroll Behavior
- Smooth scroll within each layer. Each layer manages its own scroll position independently.
- When pushing to a new layer, the new layer starts scrolled to top.
- When popping back, the previous layer's scroll position is restored exactly.
- The breadcrumb bar is `position: sticky` at the top. It does not hide on scroll -- it is always present as the wayfinding anchor.
---
## Responsive Strategy
### Desktop (1024px+)
- Layers slide in from the right, creating the full stack-edge depth effect on the left.
- Background layers peek out 20px on the left edge (visible stack).
- Detail sheets cover 70% viewport width, centered, with darkened backdrop.
- Breadcrumb bar shows full trail. Section picker is fully visible.
- Content column holds at 680px max-width. Pull quotes at 800-900px.
- Project cards in 2-column grid.
### Tablet (768px -- 1023px)
- Same z-axis layer model. Layers push to full width (no visible stack edge -- screen is too narrow for it to read clearly).
- Detail sheets slide up from bottom, covering 80% viewport height.
- Breadcrumb bar shows full trail. Section picker horizontally scrollable.
- Content column at 680px or viewport width minus 48px padding, whichever is smaller.
- Project cards in 2-column grid (tighter, 280px max-width per card).
### Mobile (< 768px)
This paradigm *excels* on mobile. The push/pop navigation maps directly to native iOS and Android navigation patterns. Users already know how this works -- swipe back, tap to go deeper.
- Layers are full-screen with no visible stack edges.
- Swipe-right gesture triggers pop transition (detected via Framer Motion `onPan`). Threshold: 80px horizontal swipe with velocity > 500px/s, or drag past 40% viewport width.
- Detail sheets are full-screen with drag-to-dismiss. A small handle at the top (32px wide, 4px tall) invites the gesture.
- Breadcrumb simplifies to: back arrow (left chevron in `#1A2B4A`) + current section name. Tapping the back arrow pops one level.
- Section picker becomes a horizontally scrollable row with fade-out indicators at the edges. Active section centered in view on activation.
- Content column is viewport width minus 32px (16px padding each side).
- Project cards switch to single-column, full-width.
- Hero stats stack vertically (one per row) instead of three-across.
- Type scale reduces: Display to `2.5rem`, body stays at `1.0625rem` (reading comfort is non-negotiable).
**Why this paradigm excels on mobile:** Most portfolio sites are long scrolling pages that feel generic on phones. The Depth Stack feels like a native app. Users navigate by tapping and swiping rather than scrolling through a monolithic page. Each "screen" (layer) has focused content optimized for the viewport. It's immediately familiar to anyone who uses a smartphone daily.
---
## Technical Implementation
### Core Components
**`LayerStack`** -- The root navigation component. Manages:
- An array of layer history (stack of pushed layers with their component references and scroll positions).
- Push/pop functions that trigger transition animations.
- Keyboard listener for Escape (pop).
- Browser history integration (`pushState`/`popState` for back button support).
- `AnimatePresence` from Framer Motion wrapping the layer transitions.
```typescript
interface LayerEntry {
id: string;
component: React.ComponentType;
props: Record<string, unknown>;
scrollPosition: number;
breadcrumbLabel: string;
}
interface LayerStackProps {
children: React.ReactNode; // Level 0 content
}
```
**`Layer`** -- Individual layer wrapper. Handles:
- Enter animation: `translateX(100%) --> translateX(0)` with scale and opacity.
- Exit animation: `translateX(0) --> translateX(100%)`.
- Background state: `scale(0.95) translateX(-20px) filter: blur(4px) opacity: 0.4` when behind another layer.
- Scroll containment (`overflow-y: auto`, `overscroll-behavior: contain`).
- Scroll position preservation via `useRef`.
Framer Motion variants:
```typescript
const layerVariants = {
enter: {
x: '100%',
opacity: 0,
},
active: {
x: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 30,
mass: 0.8,
},
},
background: {
x: -20,
scale: 0.95,
opacity: 0.4,
filter: 'blur(4px)',
transition: { duration: 0.3, ease: [0.32, 0.72, 0, 1] },
},
exit: {
x: '100%',
opacity: 0,
transition: { duration: 0.25, ease: [0.32, 0.72, 0, 1] },
},
};
```
**`DetailSheet`** -- Bottom sheet component. Handles:
- Slide-up enter / slide-down exit animations.
- Drag-to-dismiss via Framer Motion `onPan` and `onPanEnd`.
- Backdrop overlay with click-to-dismiss.
- Focus trap (tab cycling within sheet, focus returns to trigger on dismiss).
- Escape key to dismiss.
```typescript
interface DetailSheetProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title: string;
}
```
**`Breadcrumb`** -- Navigation breadcrumb. Consumes the layer stack context to display the current trail. Each segment is clickable to pop to that level.
**`SectionPicker`** -- Horizontal section navigation. Tracks active section via layer stack state. On mobile, uses horizontal scroll with `scroll-snap-type: x mandatory`.
**`CopperThread`** -- Reusable component for the signature line. Uses `useScrollReveal` to trigger the `scaleX` draw animation when entering the viewport.
```typescript
interface CopperThreadProps {
width?: string; // default '100%'
className?: string;
}
```
### CSS Architecture
- Tailwind CSS for utility classes and responsive breakpoints.
- CSS custom properties for the design tokens:
```css
:root {
--color-navy: #1A2B4A;
--color-copper: #B87333;
--color-sage: #7A9E7E;
--color-surface: #F5F5F7;
--color-border: #D2D2D7;
--color-text-primary: #111111;
--color-text-secondary: #6E6E73;
--color-highlight: #E8F0FE;
--font-heading: 'Fraunces', serif;
--font-body: 'Plus Jakarta Sans', sans-serif;
--font-mono: 'Source Code Pro', monospace;
--thread-width: 1.5px;
--layer-transition-duration: 300ms;
--reveal-duration: 600ms;
}
```
- `perspective` on the layer stack container for true 3D depth cues:
```css
.layer-stack {
perspective: 1200px;
perspective-origin: center center;
}
```
- `transform: translateZ()` on individual layers for z-axis positioning.
- `will-change: transform, opacity, filter` on animating layer elements for GPU compositing.
### State Management
Layer navigation state is managed via React Context:
```typescript
interface LayerStackContext {
stack: LayerEntry[];
push: (entry: Omit<LayerEntry, 'scrollPosition'>) => void;
pop: () => void;
popTo: (layerId: string) => void;
currentDepth: number;
}
```
No external state library required. The layer stack is the single source of navigation truth. URL state is synced via `window.history` for back button support and deep linking.
### Performance Considerations
- Layers behind the active layer are set to `pointer-events: none` and `will-change: auto` (remove from GPU layer when not transitioning) to reduce memory overhead.
- Content within background layers is set to `visibility: hidden` after the push transition completes (but remains in the DOM for instant restore on pop).
- Images lazy-load within detail sheets (they only load when the sheet opens).
- Font loading: Fraunces and Plus Jakarta Sans are loaded as variable fonts to minimize network requests. Use `font-display: swap` with a system serif fallback for Fraunces and system sans-serif for Plus Jakarta Sans.
### Browser History Integration
Each push operation calls `window.history.pushState()` with the layer ID. The `popstate` event listener triggers the `pop()` function. This means:
- The browser back button works naturally for navigating the layer stack.
- Deep links can reconstruct the layer stack (e.g., `/experience/nhs-icb` opens Overview → Experience → NHS ICB detail).
- Bookmarking a deep layer works correctly.
---
## Accessibility
### Semantic Structure
- DOM order follows logical reading sequence regardless of visual layer presentation.
- Each layer is an `<article>` or `<section>` with appropriate heading hierarchy.
- The breadcrumb uses `<nav aria-label="Breadcrumb">` with an `<ol>` of `<li>` items.
- Detail sheets use `role="dialog"` with `aria-modal="true"` and `aria-labelledby` pointing to the sheet title.
### Keyboard Navigation
- **Tab:** Cycles through interactive elements within the active layer.
- **Enter/Space:** Activates buttons and links (pushes layers, opens sheets).
- **Escape:** Pops the current layer or closes the current detail sheet. At Level 0, Escape does nothing.
- **Arrow keys:** Navigate the section picker horizontally.
### Focus Management
- When a layer pushes, focus moves to the first heading or interactive element in the new layer.
- When a layer pops, focus returns to the element that triggered the push.
- Detail sheets trap focus within the sheet while open. Tab cycling wraps from last to first focusable element.
- On sheet dismiss, focus returns to the triggering element.
### Screen Reader Support
- Layer transitions are announced via an `aria-live="polite"` region: "Navigated to Experience section" / "Returned to Overview."
- Detail sheet open/close is announced: "Opened NHS Norfolk & Waveney ICB details" / "Closed details."
- Breadcrumb trail is read naturally as an ordered list.
- Statistics use `aria-label` for full context: `<span aria-label="14,000 patients identified">14,000</span>`.
### Motion Sensitivity
When `prefers-reduced-motion: reduce` is active:
- Layer push/pop transitions change to immediate opacity crossfade (200ms). No translateX, no scale, no blur.
- Detail sheets appear/disappear via opacity fade (200ms). No slide.
- Copper thread lines appear immediately at full width (no draw animation).
- Content reveals are instant (no 600ms fade).
- All easing functions default to `linear` for the reduced durations.
### Color Contrast
All text combinations meet WCAG 2.1 AA standards:
- `#111111` on `#FFFFFF`: contrast ratio 18.9:1 (AAA)
- `#6E6E73` on `#FFFFFF`: contrast ratio 4.6:1 (AA)
- `#1A2B4A` on `#FFFFFF`: contrast ratio 12.5:1 (AAA)
- `#B87333` on `#FFFFFF`: contrast ratio 3.6:1 (AA for large text only; copper is only used on text >= 18px or 14px bold, or on decorative elements)
- `#111111` on `#F5F5F7`: contrast ratio 17.4:1 (AAA)
---
## What Makes This Special
The Depth Stack is the most **mature** design of all six. It communicates executive seniority through restraint -- luxury whitespace, deliberate pacing, copper accents on pure white. Where other designs demonstrate technical skill through animation complexity or information density, this design demonstrates it through *editorial confidence* and *structural thinking*.
The z-axis navigation model mirrors how clinical data is structured: patient summary leads to medication history leads to individual prescription detail. It mirrors how Andy presents to executives: headline leads to evidence leads to methodology. Every transition says "there's substance beneath this surface."
The Fraunces serif adds a warmth and personality that sans-serif-only designs cannot match. It's distinctive without being heavy, authoritative without being cold. The variable optical sizing means it performs beautifully from 14px metadata to 80px display headings, always looking intentionally designed for that specific size.
The Copper Thread provides visual continuity without visual noise. It's the red thread of narrative (in copper) that ties the entire experience together -- from its birth in the ECG transition through every section divider, achievement callout, and interaction state.
On mobile, this design has a structural advantage: while other portfolio sites become generic scroll-fests on small screens, the Depth Stack maps to native mobile navigation patterns. Users don't need to learn anything -- they already know how to tap deeper and swipe back. The portfolio feels like an app, not a web page.
Someone managing a GBP220M budget should have a site that feels commensurate. The Depth Stack doesn't shout about its quality -- it demonstrates it through the precision of every typographic choice, the restraint of every whitespace decision, and the confidence to let content speak without visual crutches.
**The design's thesis, in one sentence:** Depth is more impressive than breadth, and silence is more powerful than noise.
-672
View File
@@ -1,672 +0,0 @@
# Design 6: The Pipeline
> A drag-to-explore data flow interface where the user IS the data, physically traveling through Andy's career as a glowing packet on a visible pipeline track.
---
## Overview
The Pipeline transforms the CV from a document into a spatial journey. After the ECG intro, a glowing pipeline track — born from the heartbeat trace itself — stretches across the viewport. The user drags a luminous data packet along this track. As the packet moves through each section, it triggers content reveals, animations, and transformations. The pipeline has branches, valves, and processing nodes. Each section of the CV is a processing stage.
This is the most physically engaging of all six designs. Dragging activates proprioception — the bodily sense of effort and movement. It demands continuous intent, creating deeper engagement than passive scrolling. The data packet becomes the user's avatar, and its journey IS Andy's career narrative made tangible.
The metaphor is literal: Andy builds data pipelines professionally. He takes raw prescribing data, processes it through SQL transformations and Python algorithms, and outputs actionable insights. On this site, the user IS the data. They don't read about data processing — they experience being processed.
### Why This Design
No portfolio site uses drag-along-a-track as its primary navigation. The mechanic is immediately novel — the moment a visitor realizes they're dragging a glowing orb along a pipeline, they're in uncharted territory. Novelty drives sharing. The "Run Algorithm" interaction at the Projects section (where the packet duplicates to process all paths simultaneously) is the kind of moment that gets screen-recorded and posted to Twitter/X. This is the design built for virality.
---
## ECG Transition
**Starting frame:** Andy's name, neon green (#00FF41), on pure black. Static.
### Sequence (2.4 seconds total)
1. **Lift** (400ms): Andy's name text gently floats upward ~60px from its current position. Simultaneously, it transitions from neon green canvas-rendered letterforms to DOM-rendered text in Plus Jakarta Sans 700, white (#F0F0F0) with a soft text-shadow glow (0 0 20px rgba(0, 255, 65, 0.3)). The glow fades over the next second — a ghost of the green, dissipating. This is the text handoff: the name is now "real" typography while the canvas layer remains active below it.
2. **Trace reveal** (300ms): With the name lifted, the original horizontal trace line that the ECG drew — the baseline the heartbeat traveled along, the path the name was formed on — is now visible below the name. It's still neon green (#00FF41), still on black. A thin, glowing horizontal line spanning roughly 60% of the viewport width, centered. This line is the seed of the pipeline.
3. **Straighten and extend** (800ms): Any remaining curvature or heartbeat waveform artifacts in the trace line smooth out. The line's path control points interpolate toward a perfectly horizontal target. It flattens with a satisfying ease — `cubic-bezier(0.16, 1, 0.3, 1)`. Simultaneously, the line begins extending in both directions, drawing itself outward from center toward the viewport edges. As it extends, its color shifts from neon green (#00FF41) to a teal-cyan gradient (#00897B at left → #22D1EE at right). The line develops a soft glow: a 4px gaussian blur at 50% opacity behind the main 2px stroke, creating a neon-tube effect.
4. **Curve and route** (600ms): The line, now spanning the full viewport width, begins to bend. The right end curves downward, forming the first gentle arc of the pipeline's S-curve track. The left end develops a small rounded terminal (a circle, 12px diameter) — the starting node. The background transitions from pure black to a dark gradient (#0D1117 at top, #1A1A2E at bottom), giving the impression of depth without losing the dark aesthetic. Faint stars (actually tiny dot-grid points at 2% opacity) appear across the background.
5. **Packet birth** (300ms): A bright orb materializes at the left terminal node — the data packet. It appears with a scale-from-zero spring animation (stiffness: 300, damping: 15). It pulses twice with a teal-white glow (expanding from 8px to 14px radius and back), echoing the heartbeat that started the entire sequence. A "drag to explore" label fades in 20px to the right of the packet, in IBM Plex Sans 400, 14px, slate (#94a3b8), with a subtle horizontal arrow animation (translating 5px right and back on a 2s loop). The pipeline is live. The user can begin.
### Why This Transition Works
The ECG heartbeat line IS the pipeline. Same visual element, new purpose. The user watches a biological signal (heartbeat trace) metamorphose into a technical structure (data pipeline) in real-time. This is the visual equivalent of Andy's career narrative — clinical pharmacist becoming data engineer. The straightening moment is the pivot: raw biological waveform becoming clean, purposeful infrastructure. The packet's double-pulse at the end is the heartbeat's final echo — a callback that ties the intro and the main experience into one continuous story.
---
## Visual System
### Color Palette
The Pipeline maintains a dark theme throughout — no transition to light. The dark background serves both the aesthetic (pipeline glow effects need contrast) and the metaphor (data flowing through infrastructure, operations centers, server rooms).
| Element | Color | Hex | Usage |
|---------|-------|-----|-------|
| Background (top) | Deep charcoal | #0D1117 | Primary background |
| Background (bottom) | Dark navy | #1A1A2E | Gradient terminus |
| Content surface | Elevated dark | #161B22 | Card backgrounds, content areas |
| Content surface hover | Lighter dark | #1C2128 | Hover states |
| Pipeline stroke | Teal | #00897B | Main pipeline track |
| Pipeline glow | Cyan | #22D1EE | Glow effect behind pipeline |
| Packet core | Bright white | #FFFFFF | Data packet center |
| Packet glow | Teal-white | #A0F0E0 | Data packet aura |
| Text primary | Off-white | #E6EDF3 | Headings, primary text |
| Text secondary | Slate | #8B949E | Secondary text, labels |
| Text tertiary | Dim slate | #6E7681 | Timestamps, metadata |
| Accent warm | Coral | #FF6B6B | Alert states, key metrics |
| Accent bright | Electric cyan | #00D4AA | Active states, highlights |
| Node inactive | Dim teal | #1A3A3A | Pipeline nodes before packet arrives |
| Node active | Bright teal | #00897B | Pipeline nodes after packet passes |
### Typography
- **Space Grotesk 500, 700** — Headings and section labels. 700 for primary headings (28-36px), 500 for subheadings and node labels (18-22px). White (#E6EDF3) or teal (#00897B) depending on hierarchy.
- **IBM Plex Sans 400, 450** — Body text, role descriptions, bullet points. 16px/1.7 for body, 14px/1.6 for secondary. Off-white (#E6EDF3) for primary, slate (#8B949E) for secondary. Weight 450 for body text to maintain readability on dark backgrounds.
- **IBM Plex Mono 400** — Metrics, numbers, data labels, code references. 14-18px. Electric cyan (#00D4AA) for active metrics, slate (#8B949E) for labels. All metric numbers use this face for visual consistency and the "data" connotation.
### Pipeline Visual Language
The pipeline is the site's skeleton — visible at all times, providing spatial orientation.
- **Track stroke**: 2px solid teal (#00897B) with a 6px gaussian blur glow (#22D1EE at 30% opacity) behind it. The track is always visible, even before the packet reaches a section.
- **Track ahead** (sections not yet reached): Dimmed to 20% opacity with no glow. Visible enough to show the path, dim enough to create anticipation.
- **Track behind** (sections already passed): Full opacity with residual glow that slowly fades (10s decay). The path you've traveled stays lit.
- **Flow particles**: Tiny dots (2px) travel along the pipeline track in the packet's direction of movement, spaced ~40px apart, moving at a constant slow speed. These create the impression of continuous data flow even when the packet is stationary. Speed increases proportionally when the packet is being dragged.
- **Processing nodes**: Circles (16px diameter) at section entry points. Inactive: dim teal outline (#1A3A3A). Active (packet has arrived): solid teal fill (#00897B) with a radial pulse animation (one pulse, 400ms). Completed (packet has passed): solid teal at 60% opacity, no pulse.
- **Branch points**: Where the pipeline splits (Projects section), a diamond shape (12px, rotated 45deg) marks the fork. The diamond pulses when the packet reaches it.
### Ambient Particle Layer
Behind the SVG pipeline and all content, a lightweight canvas particle system provides atmospheric depth:
- **Particle count**: 150-300 (based on viewport size and device performance)
- **Particle size**: 1-2px circles, teal at 5-15% opacity
- **Default behavior**: Slow brownian drift, random direction, ~0.2px/frame velocity
- **Packet proximity reaction**: Particles within 120px of the data packet accelerate in the pipeline's direction of flow at that point. They stream alongside the packet like current in a river. This creates a "wake" effect behind the moving packet.
- **Section transitions**: When the packet enters a new section, nearby particles briefly brighten (5% → 20% → 5% over 600ms) and swirl inward toward the packet, as if being "processed."
- **Performance**: Canvas renders at 30fps (not 60) to save resources. Particles are simple circles with no complex rendering. The canvas is behind all content (`z-index: 0`, `pointer-events: none`).
### Texture
- **Dot grid**: 2% opacity, 32px spacing, covering the entire viewport. Barely visible but provides subconscious structure to the dark space. Grid dots near the pipeline track are slightly brighter (4% opacity).
- **Vignette**: A subtle radial gradient darkens the viewport corners (black at 15% opacity), focusing attention on the center where the pipeline and content live.
- **Noise texture**: An extremely subtle (1% opacity) noise overlay on the background gradient prevents color banding on displays with limited color depth. Applied via CSS `background-image` with a tiny tiling SVG.
---
## Section-by-Section Design
### Hero / Entry Point
**Pipeline position:** The far-left terminal node. This is where the journey begins.
**Layout:**
- Andy's name (Space Grotesk 700, 36px, white) sits above the pipeline starting node, vertically centered in the viewport.
- Title: "Population Health & Data Analysis | NHS" (Space Grotesk 500, 18px, slate #8B949E) below the name.
- The pipeline track extends to the right from the starting node, curving gently downward.
- The data packet sits at the starting node, pulsing softly (scale oscillation 1.0 → 1.1 → 1.0, 3s period).
- "Drag to explore" label with animated arrow, positioned right of the packet.
- Below the pipeline, a brief profile summary in IBM Plex Sans 450, 16px, off-white.
**Interaction:**
- The user clicks/touches the data packet and begins dragging it along the pipeline track.
- As the packet moves right from the starting node, the hero content fades (opacity 1 → 0 over the first 15% of the pipeline's total length).
- The pipeline track ahead brightens from 20% to 100% opacity as the packet approaches.
- If the user releases the packet, it coasts forward on momentum (spring physics), then decelerates and stops. It can also coast backward if released while dragging left.
### Skills — The Processing Matrix
**Pipeline position:** First major section, 15-35% along the pipeline's total length.
**Pipeline behavior:** The pipeline enters a rectangular area (the "processing matrix"). Inside, the single track splits into a grid-like arrangement — horizontal parallel tracks stacked vertically, connected by short vertical segments. Each horizontal track passes through 2-3 skill nodes. The packet follows the path through this matrix, lighting up skills as it passes.
**Layout:**
The processing matrix is a contained visual area (roughly 80% viewport width, centered). Skill nodes are arranged in a grid:
```
ROW 1 (Technical): [Python] ——— [SQL] ——— [Power BI] ——— [JS/TS]
| |
ROW 2 (Data): [Algorithm Design] — [Data Pipelines] — [Dashboard Dev]
| |
ROW 3 (Healthcare): [Medicines Opt.] — [Population Health] — [NICE Implementation]
| |
ROW 4 (Leadership): [Budget Mgmt] ——— [Stakeholder Eng.] —— [Team Dev]
```
**Node design:**
- Each skill is a node on the pipeline: a rounded rectangle (120px x 48px) with a dim teal border (#1A3A3A) and dark fill (#161B22).
- Skill name inside in IBM Plex Sans 450, 13px, slate (#8B949E).
- Below the name, a thin proficiency bar (60px wide, 3px tall, empty).
**Interaction — Packet traversal:**
- As the packet passes through a skill node, the node activates in sequence:
1. Border brightens to full teal (#00897B) (100ms)
2. Fill lightens to elevated dark (#1C2128) (100ms)
3. Skill name text brightens to white (#E6EDF3) (100ms)
4. Proficiency bar fills left-to-right with a teal-to-cyan gradient (200ms)
5. A brief particle absorption effect: 10-15 ambient particles rush inward toward the node and disappear, as if the packet is "absorbing" the skill (300ms)
6. The packet itself briefly brightens and grows (radius 8px → 12px → 8px) — it's gaining capability
- Skills are ordered by acquisition timeline: pharmacy domain skills first (bottom rows), then data skills, then technical skills. The user experiences Andy's learning journey chronologically — pharmacist → analyst → developer.
- Once activated, skill nodes remain lit. If the user drags backward, nodes dim back to inactive state.
**Ambient detail:**
- Faint data-flow particles travel along the matrix tracks at constant slow speed, even before the packet arrives. This signals that the matrix is "alive" and waiting.
- A section label "PROCESSING // SKILLS" appears at the top of the matrix area in IBM Plex Mono 400, 12px, dim slate (#6E7681), uppercase, tracking 0.15em.
### Experience — The Branching Pipeline
**Pipeline position:** 35-70% along the pipeline's total length. The longest section.
**Pipeline behavior:** The pipeline exits the skills matrix and enters the experience section. Here, it branches: the main track splits into separate parallel tracks, one per role. Each branch contains a processing node (the role). Branches converge back to the main track after each role, creating a pattern of split → process → merge → split → process → merge.
The branching order is chronological (earliest role first, most recent last), so the user processes Andy's career in order.
**Branch layout (desktop):**
```
Main track ──┬── [Branch: Tesco Pharmacy Manager 2017-2022] ──┬── Main track
│ │
└──────────────────────────────────────────────────┘
┌──────────────────────┘
Main track ──┬── [Branch: HCD & Interface Pharmacist 2022-24] ─┬── Main track
│ │
└───────────────────────────────────────────────────┘
┌──────────────────────┘
Main track ──┬── [Branch: Deputy Head 2024-Present] ───────────┬── Main track
│ │
└───────────────────────────────────────────────────┘
┌──────────────────────┘
Main track ──┬── [Branch: Interim Head May-Nov 2025] ──────────┬── Main track
```
**Role card design:**
Each branch contains a role card that builds itself as the packet passes through:
- **Container**: Rounded rectangle, dark surface (#161B22), subtle border (#1C2128), generous padding (24px 32px).
- **Left accent**: A 3px vertical line on the left side, teal (#00897B), extends from top to bottom of the card. Animates: draws top-to-bottom as the packet arrives.
- **Role title**: Space Grotesk 700, 22px, white (#E6EDF3). Types itself character-by-character as the packet enters the branch.
- **Company + date**: IBM Plex Sans 400, 14px, slate (#8B949E). Slides in from left after title.
- **Context line**: IBM Plex Sans 450, 15px, off-white (#E6EDF3). Fades in.
- **Bullet points**: IBM Plex Sans 400, 15px, off-white. Each fades in from below with 100ms stagger.
- **Key metrics**: Displayed in IBM Plex Mono 400, 18px, electric cyan (#00D4AA), with a subtle glow. Each metric has a small throughput indicator animation — a mini progress bar that fills as the packet passes the metric.
**Throughput indicators:**
At each branch point, small counters display the role's key metrics:
- Tesco: `~£1M revenue` | `300 branches` | `60→6 hrs/month`
- HCD: `70% form reduction` | `200 hrs saved` | `7-8 hrs/week`
- Deputy Head: `£220M budget` | `£2.6M savings` | `14,000 patients`
- Interim Head: `£14.6M programme` | `3 days vs months` | `50% reduction`
These counters are IBM Plex Mono 400, 14px, positioned along the branch track. They count up from zero as the packet passes, with the count rate proportional to drag velocity.
**Interaction:**
- The packet enters a branch and the role card begins building.
- Dragging further through the branch reveals more content (bullets, metrics).
- At the merge point (where the branch rejoins the main track), the card is fully built and the packet continues to the next branch.
- If the user drags backward, the card deconstructs in reverse order.
- The ambient particles in the pipeline increase in density and speed within branches, suggesting "heavy processing." They slow back to normal on the main track between branches.
### Education — The Research Lab
**Pipeline position:** 70-82% along the pipeline's total length.
**Pipeline behavior:** The pipeline enters a visually distinct zone. The background lightens slightly within this area (from #0D1117 to #111822), and a faint rectangular border (1px, #1C2128) delineates the "lab" space. The pipeline coils through education milestones — a tighter, more compact S-curve than the wide branching of the Experience section.
**Section label:** "RESEARCH_LAB // EDUCATION" in IBM Plex Mono 400, 12px, dim slate, uppercase.
**Milestone layout:**
The pipeline passes through 4 milestone nodes, each triggering a content reveal:
1. **A-Levels (2009-2011)**
- Node: Circle, 20px, with a graduation cap icon (Lucide `GraduationCap`, 12px) inside.
- Content card (appears when packet arrives): Highworth Grammar School. Mathematics A*, Chemistry B, Politics C. Compact card, single line of detail.
- Pipeline behavior: Straight horizontal track through this node.
2. **MPharm (2011-2015)**
- Node: Circle, 24px (slightly larger — this is a major milestone), with a flask icon (Lucide `FlaskConical`, 14px).
- Content card: University of East Anglia. Master of Pharmacy, 2:1 Honours. More detailed card with 2-3 lines.
- **Branch**: At this node, the pipeline briefly splits into a short side branch that curves upward and terminates at a small terminal node labeled "Research Project." This branch card reads: "Drug delivery and cocrystals: 75.1% (Distinction)." The side branch represents the experimental methodology — a controlled divergence from the main path that produces a result, then merges back. The packet can optionally be dragged down the research branch (or it can auto-traverse with a small duplicate packet if the user continues on the main track).
3. **GPhC Registration (2016)**
- Node: Circle, 20px, with a shield icon (Lucide `ShieldCheck`, 12px).
- Content card: General Pharmaceutical Council. Registered Pharmacist. Brief card — this is a credentialing milestone.
- Pipeline behavior: The track brightens momentarily as the packet passes this node (the "authorization" node), as if the pipeline has been certified.
4. **Mary Seacole Programme (2018)**
- Node: Circle, 20px, with a star icon (Lucide `Star`, 12px).
- Content card: NHS Leadership Academy. 78%. Change management, healthcare leadership, system-level thinking.
- Pipeline behavior: Standard pass-through. After this node, the pipeline curves toward the Projects section.
**Ambient detail:**
- The research lab zone has a slightly different particle behavior: particles drift more slowly and in more organized patterns (subtle grid-aligned movement rather than brownian), suggesting the structured environment of academic research.
- A faint molecule-like structure (3 interconnected circles, purely decorative, very low opacity) floats in the background of this zone — a nod to Andy's cocrystal research without being heavy-handed.
### Projects — The Algorithm (Signature Interaction)
**Pipeline position:** 82-95% along the pipeline's total length. The most interactive section.
**Pipeline behavior:** The main track reaches a diamond-shaped branch point (the "decision node"). The pipeline splits into multiple parallel tracks — one per project. Each track leads to a project node, then terminates in a small endpoint. The main track continues straight through to the Contact section, but the user must choose which project branch to explore.
**Branch layout (desktop):**
```
┌── [Switching Algorithm] ── (endpoint)
Main track ── ◆ ── ┼── [Blueteq Automation] ── (endpoint)
│ │
│ ├── [Sankey Chart Tool] ── (endpoint)
│ │
│ └── [CD Monitoring] ──── (endpoint)
└──────────────────────────── Main track continues → Contact
```
**Manual exploration (default):**
The user drags the packet to the branch point. The diamond node activates and all four project branches illuminate at 40% opacity. The user can drag the packet down any branch to explore that project. At the project node, a project card builds itself (similar to experience cards):
**Project card design:**
- **Header**: Project name (Space Grotesk 700, 20px, white) + technology tags (IBM Plex Mono 400, 12px, electric cyan, pill-shaped backgrounds).
- **Description**: IBM Plex Sans 450, 15px, off-white. 2-3 sentences.
- **Visualization**: Each project card contains a mini-visualization that animates as the packet arrives:
- **Switching Algorithm**: A field of small dots (100-150) representing patients. As the card activates, dots stream through a funnel shape (two converging lines) and emerge organized into color-coded groups on the other side. Counter: `14,000 patients → £2.6M savings`. Duration: 2s auto-animation triggered by packet arrival.
- **Blueteq Automation**: A stack of 10 small rectangle icons (representing forms). 7 of them slide off-screen with a smooth exit animation, leaving 3. Counter: `70% reduction | 200 hrs immediate savings`. Simple and devastating.
- **Sankey Chart Tool**: A mini Sankey diagram (4 left nodes → 3 middle nodes → 3 right nodes) with colored flow paths that animate with flowing particles. The paths draw themselves over 1.5s. This is a live visualization of what Andy built.
- **CD Monitoring**: A mini line chart that draws itself left-to-right. A horizontal threshold line is pre-drawn. When the data line crosses the threshold, the line and the area above it shift to coral (#FF6B6B) and pulse once. Counter: `Population-scale safety analysis`.
- **Impact metric**: A large number in IBM Plex Mono 700, 28px, electric cyan, with glow. Positioned prominently in the card.
After exploring a project, the user drags the packet back to the branch point and can choose another branch, or continue to Contact.
**"Run Algorithm" interaction (signature moment):**
At the branch point, a button appears: `[ ▶ RUN ALGORITHM ]` — styled as a pipeline control element (rounded rectangle, teal border, IBM Plex Mono 500, 14px, uppercase). The button pulses gently with a teal glow.
When clicked:
1. The data packet at the branch point duplicates — it splits into 4 identical orbs (300ms spring animation outward).
2. Each duplicate travels down a different project branch simultaneously. All 4 project cards build in parallel.
3. The ambient particles surge — increased density and speed along all 4 branches, creating visible "data flow" in every direction.
4. All 4 mini-visualizations animate simultaneously.
5. A label appears at the branch point: `PARALLEL PROCESSING // 4 THREADS` in IBM Plex Mono 400, 12px, electric cyan.
6. After all 4 packets reach their endpoints (2-3 seconds), they reverse — traveling back along the branches to the decision node, where they merge back into a single packet. The merge is accompanied by a bright flash and a brief particle burst.
7. The main track forward to Contact now illuminates fully. All project cards remain visible and explored.
**Why this works:** This directly demonstrates what Andy's algorithms do — automated parallel processing versus manual single-track work. The user sees the difference viscerally. Processing one project at a time is slow and requires backtracking. Running the algorithm processes everything simultaneously. It's a live demo of the value proposition on Andy's CV.
### Contact — The Output Terminal
**Pipeline position:** 95-100% along the pipeline's total length. The endpoint.
**Pipeline behavior:** The pipeline track approaches a final processing node — larger than the others (24px diameter), with a distinctive glow. The track terminates here with a rounded endpoint. This is the "output terminal."
**Layout:**
- Section label: `OUTPUT_TERMINAL // CONTACT` in IBM Plex Mono 400, 12px, dim slate.
- A summary card appears above the contact form, pulling together key numbers:
```
PROCESSING COMPLETE
£14.6M efficiency programme identified
14,000 patients flagged by algorithm
£2.6M annual savings on target
1.2M population served
```
Each number is IBM Plex Mono 700, 24px, electric cyan, with glow. They count up sequentially (staggered by 200ms) as the packet reaches the terminal node.
- **Contact form**: Below the summary. Clean design on a dark surface (#161B22):
- Fields: Name, Email, Message. Each has a bottom border (1px, #1C2128) that brightens to teal on focus. Labels float above in slate.
- Submit button: Rounded rectangle, solid teal fill, white text, IBM Plex Sans 500, 15px. Hover: lighter teal + glow.
- Contact details alongside: email (andy@charlwood.xyz), phone, location (Norwich, UK). Each with a Lucide icon (Mail, Phone, MapPin) in teal.
- **Form submission animation**: On successful submit, the data packet (which has settled in the terminal node) launches upward — it accelerates off the top of the viewport, leaving a trail of particles behind it. A "Message sent" confirmation appears at the terminal node. The packet slowly regenerates (fading back in at the terminal) after 3 seconds. The visual metaphor: data entered → processed → transmitted.
**Pipeline completion state:**
Once the packet reaches the terminal, the entire pipeline track behind it achieves full brightness — every node is active, every branch is lit, flow particles are moving along the full length. The complete pipeline is visible as a glowing map of everything the user explored. This provides a satisfying sense of completion and a visual summary of the journey.
---
## Interactions and Micro-interactions
### Packet Drag Mechanics
The data packet is the primary interactive element. Its behavior must feel physically satisfying:
- **Grab**: Clicking/touching the packet scales it up (1.0 → 1.2) with a spring animation (stiffness: 400, damping: 20) and increases its glow radius. Cursor changes to `grabbing`.
- **Drag**: The packet follows the user's pointer along the pipeline track. It cannot leave the track — movement is constrained to the SVG path. The position is calculated as the nearest point on the path to the cursor position.
- **Velocity**: Drag velocity is tracked. Faster dragging increases ambient particle flow speed and throughput counter count-up rate. This creates a satisfying "the faster I go, the more data processes" feedback loop.
- **Release with momentum**: When released, the packet coasts in the direction of the last drag velocity. Deceleration follows spring physics (`dragMomentum: true`, damping: 0.8). The packet can coast through multiple nodes if released with enough velocity. This creates a playful "launch" interaction.
- **Release without momentum**: If released while stationary (no velocity), the packet stays in place. No auto-advancing.
- **Backward dragging**: Fully supported. Dragging backward reverses all animations — cards deconstruct, nodes deactivate, metrics count down. The experience is fully bidirectional.
- **Snap points**: At each processing node, the packet has a slight magnetic snap (subtle resistance when dragging past, requiring a small threshold of force to break free). This encourages the user to pause at each section. Snap force: 5px snap radius, breakaway at 15px drag distance.
### Pipeline Glow Dynamics
The pipeline's glow reacts to the packet's position and state:
- **Proximity glow**: The pipeline track within 200px of the packet has enhanced glow (30% → 60% opacity). The glow falls off with distance using an ease-out curve.
- **Drag glow**: While the packet is being actively dragged, the glow intensifies further (to 80%) and the glow color shifts from teal toward brighter cyan.
- **Pulse on node activation**: When the packet crosses a processing node, the pipeline segment behind the node pulses (brightness spikes to 100%, then settles to the completed-segment baseline of 50%).
- **Idle glow**: If the packet sits idle for >5 seconds, it emits a gentle pulse (breathing glow, 3s period) to remind the user it's there and draggable.
### Content Card Reveal Choreography
All content cards (skills, experience, education, projects) follow a consistent build choreography:
1. **Card surface appears** (100ms): Dark surface fades in from 0 → 100% opacity.
2. **Left accent draws** (200ms): The 3px teal left border draws top-to-bottom.
3. **Title types** (variable, 30ms per character): Characters appear left-to-right.
4. **Subtitle slides** (200ms): Company/date slides in from 20px left.
5. **Body fades** (200ms per element, 100ms stagger): Each line fades in from 10px below.
6. **Metrics count** (variable): Numbers count up at 30ms per digit.
7. **Visualization animates** (if applicable, 1-2s): Mini-viz plays after text is settled.
Easing for all: `cubic-bezier(0.16, 1, 0.3, 1)`.
Reverse: On backward drag, steps play in reverse order at 1.5x speed (deconstruction feels faster than construction, which is psychologically satisfying).
### Ambient Particle Behaviors
The particle system has contextual behaviors per section:
| Section | Particle Behavior | Emotional Register |
|---------|-------------------|-------------------|
| Hero | Slow drift, random direction | Calm, waiting |
| Skills | Stream toward activated skill nodes | Learning, acquisition |
| Experience | Dense, fast along branches | Heavy processing |
| Education | Organized grid-aligned drift | Structured, academic |
| Projects | Surge along all active branches | High throughput |
| Contact | Converge toward terminal node | Resolution, completion |
---
## Navigation
### Pipeline as Navigation
The pipeline itself IS the navigation. The user's position on the pipeline determines what content is visible. However, auxiliary navigation is needed for:
1. **Direct section access**: Five small node icons arranged vertically on the right edge of the viewport. Each corresponds to a section (Skills, Experience, Education, Projects, Contact). Clicking a node animates the packet along the pipeline to that section's entry point (the packet travels the pipeline visually — it doesn't teleport). The travel animation takes 800ms regardless of distance.
2. **Mini-map**: At the bottom of the viewport, a thin horizontal representation of the entire pipeline (height 4px, width 200px). The packet's current position is shown as a bright dot on this minimap. Section boundaries are marked with tiny notches. The minimap provides spatial orientation — "I'm halfway through the pipeline." Clicking a position on the minimap moves the packet there.
3. **Pipeline overview** (optional): Double-clicking/double-tapping anywhere off the pipeline triggers a "zoom out" — the viewport smoothly scales down to show the entire pipeline at once (scale 0.3-0.4x), with all sections visible as labeled nodes. The user can click any section to zoom back in at that position. This provides a bird's-eye view of the journey.
### Keyboard Navigation
- **Arrow Right / Arrow Down**: Advance packet to next processing node (with travel animation).
- **Arrow Left / Arrow Up**: Move packet to previous processing node.
- **Tab**: Focus moves between interactive elements (project cards, contact form fields) in DOM order.
- **Enter**: At a branch point, Enter activates the "Run Algorithm" button.
- **Number keys 1-5**: Jump to sections (1=Skills, 2=Experience, 3=Education, 4=Projects, 5=Contact).
- **Home**: Return packet to start.
- **End**: Advance packet to Contact terminal.
### Scroll Fallback
A "scroll mode" toggle is available in the header (a small icon: pipeline icon → scroll icon). When activated:
- The pipeline track becomes a decorative sidebar element (fixed on the left, thin)
- Content converts to traditional vertical scroll layout
- The packet still travels down the sidebar pipeline synchronized to scroll position
- All content is visible via standard scrolling
- This mode is automatically activated for keyboard-only users (detected via `keydown` without prior `pointerdown`)
---
## Responsive Strategy
### Desktop (>1024px)
Full horizontal pipeline experience. The pipeline track winds across the full viewport width. Content cards appear beside the pipeline track (alternating left and right). The skills matrix is a wide grid. Experience branches spread horizontally. Drag is horizontal (left-to-right). Ambient particles at full density (300). Mini-map and side navigation are visible.
Pipeline orientation: Horizontal S-curve spanning the viewport.
### Tablet (768px - 1024px)
Hybrid layout. The pipeline rotates to a diagonal — still primarily horizontal but with more vertical S-curves to fit the narrower viewport. Content cards appear below the pipeline track rather than beside it. Skills matrix reduces to 2 columns. Experience branches are shorter. Drag direction follows the pipeline (mixed horizontal/vertical). Ambient particles reduced to 200. Mini-map visible, side navigation collapsed to a hamburger.
### Mobile (<768px)
The pipeline rotates fully vertical. The track runs top-to-bottom, fitting naturally with the device's primary scroll direction. The drag gesture is vertical (up-to-down).
Key mobile adaptations:
- **Drag direction**: Vertical drag replaces horizontal. The pipeline S-curves become horizontal zigzags (left-to-right then right-to-left, repeating downward).
- **Content cards**: Full-width, appearing below each processing node. Single-column layout.
- **Skills matrix**: Single-column vertical list. Nodes activate as the packet descends through them.
- **Experience branches**: Simplified — instead of visual branching, the track passes through role nodes sequentially. Branch visualizations are implied through a slightly wider track at each role.
- **Projects**: The parallel branch split is replaced by a sequential layout with the "Run Algorithm" button still available (packet duplicates downward into parallel vertical tracks, then merges).
- **Ambient particles**: Reduced to 100. No particle proximity reactions (too CPU-intensive on mobile with touch tracking).
- **Packet size**: Slightly larger (12px radius vs 8px desktop) for easier touch targeting. Touch target area is 48x48px minimum.
- **Scroll fallback**: Active by default on very small screens (<480px). Pipeline is decorative, content scrolls normally.
### Touch Interaction
- **Grab**: Long-press (200ms) or single tap on the packet activates drag mode. The packet scales up and vibrates once (haptic feedback on supported devices).
- **Drag**: Touch move drags the packet along the pipeline. Drag is constrained to the track.
- **Release**: Lift finger. Momentum and coast physics apply.
- **Tap node**: Tapping a processing node on the pipeline (not the packet) animates the packet to that node. This provides an alternative to dragging on small screens.
---
## Technical Implementation
### Pipeline Track (SVG Path System)
The pipeline is a single SVG `<svg>` element spanning the full layout dimensions:
```
Architecture:
- Pipeline track: SVG <path> elements (one per segment/branch)
- Processing nodes: SVG <circle> elements at segment junctions
- Branch points: SVG <polygon> (diamond shape) elements
- Flow particles: Small SVG <circle> elements animated along paths via getPointAtLength()
- Glow effect: Duplicate <path> elements with SVG <filter> (feGaussianBlur)
- All pipeline elements have pointer-events: none (except nodes for click navigation)
```
Path coordinates are computed based on viewport dimensions and section positions. On resize, paths recompute (debounced, 200ms). The pipeline is responsive — it redraws its curves to fit the new viewport.
### Packet Position System
```
Core:
- useMotionValue('packetProgress') — a 0-1 value representing position along total pipeline length
- Packet screen position: pathElement.getPointAtLength(progress * totalLength)
- Framer Motion drag event maps pointer movement to progress delta
- Constraints: progress clamped to [0, 1], packet cannot leave the pipeline
Drag physics:
- dragMomentum: true
- dragElastic: 0.05 (very slight elasticity at endpoints)
- Custom velocity tracking: store last 5 position samples (16ms apart), compute average velocity
- On release: apply velocity as spring animation (stiffness: 80, damping: 25)
- Snap points: implemented as modulated spring stiffness at node positions
Section mapping:
- Each section registers a progress range: { start: 0.15, end: 0.35 }
- Section's internal animation progress = (packetProgress - section.start) / (section.end - section.start)
- Clamped to [0, 1] — 0 = section hasn't started, 1 = section fully revealed
```
### Content Reveal System
```
Architecture:
- Each section component receives its animation progress (0-1) as a prop
- Internal elements map sub-ranges of this progress to their individual animations
- Example: Experience card bullets occupy progress 0.5-1.0 of the section
- Bullet 1: 0.5-0.6, Bullet 2: 0.6-0.7, Bullet 3: 0.7-0.8, etc.
- Framer Motion useTransform for all progress-to-style mappings
- All animated properties are transform/opacity only (GPU composited)
Card assembly:
- Each card is a React component with sub-elements
- useTransform maps section progress to sub-element animations
- Sub-elements animate in sequence (see choreography above)
- Reverse animations are computed automatically (progress decreasing)
```
### Ambient Particle System
```
Implementation:
- HTML5 Canvas element, position: fixed, z-index: 0, pointer-events: none
- Particle class: { x, y, vx, vy, size, opacity, sectionBehavior }
- requestAnimationFrame loop at 30fps (16.67ms frame budget * 2 = 33ms interval)
- Per frame:
1. Read packet position from shared ref (no React re-render)
2. For each particle: apply section-specific behavior, apply packet proximity force, update position
3. Clear canvas, draw all particles
- Particle count adapts to device: navigator.hardwareConcurrency > 4 ? 300 : 150
- Canvas resolution: window.devicePixelRatio (retina support) capped at 2x
```
### "Run Algorithm" Implementation
```
Sequence:
1. User clicks "Run Algorithm" button
2. Create 4 additional useMotionValue instances (one per branch)
3. Animate all 4 from branch start to branch end simultaneously (spring animation, 2s duration)
4. Each branch's project component receives its packet progress and builds its card
5. On completion (all 4 reach endpoint), reverse-animate all 4 back to the branch point
6. Merge: scale all 4 packets to 0 while scaling the main packet back to 1
7. Clean up: remove branch useMotionValue instances
8. Mark all projects as explored, illuminate main track forward
State:
- algorithmRunning: boolean
- branchProgresses: MotionValue[] (created on demand)
- exploredProjects: Set<string>
```
### Performance Budget
- **Target**: 60fps for packet drag interaction, 30fps for ambient particles
- **SVG elements**: <100 total (paths, nodes, flow particles). No DOM-heavy rendering.
- **Canvas**: Single canvas for particles. 150-300 particles at 30fps is well within budget.
- **React renders**: Packet position uses useMotionValue (bypasses React render cycle). Section components only re-render when their progress crosses a threshold (not every frame).
- **Path calculations**: `getPointAtLength()` is called per frame for the packet — cached via lookup table (pre-compute 1000 points along the path at mount time, interpolate between them).
- **Bundle**: Framer Motion (~30kb gzip) + lightweight d3-path for SVG path math (~3kb gzip). Total JS: <80kb gzip.
- **will-change**: Applied to the packet element and all currently-animating card elements. Removed when animation completes.
### Reduced Motion
When `prefers-reduced-motion: reduce` is active:
- Pipeline track is visible but static (no glow animation, no flow particles)
- Packet is replaced by a section indicator — clicking pipeline nodes reveals content directly
- Content cards appear with simple opacity fades (200ms) instead of assembly choreography
- No ambient particles
- "Run Algorithm" shows all project cards simultaneously without animation
- Navigation reverts to scroll mode with pipeline as decorative sidebar
- All metric numbers display final values immediately
---
## Accessibility
### ARIA Structure
```html
<main aria-label="Andy Charlwood - Interactive Portfolio">
<nav aria-label="Pipeline navigation">
<!-- Pipeline node buttons for section access -->
<button aria-label="Navigate to Skills section">Skills</button>
<button aria-label="Navigate to Experience section">Experience</button>
<!-- etc. -->
</nav>
<div role="application" aria-label="Interactive data pipeline. Drag the data packet or use arrow keys to explore.">
<!-- Pipeline SVG and packet (application role for custom keyboard interaction) -->
</div>
<section aria-label="Skills" role="region">
<!-- Skills content, always in DOM, visibility controlled by CSS -->
</section>
<section aria-label="Professional Experience" role="region">
<!-- Experience cards -->
</section>
<!-- etc. -->
</main>
```
### Screen Reader Experience
Screen readers receive content in logical order regardless of pipeline state. All section content is present in the DOM (not dynamically loaded) — visual reveal is CSS-only (opacity, transform). This means screen readers can traverse the entire CV content immediately.
The pipeline interaction is wrapped in `role="application"` with clear keyboard instructions. Screen reader users can also bypass the pipeline entirely via the section navigation buttons.
### Keyboard Navigation
Full keyboard support as detailed in the Navigation section:
- Arrow keys move the packet between nodes
- Number keys jump to sections
- Tab navigates interactive elements
- Enter activates the "Run Algorithm" button
- Home/End for start/finish
### Focus Management
- When the packet reaches a new section, focus is not automatically moved (this would be disorienting). Instead, the section navigation button for the current section receives an `aria-current="section"` attribute.
- Tab order follows logical CV structure: Hero → Skills → Experience → Education → Projects → Contact.
- All focusable elements have visible focus indicators (2px solid #22D1EE, 2px offset, 4px border-radius).
### Color Contrast
All text on dark backgrounds meets WCAG AA minimum:
- Off-white (#E6EDF3) on deep charcoal (#0D1117) = contrast ratio 13.2:1 (AAA)
- Slate (#8B949E) on deep charcoal (#0D1117) = contrast ratio 5.1:1 (AA)
- Electric cyan (#00D4AA) on deep charcoal (#0D1117) = contrast ratio 8.9:1 (AAA)
- Teal (#00897B) on deep charcoal (#0D1117) = contrast ratio 5.3:1 (AA)
- White (#FFFFFF) on elevated dark (#161B22) = contrast ratio 15.4:1 (AAA)
### Touch Targets
All interactive elements meet minimum 48x48px touch target size:
- Data packet: 48x48px touch area (visually 16-24px, but touch target is expanded)
- Pipeline nodes (mobile tap navigation): 48x48px
- "Run Algorithm" button: minimum 48px height
- Side navigation nodes: 48x48px touch areas
---
## What Makes This Special
1. **It's the only portfolio site with drag-as-primary-navigation.** No one has seen this before. The moment a visitor realizes they're dragging a glowing orb through a pipeline, they know this isn't a template. Novelty is the strongest driver of link sharing.
2. **The metaphor is literal.** Andy builds data pipelines. His CV IS a data pipeline. The user IS the data being processed. There's no metaphorical stretch — this is exactly what his work looks like, translated into an interactive experience. Every recruiter who asks "what do you actually DO?" gets their answer through the medium, not just the text.
3. **"Run Algorithm" is the share moment.** Watching a single packet duplicate into four simultaneous parallel-processing streams, each building a project card in real-time, is the kind of interaction people screen-record. It directly demonstrates the value of automation versus manual work — the user has been doing it manually (one project at a time), then sees the algorithm do it all at once. That contrast IS Andy's professional pitch.
4. **The transition is seamless.** The ECG heartbeat line literally straightens into the pipeline track. The heartbeat pulse echoes in the packet's birth. The biological becomes technical. The entire site is one continuous visual thread from the first terminal boot character to the contact form submission animation. No seam, no break, no "now the real site starts" moment.
5. **It rewards exploration.** The momentum physics make dragging playful — you can launch the packet and watch it coast. The branch points create genuine choices. The ambient particles create a living environment. The snap points encourage pausing. The glow dynamics make movement feel powerful. The bidirectional animation means exploring backward is just as satisfying as going forward.
6. **Dark theme serves the content.** A data analyst's portfolio should feel like a command center, not a brochure. The dark background with glowing pipeline and bright metrics creates immediate technical credibility. It says "this person works with data infrastructure" before you read a single word.
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}`)
})
@@ -1,111 +0,0 @@
# Accessibility Essentials
Accessibility enables creativity - it's a foundation, not a limitation. WCAG 2.1 AA compliance.
## Core Principles (POUR)
- **Perceivable**: Content must be perceivable (alt text, contrast, captions)
- **Operable**: UI must be keyboard/touch accessible
- **Understandable**: Clear, predictable behavior
- **Robust**: Works with assistive technologies
## Contrast Requirements
| Element | Minimum Ratio |
|---------|---------------|
| Normal text | 4.5:1 |
| Large text (18pt+) | 3:1 |
| UI components | 3:1 |
**Tools**: Chrome DevTools Accessibility tab, WebAIM Contrast Checker
## Keyboard Navigation
```tsx
// All interactive elements need focus states
<button className="focus:ring-4 focus:ring-blue-500 focus:outline-none">
Accessible
</button>
// Custom elements need tabindex and key handlers
<div
role="button"
tabIndex={0}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
Custom Button
</div>
```
**Essentials:**
- Tab through entire interface
- Enter/Space activates elements
- Escape closes modals
- Visible focus indicators always
## Essential ARIA
```tsx
// Buttons without text
<button aria-label="Close dialog"><X /></button>
// Expandable elements
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
// Live regions for dynamic content
<div role="status" aria-live="polite">{statusMessage}</div>
<div role="alert" aria-live="assertive">{errorMessage}</div>
// Form errors
<input aria-invalid={hasError} aria-describedby="error-msg" />
{hasError && <p id="error-msg" role="alert">Error text</p>}
```
## Semantic HTML
```tsx
// Use semantic elements, not divs
<header><nav>...</nav></header>
<main><article><h1>...</h1></article></main>
<footer>...</footer>
// Heading hierarchy (never skip levels)
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Adequate spacing between targets
- `touch-manipulation` CSS for responsive touch
## Screen Reader Content
```tsx
// Hidden but announced
<span className="sr-only">Additional context</span>
// Skip link
<a href="#main" className="sr-only focus:not-sr-only">
Skip to main content
</a>
```
## Quick Checklist
- [ ] Keyboard: Can tab through everything
- [ ] Focus: Visible focus indicators
- [ ] Contrast: 4.5:1 for text
- [ ] Alt text: All images have appropriate alt
- [ ] Headings: Logical h1-h6 hierarchy
- [ ] Forms: Labels associated with inputs
- [ ] Errors: Announced to screen readers
- [ ] Touch: 44px minimum targets
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
@@ -1,577 +0,0 @@
# Design System Template
Meta-framework for understanding what's fixed, project-specific, and adaptable in your design system.
## Purpose
This template helps you distinguish between:
- **Fixed Elements**: Universal rules that never change
- **Project-Specific Elements**: Filled in for each project based on brand
- **Adaptable Elements**: Context-dependent implementations
---
## I. FIXED ELEMENTS
These foundations remain consistent across all projects, regardless of brand or context.
### 1. Spacing Scale
**Fixed System:**
```
4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px, 96px
```
**Usage:**
- Margins, padding, gaps between elements
- Mathematical relationships ensure visual harmony
- Use multipliers of base unit (4px)
**Why Fixed:**
Consistent spacing creates visual rhythm regardless of brand personality.
### 2. Grid System
**Fixed Structure:**
- **12-column grid** for most layouts (divisible by 2, 3, 4, 6)
- **16-column grid** for data-heavy interfaces
- **Gutters**: 16px (mobile), 24px (tablet), 32px (desktop)
**Why Fixed:**
Grid provides structural order. Brand personality shows through color, typography, content—not grid structure.
### 3. Accessibility Standards
**Fixed Requirements:**
- **WCAG 2.1 AA** compliance minimum
- **Contrast**: 4.5:1 for normal text, 3:1 for large text
- **Touch targets**: Minimum 44×44px
- **Keyboard navigation**: All interactive elements accessible
- **Screen reader**: Semantic HTML, ARIA labels where needed
**Why Fixed:**
Accessibility is not negotiable. It's a baseline requirement for ethical, legal, and usable products.
### 4. Typography Hierarchy Logic
**Fixed Structure:**
- **Mathematical scaling**: 1.25x (major third) or 1.333x (perfect fourth)
- **Hierarchy levels**: Display → H1 → H2 → H3 → Body → Small → Caption
- **Line height**: 1.5x for body text, 1.2-1.3x for headlines
- **Line length**: 45-75 characters optimal
**Why Fixed:**
Mathematical relationships create predictable, harmonious hierarchy. Specific fonts change, but the logic doesn't.
### 5. Component Architecture
**Fixed Patterns:**
- **Button states**: Default, Hover, Active, Focus, Disabled
- **Form structure**: Label above input, error below, helper text optional
- **Modal pattern**: Overlay + centered content + close mechanism
- **Card structure**: Container → Header → Body → Footer (optional)
**Why Fixed:**
Users expect consistent component behavior. Architecture is fixed; appearance is project-specific.
### 6. Animation Timing Framework
**Fixed Physics Profiles:**
- **Lightweight** (icons, chips): 150ms
- **Standard** (cards, panels): 300ms
- **Weighty** (modals, pages): 500ms
**Fixed Easing:**
- **Ease-out**: Entrances (fast start, slow end)
- **Ease-in**: Exits (slow start, fast end)
- **Ease-in-out**: Transitions (smooth both ends)
**Why Fixed:**
Natural physics feel consistent across brands. Duration and easing create that feeling.
---
## II. PROJECT-SPECIFIC ELEMENTS
Fill in these for each project based on brand personality and purpose.
### 1. Brand Color System
**Template Structure:**
```
NEUTRALS (4-5 colors):
- Background lightest: _______ (e.g., slate-50 or warm-white)
- Surface: _______ (e.g., slate-100)
- Border/divider: _______ (e.g., slate-300)
- Text secondary: _______ (e.g., slate-600)
- Text primary: _______ (e.g., slate-900)
ACCENTS (1-3 colors):
- Primary (main CTA): _______ (e.g., teal-500)
- Secondary (alternative action): _______ (optional)
- Status colors:
- Success: _______ (green-ish)
- Warning: _______ (amber-ish)
- Error: _______ (red-ish)
- Info: _______ (blue-ish)
```
**Questions to Answer:**
- What emotion should the brand evoke? (Trust, excitement, calm, urgency)
- Warm or cool neutrals?
- Conservative or bold accents?
**Examples:**
**Project A: Fintech App**
```
Neutrals: Cool greys (slate-50 → slate-900)
Primary: Deep blue (#0A2463) trust, professionalism
Success: Muted green (#10B981)
Why: Financial products need trust, not playfulness
```
**Project B: Creative Community**
```
Neutrals: Warm greys with beige undertones
Primary: Coral (#FF6B6B) energy, creativity
Success: Teal (#06D6A0) fresh, unexpected
Why: Creative spaces should feel inviting, not corporate
```
**Project C: Healthcare Platform**
```
Neutrals: Pure greys (minimal color temperature)
Primary: Soft blue (#4A90E2) calm, clinical
Success: Medical green (#38A169)
Why: Healthcare needs clarity and calm, not distraction
```
### 2. Typography Pairing
**Template:**
```
HEADLINE FONT: _______
- Weight: _______ (e.g., Bold 700)
- Use case: H1, H2, display text
- Personality: _______ (geometric/humanist/serif/etc.)
BODY FONT: _______
- Weight: _______ (e.g., Regular 400, Medium 500)
- Use case: Paragraphs, UI text
- Personality: _______ (neutral/readable/efficient)
OPTIONAL ACCENT FONT: _______
- Weight: _______
- Use case: _______ (special headlines, callouts)
```
**Pairing Logic:**
- Serif + Sans-serif (classic, editorial)
- Geometric + Humanist (modern + warm)
- Display + System (distinctive + efficient)
**Examples:**
**Project A: Editorial Platform**
```
Headline: Playfair Display (Serif, Bold 700)
Body: Inter (Sans-serif, Regular 400)
Why: Serif headlines = trustworthy, editorial feel
```
**Project B: Tech Startup**
```
Headline: DM Sans (Sans-serif, Bold 700)
Body: DM Sans (Regular 400, Medium 500)
Why: Single-font system = modern, efficient, cohesive
```
**Project C: Luxury Brand**
```
Headline: Cormorant Garamond (Serif, Light 300)
Body: Lato (Sans-serif, Regular 400)
Why: Elegant serif + readable sans = sophisticated
```
### 3. Tone of Voice
**Template:**
```
BRAND PERSONALITY:
- Formal ↔ Casual: _______ (1-10 scale)
- Professional ↔ Friendly: _______ (1-10 scale)
- Serious ↔ Playful: _______ (1-10 scale)
- Authoritative ↔ Conversational: _______ (1-10 scale)
MICROCOPY EXAMPLES:
- Button label (submit form): _______
- Error message (invalid email): _______
- Success message (saved): _______
- Empty state: _______
ANIMATION PERSONALITY:
- Speed: _______ (quick/moderate/slow)
- Feel: _______ (precise/smooth/bouncy)
```
**Examples:**
**Project A: Banking App**
```
Personality: Formal (8), Professional (9), Serious (8)
Button: "Submit Application"
Error: "Email address format is invalid"
Success: "Application submitted successfully"
Animation: Quick (precise, efficient, no-nonsense)
```
**Project B: Social App**
```
Personality: Casual (8), Friendly (9), Playful (7)
Button: "Let's go!"
Error: "Hmm, that email doesn't look right"
Success: "Nice! You're all set 🎉"
Animation: Moderate (smooth, friendly bounce)
```
### 4. Animation Speed & Feel
**Template:**
```
SPEED PREFERENCE:
- UI interactions: _______ (100-150ms / 150-200ms / 200-300ms)
- State changes: _______ (200ms / 300ms / 400ms)
- Page transitions: _______ (300ms / 500ms / 700ms)
ANIMATION STYLE:
- Easing preference: _______ (sharp / standard / bouncy)
- Movement type: _______ (minimal / smooth / expressive)
```
**Examples:**
**Project A: Trading Platform**
```
Speed: Fast (100ms UI, 200ms states, 300ms pages)
Style: Sharp easing, minimal movement
Why: Traders need speed, not distraction
```
**Project B: Wellness App**
```
Speed: Slow (200ms UI, 400ms states, 500ms pages)
Style: Smooth easing, gentle movement
Why: Calm, relaxing experience matches brand
```
---
## III. ADAPTABLE ELEMENTS
Context-dependent implementations that vary based on use case.
### 1. Component Variations
**Button Variants:**
- **Primary**: Full background color (high emphasis)
- **Secondary**: Outline only (medium emphasis)
- **Tertiary**: Text only (low emphasis)
- **Destructive**: Red-ish (danger actions)
- **Ghost**: Minimal (navigation, toolbars)
**Adaptation Rules:**
- Primary: Main CTA, one per screen section
- Secondary: Alternative actions
- Tertiary: Less important actions, multiple allowed
- Use brand colors, but hierarchy logic is fixed
### 2. Responsive Breakpoints
**Fixed Ranges:**
- XS: 0-479px (small phones)
- SM: 480-767px (large phones)
- MD: 768-1023px (tablets)
- LG: 1024-1439px (laptops)
- XL: 1440px+ (desktop)
**Adaptable Implementations:**
**Simple Content Site:**
```
XS-SM: Single column
MD: 2 columns
LG-XL: 3 columns max
Why: Content-focused, don't overwhelm
```
**Dashboard/Data App:**
```
XS: Collapsed, cards stack
SM: Simplified sidebar
MD: Full sidebar + main content
LG-XL: Sidebar + main + right panel
Why: Data apps need more screen real estate
```
### 3. Dark Mode Palette
**Adaptation Strategy:**
Not a simple inversion. Dark mode needs adjusted contrast:
**Light Mode:**
```
Background: #FFFFFF (white)
Text: #0F172A (slate-900) → 21:1 contrast
```
**Dark Mode (Adapted):**
```
Background: #0F172A (slate-900)
Text: #E2E8F0 (slate-200) → 15.8:1 contrast (still AA, but softer)
```
**Why Adapt:**
Pure white on pure black is too harsh. Dark mode needs slightly lower contrast for eye comfort.
### 4. Loading States
**Context-Dependent:**
**Fast operations (<500ms):**
- No loading indicator (feels instant)
**Medium operations (500ms-2s):**
- Spinner or skeleton screen
**Long operations (>2s):**
- Progress bar with percentage
- Or: Skeleton + estimated time
**Interactive Operations:**
- Button shows spinner inside (don't disable, show state)
### 5. Error Handling Strategy
**Context-Dependent:**
**Form Errors:**
```
Validate: On blur (after user leaves field)
Display: Inline below field
Recovery: Clear error on fix
```
**API Errors:**
```
Transient (network): Show retry button
Permanent (404): Show helpful message + next steps
Critical (500): Contact support option
```
**Data Errors:**
```
Missing: Show empty state with action
Corrupt: Show error boundary with reload
Invalid: Highlight + explain what's wrong
```
---
## DECISION TREE
When implementing a feature, ask:
### Is this...
**FIXED?**
- Does it affect structure, accessibility, or universal UX?
- Examples: Spacing scale, grid, contrast ratios, component architecture
- **Action**: Use the fixed system, no variation
**PROJECT-SPECIFIC?**
- Does it express brand personality or purpose?
- Examples: Colors, typography, tone of voice, animation feel
- **Action**: Fill in the template for this project
**ADAPTABLE?**
- Does it depend on context, content, or use case?
- Examples: Component variants, responsive behavior, error handling
- **Action**: Choose appropriate variation based on context
---
## EXAMPLE: Implementing a "Submit" Button
### Fixed Elements (Always the same):
- Touch target: 44px minimum height
- Padding: 16px horizontal (from spacing scale)
- States: Default, Hover, Active, Focus, Disabled
- Animation: 150ms ease-out (lightweight profile)
### Project-Specific (Filled per project):
- **Project A (Bank)**: Dark blue background, white text, "Submit Application"
- **Project B (Social)**: Coral background, white text, "Let's Go!"
- **Project C (Healthcare)**: Soft blue background, white text, "Continue"
### Adaptable (Context-dependent):
- **Form context**: Primary button (full color)
- **Toolbar context**: Ghost button (text only)
- **Danger context**: Destructive variant (red-ish)
---
## VALIDATION CHECKLIST
Before finalizing a design, check:
### Fixed Elements
- [ ] Uses spacing scale (4/8/12/16/24/32/48/64/96px)
- [ ] Follows grid system (12 or 16 columns)
- [ ] Meets WCAG AA contrast (4.5:1 normal, 3:1 large)
- [ ] Touch targets ≥ 44px
- [ ] Typography follows mathematical scale
- [ ] Components follow standard architecture
### Project-Specific Elements
- [ ] Brand colors filled in and intentional
- [ ] Typography pairing chosen and justified
- [ ] Tone of voice defined and consistent
- [ ] Animation speed matches brand personality
### Adaptable Elements
- [ ] Component variants appropriate for context
- [ ] Responsive behavior fits content type
- [ ] Loading states match operation duration
- [ ] Error handling fits error type
---
## PROJECT KICKOFF TEMPLATE
Use this to start a new project:
```
PROJECT NAME: _______________________
PURPOSE: ____________________________
BRAND PERSONALITY:
- Primary emotion: _______
- Warm or cool: _______
- Formal or casual: _______
- Conservative or bold: _______
COLORS (fill the template):
- Neutral base: _______
- Primary accent: _______
- Status colors: _______ / _______ / _______
TYPOGRAPHY (fill the template):
- Headline font: _______
- Body font: _______
- Pairing rationale: _______
TONE:
- Button labels style: _______
- Error message style: _______
- Success message style: _______
ANIMATION:
- Speed preference: _______ (fast/moderate/slow)
- Feel preference: _______ (sharp/smooth/bouncy)
TARGET DEVICES:
- Primary: _______ (mobile/desktop/both)
- Secondary: _______
```
---
## MAINTAINING CONSISTENCY
### Documentation
- Keep this template updated as system evolves
- Document WHY choices were made, not just WHAT
### Communication
- Share with designers: "Here's what varies vs. what's fixed"
- Share with developers: "Here are the design tokens"
### Tooling
- Use CSS variables for project-specific values
- Use Tailwind config for spacing scale
- Use design tokens in Figma/Storybook
### Reviews
- Audit: Does new work follow fixed elements?
- Validate: Are project-specific elements intentional?
- Question: Are adaptations justified by context?
---
## EXAMPLES OF COMPLETE SYSTEMS
### System A: B2B SaaS (Conservative)
**Fixed**: Standard spacing, 12-col grid, WCAG AA, major third type scale
**Project-Specific**:
- Colors: Cool greys + corporate blue
- Typography: DM Sans (headlines + body)
- Tone: Professional, formal
- Animation: Quick, precise (150ms)
**Adaptable**:
- Dashboard gets multi-panel layout
- Forms are extensive (use progressive disclosure)
- Errors show detailed technical info
### System B: Consumer Social App (Playful)
**Fixed**: Same spacing/grid/accessibility/type logic
**Project-Specific**:
- Colors: Warm greys + vibrant coral
- Typography: Poppins (headlines) + Inter (body)
- Tone: Casual, friendly, playful
- Animation: Moderate, bouncy (200ms)
**Adaptable**:
- Mobile-first (most users on phones)
- Forms are minimal (progressive profiling)
- Errors are friendly, not technical
### System C: Healthcare Platform (Clinical)
**Fixed**: Same foundational structure
**Project-Specific**:
- Colors: Pure greys + medical blue
- Typography: System fonts (SF Pro / Segoe)
- Tone: Clear, authoritative, calm
- Animation: Slow, smooth (300ms)
**Adaptable**:
- Desktop-first (clinical use at workstations)
- Forms are complex (HIPAA compliance)
- Errors are precise with next steps
---
## KEY TAKEAWAY
**The system flexibility framework lets you:**
- Maintain consistency (fixed elements)
- Express brand personality (project-specific)
- Adapt to context (adaptable elements)
**Without this framework:**
- Designers reinvent spacing every project
- Components feel inconsistent across products
- Brand personality overrides accessibility
- Context-blind implementations feel wrong
**With this framework:**
- Speed: Start from proven foundations
- Consistency: Fixed elements guarantee it
- Flexibility: Express unique brand identity
- Context: Adapt without breaking system
@@ -1,72 +0,0 @@
# Motion Specification
Motion should surprise and delight while serving function. Animation is a creative tool.
## Easing Curves
| Easing | CSS | Use For |
|--------|-----|---------|
| **Ease-out** | `cubic-bezier(0.0, 0.0, 0.2, 1)` | Entrances, appearing |
| **Ease-in** | `cubic-bezier(0.4, 0.0, 1, 1)` | Exits, disappearing |
| **Ease-in-out** | `cubic-bezier(0.4, 0.0, 0.2, 1)` | State changes, transforms |
| **Spring** | `cubic-bezier(0.68, -0.55, 0.265, 1.55)` | Playful, attention-grabbing |
| **Linear** | `linear` | Spinners, continuous loops |
## Duration by Element Weight
| Weight | Duration | Examples |
|--------|----------|----------|
| **Lightweight** | 150ms | Icons, badges, chips |
| **Standard** | 300ms | Cards, panels, list items |
| **Weighty** | 500ms | Modals, page transitions |
## Duration by Interaction
| Interaction | Duration |
|-------------|----------|
| Button press | 100ms |
| Hover state | 150ms |
| Tooltip appear | 200ms |
| Tab switch | 250ms |
| Modal open | 300ms |
| Page transition | 400ms |
## Common Patterns
```tsx
// Hover transition (CSS)
<button className="transition-colors duration-150 ease-out hover:bg-blue-700">
// Fade + slide (Framer Motion)
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
// Stagger children
<motion.ul variants={{ visible: { transition: { staggerChildren: 0.1 } } }}>
<motion.li variants={{ hidden: { opacity: 0 }, visible: { opacity: 1 } }} />
</motion.ul>
```
## Performance Rules
- Only animate `transform` and `opacity` (GPU-accelerated)
- Avoid animating `width`, `height`, `margin`, `padding`
- Keep durations under 500ms for UI interactions
- Respect `prefers-reduced-motion`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
## Resources
- [Framer Motion](https://www.framer.com/motion/)
- [CSS Easing Functions](https://easings.net/)
@@ -1,90 +0,0 @@
# Responsive Design Essentials
Mobile-first approach: start with mobile, progressively enhance for larger screens.
## Breakpoints
| Range | Pixels | Devices | Strategy |
|-------|--------|---------|----------|
| **XS** | 0-479px | Small phones | Single column, stacked nav, 44px touch targets |
| **SM** | 480-767px | Large phones | Single column, bottom nav, simplified UI |
| **MD** | 768-1023px | Tablets | 2 columns possible, sidebar nav |
| **LG** | 1024-1439px | Laptops | Multi-column, full nav, desktop UI |
| **XL** | 1440px+ | Desktop | Max-width containers, multi-panel layouts |
## Tailwind Responsive
```tsx
// Mobile-first: base styles, then scale up
<div className="
w-full // mobile: full width
sm:w-1/2 // 480px+: half
md:w-1/3 // 768px+: third
lg:w-1/4 // 1024px+: quarter
">
// Responsive grid
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
// Responsive typography
<h1 className="text-3xl md:text-4xl lg:text-5xl">
// Show/hide by breakpoint
<div className="block md:hidden">Mobile only</div>
<div className="hidden md:block">Desktop only</div>
```
## Fluid Typography
```css
h1 { font-size: clamp(2rem, 5vw, 4rem); }
p { font-size: clamp(1rem, 2.5vw, 1.25rem); }
```
## Touch Targets
- Minimum **44x44px** for all interactive elements
- Use `touch-manipulation` to prevent 300ms tap delay
- Adequate spacing between targets
```tsx
<button className="min-w-[44px] min-h-[44px] touch-manipulation">
```
## Mobile Simplification
| Desktop | Mobile |
|---------|--------|
| Full nav bar | Hamburger menu |
| Side-by-side fields | Stacked fields |
| Multi-column grid | Single column |
| Inline buttons | Fixed bottom bar |
| Data table | Collapsed cards |
| Visible sidebar | Hidden/collapsible |
## Images
```tsx
// Responsive images
<img
srcSet="image-400w.jpg 400w, image-800w.jpg 800w, image-1200w.jpg 1200w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
loading="lazy"
/>
// Next.js
<Image src="/hero.jpg" width={1200} height={600} priority className="w-full h-auto" />
```
## Testing
Test at these widths:
- 375px (iPhone SE)
- 390px (iPhone 14)
- 768px (iPad)
- 1024px (iPad Pro)
- 1280px+ (Desktop)
## Resources
- [Tailwind Responsive](https://tailwindcss.com/docs/responsive-design)
@@ -1,718 +0,0 @@
---
name: bencium-innovative-ux-designer
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
metadata:
version: 2.0.0
---
# Innovative UX Designer
Create distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. Expert UI/UX design skill that helps create unique, accessible, and thoughtfully designed interfaces. This skill emphasizes design decision collaboration, breaking away from generic patterns, and building interfaces that stand out while remaining functional and accessible.
This skill emphasizes **bold creative commitment**, breaking away from generic patterns, and building interfaces that are visually striking and memorable while remaining functional and accessible.
## Core Philosophy
**CRITICAL: Design Thinking Protocol**
Before coding, **ASK to understand context**, then **COMMIT BOLDLY** to a distinctive direction:
### Questions to Ask First
1. **Purpose**: What problem does this interface solve? Who uses it?
2. **Tone**: What aesthetic extreme fits? (see Tone Options below)
3. **Constraints**: Technical requirements (framework, performance, accessibility)?
4. **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
### Tone Options (Pick an Extreme)
Choose a clear aesthetic direction and execute with precision:
- **Brutally minimal** - stripped to essence, bold typography, vast whitespace
- **Maximalist chaos** - layered, dense, visually rich, controlled disorder
- **Retro-futuristic** - vintage meets sci-fi, nostalgic tech aesthetics
- **Organic/natural** - soft edges, earthy colors, nature-inspired textures
- **Luxury/refined** - elegant spacing, premium typography, subtle details
- **Playful/toy-like** - bright colors, rounded shapes, delightful interactions
- **Editorial/magazine** - strong typography hierarchy, asymmetric layouts
- **Brutalist/raw** - exposed structure, harsh contrasts, intentionally rough
- **Art deco/geometric** - bold patterns, metallic accents, symmetric elegance
- **Soft/pastel** - gentle gradients, muted tones, calming atmosphere
- **Industrial/utilitarian** - functional, no-nonsense, mechanical precision
### After Getting Context
- **Commit fully** to the chosen direction - no half measures
- Present 2-3 alternative approaches with trade-offs
- Then implement with precision: production-grade, visually striking, memorable
## Foundational Design Principles
### Stand Out From Generic Patterns
**NEVER Use These AI-Generated Aesthetics:**
- **Fonts**: Inter, Roboto, Arial, system fonts as primary choice, Space Grotesk (overused by AI)
- **Colors**: Generic SaaS blue (#3B82F6), purple gradients on white backgrounds
- **Patterns**: Cookie-cutter layouts, predictable component arrangements
- **Effects**: Glass morphism, Apple design mimicry, liquid/blob backgrounds
- **Overall**: Anything that looks "Claude-generated" or machine-made
**Instead, Create Atmosphere:**
- Suggest photography, patterns, textures over flat solid colors
- Apply gradient meshes, noise textures, geometric patterns
- Use layered transparencies, dramatic shadows, decorative borders
- Consider custom cursors, grain overlays, contextual effects
- Think beyond typical patterns - you can step off the written path
**Draw Inspiration From:**
- Modern landing pages (Perplexity, Comet Browser, Dia Browser)
- Framer templates and their innovative approaches
- Leading brand design studios
- Historical design movements (Bauhaus, Otl Aicher, Braun) - but as inspiration, not imitation
- Beautiful background animations (CSS, SVG) - slow, looping, subtle
**Visual Interest Strategies:**
- Unique color pairs that aren't typical
- Animation effects that feel fresh
- Background patterns that add depth without distraction
- Typography combinations that create contrast
- Visual assets that tell a story
### Core Design Philosophy
1. **Simplicity Through Reduction**
- Identify the essential purpose and eliminate distractions
- Begin with complexity, then deliberately remove until reaching the simplest effective solution
- Every element must justify its existence
2. **Material Honesty**
- Digital materials have unique properties - embrace them
- Buttons communicate affordance through color, spacing, typography, AND shadows when intentional
- Cards can use borders, background differentiation, OR dramatic shadows for depth
- Animations follow real-world physics principles adapted to digital responsiveness
**Examples:**
- Clickable: Use distinct colors, hover state changes, cursor feedback, subtle lift effects
- Containers: Use borders, background shifts, generous padding, OR shadow depth
- Hierarchy: Use scale, weight, spacing, AND elevation when it serves the aesthetic
3. **Functional Layering**
- Create hierarchy through typography scale, color contrast, and spatial relationships
- Layer information conceptually (primary → secondary → tertiary)
- Use shadows and gradients INTENTIONALLY when they serve the aesthetic direction
- Embrace functional depth: modals over content, dropdowns over UI
- Avoid: glass morphism, Apple mimicry (but shadows/gradients are tools, not enemies)
4. **Obsessive Detail**
- Consider every pixel, interaction, and transition
- Excellence emerges from hundreds of small, intentional decisions
- Balance: Details should serve simplicity, not complexity
- When detail conflicts with clarity, clarity wins
5. **Coherent Design Language**
- Every element should visually communicate its function
- Elements should feel part of a unified system
- Nothing should feel arbitrary
6. **Invisibility of Technology**
- The best technology disappears
- Users should focus on content and goals, not on understanding the interface
### What This Means in Practice
**Color Usage:**
- Base palette: 4-5 neutral shades (backgrounds, borders, text)
- Accent palette: 1-3 bold colors (CTAs, status, emphasis)
- Neutrals are slightly desaturated, warm or cool based on brand intent
- Accents are saturated enough to create clear contrast
**Typography:**
- Headlines: Emotional, attention-grabbing, UNEXPECTED (personality over pure legibility)
- Body/UI: Functional, highly legible (clarity over expression)
- 2-3 typefaces maximum, but make them CHARACTERFUL and distinctive
- Clear mathematical scale (e.g., 1.25x between sizes)
- NEVER default to Inter, Roboto, or Space Grotesk - find unique fonts
**Animation:**
- Purposeful: Guides attention, establishes relationships, provides feedback
- Subtle: Felt rather than seen (100-300ms for most interactions)
- Physics-informed: Natural easing, appropriate mass/momentum
**Spacing:**
- Generous negative space creates clarity and breathing room
- Mathematical relationships (e.g., 4px base, 8/16/24/32/48px scale)
- Consistent application creates visual rhythm
### Design Decision Checklist
Before presenting any design, verify:
1. **Purpose**: Does every element serve a clear function?
2. **Hierarchy**: Is visual importance aligned with content importance?
3. **Consistency**: Do similar elements look and behave similarly?
4. **Accessibility**: Does it meet WCAG AA standards? (contrast, touch targets, keyboard nav)
5. **Responsiveness**: Does it work on mobile, tablet, desktop?
6. **Uniqueness**: Does this break from generic SaaS patterns?
7. **Approval**: Have I asked before implementing colors, fonts, sizes, layouts?
**Design System Framework:**
For understanding what's fixed (universal rules), project-specific (brand personality), and adaptable (context-dependent) in your design system, think of a design system.
## Visual Design Standards
### Color & Contrast
**Color System Architecture:**
Every interface needs two color roles:
1. **Base/Neutral Palette (4-5 colors):**
- Backgrounds (lightest)
- Surface colors (cards, inputs)
- Borders and dividers
- Text (darkest)
- Use slightly desaturated, warm or cool greys based on brand
2. **Accent Palette (1-3 colors):**
- Primary action (CTA buttons)
- Status indicators (success, warning, error, info)
- Focus/hover states
- Use saturated colors for clear contrast against neutrals
**Palette Structure Example:**
```
Neutrals: slate-50, slate-100, slate-300, slate-700, slate-900
Accents: teal-500 (primary), amber-500 (warning), red-500 (error)
```
**Color Application Rules:**
- **Backgrounds**: Lightest neutral (slate-50 or white)
- **Text**: Darkest neutral for primary text (slate-900), mid-tone for secondary (slate-600)
- **Buttons (primary)**: Accent color with white text
- **Buttons (secondary)**: Neutral with border and dark text
- **Status indicators**: Specific accent (green=success, red=error, amber=warning, blue=info)
- **Interactive states**:
- Hover: Darken by 10-15% or shift hue slightly
- Focus: Use ring/outline in accent color
- Disabled: Reduce opacity to 40-50% and remove hover effects
**Color Relationships:**
Choose warm or cool intentionally based on brand:
- **Warm greys** (beige/brown undertones): Organic, approachable, trustworthy
- **Cool greys** (blue undertones): Modern, tech-forward, professional
Accent colors should have clear contrast with both:
- Light backgrounds (for buttons on white)
- Dark text (if used as backgrounds for white text)
**Intentional Color Usage:**
- Every color must serve a purpose (hierarchy, function, status, or action)
- Avoid decorative colors that don't communicate meaning
- Maintain consistency: same color = same meaning throughout
**Accessibility:**
- Ensure sufficient contrast for color-blind users
- Follow WCAG 2.1 AA: minimum 4.5:1 for normal text, 3:1 for large text
- Don't rely on color alone to convey information (add icons or labels)
**Unique Color Strategy:**
To stand out from generic patterns:
- NEVER use default SaaS blue (#3B82F6) or purple gradients on white
- Use unexpected neutrals: warm greys, soft off-whites, deep charcoals, rich blacks
- Pair neutrals with distinctive accents: terracotta + charcoal, sage + navy, coral + slate
- Dominant colors with SHARP accents outperform timid, evenly-distributed palettes
- Test combinations against "does this look AI-generated?" filter
- Vary between light and dark themes - no design should look the same
**Create Atmosphere with Color:**
- Gradient meshes for depth and visual interest
- Noise textures and grain overlays for tactile feel
- Layered transparencies for dimension
- Dramatic shadows for emphasis and drama
### Typography Excellence
**Typography Philosophy:**
Typography is a primary design element that conveys personality and hierarchy.
**Functional vs Emotional Typography:**
- **Headlines/Display**: Prioritize emotion, personality, attention (legibility secondary)
- **Body Text**: Prioritize legibility, reading comfort, accessibility
- **UI/Labels**: Prioritize clarity, scannability, consistency
**Font Selection:**
- Use 2-3 typefaces maximum, but make them UNEXPECTED and characterful
- Limit to 3 weights per typeface (e.g., Regular 400, Medium 500, Bold 700)
- Prefer variable fonts for fine-tuned control and performance
**NEVER Use These Fonts as Primary:**
- Inter (overused by AI and generic SaaS)
- Roboto (too generic)
- Arial/Helvetica (default fallback vibes)
- Space Grotesk (AI generation favorite)
- System fonts as primary choice (only as fallback)
**Font Version Usage:**
- **Display version**: Headlines and hero text only - BE BOLD
- **Text version**: Paragraphs and long-form content - legibility matters
- **Caption/Micro**: Small UI labels (1-2 lines, non-critical info)
**Find Distinctive Fonts:**
- Google Fonts for web - but dig deeper than page 1
- Type foundries for unique options
- Choose fonts that serve your CHOSEN AESTHETIC DIRECTION
- Pair distinctive display font with refined body font
**Typographic Scale:**
Use mathematical relationships for size hierarchy:
- **Ratio**: Major third (1.25x) for moderate contrast, Perfect fourth (1.333x) for dramatic
- **Base size**: 16px (1rem) for body text
- **Example scale (1.25x)**:
```
xs: 0.64rem (10px)
sm: 0.8rem (13px)
base: 1rem (16px)
lg: 1.25rem (20px)
xl: 1.563rem (25px)
2xl: 1.953rem (31px)
3xl: 2.441rem (39px)
4xl: 3.052rem (49px)
5xl: 3.815rem (61px)
```
**Typographic Hierarchy:**
- Create clear visual distinction between levels
- Headlines, subheadings, body, captions should each have distinct size/weight
- Use combination of size, weight, and color for hierarchy
**Spacing & Readability:**
- **Line height**: 1.5x font size for body text (e.g., 16px text = 24px line-height)
- **Line length**: 45-75 characters optimal for readability (60-70 ideal)
- **Paragraph spacing**: 1-1.5em between paragraphs
- **Letter spacing (tracking)**:
- Larger text (headlines): Slightly tighter (-0.02em to -0.05em)
- Normal text (body): Default (0)
- Small text (captions): Slightly looser (+0.01em to +0.03em)
- General rule: As size increases, reduce tracking; as size decreases, increase tracking
**Font Pairing Logic:**
When using multiple typefaces, create contrast through:
- **Category contrast**: Serif + Sans-serif (classic, clear distinction)
- **Weight contrast**: Light + Bold (dynamic, energetic)
- **Personality contrast**: Geometric + Humanist (modern + warm)
Examples:
- Serif headlines + Sans body (editorial, trustworthy)
- Display headlines + System body (distinctive + efficient)
- Bold sans headlines + Light sans body (modern, clean)
**UI Typography:**
Specific guidance for interface elements:
- **Button text**: Semi-Bold (600), 14-16px, consistent casing (all-caps OR title case)
- **Form labels**: Regular (400), 14px, positioned above input
- **Form input text**: Regular (400), 16px minimum (prevents iOS zoom on focus)
- **Placeholder text**: Light (300) or desaturated color, same size as input
- **Error messages**: Regular (400), 12-14px, color-coded (red-ish)
**Responsive Typography:**
Scale type sizes across breakpoints:
```tsx
// Example with Tailwind
<h1 className="text-3xl md:text-4xl lg:text-5xl">
Responsive Headline
</h1>
// Or with CSS clamp (fluid)
h1 {
font-size: clamp(2rem, 5vw, 4rem);
}
```
Reduce sizes on mobile (20-30% smaller than desktop)
Reduce hierarchy levels on small screens (fewer distinct sizes)
### Layout & Spatial Design
**Compositional Balance:**
- Every screen should feel balanced
- Pay attention to visual weight and negative space
- Use generous negative space to focus attention
- Add sufficient margins and paddings for professional, spacious look
**Grid Discipline:**
- Maintain consistent underlying grid system
- Create sense of order while allowing meaningful exceptions
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
**Spatial Relationships:**
- Group related elements through proximity, alignment, and shared attributes
- Use size, color, and spacing to highlight important elements
- Guide user focus through visual hierarchy
**Attention Guidance:**
- Design interfaces that guide user attention effectively
- Avoid cluttered interfaces where elements compete
- Create clear paths through the content
## Interaction Design
**Motion Specification:**
For detailed motion specs, see MOTION-SPEC.md (easing curves, duration tables, state-specific animations, implementation patterns).
### User Experience Patterns
**Core UX Principles:**
1. **Direct Manipulation**
- Users interact directly with content, not through abstract controls
- Examples:
- Drag & drop to reorder items (not up/down buttons)
- Inline editing (click to edit, not separate form)
- Sliders for ranges (not numeric input with +/-)
- Pinch/zoom gestures on mobile (not +/- buttons)
2. **Immediate Feedback**
- Every interaction provides instantaneous visual feedback (within 100ms)
- Types of feedback:
- **Visual**: Button pressed state, hover effects, color changes
- **Haptic**: Vibration on mobile (submit, error, success)
- **Audio**: Subtle sounds for critical actions (optional, user-controlled)
- **Loading**: Skeleton screens, spinners for >300ms operations
- **Success**: Checkmarks, green highlights, toast notifications
- **Error**: Red highlights, inline error messages, shake animations
3. **Consistent Behavior**
- Similar-looking elements behave similarly
- Examples:
- **Visual consistency**: All primary buttons have same colors, sizes, hover states
- **Behavioral consistency**: All modals close via X button, ESC key, and outside click
- **Interaction consistency**: All drag targets have same hover state and drop feedback
- **Pattern consistency**: All forms validate on blur and submit
4. **Forgiveness**
- Make errors difficult, but recovery easy
- **Prevention strategies**:
- Disable invalid actions (grey out unavailable buttons)
- Validate inputs inline (before submission)
- Confirm destructive actions (delete, overwrite)
- Auto-save in background (drafts, progress)
- **Recovery strategies**:
- Undo/redo for all state changes
- Soft deletes (trash/archive before permanent delete)
- Clear error messages with actionable fixes
- Preserve user input on errors (don't clear forms)
5. **Progressive Disclosure**
- Reveal details as needed rather than overwhelming users
- Levels of disclosure:
- **Summary**: Show essential info by default (card title, price, rating)
- **Details**: Expand to show more info (description, specs, reviews)
- **Advanced**: Hide complex options behind "Advanced settings" toggle
- Examples:
- Accordion: Start collapsed, expand on click
- Search filters: Show 3-5 common filters, hide rest behind "More filters"
- Settings: Basic settings visible, advanced behind "Show advanced"
**Modern UX Patterns:**
1. **Conversational Interfaces**
Prioritize natural language interaction where appropriate:
**Four types:**
- **Pure chat**: Full conversation (AI assistants, support bots)
- **Command palette**: Text-based shortcuts (Cmd+K, search everywhere)
- **Smart search**: Natural language queries (search "meetings next week" vs filtering)
- **Form alternatives**: Conversational data collection ("What's your name?" vs form fields)
**When to use:**
- Complex searches with multiple variables
- Task guidance (wizards, onboarding)
- Contextual help
- Quick actions (command palette)
**When NOT to use:**
- Simple forms (just use inputs)
- Precise control interfaces (design tools, dashboards)
- High-frequency repetitive tasks
2. **Adaptive Layouts**
Respond to user context automatically:
- **Time-based**: Dark mode at night, light during day
- **Device-based**: Simplified UI on mobile, full features on desktop
- **Connection-based**: Reduce images/video on slow connections
- **Usage-based**: Prioritize frequent actions, hide rarely-used features
Examples:
- Auto dark/light mode based on time or system preference
- Simplified mobile navigation (hamburger menu) vs full desktop nav
- Collapsed sidebar on small screens, expanded on large
3. **Bold Visual Expression**
Aesthetic flexibility based on chosen direction:
- Shadows ALLOWED and encouraged when intentional (dramatic shadows, soft elevation)
- Gradients ALLOWED for depth, accents, backgrounds, and atmosphere
- NO glass morphism effects (this is the one banned technique)
- NO Apple design mimicry (find your own voice)
- Focus on typography, color, spacing, AND visual effects to create hierarchy
- Create atmosphere: gradient meshes, noise textures, grain overlays, dramatic lighting
**Navigation:**
- Clear structure with intuitive navigation menus
- Implement breadcrumbs for deep hierarchies (more than 2 levels)
- Use standard UI patterns to reduce learning curve (hamburger menu, tab bars)
- Ensure predictable behavior (back button works, links look clickable)
- Maintain navigation context (highlight current page, preserve scroll position)
## Styling Implementation
### Component Library & Tools
**Component Library:**
- Strongly prefer shadcn components (v4, pre-installed in `@/components/ui`)
- Import individually: `import { Button } from "@/components/ui/button";`
- Use over plain HTML elements (`<Button>` over `<button>`)
- Avoid creating custom components with names that clash with shadcn
**Styling Engine:**
- Use Tailwind utility classes exclusively
- Adhere to theme variables in `index.css` via CSS custom properties
- Map variables in `@theme` (see `tailwind.config.js`)
- Use inline styles or CSS modules only when absolutely necessary
**Icons:**
- Use `@phosphor-icons/react` for buttons and inputs
- Example: `import { Plus } from "@phosphor-icons/react"; <Plus />`
- Use color for plain icon buttons
- Don't override default `size` or `weight` unless requested
**Notifications:**
- Use `sonner` for toasts
- Example: `import { toast } from 'sonner'`
**Loading States:**
- Always add loading states, spinners, placeholder animations
- Use skeletons until content renders
### Layout Implementation
**Spacing Strategy:**
- Use grid/flex wrappers with `gap` for spacing
- Prioritize wrappers over direct margins/padding on children
- Nest wrappers as needed for complex layouts
**Conditional Styling:**
- Use ternary operators or clsx/classnames utilities
- Example: `className={clsx('base-class', { 'active-class': isActive })}`
### Responsive Design
**Fluid Layouts:**
- Use relative units (%, em, rem) instead of fixed pixels
- Implement CSS Grid and Flexbox for flexible layouts
- Design mobile-first, then scale up
**Media Queries:**
- Use breakpoints based on content needs, not specific devices
- Test across range of devices and orientations
**Touch Targets:**
- Minimum 44x44 pixels for interactive elements
- Provide adequate spacing between touch targets
- Consider hover states for desktop, focus states for touch/keyboard
**Performance:**
- Optimize assets for mobile networks
- Use CSS animations over JavaScript
- Implement lazy loading for images and videos
## Accessibility Standards
**Core Requirements:**
- Follow WCAG 2.1 AA guidelines
- Ensure keyboard navigability for all interactive elements
- Minimum touch target size: 44×44px
- Use semantic HTML for screen reader compatibility
- Provide alternative text for images and non-text content
**Implementation Details:**
- Use descriptive variable and function names
- Event functions: prefix with "handle" (handleClick, handleKeyDown)
- Add accessibility attributes:
- `tabindex="0"` for custom interactive elements
- `aria-label` for buttons without text
- `role` attributes when semantic HTML isn't sufficient
- Ensure logical tab order
- Provide visible focus states
## Design Process & Testing
### Design Workflow
1. **Understand Context:**
- What problem are we solving?
- Who are the users and when will they use this?
- What are the success criteria?
2. **Explore Options:**
- Present 2-3 alternative approaches
- Explain trade-offs of each option
- Ask which direction resonates
3. **Implement Iteratively:**
- Start with structure and hierarchy
- Add visual polish progressively
- Test at each stage
4. **Validate:**
- Use playwright MCP to test visual changes
- Check across different screen sizes
- Verify accessibility
### Testing Checklist
**Visual Testing:**
- Use playwright MCP when available for automated testing
- Check responsive behavior at common breakpoints
- Verify touch targets on mobile
- Test with different content lengths (short, long, edge cases)
**Accessibility Testing:**
- Test keyboard navigation
- Verify screen reader compatibility
- Check color contrast ratios
- Ensure focus states are visible
**Cross-Device Testing:**
- Test on actual devices, not just emulators
- Check different browsers (Chrome, Firefox, Safari)
- Verify touch interactions on mobile
- Test landscape and portrait orientations
## Examples
### Example 1: Designing a Button
**Initial Question to Ask:**
```
Before I implement this button, I have a few design questions:
1. What's the primary action you want to emphasize?
2. Do you have brand colors in mind, or should I suggest options?
3. What emotion should this button convey? (urgent, calm, playful, professional)
```
**After Getting Approval, Implementation:**
```tsx
import { Button } from "@/components/ui/button";
import { ArrowRight } from "@phosphor-icons/react";
<Button
className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-lg transition-colors duration-200"
>
Get Started
<ArrowRight className="ml-2" />
</Button>
```
### Example 2: Typography Hierarchy
**Question First:**
```
For this content hierarchy, I'm thinking:
- Headline: Large, bold, attention-grabbing
- Subheading: Medium, regular weight
- Body: Smaller, optimized for reading
Should we go bold and contemporary, or subtle and refined?
```
**Implementation After Approval:**
```tsx
<div className="space-y-4">
<h1 className="text-5xl font-bold tracking-tight text-slate-900">
Headline Here
</h1>
<h2 className="text-2xl text-slate-600">
Supporting subheading
</h2>
<p className="text-base leading-relaxed text-slate-700">
Body text optimized for readability with proper line height.
</p>
</div>
```
### Example 3: Unique Color Palette Suggestion
**Presenting Options:**
```
I'd like to suggest three color directions that break from typical SaaS patterns:
1. **Warm Earth Tones**
- Base: Warm grey (#E8E2DC)
- Accent: Terracotta (#C86E4B)
- For: Organic, trustworthy feel
2. **Cool Midnight**
- Base: Deep navy (#1A2332)
- Accent: Cyan (#4ECDC4)
- For: Modern, tech-forward feel
3. **Soft Pastels**
- Base: Soft pink (#FFE5E5)
- Accent: Sage green (#9DB5A4)
- For: Calm, approachable feel
Which direction feels right for your brand?
```
## Common Patterns to Avoid
**NEVER:**
- Use Inter, Roboto, Arial, Space Grotesk as primary fonts
- Use generic SaaS blue (#3B82F6) or purple gradients on white
- Copy Apple's design language or use glass morphism
- Create cookie-cutter layouts that look AI-generated
- Skip asking about context before designing
- Converge on common choices across generations (vary everything!)
- Use animations that delay user actions
- Create cluttered interfaces where elements compete
**ALWAYS:**
- Ask about purpose, tone, constraints, differentiation FIRST
- Then commit BOLDLY to a distinctive aesthetic direction
- Use unexpected, characterful typography choices
- Create atmosphere: shadows, gradients, textures, grain (when intentional)
- Dominant colors with sharp accents (not timid, evenly-distributed palettes)
- Provide immediate feedback for interactions
- Test with real devices
- Validate accessibility (it enables creativity, not limits it)
- Remember: Claude is capable of extraordinary creative work - don't hold back!
## Version History
- v2.0.0 (2025-11-22): Creative liberation update - bold aesthetics, shadows/gradients allowed, Design Thinking protocol
- v1.0.0 (2025-10-18): Initial release with comprehensive UI/UX design guidance
## References
For additional context, see:
- **Anthropic Frontend Aesthetics Cookbook**: https://github.com/anthropics/claude-cookbooks/blob/main/coding/prompting_for_frontend_aesthetics.ipynb
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- Google Fonts: https://fonts.google.com/
- Tailwind CSS Docs: https://tailwindcss.com/docs
- Shadcn UI Components: https://ui.shadcn.com/
**Progressive Disclosure Files:**
- ACCESSIBILITY.md - Accessibility essentials (WCAG AA baseline)
- MOTION-SPEC.md - Animation timing and easing
- RESPONSIVE-DESIGN.md - Mobile-first breakpoints and patterns
+87 -34
View File
@@ -1,49 +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 { Footer } from './components/Footer'
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">
{phase === 'boot' && (
<BootSequence onComplete={() => setPhase('ecg')} />
)}
<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>
)
}
{phase === 'ecg' && (
<ECGAnimation onComplete={() => setPhase('content')} />
)}
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'
})
{phase === 'content' && (
<>
<FloatingNav />
<main className="max-w-[1000px] mx-auto px-5 xs:px-6 md:px-8">
<Hero />
useEffect(() => {
if (phase === 'login' || phase === 'pmr') {
initModel()
}
if (phase === 'pmr') {
sessionStorage.setItem('portfolio-visited', String(Date.now()))
}
}, [phase])
<Skills />
const skipToDashboard = () => setPhase('pmr')
<Experience />
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>
)}
<Education />
{phase === 'boot' && (
<BootSequence
onComplete={() => setPhase('login')}
/>
)}
<Projects />
{(phase === 'login' || phase === 'pmr') && (
<DetailPanelProvider>
<DashboardLayout />
</DetailPanelProvider>
)}
<Contact />
</main>
<Footer />
</>
)}
</div>
{phase === 'login' && (
<LoginScreen onComplete={() => setPhase('pmr')} />
)}
{(phase === 'boot' || phase === 'login') && (
<SkipButton onSkip={skipToDashboard} />
)}
</div>
</AccessibilityProvider>
)
}
+538 -62
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)
}
}, [onComplete])
}, [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>
)
}
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) => (
<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',
}}
dangerouslySetInnerHTML={{ __html: line.html }}
/>
))}
{/* CRT Scanlines */}
<motion.div
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
)`,
}}
/>
{/* 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: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
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-12 xs:py-16 md: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: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
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-12 xs:py-16 md: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 sm: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-[120px] xs:w-[200px] h-[30px]"
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: 24 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
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-4 xs: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-12 xs:py-16 md: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-[calc(100%-32px)] md:w-auto bg-white rounded-full py-2 px-4 md: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-[11px] xs:text-[13px] font-medium py-1.5 px-2.5 xs: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>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { motion } from 'framer-motion'
const Footer: React.FC = () => {
return (
<motion.footer
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="text-center pt-8 xs:pt-12 pb-6 xs:pb-8 border-t border-slate-200"
>
<svg
className="block mx-auto mb-3"
width="120"
height="20"
viewBox="0 0 120 20"
fill="none"
>
<path
d="M 0 10 L 35 10 L 40 10 C 42 10 43 7 45 7 C 47 7 48 10 50 10 L 54 10 L 56 13 L 60 2 L 64 15 L 66 10 L 70 10 C 72 10 73 7 75 7 C 77 7 78 10 80 10 L 120 10"
stroke="#00897B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.3"
fill="none"
/>
</svg>
<p className="font-secondary text-xs text-muted">
Andy Charlwood &mdash; MPharm, GPhC Registered Pharmacist
</p>
</motion.footer>
)
}
export { Footer }
-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-12 xs:py-16 md:py-20"
>
<motion.h1
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="font-primary font-bold text-heading leading-tight"
style={{ fontSize: 'clamp(28px, 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="grid grid-cols-1 xs:grid-cols-2 md:flex gap-4 mt-10 justify-center md: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>
)
}

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