Compare commits
333 Commits
fc3c0659b2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d7888071b8 | |||
| 5040b9a9fd | |||
| c778d79aec | |||
| 28d2ae61ff | |||
| c651f0ed44 | |||
| d478276c3b | |||
| 46c049def0 | |||
| 98442c0f9f | |||
| 82fcd6bc94 | |||
| 9d153e95d1 | |||
| e452b66a7f | |||
| edc1327987 | |||
| 72d159484f | |||
| cb1c958f68 | |||
| 6bf5a6b6b2 | |||
| 3ddd4ecdbd | |||
| d403e96d34 | |||
| 3773268706 | |||
| b13252be71 | |||
| 3ae4abeb9f | |||
| 1fc2ba2385 | |||
| 30511cac81 | |||
| 95ea088a00 | |||
| a1f7088b48 | |||
| 012c905c90 | |||
| 5806f7a134 | |||
| 9f2be70fd6 | |||
| 9186be7e3e | |||
| 9baa6e605b | |||
| 8b79f7b273 | |||
| 134e41f4f9 | |||
| 62c0d2ea19 | |||
| 836305e2a3 | |||
| d51efb535d | |||
| 025f860815 | |||
| 06ca2a2b46 | |||
| 851d62fcbb | |||
| 0a337b41c2 | |||
| 47b52b5a93 | |||
| 82db5fda54 | |||
| 38e40d36c0 | |||
| 841c1869d6 | |||
| a867c75e9b | |||
| 150b452bb5 | |||
| b266f1f149 | |||
| 0fc7985a7c | |||
| 49bddeaa45 | |||
| e2ba2575b6 | |||
| 61299100d9 | |||
| abb4fcd909 | |||
| 0fba10d469 | |||
| 3c5f9a506c | |||
| de5b5939d6 | |||
| 661dba4b75 | |||
| 9e31843fc9 | |||
| f7469f487f | |||
| 9a58b3c312 | |||
| 01a48ce691 | |||
| 5eb46b02d8 | |||
| 1b19087782 | |||
| 49c9e0cecf | |||
| 7528935d2b | |||
| 8f4ddc454a | |||
| 296b18f025 | |||
| 45b87466be | |||
| bbe7900968 | |||
| 0ee7b5d44c | |||
| 83b327d58e | |||
| 6605966fab | |||
| 8178d03cb2 | |||
| e9a7581aa5 | |||
| aca57714e4 | |||
| 9276955fa8 | |||
| 8b674ffe14 | |||
| 7d7628c8a7 | |||
| 65b265733e | |||
| b34ecb89e2 | |||
| 4dfb1607c1 | |||
| 2e242a650a | |||
| 683275416e | |||
| 18d2704677 | |||
| c3a72d0bee | |||
| 5a657c4aac | |||
| 78e994ec5e | |||
| 68f92fb9a0 | |||
| be7a65ef8a | |||
| 5fa01b8d66 | |||
| 98d767fa7f | |||
| a6df900605 | |||
| 5637d56e02 | |||
| 24ffe03c0f | |||
| e5c7d9bb41 | |||
| 960c9b7729 | |||
| dad638e68e | |||
| b67c3b041f | |||
| ab80d65958 | |||
| 2306d2ec2e | |||
| b418338cd7 | |||
| c9dd93ac70 | |||
| a258706bf3 | |||
| 67fe5567a9 | |||
| f3e9b58e8d | |||
| 76692682da | |||
| f3e6f6670b | |||
| 354096fd70 | |||
| f48d98b7fc | |||
| 408cd9573c | |||
| 622baeb449 | |||
| 21233c98bb | |||
| 89d778b2df | |||
| 13b341abcd | |||
| 752f1c2947 | |||
| 743fb625d5 | |||
| 52238c5662 | |||
| 46cc22500b | |||
| 832c904376 | |||
| 8c8329f6e3 | |||
| 634eb10b2c | |||
| 5fcc59414f | |||
| 68b293dc6d | |||
| c9c69d2417 | |||
| b41a422cf0 | |||
| d2efc7030a | |||
| c9cc832382 | |||
| f0870cf320 | |||
| 194f83f490 | |||
| 8cc7038942 | |||
| 4bab9b369c | |||
| 7f3428184f | |||
| be443907ee | |||
| 0bcdc89427 | |||
| 0fbbf9e46f | |||
| 4580ca9c84 | |||
| 667e5b249c | |||
| 9e9dd1ae4b | |||
| ab5444ee94 | |||
| 657d2f299e | |||
| 5f3e0db712 | |||
| 29e1728e11 | |||
| 273c143d5e | |||
| 7ee1a2d9de | |||
| 2fca61b43a | |||
| c4480d7c99 | |||
| ae15ccf961 | |||
| 91f8dac261 | |||
| aa1774320a | |||
| 219a3f04be | |||
| 384e393963 | |||
| 489e306b0a | |||
| 19a4360a8c | |||
| 0e450c4b17 | |||
| 4fe68aa1b2 | |||
| 526ee7dd90 | |||
| 615198b080 | |||
| fc1581a9ff | |||
| 274188b6aa | |||
| cd7184cfd4 | |||
| 42293c5336 | |||
| 49f0f1aaf8 | |||
| c8eb38f083 | |||
| a56a4dd848 | |||
| e5be969308 | |||
| 83b941262e | |||
| 7fbf1dcb95 | |||
| 598b1c11f6 | |||
| c4d73f970d | |||
| 1bd735e90a | |||
| 939b2cddf2 | |||
| 73a390ce76 | |||
| cfc1c5797d | |||
| 8c04e517bc | |||
| 09af29f32d | |||
| 3f6ac5ef8d | |||
| 0ffacf8a0a | |||
| 38fdb6fa27 | |||
| f28693b0ad | |||
| 05b48b995e | |||
| 962729ae92 | |||
| 6e66cd631b | |||
| c5b0f7da43 | |||
| 649f4d7c68 | |||
| b515b3d70c | |||
| 2d7ff7e77a | |||
| f88cbf6e08 | |||
| daabfb7fd2 | |||
| 3f026a0701 | |||
| cf1f466452 | |||
| 64973176fb | |||
| 364efb8805 | |||
| a7537083e6 | |||
| 645088bbc1 | |||
| f6463cc4b1 | |||
| 5ef7cdb259 | |||
| a961518ebf | |||
| 1ca6175b93 | |||
| f9b4062dd5 | |||
| 6bd12dd776 | |||
| 9e9962f114 | |||
| b90706a3f6 | |||
| fcc1232d9b | |||
| 9ffed8d153 | |||
| b5de609cd5 | |||
| 0e7bef0206 | |||
| 7285ea8f45 | |||
| c86b252629 | |||
| 9be2fb9017 | |||
| 4bfc4de956 | |||
| 80d4cc9d7a | |||
| 13131e4c3e | |||
| 1a3d3515f8 | |||
| fa64c98406 | |||
| 0d42db7111 | |||
| 088b783731 | |||
| 071b1b78ae | |||
| 97d353930c | |||
| dbdd51243d | |||
| a8c7d5b41d | |||
| 120d8a7a7b | |||
| 4c92a3a559 | |||
| 24e0f8963f | |||
| 6956ad001b | |||
| 75c03029bf | |||
| 2f8db26cc4 | |||
| a5deb0ea8b | |||
| bbe17fc66a | |||
| 9ec71ae0ed | |||
| 9d61d2c8ca | |||
| fbfd25ffff | |||
| f38e67252b | |||
| 0c87d9f5a4 | |||
| 8830c223aa | |||
| 52ee98d8aa | |||
| 03b4c6cafb | |||
| 9ed77f99a8 | |||
| afc3876210 | |||
| c37fdab8fa | |||
| 980297ea92 | |||
| 8bdb162a07 | |||
| 2886685573 | |||
| b18746ecee | |||
| 6c26518806 | |||
| f4a6b5e32c | |||
| 92502beb03 | |||
| a596b5ac82 | |||
| cf5399a767 | |||
| f7e9c88762 | |||
| ee73efce11 | |||
| 72c75fd1a9 | |||
| 7c31ec07ba | |||
| 6a4fc86387 | |||
| 8dc27ff8a9 | |||
| 29956665ac | |||
| f65bf2ef5c | |||
| aafdeba93e | |||
| acee97a579 | |||
| 38b8e36fab | |||
| 3ad368f935 | |||
| d89ae0c64a | |||
| 7dae67d954 | |||
| 334ea2c02f | |||
| 2c360176c8 | |||
| 4be1b10137 | |||
| 905b3d957a | |||
| c8032f80df | |||
| e2409183f3 | |||
| d0df9137f9 | |||
| dec8ec9769 | |||
| 6d47f2a948 | |||
| 5ad67a512f | |||
| 040e46cbea | |||
| 6501439cef | |||
| 41ddbf6d1d | |||
| 00a5dd0105 | |||
| 69e322af28 | |||
| a2e01270a1 | |||
| adc32b9005 | |||
| a60b0701c2 | |||
| 670c9cc74c | |||
| 37c08387af | |||
| 62b6718cc3 | |||
| 2e48cefc6f | |||
| 2b9a6210ec | |||
| c88ceba136 | |||
| 3176761d9c | |||
| e13a073a6f | |||
| 000df670a3 | |||
| b9db2f5401 | |||
| c3316b9c45 | |||
| b3ebff26bf | |||
| 85ac1b879f | |||
| 4db3be0abb | |||
| f96c6a99d1 | |||
| 7461a83b9d | |||
| b480b742c8 | |||
| bfd17a3e80 | |||
| bba61f73b6 | |||
| 8765470627 | |||
| 43aa836317 | |||
| f0cb6b924f | |||
| 06f0d658b0 | |||
| ad1ce81948 | |||
| 2be346144c | |||
| 1d8cb78143 | |||
| cd4aa1e240 | |||
| fd9dd7d00e | |||
| 8f6bfd0b5e | |||
| 803c4f8a48 | |||
| 5533cded82 | |||
| 86e0015393 | |||
| d16656b954 | |||
| b7471c5cf8 | |||
| 5579e2741a | |||
| f75a6b9a5f | |||
| 8094f74800 | |||
| 4324f06186 | |||
| 5e1c96edfa | |||
| 556940c3c8 | |||
| b8c1aedb5a | |||
| 5a000d6457 | |||
| 3afadbdc73 | |||
| 4eeeb05744 | |||
| 959f0e1842 | |||
| cfd0283c78 | |||
| 192d629125 | |||
| 1a1f1f1938 | |||
| 93051021fc | |||
| a52cb9f84b | |||
| 06ebef80c1 | |||
| ef5bc9c3a6 | |||
| ac113f23c7 | |||
| 4ec108484e | |||
| a7df2d0037 | |||
| f7f7e0db8c |
@@ -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`
|
|
||||||
@@ -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`
|
|
||||||
@@ -7,9 +7,16 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Build output
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dist-server
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@@ -25,3 +32,41 @@ dist-ssr
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.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
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
{
|
|
||||||
"iterations": [
|
|
||||||
{
|
|
||||||
"iteration": 1,
|
|
||||||
"startedAt": "2026-02-11T01:23:53.316Z",
|
|
||||||
"endedAt": "2026-02-11T01:35:05.771Z",
|
|
||||||
"durationMs": 668162,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/SummaryView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 2,
|
|
||||||
"startedAt": "2026-02-11T01:35:10.653Z",
|
|
||||||
"endedAt": "2026-02-11T01:41:17.649Z",
|
|
||||||
"durationMs": 362952,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ConsultationsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 3,
|
|
||||||
"startedAt": "2026-02-11T01:41:22.593Z",
|
|
||||||
"endedAt": "2026-02-11T01:42:12.448Z",
|
|
||||||
"durationMs": 45945,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 4,
|
|
||||||
"startedAt": "2026-02-11T01:42:17.819Z",
|
|
||||||
"endedAt": "2026-02-11T01:50:07.083Z",
|
|
||||||
"durationMs": 465353,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/MedicationsView.tsx",
|
|
||||||
"src/index.css"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 5,
|
|
||||||
"startedAt": "2026-02-11T01:50:12.105Z",
|
|
||||||
"endedAt": "2026-02-11T01:58:48.582Z",
|
|
||||||
"durationMs": 512760,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ProblemsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 6,
|
|
||||||
"startedAt": "2026-02-11T01:58:53.428Z",
|
|
||||||
"endedAt": "2026-02-11T02:05:52.941Z",
|
|
||||||
"durationMs": 415696,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/InvestigationsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 7,
|
|
||||||
"startedAt": "2026-02-11T02:05:57.951Z",
|
|
||||||
"endedAt": "2026-02-11T02:16:56.192Z",
|
|
||||||
"durationMs": 654352,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/DocumentsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"iteration": 8,
|
|
||||||
"startedAt": "2026-02-11T02:17:01.348Z",
|
|
||||||
"endedAt": "2026-02-11T02:30:01.815Z",
|
|
||||||
"durationMs": 776565,
|
|
||||||
"toolsUsed": {},
|
|
||||||
"filesModified": [
|
|
||||||
"Ralph/IMPLEMENTATION_PLAN.md",
|
|
||||||
"Ralph/progress.txt",
|
|
||||||
"src/components/PMRInterface.tsx",
|
|
||||||
"src/components/views/ReferralsView.tsx"
|
|
||||||
],
|
|
||||||
"exitCode": 0,
|
|
||||||
"completionDetected": false,
|
|
||||||
"errors": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"totalDurationMs": 3901785,
|
|
||||||
"struggleIndicators": {
|
|
||||||
"repeatedErrors": {},
|
|
||||||
"noProgressIterations": 0,
|
|
||||||
"shortIterations": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/)
|
|
||||||
```
|
|
||||||
@@ -2,74 +2,63 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## Commands
|
||||||
|
|
||||||
- `npm run dev` — Start dev server (localhost:5173)
|
```bash
|
||||||
- `npm run build` — TypeScript compile + Vite production build
|
npm run dev # Vite dev server (localhost:5173)
|
||||||
- `npm run typecheck` — TypeScript type checking only (`tsc --noEmit`)
|
npm run build # TypeScript compile + Vite production build
|
||||||
- `npm run lint` — ESLint
|
npm run preview # Preview production build locally
|
||||||
- `npm run preview` — Preview production build
|
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
|
## 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
|
### Data flow
|
||||||
2. **ECGAnimation** — Canvas-based heartbeat animation (~5-6s) with letter tracing, background transitions from black to white
|
- **Canonical source:** `src/data/timeline.ts` — all career + education entities live here
|
||||||
3. **Content** — FloatingNav + all CV sections (Hero, Skills, Experience, Education, Projects, Contact, Footer)
|
- **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.
|
### D3 integration pattern
|
||||||
- **Active nav tracking**: `useActiveSection` hook tracks which section is in viewport for FloatingNav highlighting.
|
`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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 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`:
|
- **Primary:** Teal `#00897B` / **Accent:** Coral `#FF6B6B`
|
||||||
- **Colors**: teal `#00897B` (primary), coral `#FF6B6B` (accent), ECG palette (green/cyan/dim)
|
- **PMR palette:** GP system-inspired greens, teals, greys (defined in `tailwind.config.js`)
|
||||||
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono/terminal)
|
- **Font tokens (CSS custom properties):**
|
||||||
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
|
- `--font-ui`: Elvaro Grotesque (dashboard UI)
|
||||||
- Inline styles only for dynamic values that Tailwind can't express (e.g., computed `strokeDashoffset`).
|
- `--font-geist-mono`: Geist Mono / Fira Code fallback (canonical mono token)
|
||||||
|
- `--font-primary` / `--font-secondary`: Plus Jakarta Sans / Inter Tight
|
||||||
### Type System
|
- **Breakpoints:** xs 480, sm 640, md 768, lg 1024, xl 1280
|
||||||
|
|
||||||
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/)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,70 +1,81 @@
|
|||||||
# Andy Charlwood - Interactive CV
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Three-Phase Loading Experience**: Terminal boot (~4s) → ECG animation (~5-6s) → content reveal
|
- **Four-Phase Loading**: Terminal boot → login screen → PMR dashboard (skippable; session-cached for returning visitors)
|
||||||
- **Interactive Sections**: Hero, Skills, Experience, Education, Projects, Contact
|
- **Career Constellation**: D3 force simulation mapping roles as clusters and skills as nodes — interactive via hover, click, tap, and keyboard
|
||||||
- **Smooth Animations**: Framer Motion for scroll reveals and staggered transitions
|
- **Semantic Search**: Pre-computed embeddings + local Xenova transformer model running in-browser
|
||||||
- **SVG Skill Visualization**: Circular progress indicators for skill levels
|
- **Command Palette**: `Ctrl+K` hybrid search (Fuse.js fuzzy + semantic)
|
||||||
- **Floating Navigation**: Active section tracking as you scroll
|
- **Chat Widget**: Gemini/OpenRouter LLM integration for conversational Q&A about career history
|
||||||
- **Responsive Design**: Tailwind CSS with custom breakpoints
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: React 18 + TypeScript
|
| Category | Technologies |
|
||||||
- **Build Tool**: Vite 6
|
|----------|-------------|
|
||||||
- **Styling**: Tailwind CSS 3
|
| **Framework** | React 18 + TypeScript (strict mode) |
|
||||||
- **Animations**: Framer Motion + Canvas API
|
| **Build** | Vite 6 |
|
||||||
- **Icons**: Lucide React
|
| **Styling** | Tailwind CSS 3 + CSS custom properties |
|
||||||
- **Linting**: ESLint 9
|
| **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
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev # Starts Vite + Express backend concurrently
|
||||||
# Start development server
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
npm run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The chat widget and contact form require API keys in a `.env` file — see `.env.example` if available.
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| 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 build` | TypeScript compile + Vite production build |
|
||||||
|
| `npm run start` | Run production server |
|
||||||
| `npm run typecheck` | TypeScript type checking only |
|
| `npm run typecheck` | TypeScript type checking only |
|
||||||
| `npm run lint` | Run ESLint |
|
| `npm run lint` | Run ESLint |
|
||||||
| `npm run preview` | Preview production build |
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run generate-embeddings` | Regenerate semantic search embeddings |
|
||||||
|
| `npm run benchmark` | Run performance benchmarks |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── components/ # React components (PascalCase)
|
├── components/ # React components (PascalCase)
|
||||||
├── hooks/ # Custom hooks (use* prefix)
|
│ ├── constellation/ # D3 career constellation + legend
|
||||||
├── lib/ # Utility functions
|
│ ├── detail/ # Detail panel views per entity type
|
||||||
├── types/ # TypeScript interfaces
|
│ └── tiles/ # Dashboard tile components
|
||||||
├── App.tsx # Phase manager (root component)
|
├── contexts/ # React contexts (DetailPanel, Accessibility)
|
||||||
└── index.css # Global styles + Tailwind
|
├── 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
|
## Design Tokens
|
||||||
|
|
||||||
- **Primary**: Teal `#00897B`
|
- **Primary**: Teal `#00897B` / **Accent**: Coral `#FF6B6B`
|
||||||
- **Accent**: Coral `#FF6B6B`
|
- **Palette**: GP system-inspired greens, teals, and greys
|
||||||
- **Fonts**: Plus Jakarta Sans (primary), Inter Tight (secondary), Fira Code (mono)
|
- **Fonts**: Elvaro Grotesque (UI), Geist Mono / Fira Code (mono), Plus Jakarta Sans / Inter Tight (fallback)
|
||||||
- **Breakpoints**: xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
|
- **Breakpoints**: xxs 360px, xs 480px, sm 640px, md 768px, lg 1024px, xl 1280px
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Private - All rights reserved.
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Implementation Plan — Clinical Record PMR System
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Transform the existing React CV application into a **Patient Medical Record (PMR) system** — a faithful digital clinical information system that presents Andy's CV as a clinician would view a patient record. This is Design 7: The Clinical Record, completely replacing the previous ECG-based design.
|
|
||||||
|
|
||||||
**Core Concept:**
|
|
||||||
The "patient" is Andy's career. Users navigate a genuine PMR interface (similar to EMIS Web, SystmOne, Vision) with:
|
|
||||||
- Patient banner with persistent demographic context
|
|
||||||
- Sidebar navigation with clinical record categories (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals)
|
|
||||||
- Consultation-journal format for experience (History/Examination/Plan structure)
|
|
||||||
- Tabular medications list for skills with proficiency "dosages"
|
|
||||||
- Clinical alert system for standout achievements
|
|
||||||
- Light-mode only (authentic to clinical systems)
|
|
||||||
- Border-heavy, table-heavy, functional aesthetic
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- ECG exit animation → Login sequence → PMR interface materialization (~2.7s total transition)
|
|
||||||
- Animated login screen with typing username/password
|
|
||||||
- 7 sidebar views with instant content swapping (authentic clinical system behavior)
|
|
||||||
- Expandable consultation entries with coded entries (SNOMED-style references)
|
|
||||||
- Sortable medications table with prescribing history expansion
|
|
||||||
- Traffic-light status system (green/amber/red/gray)
|
|
||||||
- Clinical alert banner with acknowledge interaction
|
|
||||||
- Responsive: desktop sidebar → tablet icon-only → mobile bottom nav
|
|
||||||
- Full keyboard navigation (Alt+1-7 shortcuts)
|
|
||||||
- Search across all PMR sections with fuse.js
|
|
||||||
|
|
||||||
**Tech Stack:**
|
|
||||||
- React 18+ with TypeScript
|
|
||||||
- Vite for build tooling
|
|
||||||
- Tailwind CSS for styling
|
|
||||||
- Framer Motion for login animation and transitions
|
|
||||||
- Lucide React for clinical icons
|
|
||||||
- fuse.js for fuzzy search
|
|
||||||
|
|
||||||
**Project Structure:**
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ ├── BootSequence.tsx # Existing terminal animation (preserved)
|
|
||||||
│ ├── ECGAnimation.tsx # Modified for PMR transition
|
|
||||||
│ ├── LoginScreen.tsx # Animated login sequence
|
|
||||||
│ ├── PMRInterface.tsx # Main PMR layout container
|
|
||||||
│ ├── PatientBanner.tsx # Full + condensed banner
|
|
||||||
│ ├── ClinicalSidebar.tsx # Navigation sidebar
|
|
||||||
│ ├── ClinicalAlert.tsx # Dismissible alert banner
|
|
||||||
│ ├── Breadcrumb.tsx # Navigation breadcrumb
|
|
||||||
│ ├── views/
|
|
||||||
│ │ ├── SummaryView.tsx # Patient summary landing
|
|
||||||
│ │ ├── ConsultationsView.tsx # Experience as consultations
|
|
||||||
│ │ ├── MedicationsView.tsx # Skills as medications
|
|
||||||
│ │ ├── ProblemsView.tsx # Achievements as problems
|
|
||||||
│ │ ├── InvestigationsView.tsx# Projects as investigations
|
|
||||||
│ │ ├── DocumentsView.tsx # Education as documents
|
|
||||||
│ │ └── ReferralsView.tsx # Contact as referral form
|
|
||||||
│ ├── ui/
|
|
||||||
│ │ ├── ConsultationEntry.tsx # Expandable consultation
|
|
||||||
│ │ ├── MedicationTable.tsx # Sortable skills table
|
|
||||||
│ │ ├── ProblemEntry.tsx # Problem list item
|
|
||||||
│ │ ├── InvestigationEntry.tsx# Investigation result
|
|
||||||
│ │ └── DocumentEntry.tsx # Document list item
|
|
||||||
├── hooks/
|
|
||||||
│ ├── useScrollCondensation.ts # Patient banner scroll behavior
|
|
||||||
│ └── useSearch.ts # Fuse.js search hook
|
|
||||||
├── data/
|
|
||||||
│ ├── consultations.ts # Experience data
|
|
||||||
│ ├── medications.ts # Skills data
|
|
||||||
│ ├── problems.ts # Achievements data
|
|
||||||
│ ├── investigations.ts # Projects data
|
|
||||||
│ └── documents.ts # Education data
|
|
||||||
├── types/
|
|
||||||
│ └── pmr.ts # All PMR TypeScript interfaces
|
|
||||||
├── lib/
|
|
||||||
│ └── utils.ts # Utility functions
|
|
||||||
├── App.tsx # Phase manager (boot → ecg → login → pmr)
|
|
||||||
└── index.css # Tailwind + PMR CSS variables
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reference Materials:**
|
|
||||||
- `designs/07-the-clinical-record.md` — Complete design specification
|
|
||||||
- `References/CV_v4.md` — Source CV content
|
|
||||||
- `References/concept.html` — Previous ECG implementation (timing reference only)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- Manual verification:
|
|
||||||
- Boot sequence plays (4s) → ECG flatlines → Login screen types username/password → PMR interface materializes
|
|
||||||
- Patient banner condenses on scroll (80px → 48px)
|
|
||||||
- All 7 sidebar views render correctly with proper data
|
|
||||||
- Consultation entries expand/collapse with History/Examination/Plan sections
|
|
||||||
- Medications table sorts correctly by all columns
|
|
||||||
- Clinical alert appears on Summary view and dismisses with animation
|
|
||||||
- Search finds content across all sections
|
|
||||||
- Keyboard shortcuts work (Alt+1-7)
|
|
||||||
- Responsive layouts work at 1024px, 768px, and 480px
|
|
||||||
- No console errors
|
|
||||||
- Accessibility: screen reader announces views, tables are navigable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [x] **Task 1: Create PMR data layer and TypeScript types**
|
|
||||||
|
|
||||||
Create `src/types/pmr.ts` with interfaces for: `Patient`, `Consultation` (History/Examination/Plan/CodedEntries), `Medication` (with PrescribingHistory), `Problem` (status, code, outcome), `Investigation` (with results), `Document`, `ReferralForm`. Create `src/data/` directory with files: `consultations.ts` (5 roles from CV_v4.md mapped to consultation format), `medications.ts` (18 skills mapped to medication format with prescribing history), `problems.ts` (8-10 achievements with traffic light status), `investigations.ts` (4 projects with methodology/results), `documents.ts` (MPharm, Mary Seacole, A-Levels, Research). All data must match CV_v4.md exactly with specific numbers (£14.6M, 14,000 patients, etc.).
|
|
||||||
|
|
||||||
- [x] **Task 2: Modify ECGAnimation for PMR flatline transition**
|
|
||||||
|
|
||||||
Modify `src/components/ECGAnimation.tsx` to change the exit phase. Instead of fading to white and revealing the CV, the animation should: 1) Complete the name tracing as normal, 2) Hold for 300ms, 3) Draw a flatline extending rightward from the name over 300ms (patient monitor flatline visual), 4) Fade entire canvas to black over 200ms, 5) Transition background to dark blue-gray (#1E293B) over 200ms. Emit `onComplete` callback to trigger LoginScreen. Total ECG phase: ~5-6 seconds. Preserve all existing animation timing for heartbeats and letter tracing.
|
|
||||||
|
|
||||||
- [x] **Task 3: Build LoginScreen component with typing animation**
|
|
||||||
|
|
||||||
Create `src/components/LoginScreen.tsx`. Dark blue-gray background (#1E293B). Centered white login card (320px wide, 12px radius, subtle shadow). NHS-blue shield icon at top. Username field: types "A.CHARLWOOD" character-by-character (30ms per char, Geist Mono font). Password field: fills with 8 dots (20ms per dot). "Log In" button: NHS blue (#005EB8), full width. After 150ms pause, button shows pressed state (darkens, 100ms), then emits `onComplete` callback. Total login animation: ~1.2s. Respect `prefers-reduced-motion`: with reduced motion, username appears instantly and login completes in ~500ms.
|
|
||||||
|
|
||||||
- [x] **Task 4: Build PatientBanner component (full and condensed)**
|
|
||||||
|
|
||||||
Create `src/components/PatientBanner.tsx` with two modes. Full banner (80px): patient name "CHARLWOOD, Andrew (Mr)", DOB "14/02/1993", NHS No "221 181 0" (GPhC number formatted), address "Norwich, NR1", phone, email, status "Active" (green dot), badge "Open to opportunities". Action buttons: Download CV, Email, LinkedIn. Condensed banner (48px, sticky after 100px scroll): name, NHS No, status dot, action buttons only. Use `useScrollCondensation` hook with IntersectionObserver. Smooth height transition (200ms). Banner spans full viewport width.
|
|
||||||
|
|
||||||
- [x] **Task 5: Build ClinicalSidebar component with navigation and search**
|
|
||||||
|
|
||||||
Create `src/components/ClinicalSidebar.tsx`. 220px width (desktop), dark blue-gray (#1E293B) background. Header: "CareerRecord PMR v1.0.0". 7 navigation items with Lucide icons: Summary (ClipboardList), Consultations (FileText), Medications (Pill), Problems (AlertTriangle), Investigations (FlaskConical), Documents (FolderOpen), Referrals (Send). Active state: 3px NHS blue left border, white background tint. Separator line after Summary. Footer: "Session: A.CHARLWOOD" and current time. Search input in header with fuse.js integration. Clicking item updates active view instantly (no animation). URL hash updates (#summary, #consultations, etc.).
|
|
||||||
|
|
||||||
- [x] **Task 6: Build SummaryView component with clinical alert**
|
|
||||||
|
|
||||||
Create `src/components/views/SummaryView.tsx`. Grid layout with cards: Patient Demographics (full width, two-column key-value table), Active Problems (left column, green/amber dots with dates), Current Medications Quick View (right column, 4-column table showing top 5 skills), Last Consultation preview (full width, truncated to 2-3 lines with "View Full Record" link). Clinical Alert banner: amber background (#FEF3C7), amber left border, warning icon, text "ALERT: This patient has identified £14.6M in prescribing efficiency savings...", Acknowledge button. Alert slides down with spring animation (250ms) after view loads. Clicking Acknowledge: icon changes to green checkmark (200ms), then alert collapses upward (200ms).
|
|
||||||
|
|
||||||
- [x] **Task 7: Build ConsultationsView with History/Examination/Plan structure**
|
|
||||||
|
|
||||||
Create `src/components/views/ConsultationsView.tsx`. Reverse-chronological journal of 5 roles. Each entry: collapsed state shows date, organization (NHS blue), role title, key coded entry, expand chevron. Click to expand: shows Duration, HISTORY section (context/background), EXAMINATION section (bullet list of analysis/findings), PLAN section (bullet list of outcomes), CODED ENTRIES (SNOMED-style codes like [EFF001], [ALG001]). Section headers styled as clinical consultation dividers (uppercase, letter-spacing). Only one entry expanded at a time. Color-coded left border: NHS blue for NHS N&W ICB, Teal (#00897B) for Tesco PLC. Expand animation: height 0→auto (200ms, ease-out).
|
|
||||||
|
|
||||||
- [x] **Task 8: Build MedicationsView with sortable table and prescribing history**
|
|
||||||
|
|
||||||
Create `src/components/views/MedicationsView.tsx`. Three category tabs: Active Medications (technical skills), Clinical Medications (healthcare domain skills), PRN (strategic skills). Each tab shows a table: Drug Name | Dose (%) | Frequency | Start | Status. Sortable columns: clicking header sorts (asc/desc toggle). Default sort: by category grouping. Table styling: gray-200 borders, alternating row colors, 40px row height. Hover: subtle blue tint (#EFF6FF). Click row to expand "Prescribing History" — mini-timeline showing skill progression (year + description). History styled in Geist Mono. 18 total medications mapped from CV skills with accurate proficiency percentages and usage frequencies.
|
|
||||||
|
|
||||||
- [x] **Task 9: Build ProblemsView with traffic light system**
|
|
||||||
|
|
||||||
Create `src/components/views/ProblemsView.tsx`. Two sections: Active Problems and Resolved Problems. Table columns: Status (traffic light dot), Code (SNOMED-style in Geist Mono), Problem description, Since/Resolved date, Outcome (for resolved). Traffic lights: 8px circles — green (resolved/current), amber (in progress), gray (inactive/historical). Active problems: £220M budget oversight, SQL transformation, data literacy programme. Resolved problems: 8 achievements with specific outcomes ("Python algorithm: 14,000 pts, £2.6M/yr", "70% reduction, 200hrs saved", etc.). Click row to expand full narrative with "linked consultations" navigation.
|
|
||||||
|
|
||||||
- [x] **Task 10: Build InvestigationsView with results panel**
|
|
||||||
|
|
||||||
Create `src/components/views/InvestigationsView.tsx`. Projects presented as diagnostic investigations. Table: Test Name | Requested | Status | Result. Status badges: Complete (green dot), Ongoing (amber dot), Live (pulsing green dot for PharMetrics). 5 investigations: PharMetrics Interactive Platform, Patient Switching Algorithm, Blueteq Generator, CD Monitoring System, Sankey Chart Analysis Tool. Click row to expand "results panel" with tree-indented structure: Date Requested, Date Reported, Status, Requesting Clinician, Methodology, Results, Tech Stack. PharMetrics has "View Results" button linking to medicines.charlwood.xyz.
|
|
||||||
|
|
||||||
- [x] **Task 11: Build DocumentsView for education/certifications**
|
|
||||||
|
|
||||||
Create `src/components/views/DocumentsView.tsx`. Education presented as attached documents. Table: Type (icon), Document, Date, Source. Icons: FileText (certificates), Award (registrations), GraduationCap (academic), FlaskConical (research). 4 documents: MPharm (Hons) 2:1 UEA 2015, GPhC Registration 2016, Mary Seacole Programme 2018, A-Levels 2011 + Drug Delivery Research. Click to expand "preview" panel with tree-indented details: Type, Date Awarded, Institution, Classification, Duration, Research details, Notes. Consistent with Investigations expanded view style.
|
|
||||||
|
|
||||||
- [x] **Task 12: Build ReferralsView with clinical referral form**
|
|
||||||
|
|
||||||
Create `src/components/views/ReferralsView.tsx`. Contact presented as clinical referral form. Form fields: Referring to (pre-filled: CHARLWOOD, Andrew), NHS Number (pre-filled), Priority toggle (radio: Urgent [red], Routine [blue/selected], Two-Week Wait [amber] with tongue-in-cheek tooltips), Referrer Name/Email/Org inputs, Reason for Referral textarea, Contact Method radio (Email/Phone/LinkedIn). Submit button: NHS blue, full width right half. On submit: loading spinner, then success message with reference number (REF-2026-0210-001 format). Below form: Direct Contact table with Email, Phone, LinkedIn, Location as clickable links.
|
|
||||||
|
|
||||||
- [ ] **Task 13: Implement keyboard shortcuts and accessibility**
|
|
||||||
|
|
||||||
Add keyboard navigation throughout. Global shortcuts: Alt+1-7 activate sidebar items, Escape closes expanded items/menus, / focuses search. Sidebar: Up/Down arrows navigate items, Enter activates. Implement focus management: after login, focus moves to first sidebar item; after view change, focus moves to view heading; after expanding item, focus moves to content. Add ARIA: `role="navigation"` on sidebar, `aria-current="page"` on active item, `role="alert"` on clinical alert, proper table markup with `scope="col"`, `aria-expanded` on expandable items. Test with screen reader: views announced, tables navigable, alert read immediately.
|
|
||||||
|
|
||||||
- [ ] **Task 14: Implement responsive design (tablet and mobile)**
|
|
||||||
|
|
||||||
Tablet (768-1024px): Sidebar collapses to 56px icon-only with tooltips on hover. Patient banner always condensed (48px). Tables may horizontally scroll with indicator. Mobile (<768px): Sidebar becomes bottom navigation bar (56px height, 7 icon buttons, safe area padding). Patient banner becomes minimal top bar. Tables switch to card layout (each row becomes stacked card). Search moves to top of each view. Add back navigation arrow in each view. Test all breakpoints: desktop (>1024), tablet (768-1024), mobile (<768). Ensure touch targets minimum 48px. Test on actual mobile device or emulator.
|
|
||||||
|
|
||||||
- [ ] **Task 15: Final integration, testing, and polish**
|
|
||||||
|
|
||||||
Wire up App.tsx with three phases: BootSequence (4s) → ECGAnimation (modified for flatline) → LoginScreen (1.2s) → PMRInterface. Ensure smooth transitions between phases. Run all quality checks. Verify TypeScript strict mode (no `any` types). Verify all CV content accuracy against CV_v4.md (dates, numbers, achievements). Test all interactive elements: sidebar nav, consultation expand, medication sort, alert acknowledge, referral form submit. Verify responsive layouts at all breakpoints. Test accessibility with keyboard navigation and screen reader. Verify search finds content across all sections. Final production build test.
|
|
||||||
@@ -1,105 +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 implementing **Design 7: The Clinical Record** — a Patient Medical Record (PMR) system that presents Andy's CV as a clinician would view a patient record. This is a complete redesign from the previous ECG Heartbeat concept.
|
|
||||||
|
|
||||||
**The Concept:**
|
|
||||||
The "patient" is Andy's career. Users navigate a genuine NHS clinical software interface (similar to EMIS Web, SystmOne, Vision) with a patient banner, sidebar navigation, consultation journal, medications table, clinical alerts, and a login sequence. The design works on two levels: clinicians recognize the interface immediately; recruiters get a novel, information-dense presentation.
|
|
||||||
|
|
||||||
## 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: LoginScreen, PatientBanner, ClinicalSidebar, ClinicalAlert, all View components (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals), and any table, card, or form component.
|
|
||||||
|
|
||||||
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 about PMR design system, data architecture, animation approach, and clinical system authenticity.
|
|
||||||
|
|
||||||
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Key guardrails include:
|
|
||||||
- Light-mode only (clinical systems don't have dark mode)
|
|
||||||
- Instant view switching (no animations between views)
|
|
||||||
- Proper semantic table markup for all data tables
|
|
||||||
- Traffic lights must always have text labels
|
|
||||||
- Exact NHS blue color (#005EB8)
|
|
||||||
- ECG must end with flatline (not fade to white)
|
|
||||||
- Login typing animation specifics
|
|
||||||
- Consultation History/Examination/Plan format
|
|
||||||
- Coded entries in [XXX000] format
|
|
||||||
|
|
||||||
5. **Implement the item**: Complete the single task you selected. Keep changes focused - one task per iteration. Write production-quality React/TypeScript code that faithfully reproduces a clinical information system. This is a design showcase requiring absolute thematic fidelity.
|
|
||||||
|
|
||||||
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. **Determine if another iteration is needed**: Review your work and the codebase. The project needs another iteration if ANY of these are true:
|
|
||||||
- Any task in the checklist is unchecked (`- [ ]`) or blocked (`- [B]`)
|
|
||||||
- Quality checks would fail (run them to verify)
|
|
||||||
- There are uncommitted changes
|
|
||||||
- progress.txt has open questions or guidance for "next iteration"
|
|
||||||
- The implementation doesn't fully satisfy the plan requirements
|
|
||||||
- You have lingering doubts about correctness or completeness
|
|
||||||
|
|
||||||
12. **Send completion signal ONLY if truly complete**: If and ONLY if the project definitely does NOT need another iteration — all tasks verified done, quality checks pass, no guidance for next iteration — output this exact signal on its own line:
|
|
||||||
|
|
||||||
```
|
|
||||||
<promise>COMPLETE</promise>
|
|
||||||
```
|
|
||||||
|
|
||||||
DO NOT output this string if there's any chance another iteration is needed. When in doubt, do NOT send the promise — leave it for the next iteration to determine.
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
- **ALWAYS invoke /frontend-design skill before writing visual component code** — this is mandatory for all UI components
|
|
||||||
- **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 must look like real clinical software
|
|
||||||
- **Preserve clinical system authenticity** — instant navigation, proper tables, NHS blue, coded entries, traffic lights
|
|
||||||
- **Use TypeScript strictly** — no `any` types, proper interfaces for all PMR data structures
|
|
||||||
- **Follow the established project structure** — components in `src/components/`, data in `src/data/`, types in `src/types/`
|
|
||||||
- **Respect prefers-reduced-motion** — animations must have instant fallbacks
|
|
||||||
|
|
||||||
## Reference Files
|
|
||||||
|
|
||||||
- `designs/07-the-clinical-record.md` — Complete design specification with all visual details, animations, and interactions
|
|
||||||
- `References/CV_v4.md` — Source CV content (roles, achievements, numbers, dates)
|
|
||||||
- `References/concept.html` — Previous ECG implementation (timing reference only for boot sequence)
|
|
||||||
|
|
||||||
## Design Document Highlights
|
|
||||||
|
|
||||||
**Color Palette (Light-mode only):**
|
|
||||||
- Main content: `#F5F7FA`
|
|
||||||
- Cards: `#FFFFFF`
|
|
||||||
- Sidebar: `#1E293B`
|
|
||||||
- NHS blue: `#005EB8`
|
|
||||||
- Green (active): `#22C55E`
|
|
||||||
- Amber (alerts): `#F59E0B`
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- Inter for general text
|
|
||||||
- Geist Mono for coded entries and data values
|
|
||||||
|
|
||||||
**Key Interactions:**
|
|
||||||
- Login sequence: typing username/password character-by-character
|
|
||||||
- Clinical alert: slides down, acknowledges with checkmark → collapse
|
|
||||||
- Consultation entries: expand/collapse with History/Examination/Plan
|
|
||||||
- Medications table: sortable columns, expandable prescribing history
|
|
||||||
- Sidebar: instant view switching, no animations
|
|
||||||
|
|
||||||
**Responsive Strategy:**
|
|
||||||
- Desktop (>1024px): 220px sidebar with labels
|
|
||||||
- Tablet (768-1024px): 56px icon-only sidebar
|
|
||||||
- Mobile (<768px): Bottom navigation bar
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Guardrails — Clinical Record PMR System
|
|
||||||
|
|
||||||
## 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: LoginScreen, PatientBanner, ClinicalSidebar, ClinicalAlert, all View components (Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals), and any table, card, or form component.
|
|
||||||
- **Why**: The frontend-design skill provides specialized capabilities for creating polished, professional-grade visual output. This is a high-fidelity clinical interface requiring exact color matching and spacing.
|
|
||||||
|
|
||||||
### Light-mode only constraint
|
|
||||||
- **When**: Implementing any styling for the PMR interface
|
|
||||||
- **Rule**: This design is LIGHT-MODE ONLY. Never implement dark mode. Clinical systems operate in light mode due to high ambient lighting in consulting rooms. Use white backgrounds (`#FFFFFF`), cool light gray content areas (`#F5F7FA`), and dark text (`#111827`).
|
|
||||||
- **Why**: Dark mode would break the clinical system metaphor entirely.
|
|
||||||
|
|
||||||
### Clinical system navigation behavior
|
|
||||||
- **When**: Implementing sidebar navigation and view switching
|
|
||||||
- **Rule**: View switching must be INSTANT — no crossfade, no slide animation, no transition. When a sidebar item is clicked, the main content area replaces immediately. This matches EMIS Web, SystmOne, and other clinical systems exactly.
|
|
||||||
- **Why**: Clinical systems prioritize speed and responsiveness over visual flair. Any animation here breaks the authenticity.
|
|
||||||
|
|
||||||
### Table markup requirements
|
|
||||||
- **When**: Building the Medications, Problems, Investigations, or Documents tables
|
|
||||||
- **Rule**: Use proper semantic HTML `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>` elements. Headers must use `scope="col"`. Never use divs styled as tables. Tables must have `1px solid #E5E7EB` borders on all cells, 40px row height, and alternating row colors.
|
|
||||||
- **Why**: Screen readers rely on proper table markup to navigate data tables. Clinical systems use real tables.
|
|
||||||
|
|
||||||
### Traffic light accessibility
|
|
||||||
- **When**: Using green/amber/red status indicators
|
|
||||||
- **Rule**: Traffic light dots (8px circles) must ALWAYS accompany text labels ("Active", "Resolved", "In Progress"). Never use color alone to communicate status. This applies to Problems status, Medications status, and Investigations status.
|
|
||||||
- **Why**: WCAG 2.1 AA requirement — color cannot be the sole means of conveying information.
|
|
||||||
|
|
||||||
### CV content accuracy
|
|
||||||
- **When**: Adding CV content to data files
|
|
||||||
- **Rule**: Use exact data from `References/CV_v4.md`. Key numbers must match: £14.6M efficiency programme, 14,000 patients, £2.6M savings, 70% reduction, 200 hours saved, £1M revenue, £220M budget. Dates must be accurate: Interim Head (May-Nov 2025), Deputy Head (Jul 2024-Present), etc.
|
|
||||||
- **Why**: Inaccurate CV data is a critical error. The PMR system presents factual career information.
|
|
||||||
|
|
||||||
### NHS blue brand color
|
|
||||||
- **When**: Using the primary accent color
|
|
||||||
- **Rule**: Use exact NHS blue `#005EB8` for: active sidebar border, buttons, links, column headers, organization names. This is the actual NHS brand blue. Never use a different shade.
|
|
||||||
- **Why**: NHS blue is instantly recognizable to healthcare professionals. Wrong blue breaks the authenticity.
|
|
||||||
|
|
||||||
### TypeScript strictness
|
|
||||||
- **When**: Writing any TypeScript code
|
|
||||||
- **Rule**: No `any` types. Define interfaces for all data structures in `src/types/pmr.ts`. Use proper React.FC types or function component signatures with typed props. Enable strict mode in tsconfig.json.
|
|
||||||
- **Why**: Type safety is critical for maintainability. The data layer has complex types (Consultation, Medication with history, Problem with codes).
|
|
||||||
|
|
||||||
### Reduced motion support
|
|
||||||
- **When**: Implementing animations (login typing, alert slide, consultation expand)
|
|
||||||
- **Rule**: All animations must respect `prefers-reduced-motion: reduce`. With reduced motion: login typing completes instantly, alert appears without slide, consultation expand is instant, banner condensation is instant.
|
|
||||||
- **Why**: Accessibility requirement for users with vestibular disorders.
|
|
||||||
|
|
||||||
### No console errors
|
|
||||||
- **When**: Writing JavaScript/TypeScript
|
|
||||||
- **Rule**: No errors in the browser console. Handle edge cases: fuse.js search with no results, table sorting with empty data, form validation, animation cleanup on unmount.
|
|
||||||
- **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 (>1024px with full sidebar), tablet (768-1024px with icon-only sidebar), mobile (<768px with bottom nav). Tables must adapt: full columns on desktop, scrollable on tablet, card layout on mobile.
|
|
||||||
- **Why**: Clinical records may be viewed on tablets in consulting rooms or mobile devices.
|
|
||||||
|
|
||||||
## Project-Specific Guardrails
|
|
||||||
|
|
||||||
### ECG flatline transition
|
|
||||||
- **When**: Modifying ECGAnimation component
|
|
||||||
- **Rule**: The ECG must end with a flatline (horizontal line extending rightward from the name) that visually reads as a patient monitor flatline. This transitions to the login screen background (#1E293B). Do NOT fade to white — the previous design did that, but this design requires the flatline → login sequence.
|
|
||||||
- **Why**: The flatline signals "end of patient monitoring, opening clinical record." It's a narrative transition.
|
|
||||||
|
|
||||||
### Login typing animation
|
|
||||||
- **When**: Implementing LoginScreen component
|
|
||||||
- **Rule**: Username "A.CHARLWOOD" types character-by-character at 30ms per character. Password fills with 8 dots at 20ms per dot. Use Geist Mono font for the typing. Blinking cursor appears during typing.
|
|
||||||
- **Why**: The login sequence is the most immersive transition. Every NHS worker recognizes typing credentials into a clinical system.
|
|
||||||
|
|
||||||
### Consultation format fidelity
|
|
||||||
- **When**: Building ConsultationsView
|
|
||||||
- **Rule**: Each consultation MUST have History, Examination, and Plan sections. Use uppercase section headers with letter-spacing (Inter 600, 12px, gray-400). History = context/background, Examination = analysis/findings (bullet list), Plan = outcomes/delivery (bullet list). Include coded entries at bottom in [XXX000] format.
|
|
||||||
- **Why**: This is the clinical SOAP note format. The mapping to career content is the core concept.
|
|
||||||
|
|
||||||
### Medication table columns
|
|
||||||
- **When**: Building MedicationsView
|
|
||||||
- **Rule**: Table must have exactly these columns: Drug Name, Dose (%), Frequency, Start (year), Status. All columns must be sortable. Default grouping: Active Medications (technical), Clinical Medications (healthcare), PRN (strategic).
|
|
||||||
- **Why**: Medications tables in clinical systems have standard columns. This mapping provides more information than typical skills sections.
|
|
||||||
|
|
||||||
### Clinical alert behavior
|
|
||||||
- **When**: Implementing ClinicalAlert component
|
|
||||||
- **Rule**: Alert appears on Summary view load with spring animation (250ms). Must include warning icon, amber background (#FEF3C7), amber left border, and "Acknowledge" button. Clicking Acknowledge: icon → green checkmark (200ms) → alert collapses upward (200ms). Use `role="alert"` and `aria-live="assertive"`.
|
|
||||||
- **Why**: The clinical alert is the signature interaction. It frames the £14.6M achievement with institutional weight.
|
|
||||||
|
|
||||||
### Coded entries format
|
|
||||||
- **When**: Adding coded entries to consultations or problems
|
|
||||||
- **Rule**: Use fictional but consistent SNOMED-style codes: [EFF001] for efficiency, [ALG001] for algorithms, [AUT001] for automation, [SQL001] for data, [BUD001] for budget, [TRN001] for transformation, [LEA001] for leadership, etc. Codes in Geist Mono 12px, gray-500.
|
|
||||||
- **Why**: Clinical systems use coded entries (SNOMED CT, Read codes). This maintains the metaphor.
|
|
||||||
|
|
||||||
### Sidebar navigation structure
|
|
||||||
- **When**: Building ClinicalSidebar
|
|
||||||
- **Rule**: Exactly 7 items in this order: Summary, Consultations, Medications, Problems, Investigations, Documents, Referrals. Use Lucide icons: ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send. Separator line after Summary. Active state: 3px NHS blue left border.
|
|
||||||
- **Why**: This matches clinical record navigation categories. Order matters for Alt+1-7 shortcuts.
|
|
||||||
|
|
||||||
### Patient banner data
|
|
||||||
- **When**: Building PatientBanner
|
|
||||||
- **Rule**: Full name "CHARLWOOD, Andrew (Mr)" (surname first, comma-separated). DOB "14/02/1993" (DD/MM/YYYY). NHS No "221 181 0" (GPhC number formatted like NHS number with tooltip). Address "Norwich, NR1". Status "Active" with green dot. Badge "Open to opportunities".
|
|
||||||
- **Why**: This is the most recognizable PMR element. Format must match clinical systems exactly.
|
|
||||||
|
|
||||||
### Keyboard shortcuts
|
|
||||||
- **When**: Implementing navigation
|
|
||||||
- **Rule**: Alt+1 through Alt+7 must activate corresponding sidebar items. Escape closes expanded items and menus. / focuses search. Implement roving tabindex in sidebar (Up/Down arrows navigate, Enter activates).
|
|
||||||
- **Why**: Clinical systems have keyboard shortcuts for rapid navigation. This is expected behavior.
|
|
||||||
|
|
||||||
### Form validation
|
|
||||||
- **When**: Building ReferralsView form
|
|
||||||
- **Rule**: Referrer Name and Email are required. Show validation errors if empty on submit. Generate reference number in format REF-YYYY-MM-DD-NNN from current date. Success message shows reference and "Expected response time: 24-48 hours."
|
|
||||||
- **Why**: Clinical referral forms have validation. The reference number mimics real NHS referral references.
|
|
||||||
|
|
||||||
### Mobile bottom navigation
|
|
||||||
- **When**: Implementing responsive mobile layout
|
|
||||||
- **Rule**: On mobile (<768px), sidebar becomes bottom nav bar with 7 icon buttons (56px height, safe area padding). Patient banner becomes minimal. Tables switch to card layout. Add back arrow in each view returning to Summary.
|
|
||||||
- **Why**: Mobile clinical apps use bottom tabs. This matches the NHS App and EMIS Mobile patterns.
|
|
||||||
|
|
||||||
### Search implementation
|
|
||||||
- **When**: Adding search functionality
|
|
||||||
- **Rule**: Use fuse.js with threshold 0.3. Index all content: consultation titles/bullets, medication names, problem descriptions, investigation names, document titles. Group results by section. Clicking result navigates to view and expands matching item.
|
|
||||||
- **Why**: Clinical systems have record search. Fuse.js provides fuzzy matching for medical record lookups.
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
# Progress Log — Clinical Record PMR Phase
|
|
||||||
|
|
||||||
## Phase Transition
|
|
||||||
|
|
||||||
**Previous phase completed:** React conversion of ECG Heartbeat CV (all 12 tasks done)
|
|
||||||
**New phase started:** Clinical Record PMR System — Design 7 implementation
|
|
||||||
**Date:** 2026-02-11
|
|
||||||
|
|
||||||
This is a complete redesign of the CV presentation, moving from the ECG animation concept to a Patient Medical Record system interface. All previous components (Hero, Skills, Experience, etc.) will be replaced with PMR-specific views and components.
|
|
||||||
|
|
||||||
## Codebase Patterns
|
|
||||||
|
|
||||||
### PMR Design System
|
|
||||||
- **Source of truth**: `designs/07-the-clinical-record.md` — Complete specification for the PMR interface
|
|
||||||
- **Color palette (light-mode only)**:
|
|
||||||
- Main content: `#F5F7FA` (cool light gray)
|
|
||||||
- Cards: `#FFFFFF` (white)
|
|
||||||
- Sidebar: `#1E293B` (dark blue-gray)
|
|
||||||
- Patient banner: `#334155` (lighter blue-gray)
|
|
||||||
- NHS blue: `#005EB8` (primary interactive)
|
|
||||||
- Green: `#22C55E` (active/resolved)
|
|
||||||
- Amber: `#F59E0B` (alerts/in-progress)
|
|
||||||
- Red: `#EF4444` (urgent)
|
|
||||||
- Borders: `#E5E7EB` (gray-200)
|
|
||||||
- **Typography**: Inter for general text, Geist Mono for coded entries/data values
|
|
||||||
- **Spacing**: 4px base unit, tighter than previous design (clinical system density)
|
|
||||||
- **Borders**: 1px solid gray-200, 4px radius (clinical systems use minimal rounding)
|
|
||||||
- **Table rows**: 40px height, alternating colors
|
|
||||||
|
|
||||||
### Data Architecture
|
|
||||||
- All PMR content lives in `src/data/` as typed arrays
|
|
||||||
- Separation of data from components enables easy CV updates
|
|
||||||
- Types defined in `src/types/pmr.ts`
|
|
||||||
|
|
||||||
### Animation Approach
|
|
||||||
- **Login typing**: `setInterval` with character-by-character reveal (30ms/char username, 20ms/dot password)
|
|
||||||
- **View switching**: Instant (no animation) — matches clinical system behavior
|
|
||||||
- **Consultation expand**: Height 0→auto, 200ms, ease-out
|
|
||||||
- **Alert entrance**: Slide down with spring, 250ms
|
|
||||||
- **Alert dismiss**: Icon → checkmark (200ms) → collapse (200ms)
|
|
||||||
- **Patient banner condensation**: Smooth height transition, 200ms
|
|
||||||
- **Reduced motion**: Typing instant, slides become fades, expand instant
|
|
||||||
|
|
||||||
### Clinical System Authenticity
|
|
||||||
- Navigation is instant — no crossfade
|
|
||||||
- Tables use explicit borders on all cells
|
|
||||||
- Traffic lights are 8px circles with text labels (never sole indicator)
|
|
||||||
- Consultation format: History / Examination / Plan (clinical SOAP note structure)
|
|
||||||
- Medications table mimics actual prescribing lists
|
|
||||||
- Coded entries use [XXX000] format (SNOMED-style)
|
|
||||||
|
|
||||||
### Responsive Breakpoints
|
|
||||||
- Desktop (>1024px): 220px sidebar, full tables
|
|
||||||
- Tablet (768-1024px): 56px icon-only sidebar, scrollable tables
|
|
||||||
- Mobile (<768px): Bottom nav bar, card layouts instead of tables
|
|
||||||
|
|
||||||
### Accessibility Requirements
|
|
||||||
- Tables must be proper `<table>` markup with `scope="col"`
|
|
||||||
- Clinical alert uses `role="alert"` and `aria-live="assertive"`
|
|
||||||
- Keyboard shortcuts: Alt+1-7 for navigation
|
|
||||||
- Focus management after view changes and expansions
|
|
||||||
- Screen reader announces views and table structure
|
|
||||||
|
|
||||||
## Iteration Log
|
|
||||||
|
|
||||||
### Iteration 1 — Task 1: Create PMR data layer and TypeScript types
|
|
||||||
- **Completed**: Task 1 - Created PMR data layer with TypeScript interfaces and data files
|
|
||||||
- **Files created**:
|
|
||||||
- `src/types/pmr.ts` - All PMR TypeScript interfaces (Patient, Consultation, Medication, Problem, Investigation, Document, etc.)
|
|
||||||
- `src/data/consultations.ts` - 5 roles mapped to consultation format with History/Examination/Plan structure
|
|
||||||
- `src/data/medications.ts` - 18 skills mapped to medication format across 3 categories (Active, Clinical, PRN)
|
|
||||||
- `src/data/problems.ts` - 11 problems with traffic light status (3 Active, 2 In Progress, 6 Resolved)
|
|
||||||
- `src/data/investigations.ts` - 5 projects as investigations with methodology/results
|
|
||||||
- `src/data/documents.ts` - 5 education/certification documents
|
|
||||||
- `src/data/patient.ts` - Patient demographic data
|
|
||||||
- **Design decisions**:
|
|
||||||
- Used SNOMED-style codes for coded entries (EFF001, ALG001, AUT001, etc.)
|
|
||||||
- Mapped employer colors: NHS blue (#005EB8) for ICB, Teal (#00897B) for Tesco
|
|
||||||
- Proficiency percentages estimated from CV skill descriptions
|
|
||||||
- Prescribing history for each skill shows progression over time
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- CV has 5 roles but only 4 explicitly listed dates - inferred Duty Pharmacy Manager from GPhC registration date (Aug 2016)
|
|
||||||
- Key numbers verified: £14.6M efficiency, 14,000 patients, £2.6M savings, 70% reduction, 200 hours, £1M revenue, £220M budget
|
|
||||||
- Skills categorized into Active (technical), Clinical (healthcare domain), PRN (strategic/leadership)
|
|
||||||
|
|
||||||
### Iteration 2 — Task 2: Modify ECGAnimation for PMR flatline transition
|
|
||||||
- **Completed**: Task 2 - Modified ECGAnimation exit phase for clinical flatline → login transition
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/ECGAnimation.tsx` - Changed exit from fade-to-white to flatline → black → login background
|
|
||||||
- **Changes made**:
|
|
||||||
- Replaced HOLD_TIME (0.75s) and EXIT_TIME (0.8s) with precise phase timings:
|
|
||||||
- FLATLINE_HOLD: 300ms (hold after name trace)
|
|
||||||
- FLATLINE_DRAW: 300ms (horizontal line extending rightward)
|
|
||||||
- FADE_TO_BLACK: 200ms (canvas opacity fade)
|
|
||||||
- BG_TRANSITION: 200ms (background to #1E293B login color)
|
|
||||||
- New timing phases: isFlatlinePhase, isFadePhase, isBgTransitionPhase
|
|
||||||
- Background now transitions to login screen color (#1E293B) instead of white
|
|
||||||
- Flatline drawn from final name position to right edge of viewport
|
|
||||||
- Scanline head dot hidden during fade/bg phases
|
|
||||||
- **Design decisions**:
|
|
||||||
- Flatline visually reads as patient monitor flatline (deliberate metaphor)
|
|
||||||
- Total ECG phase still ~5-6 seconds, exit adds ~1 second
|
|
||||||
- Background transition uses CSS transition for smooth handoff to LoginScreen
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Canvas fade must complete before background transition for clean visual
|
|
||||||
- The flatline extension needs to go slightly past viewport edge (+50px) for smooth visual
|
|
||||||
|
|
||||||
### Iteration 3 — Task 3: Build LoginScreen component with typing animation
|
|
||||||
- **Completed**: Task 3 - Created LoginScreen component with authentic clinical login typing animation
|
|
||||||
- **Files created/modified**:
|
|
||||||
- `src/components/LoginScreen.tsx` - New component with typing animation
|
|
||||||
- `src/App.tsx` - Added 'login' phase between 'ecg' and 'content'
|
|
||||||
- `src/types/index.ts` - Added 'login' to Phase type
|
|
||||||
- `index.html` - Added Inter font family
|
|
||||||
- `tailwind.config.js` - Added PMR colors (sidebar, banner, nhsblue, etc.) and fonts (inter, geist)
|
|
||||||
- **Design decisions**:
|
|
||||||
- Username types at 30ms per character (A.CHARLWOOD = 11 chars + space = ~350ms)
|
|
||||||
- Password fills 8 dots at 20ms per dot (~160ms)
|
|
||||||
- Button shows pressed state (darker, scale) before onComplete callback
|
|
||||||
- Blinking cursor at 530ms interval during typing
|
|
||||||
- Uses Fira Code as monospace font (Geist Mono not available via Google Fonts)
|
|
||||||
- NHS blue shield icon for clinical system branding
|
|
||||||
- White login card: 320px wide, 12px radius, subtle shadow
|
|
||||||
- **Accessibility**:
|
|
||||||
- Respects prefers-reduced-motion: instant text appearance, ~500ms total
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Geist Mono not available via Google Fonts, using Fira Code as fallback
|
|
||||||
- Total login animation timing: ~1.2s (350ms username + 150ms pause + 160ms password + 150ms pause + 100ms button + 200ms hold)
|
|
||||||
|
|
||||||
### Iteration 4 — Task 4: Build PatientBanner component with full and condensed modes
|
|
||||||
- **Completed**: Task 4 - Created PatientBanner component with scroll-based condensation
|
|
||||||
- **Files created**:
|
|
||||||
- `src/hooks/useScrollCondensation.ts` - IntersectionObserver hook for scroll detection
|
|
||||||
- `src/components/PatientBanner.tsx` - Full (80px) and condensed (48px) banner modes
|
|
||||||
- **Design decisions**:
|
|
||||||
- IntersectionObserver with rootMargin -100px to detect scroll past threshold
|
|
||||||
- Smooth height transition using CSS `transition-all duration-200 ease-out`
|
|
||||||
- Sticky positioning with z-40 for persistent visibility
|
|
||||||
- Status dot: 8px circle, green for Active
|
|
||||||
- Badge: NHS blue background, white text, small pill shape
|
|
||||||
- Action buttons: outlined with NHS blue, fill on hover
|
|
||||||
- GPhC number formatted with spaces like NHS number (221 181 0)
|
|
||||||
- Tooltip on NHS No field explaining it's GPhC Registration Number
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="banner"` on header element
|
|
||||||
- `aria-label` on status dot
|
|
||||||
- Proper link semantics for phone and email
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- IntersectionObserver with rootMargin provides cleaner scroll detection than scroll event listeners
|
|
||||||
- Sentinel element at top of viewport triggers condensation when it leaves view
|
|
||||||
- Sticky positioning requires no JavaScript for the sticky behavior itself
|
|
||||||
|
|
||||||
### Iteration 5 — Task 5: Build ClinicalSidebar component with navigation and search
|
|
||||||
- **Completed**: Task 5 - Created ClinicalSidebar with navigation and search functionality
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/ClinicalSidebar.tsx` - Sidebar navigation with 7 items
|
|
||||||
- `src/components/PMRInterface.tsx` - Main PMR layout container
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/App.tsx` - Changed from 'content' to 'pmr' phase, uses PMRInterface
|
|
||||||
- `src/types/index.ts` - Updated Phase type: 'content' → 'pmr'
|
|
||||||
- **Design decisions**:
|
|
||||||
- 220px fixed width sidebar with dark blue-gray background (#1E293B)
|
|
||||||
- Header: "CareerRecord PMR v1.0.0" in 50% opacity white
|
|
||||||
- 7 navigation items with Lucide icons (ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send)
|
|
||||||
- Separator line between Summary and Consultations
|
|
||||||
- Active state: 3px NHS blue left border, white text, background rgba(255,255,255,0.12)
|
|
||||||
- Hover state: white text at 100%, background rgba(255,255,255,0.08)
|
|
||||||
- Search input in header with basic filtering (fuse.js not installed yet)
|
|
||||||
- Footer with "Session: A.CHARLWOOD" and current time (updates every minute)
|
|
||||||
- URL hash routing (#summary, #consultations, etc.)
|
|
||||||
- Keyboard shortcuts: Alt+1-7 for navigation, / to focus search, Escape to clear search
|
|
||||||
- **Navigation behavior**:
|
|
||||||
- Instant view switching (no animation) — matches clinical system authenticity
|
|
||||||
- Click updates URL hash and activeView state simultaneously
|
|
||||||
- On page load, hash is read to set initial view
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="navigation"` and `aria-label` on sidebar
|
|
||||||
- `aria-current="page"` on active nav item
|
|
||||||
- Keyboard navigation with Alt+1-7 shortcuts
|
|
||||||
- Search has escape key to clear and blur
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Lucide React already installed, provides all required icons
|
|
||||||
- fuse.js not installed — basic search filtering implemented, can enhance later
|
|
||||||
- Sticky positioning with `h-screen sticky top-0` keeps sidebar fixed while content scrolls
|
|
||||||
- PMRInterface wraps PatientBanner + sidebar + main content layout
|
|
||||||
|
|
||||||
### Iteration 6 — Task 6: Build SummaryView component with clinical alert
|
|
||||||
- **Completed**: Task 6 - Created SummaryView with Clinical Alert and summary cards
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/SummaryView.tsx` - Full Summary view with Clinical Alert and 4 cards
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Updated to render SummaryView when activeView is 'summary'
|
|
||||||
- **Design decisions**:
|
|
||||||
- Clinical Alert: amber background (#FEF3C7), 4px amber left border, AlertTriangle icon
|
|
||||||
- Alert animates in with max-height transition (300ms delay after view loads)
|
|
||||||
- Acknowledge button: on click, icon cross-fades to green Check (200ms), then alert collapses (200ms)
|
|
||||||
- Patient Demographics card: full width, two-column key-value layout with right-aligned labels
|
|
||||||
- Active Problems card: shows 3 active/in-progress problems with traffic light dots and dates
|
|
||||||
- Current Medications Quick View: 4-column table (Drug, Dose, Freq, Status), top 5 Active meds
|
|
||||||
- Last Consultation card: shows most recent role with truncated history text
|
|
||||||
- Traffic lights: 8px circles, green for Active/Resolved, amber for In Progress
|
|
||||||
- All tables use proper semantic `<table>` markup with `scope="col"`
|
|
||||||
- "View Full List" / "View Full Record" links navigate to corresponding views
|
|
||||||
- **Accessibility**:
|
|
||||||
- `role="alert"` and `aria-live="assertive"` on Clinical Alert
|
|
||||||
- `aria-label` on main content area with current view name
|
|
||||||
- Proper table semantics for medications table
|
|
||||||
- Traffic lights always accompanied by text labels (never sole indicator)
|
|
||||||
- Respects `prefers-reduced-motion`: alert appears instantly, no animations
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Alert animation uses max-height transition for smooth expand/collapse
|
|
||||||
- Clinical Alert text uses amber-800 (#92400E) for contrast against amber-100 background
|
|
||||||
- Grid layout: demographics full width, problems/medications side-by-side, last consultation full width
|
|
||||||
- `line-clamp-2` and `line-clamp-3` utilities work well for truncating text in cards
|
|
||||||
|
|
||||||
### Iteration 7 — Task 7: Build ConsultationsView with History/Examination/Plan structure
|
|
||||||
- **Completed**: Task 7 - Created ConsultationsView with expandable consultation entries
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ConsultationsView.tsx` - Full Consultations view with 5 expandable entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ConsultationsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Each entry has 3px left border color-coded by employer: NHS blue (#005EB8) for ICB, Teal (#00897B) for Tesco
|
|
||||||
- Collapsed state shows: status dot, date, organization (colored), role title, key coded entry summary
|
|
||||||
- Status dot: green for current roles, gray for historical
|
|
||||||
- Expanded state shows: Duration, HISTORY (paragraph), EXAMINATION (bullets), PLAN (bullets), CODED ENTRIES
|
|
||||||
- Section headers styled in Inter 600, 12px, uppercase, tracking-wider, gray-400
|
|
||||||
- Coded entries use [XXX000] format in Geist Mono, gray-400
|
|
||||||
- Only one entry expanded at a time (accordion behavior)
|
|
||||||
- Expand animation: height 0→auto (200ms, ease-out)
|
|
||||||
- Chevron icon rotates 180° when expanded
|
|
||||||
- **Accessibility**:
|
|
||||||
- `aria-expanded` on toggle buttons
|
|
||||||
- Status dots have `aria-label` describing current vs historical
|
|
||||||
- Respects `prefers-reduced-motion`: expand is instant
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Height animation uses `height: auto` which requires setting height to undefined after animation
|
|
||||||
- Content inside expanded area uses separate opacity transition for smooth appearance
|
|
||||||
- Border-left styling with explicit width/color in style prop for dynamic org colors
|
|
||||||
|
|
||||||
### Iteration 8 — Task 8: Build MedicationsView with sortable table and prescribing history
|
|
||||||
- **Completed**: Task 8 - Created MedicationsView with sortable table and category tabs
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/MedicationsView.tsx` - Full Medications view with 3 category tabs
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added MedicationsView to renderView switch
|
|
||||||
- `src/index.css` - Added fadeIn animation for expanded content
|
|
||||||
- **Design decisions**:
|
|
||||||
- Three category tabs: Active Medications (technical), Clinical Medications (healthcare), PRN (strategic)
|
|
||||||
- Tabs have descriptive subtitles to explain the mapping
|
|
||||||
- Sortable columns: clicking header toggles asc/desc/null with visual indicators
|
|
||||||
- Sort icons: ArrowUpDown (unsorted), ArrowUp (asc), ArrowDown (desc) in NHS blue
|
|
||||||
- Table columns: Drug Name, Dose (%), Frequency, Start (year), Status
|
|
||||||
- Row height: ~40px with 10px py padding
|
|
||||||
- Alternating row colors: white / gray-50
|
|
||||||
- Hover state: blue-50 (#EFF6FF) tint
|
|
||||||
- Traffic light status dots: 8px circles (green=Active, gray=Historical) with text labels
|
|
||||||
- Expandable rows: click to show "Prescribing History" mini-timeline
|
|
||||||
- Prescribing history: year + description, styled in Geist Mono
|
|
||||||
- fadeIn animation (200ms ease-out) for expanded content
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `role="tablist"` and `aria-selected` on tab buttons
|
|
||||||
- `aria-expanded` on expandable rows
|
|
||||||
- Traffic lights always accompanied by text labels
|
|
||||||
- Respects `prefers-reduced-motion`: fadeIn disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Case block lexical declarations need curly braces wrapping to satisfy ESLint
|
|
||||||
- Sort state with three values (null/asc/desc) provides intuitive toggle behavior
|
|
||||||
- Frequency sort uses custom order object mapping
|
|
||||||
|
|
||||||
### Iteration 9 — Task 9: Build ProblemsView with traffic light system
|
|
||||||
- **Completed**: Task 9 - Created ProblemsView with two tables and expandable narrative
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ProblemsView.tsx` - Full Problems view with Active and Resolved sections
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ProblemsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Two sections: Active Problems (3 items) and Resolved Problems (8 items)
|
|
||||||
- Traffic light component: 8px circles with text labels (green=Active/Resolved, amber=In Progress)
|
|
||||||
- Active Problems table: Status, Code, Problem, Since columns
|
|
||||||
- Resolved Problems table: Status, Code, Problem, Resolved, Outcome columns
|
|
||||||
- Code column: [XXX000] format in Geist Mono, gray-500
|
|
||||||
- Expandable rows: click to show narrative and linked consultations
|
|
||||||
- Linked consultations: clickable buttons navigate to Consultations view with item ID
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- Accordion behavior: only one row expanded at a time (per section? globally? went with global)
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Traffic lights have `aria-label` with status text
|
|
||||||
- Expand button has `aria-label` for screen readers
|
|
||||||
- Screen reader-only column header for expand button
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- `problem.linkedConsultations` needed null coalescing since it's optional in the type
|
|
||||||
- Height animation uses refs to measure content height for smooth expansion
|
|
||||||
- Linked consultations use external link icon to indicate navigation
|
|
||||||
|
|
||||||
### Iteration 10 — Task 10: Build InvestigationsView with results panel
|
|
||||||
- **Completed**: Task 10 - Created InvestigationsView with expandable rows and results panel
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/InvestigationsView.tsx` - Full Investigations view with 5 project entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added InvestigationsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Status badges: Complete (green dot), Ongoing (amber dot), Live (pulsing green dot with ping animation)
|
|
||||||
- Table columns: Test Name, Requested, Status, Result
|
|
||||||
- Expandable rows: click to show tree-indented results panel
|
|
||||||
- Results panel uses key-value layout with fixed-width labels (w-40) for alignment
|
|
||||||
- Methodology and Results sections display multi-line content
|
|
||||||
- Tech Stack displayed as comma-separated list
|
|
||||||
- PharMetrics has "View Results" button linking to medicines.charlwood.xyz
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Accordion behavior: only one row expanded at a time (global)
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Status badges have `aria-label` with status text
|
|
||||||
- Traffic lights always accompanied by text labels
|
|
||||||
- External link has proper target="_blank" rel="noopener noreferrer"
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Live status uses Tailwind animate-ping for pulsing effect (requires relative container)
|
|
||||||
- Tree-indented structure uses flex with fixed-width labels for clean alignment
|
|
||||||
- Results as bullet list provides better readability than comma-separated text
|
|
||||||
|
|
||||||
### Iteration 11 — Task 11: Build DocumentsView for education/certifications
|
|
||||||
- **Completed**: Task 11 - Created DocumentsView with expandable rows and preview panel
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/DocumentsView.tsx` - Full Documents view with 5 document entries
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added DocumentsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Document type icons: FileText (Certificate), Award (Registration), GraduationCap (Results), FlaskConical (Research)
|
|
||||||
- Table columns: Type (icon), Document, Date, Source
|
|
||||||
- Expandable rows: click to show tree-indented preview panel
|
|
||||||
- Preview panel uses key-value layout with fixed-width labels (w-40) for alignment
|
|
||||||
- Shows: Type, Date Awarded, Institution, Classification, Duration, Research (with grade), Notes
|
|
||||||
- Height animation: 200ms ease-out for expand/collapse
|
|
||||||
- Accordion behavior: only one row expanded at a time (global)
|
|
||||||
- Hover state: blue-50 (#EFF6FF) background tint
|
|
||||||
- Consistent with InvestigationsView expanded view style
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper semantic `<table>` markup with `scope="col"` on headers
|
|
||||||
- `aria-expanded` on clickable rows
|
|
||||||
- Screen reader-only column header for expand button
|
|
||||||
- Respects `prefers-reduced-motion`: height transition disabled
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Document type icons from Lucide match design spec exactly
|
|
||||||
- Research documents show both detail and grade in same field with line break
|
|
||||||
- Tree-indented structure consistency across Investigations and Documents views
|
|
||||||
|
|
||||||
### Iteration 12 — Task 12: Build ReferralsView with clinical referral form
|
|
||||||
- **Completed**: Task 12 - Created ReferralsView with clinical referral form and direct contact section
|
|
||||||
- **Files created**:
|
|
||||||
- `src/components/views/ReferralsView.tsx` - Full Referrals view with form and contact table
|
|
||||||
- **Files modified**:
|
|
||||||
- `src/components/PMRInterface.tsx` - Added ReferralsView to renderView switch
|
|
||||||
- **Design decisions**:
|
|
||||||
- Form layout: two-column grid for patient info and referrer fields
|
|
||||||
- Pre-filled patient info: CHARLWOOD, Andrew (Mr); NHS Number: 221 181 0
|
|
||||||
- Priority radio buttons: Urgent (red), Routine (NHS blue, default), Two-Week Wait (amber)
|
|
||||||
- Tongue-in-cheek tooltips on priority options
|
|
||||||
- Form fields: Referrer Name (required), Referrer Email (required, validated), Referrer Org (optional), Reason (textarea)
|
|
||||||
- Contact method radio: Email, Phone, LinkedIn
|
|
||||||
- Submit button: NHS blue (#005EB8), loading spinner on submit
|
|
||||||
- Success state: green checkmark, reference number (REF-YYYY-MMDD-NNN), 24-48hr response message
|
|
||||||
- Direct Contact table below form: Email (mailto), Phone (tel), LinkedIn (external link), Location
|
|
||||||
- Form validation with inline error messages
|
|
||||||
- Input styling: 1px border-gray-300, 4px radius, 8px 12px padding, NHS blue focus ring
|
|
||||||
- **Accessibility**:
|
|
||||||
- Proper form labels with `htmlFor` associations
|
|
||||||
- Required field indicators (red asterisk)
|
|
||||||
- Error messages announced
|
|
||||||
- Radio buttons properly grouped with hidden inputs and visible styled indicators
|
|
||||||
- Respects `prefers-reduced-motion`: no animations
|
|
||||||
- **Quality checks**: `npm run typecheck` ✓, `npm run lint` ✓, `npm run build` ✓
|
|
||||||
- **Learnings**:
|
|
||||||
- Form validation uses simple state-based approach with error objects
|
|
||||||
- Radio buttons styled with hidden `<input>` and custom styled `<span>` for visual control
|
|
||||||
- Reference number uses current date plus random sequence for uniqueness
|
|
||||||
- Direct Contact table uses same key-value layout as Patient Demographics card
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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** | May–Nov 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 2024–Present | 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 2022–Jul 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 7–8 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 2017–May 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 | 2011–2015
|
|
||||||
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 | 2009–2011
|
|
||||||
|
|
||||||
**GPhC Registered Pharmacist** | General Pharmaceutical Council | August 2016–Present
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*References available upon request.*
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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).
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist'] },
|
{ ignores: ['dist', 'dist-server', 'server.ts'] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
|||||||
@@ -2,13 +2,24 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
<link rel="preconnect" href="https://analytics.charlwood.xyz" crossorigin>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,20 +4,37 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently \"vite\" \"npx tsx server.ts\"",
|
||||||
|
"dev:frontend": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"generate-embeddings": "npx tsx scripts/generate-embeddings.ts",
|
||||||
|
"benchmark": "npx tsx scripts/benchmark.ts",
|
||||||
|
"start": "node dist-server/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"framer-motion": "^11.15.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/assets/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/Fonts/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
@@ -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 |
|
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
|
||||||
|
}
|
||||||
@@ -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]"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 2022–present (all roles at NHS Norfolk & Waveney ICB). Total NHS service: ~4 years.
|
||||||
|
- **Private sector**: Nov 2017–May 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 | May–Nov 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 2024–Present (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 2022–Jul 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, 7–8 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 2017–May 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, 7–8 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 (2011–2015)
|
||||||
|
4-year integrated Master's degree. Research project on drug delivery and cocrystals: 75.1% (Distinction).
|
||||||
|
|
||||||
|
### [edu-2] A-Levels — Highworth Grammar School (2009–2011)
|
||||||
|
Mathematics A*, Chemistry B, Politics C.
|
||||||
|
|
||||||
|
### [edu-3] GPhC Registration — General Pharmaceutical Council (August 2016–Present)
|
||||||
|
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 2022–present, ~4 years, all at Norfolk & Waveney ICB) from private sector (Tesco PLC, Nov 2017–May 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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
|
|
||||||
@@ -1,29 +1,102 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { Phase } from './types'
|
import type { Phase } from './types'
|
||||||
import { BootSequence } from './components/BootSequence'
|
import { BootSequence } from './components/BootSequence'
|
||||||
import { ECGAnimation } from './components/ECGAnimation'
|
|
||||||
import { LoginScreen } from './components/LoginScreen'
|
import { LoginScreen } from './components/LoginScreen'
|
||||||
import { PMRInterface } from './components/PMRInterface'
|
import { DashboardLayout } from './components/DashboardLayout'
|
||||||
|
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||||
|
import { DetailPanelProvider } from './contexts/DetailPanelContext'
|
||||||
|
import { initModel } from './lib/embedding-model'
|
||||||
|
|
||||||
function App() {
|
function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||||
const [phase, setPhase] = useState<Phase>('boot')
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setVisible(true), 1500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<button
|
||||||
{phase === 'boot' && (
|
onClick={onSkip}
|
||||||
<BootSequence onComplete={() => setPhase('ecg')} />
|
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={{
|
||||||
{phase === 'ecg' && (
|
color: '#555',
|
||||||
<ECGAnimation onComplete={() => setPhase('login')} />
|
borderColor: '#333',
|
||||||
)}
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
{phase === 'login' && (
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
}}
|
||||||
)}
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = '#888'
|
||||||
{phase === 'pmr' && <PMRInterface />}
|
e.currentTarget.style.borderColor = '#555'
|
||||||
</div>
|
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = '#555'
|
||||||
|
e.currentTarget.style.borderColor = '#333'
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.03)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [phase, setPhase] = useState<Phase>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const visitedAt = sessionStorage.getItem('portfolio-visited')
|
||||||
|
if (visitedAt && Date.now() - Number(visitedAt) < 60 * 60 * 1000) {
|
||||||
|
return 'pmr'
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem('portfolio-visited')
|
||||||
|
}
|
||||||
|
return 'boot'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'login' || phase === 'pmr') {
|
||||||
|
initModel()
|
||||||
|
}
|
||||||
|
if (phase === 'pmr') {
|
||||||
|
sessionStorage.setItem('portfolio-visited', String(Date.now()))
|
||||||
|
}
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
const skipToDashboard = () => setPhase('pmr')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccessibilityProvider>
|
||||||
|
<div className="min-h-screen bg-black">
|
||||||
|
{/* Screen reader announcement for PMR phase */}
|
||||||
|
{phase === 'pmr' && (
|
||||||
|
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
Patient Record for Charlwood, Andrew. Summary view.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'boot' && (
|
||||||
|
<BootSequence
|
||||||
|
onComplete={() => setPhase('login')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'login' || phase === 'pmr') && (
|
||||||
|
<DetailPanelProvider>
|
||||||
|
<DashboardLayout />
|
||||||
|
</DetailPanelProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'login' && (
|
||||||
|
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(phase === 'boot' || phase === 'login') && (
|
||||||
|
<SkipButton onSkip={skipToDashboard} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccessibilityProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +1,571 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState, useRef } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 {
|
interface BootLine {
|
||||||
html: string
|
type: BootLineType
|
||||||
delay: number
|
text?: string
|
||||||
|
label?: string
|
||||||
|
value?: string
|
||||||
|
style?: BootLineStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootLines: BootLine[] = [
|
interface BootConfig {
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">CLINICAL TERMINAL v3.2.1</span>', delay: 0 },
|
header: string
|
||||||
{ html: '<span class="text-[#3a6b45]">Initialising pharmacist profile...</span>', delay: 220 },
|
lines: BootLine[]
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
timing: {
|
||||||
{ html: '<span class="text-[#00e5ff]">SYSTEM </span><span class="text-[#00ff41]">NHS Norfolk & Waveney ICB</span>', delay: 220 },
|
lineDelay: number
|
||||||
{ html: '<span class="text-[#00e5ff]">USER </span><span class="text-[#00ff41]">Andy Charlwood</span>', delay: 220 },
|
cursorBlinkInterval: number
|
||||||
{ html: '<span class="text-[#00e5ff]">ROLE </span><span class="text-[#00ff41]">Deputy Head of Population Health & Data Analysis</span>', delay: 220 },
|
holdAfterComplete: number
|
||||||
{ html: '<span class="text-[#00e5ff]">LOCATION </span><span class="text-[#00ff41]">Norwich, UK</span>', delay: 220 },
|
loadingDuration: number
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
fadeOutDuration: number
|
||||||
{ html: '<span class="text-[#3a6b45]">Loading modules...</span>', delay: 220 },
|
cursorShrinkDuration: number
|
||||||
{ 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 },
|
colors: {
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">[OK]</span> <span class="text-[#3a6b45]">data_analytics.eng</span>', delay: 220 },
|
bright: string
|
||||||
{ html: '<span class="text-[#3a6b45]">---</span>', delay: 220 },
|
dim: string
|
||||||
{ html: '<span class="text-[#00ff41] font-bold">> READY — Rendering CV..<span class="ecg-seed-dot" id="ecg-seed-dot">.</span></span>', delay: 220 },
|
cyan: string
|
||||||
]
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface BootSequenceProps {
|
interface BootSequenceProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BootSequence({ onComplete }: BootSequenceProps) {
|
interface TypedSegment {
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
text: string
|
||||||
const [lineDelays, setLineDelays] = useState<number[]>([])
|
color: string
|
||||||
|
bold?: boolean
|
||||||
useEffect(() => {
|
isSeedDot?: boolean
|
||||||
const delays: number[] = []
|
}
|
||||||
let totalDelay = 0
|
|
||||||
bootLines.forEach((line) => {
|
interface TypedLine {
|
||||||
delays.push(totalDelay)
|
segments: TypedSegment[]
|
||||||
totalDelay += line.delay
|
totalChars: number
|
||||||
})
|
pauseAfter: number // ms to pause after this line completes
|
||||||
setLineDelays(delays)
|
speed: number // ms per character (0 = instant)
|
||||||
|
}
|
||||||
const totalBootTime = totalDelay
|
|
||||||
const fadeStartTime = totalBootTime + 400
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
const fadeTimer = setTimeout(() => {
|
// =============================================================================
|
||||||
setIsVisible(false)
|
|
||||||
}, fadeStartTime)
|
// Global speed multiplier for typing animation.
|
||||||
|
// 1.0 = default (~3.3s typing). Lower = faster, higher = slower.
|
||||||
const completeTimer = setTimeout(() => {
|
const TYPING_SPEED = 1.0
|
||||||
onComplete()
|
|
||||||
}, fadeStartTime + 800)
|
const COLORS = {
|
||||||
|
bright: '#00ff41',
|
||||||
return () => {
|
dim: '#3a6b45',
|
||||||
clearTimeout(fadeTimer)
|
cyan: '#00e5ff',
|
||||||
clearTimeout(completeTimer)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [onComplete])
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (!active) return
|
||||||
|
const start = performance.now()
|
||||||
|
let raf: number
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}, [typedCount, phase, reducedMotion])
|
||||||
|
|
||||||
|
// Hold phase → loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'holding') return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setPhase('loading')
|
||||||
|
}, BOOT_CONFIG.timing.holdAfterComplete)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
// Loading phase → fading (after progress bar completes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'loading') return
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setPhase('fading')
|
||||||
|
}, BOOT_CONFIG.timing.loadingDuration + 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
// Fade phase: notify parent immediately so login can mount alongside fade
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'fading') return
|
||||||
|
|
||||||
|
onComplete()
|
||||||
|
|
||||||
|
const hideTimer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setPhase('done')
|
||||||
|
}, BOOT_CONFIG.timing.fadeOutDuration)
|
||||||
|
|
||||||
|
return () => clearTimeout(hideTimer)
|
||||||
|
}, [phase, onComplete])
|
||||||
|
|
||||||
|
// Reduced motion: skip animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reducedMotion) return
|
||||||
|
const timer = setTimeout(onComplete, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [reducedMotion, onComplete])
|
||||||
|
|
||||||
|
// Track cursor anchor position relative to the content container
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!cursorAnchorRef.current || !containerRef.current || phase === 'done') return
|
||||||
|
const anchor = cursorAnchorRef.current.getBoundingClientRect()
|
||||||
|
const container = containerRef.current.getBoundingClientRect()
|
||||||
|
setCursorPos({
|
||||||
|
left: anchor.left - container.left,
|
||||||
|
top: anchor.top - container.top,
|
||||||
|
})
|
||||||
|
}, [typedCount, phase])
|
||||||
|
|
||||||
|
// Render the typed lines up to typedCount
|
||||||
|
const renderLines = () => {
|
||||||
|
let remaining = typedCount
|
||||||
|
const renderedLines: React.ReactNode[] = []
|
||||||
|
let cursorPlaced = false
|
||||||
|
|
||||||
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
|
||||||
|
// During typing, render this line if we've started typing into it (or it's the first line with cursor)
|
||||||
|
if (phase === 'typing' && remaining <= 0 && lineIdx > 0) break
|
||||||
|
|
||||||
|
const charsForLine = Math.min(Math.max(0, remaining), line.totalChars)
|
||||||
|
remaining -= charsForLine
|
||||||
|
|
||||||
|
// During typing: cursor inline on the line being typed
|
||||||
|
// During holding/loading: cursor handled after the loop (on a new line)
|
||||||
|
const isCursorLine = phase === 'typing'
|
||||||
|
? !cursorPlaced && (charsForLine < line.totalChars || remaining <= 0)
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Render segments
|
||||||
|
let charBudget = phase === 'typing' ? charsForLine : line.totalChars
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
if (charBudget <= 0 && phase === 'typing') break
|
||||||
|
|
||||||
|
const visibleChars = phase === 'typing'
|
||||||
|
? Math.min(charBudget, seg.text.length)
|
||||||
|
: seg.text.length
|
||||||
|
const visibleText = seg.text.slice(0, visibleChars)
|
||||||
|
charBudget -= visibleChars
|
||||||
|
|
||||||
|
if (seg.isSeedDot && visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={phase === 'holding' ? 'boot-seed-dot animate-seed-pulse' : 'boot-seed-dot'}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
} else if (visibleChars > 0) {
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{visibleText}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible placeholder to mark cursor position (actual cursor rendered outside fading wrapper)
|
||||||
|
if (isCursorLine && phase !== 'done') {
|
||||||
|
cursorPlaced = true
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key="cursor-anchor"
|
||||||
|
ref={cursorAnchorRef}
|
||||||
|
className="inline-block align-middle"
|
||||||
|
style={{ width: 8, height: 16, marginLeft: 1 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedLines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After typing completes: cursor on new line, or loading bar replacing it
|
||||||
|
if (phase === 'holding') {
|
||||||
|
renderedLines.push(
|
||||||
|
<div key="cursor-line" className="font-mono text-sm leading-relaxed">
|
||||||
|
<span
|
||||||
|
ref={cursorAnchorRef}
|
||||||
|
className="inline-block align-middle"
|
||||||
|
style={{ width: 8, height: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (phase === 'loading' || phase === 'fading') {
|
||||||
|
renderedLines.push(
|
||||||
|
<div key="bar-line" style={{ marginTop: 4 }}>
|
||||||
|
<LoadingBar active={phase === 'loading'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedLines
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFadingOut = phase === 'fading' || phase === 'done'
|
||||||
|
|
||||||
|
// Reduced motion: instant render
|
||||||
|
if (reducedMotion) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col justify-center bg-black px-5 py-8 sm:p-10 font-mono text-sm overflow-hidden">
|
||||||
|
<div className="flex flex-col gap-1 max-w-[640px] transform -translate-y-1/2">
|
||||||
|
{(() => {
|
||||||
|
// Render all lines fully
|
||||||
|
const lines: React.ReactNode[] = []
|
||||||
|
for (let lineIdx = 0; lineIdx < TYPED_LINES.length; lineIdx++) {
|
||||||
|
const line = TYPED_LINES[lineIdx]
|
||||||
|
const spans: React.ReactNode[] = []
|
||||||
|
for (let segIdx = 0; segIdx < line.segments.length; segIdx++) {
|
||||||
|
const seg = line.segments[segIdx]
|
||||||
|
spans.push(
|
||||||
|
<span
|
||||||
|
key={segIdx}
|
||||||
|
className={seg.isSeedDot ? 'boot-seed-dot' : undefined}
|
||||||
|
style={{ color: seg.color, fontWeight: seg.bold ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
{seg.text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
<div key={lineIdx} className="font-mono text-sm leading-relaxed">
|
||||||
|
{spans}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
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]">
|
{/* CRT Scanlines */}
|
||||||
{bootLines.map((line, index) => (
|
<motion.div
|
||||||
<motion.div
|
className="absolute inset-0 pointer-events-none"
|
||||||
key={index}
|
animate={{ opacity: isFadingOut ? 0 : 1 }}
|
||||||
className="whitespace-nowrap leading-relaxed"
|
transition={{ duration: BOOT_CONFIG.timing.fadeOutDuration / 1000, ease: 'easeOut' }}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
style={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
background: `repeating-linear-gradient(
|
||||||
transition={{
|
0deg,
|
||||||
delay: lineDelays[index] / 1000,
|
rgba(0, 0, 0, 0.15) 0px,
|
||||||
duration: 0.4,
|
transparent 1px,
|
||||||
ease: 'easeOut',
|
transparent 2px,
|
||||||
}}
|
rgba(0, 0, 0, 0.15) 3px
|
||||||
dangerouslySetInnerHTML={{ __html: line.html }}
|
)`,
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
|
|
||||||
|
{/* Content container — text always visible, bar appears below during loading */}
|
||||||
|
<div ref={containerRef} className="flex flex-col gap-1 transform -translate-y-1/2 relative z-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="inline-block w-2 h-4 bg-[#00ff41] ml-1 animate-blink"
|
animate={{
|
||||||
initial={{ opacity: 0 }}
|
opacity: isFadingOut ? 0 : 1,
|
||||||
animate={{ opacity: 1 }}
|
y: isFadingOut ? -20 : 0,
|
||||||
transition={{ delay: lineDelays[lineDelays.length - 1] / 1000 }}
|
}}
|
||||||
/>
|
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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
||||||
import {
|
|
||||||
ClipboardList,
|
|
||||||
FileText,
|
|
||||||
Pill,
|
|
||||||
AlertTriangle,
|
|
||||||
FlaskConical,
|
|
||||||
FolderOpen,
|
|
||||||
Send,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import type { ViewId } from '../types/pmr'
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
id: ViewId
|
|
||||||
label: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClinicalSidebarProps {
|
|
||||||
activeView: ViewId
|
|
||||||
onViewChange: (view: ViewId) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
|
|
||||||
{ id: 'consultations', label: 'Consultations', icon: <FileText size={18} /> },
|
|
||||||
{ id: 'medications', label: 'Medications', icon: <Pill size={18} /> },
|
|
||||||
{ id: 'problems', label: 'Problems', icon: <AlertTriangle size={18} /> },
|
|
||||||
{ id: 'investigations', label: 'Investigations', icon: <FlaskConical size={18} /> },
|
|
||||||
{ id: 'documents', label: 'Documents', icon: <FolderOpen size={18} /> },
|
|
||||||
{ id: 'referrals', label: 'Referrals', icon: <Send size={18} /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
function getCurrentTime(): string {
|
|
||||||
const now = new Date()
|
|
||||||
return now.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClinicalSidebar({ activeView, onViewChange }: ClinicalSidebarProps) {
|
|
||||||
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setCurrentTime(getCurrentTime())
|
|
||||||
}, 60000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleHashChange = () => {
|
|
||||||
const hash = window.location.hash.slice(1) as ViewId
|
|
||||||
if (navItems.some(item => item.id === hash)) {
|
|
||||||
onViewChange(hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHashChange()
|
|
||||||
window.addEventListener('hashchange', handleHashChange)
|
|
||||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
|
||||||
}, [onViewChange])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.altKey && e.key >= '1' && e.key <= '7') {
|
|
||||||
e.preventDefault()
|
|
||||||
const index = parseInt(e.key) - 1
|
|
||||||
if (navItems[index]) {
|
|
||||||
const view = navItems[index].id
|
|
||||||
onViewChange(view)
|
|
||||||
window.location.hash = view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
|
|
||||||
e.preventDefault()
|
|
||||||
const searchInput = document.getElementById('sidebar-search')
|
|
||||||
searchInput?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [onViewChange, isSearchFocused])
|
|
||||||
|
|
||||||
const handleNavClick = useCallback(
|
|
||||||
(view: ViewId) => {
|
|
||||||
onViewChange(view)
|
|
||||||
window.location.hash = view
|
|
||||||
},
|
|
||||||
[onViewChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setSearchQuery('')
|
|
||||||
;(e.target as HTMLInputElement).blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
setSearchQuery('')
|
|
||||||
const searchInput = document.getElementById('sidebar-search')
|
|
||||||
searchInput?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return []
|
|
||||||
const query = searchQuery.toLowerCase()
|
|
||||||
return navItems.filter(item =>
|
|
||||||
item.label.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
}, [searchQuery])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Clinical record navigation"
|
|
||||||
className="hidden lg:flex flex-col w-[220px] h-screen sticky top-0 bg-pmr-sidebar text-white"
|
|
||||||
>
|
|
||||||
<div className="p-4 border-b border-white/10">
|
|
||||||
<div className="font-inter font-medium text-[13px] text-white/50 leading-tight">
|
|
||||||
CareerRecord PMR
|
|
||||||
</div>
|
|
||||||
<div className="font-inter text-[11px] text-white/40 mt-0.5">v1.0.0</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 border-b border-white/10">
|
|
||||||
<div className="relative">
|
|
||||||
<Search
|
|
||||||
size={14}
|
|
||||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
id="sidebar-search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search record..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={() => setIsSearchFocused(true)}
|
|
||||||
onBlur={() => setIsSearchFocused(false)}
|
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
className="w-full h-9 pl-8 pr-7 bg-white/5 border border-white/10 rounded text-sm font-inter text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/10 transition-colors"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearSearch}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{searchQuery && filteredItems.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50">
|
|
||||||
{filteredItems.map(item => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
handleNavClick(item.id)
|
|
||||||
setSearchQuery('')
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-white/60">{item.icon}</span>
|
|
||||||
<span className="font-inter text-sm">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 py-2 overflow-y-auto">
|
|
||||||
<ul role="list">
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
{index === 1 && (
|
|
||||||
<div className="mx-3 my-1 border-t border-white/10" role="separator" />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
aria-current={activeView === item.id ? 'page' : undefined}
|
|
||||||
onClick={() => handleNavClick(item.id)}
|
|
||||||
className={`w-full flex items-center gap-3 h-11 px-4 text-left transition-colors ${
|
|
||||||
activeView === item.id
|
|
||||||
? 'text-white bg-white/12 border-l-[3px] border-pmr-nhsblue font-semibold'
|
|
||||||
: 'text-white/70 hover:text-white hover:bg-white/8'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={activeView === item.id ? 'text-white' : 'text-white/60'}>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
<span className="font-inter text-sm">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/10">
|
|
||||||
<div className="font-inter text-[11px] text-slate-400 leading-relaxed">
|
|
||||||
<div>Session: A.CHARLWOOD</div>
|
|
||||||
<div>Logged in: {currentTime}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 “{query}”
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,359 +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 FLATLINE_HOLD = 0.3
|
|
||||||
const FLATLINE_DRAW = 0.3
|
|
||||||
const FADE_TO_BLACK = 0.2
|
|
||||||
const BG_TRANSITION = 0.2
|
|
||||||
const baselineY = vh * 0.5
|
|
||||||
const ecgMaxDefl = vh * 0.25
|
|
||||||
const textMaxDefl = vh * 0.08
|
|
||||||
const lineColor = '#00ff41'
|
|
||||||
const loginBgColor = '#1E293B'
|
|
||||||
|
|
||||||
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 + FLATLINE_HOLD
|
|
||||||
const flatlineEndTime = holdEndTime + FLATLINE_DRAW
|
|
||||||
const fadeEndTime = flatlineEndTime + FADE_TO_BLACK
|
|
||||||
const bgTransitionEndTime = fadeEndTime + BG_TRANSITION
|
|
||||||
const exitEndTime = bgTransitionEndTime
|
|
||||||
|
|
||||||
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 isFlatlinePhase = elapsed >= holdEndTime && elapsed < flatlineEndTime
|
|
||||||
const isFadePhase = elapsed >= flatlineEndTime && elapsed < fadeEndTime
|
|
||||||
const isBgTransitionPhase = elapsed >= fadeEndTime
|
|
||||||
|
|
||||||
if (elapsed >= textEndTime) {
|
|
||||||
headWX = textEndWX
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || elapsed >= textEndTime) {
|
|
||||||
viewOff = textEndWX - finalHeadSX
|
|
||||||
headSX = headWX - viewOff
|
|
||||||
} else {
|
|
||||||
const p = (headWX - textStartWX) / (textEndWX - textStartWX)
|
|
||||||
headSX = headSXEcg + p * (finalHeadSX - headSXEcg)
|
|
||||||
viewOff = headWX - headSX
|
|
||||||
}
|
|
||||||
|
|
||||||
let fadeAlpha = 1
|
|
||||||
if (isFadePhase) {
|
|
||||||
fadeAlpha = Math.max(0, 1 - (elapsed - flatlineEndTime) / FADE_TO_BLACK)
|
|
||||||
} else if (isBgTransitionPhase) {
|
|
||||||
fadeAlpha = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bgTransitionedRef.current && elapsed >= flatlineEndTime) {
|
|
||||||
bgTransitionedRef.current = true
|
|
||||||
container.style.transition = `background ${BG_TRANSITION * 1000}ms ease-out`
|
|
||||||
container.style.background = loginBgColor
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.save()
|
|
||||||
ctx.globalAlpha = fadeAlpha
|
|
||||||
|
|
||||||
const traceStart = Math.max(0, Math.floor(viewOff))
|
|
||||||
const traceEnd = Math.min(Math.ceil(elapsed >= textEndTime ? 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 (isFlatlinePhase) {
|
|
||||||
const flatlineProgress = (elapsed - holdEndTime) / FLATLINE_DRAW
|
|
||||||
const flatlineEndSX = finalHeadSX + flatlineProgress * (vw - finalHeadSX + 50)
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.strokeStyle = lineColor
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.shadowBlur = 8
|
|
||||||
ctx.shadowColor = lineColor
|
|
||||||
ctx.moveTo(finalHeadSX, baselineY)
|
|
||||||
ctx.lineTo(flatlineEndSX, 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 && elapsed < flatlineEndTime) {
|
|
||||||
const headY = isFlatlinePhase ? 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 — MPharm, GPhC Registered Pharmacist
|
|
||||||
</p>
|
|
||||||
</motion.footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Footer }
|
|
||||||
@@ -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 & 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||