Compare commits
8 Commits
dad638e68e
...
a6df900605
| Author | SHA1 | Date | |
|---|---|---|---|
| a6df900605 | |||
| 5637d56e02 | |||
| 24ffe03c0f | |||
| e5c7d9bb41 | |||
| 960c9b7729 | |||
| b67c3b041f | |||
| ab80d65958 | |||
| 2306d2ec2e |
@@ -0,0 +1,111 @@
|
|||||||
|
# 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/)
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 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/)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
---
|
||||||
|
name: d3-viz
|
||||||
|
description: Creating interactive data visualisations using d3.js. This skill should be used when creating custom charts, graphs, network diagrams, geographic visualisations, or any complex SVG-based data visualisation that requires fine-grained control over visual elements, transitions, or interactions. Use this for bespoke visualisations beyond standard charting libraries, whether in React, Vue, Svelte, vanilla JavaScript, or any other environment.
|
||||||
|
---
|
||||||
|
|
||||||
|
# D3.js Visualisation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This skill provides guidance for creating sophisticated, interactive data visualisations using d3.js. D3.js (Data-Driven Documents) excels at binding data to DOM elements and applying data-driven transformations to create custom, publication-quality visualisations with precise control over every visual element. The techniques work across any JavaScript environment, including vanilla JavaScript, React, Vue, Svelte, and other frameworks.
|
||||||
|
|
||||||
|
## When to use d3.js
|
||||||
|
|
||||||
|
**Use d3.js for:**
|
||||||
|
- Custom visualisations requiring unique visual encodings or layouts
|
||||||
|
- Interactive explorations with complex pan, zoom, or brush behaviours
|
||||||
|
- Network/graph visualisations (force-directed layouts, tree diagrams, hierarchies, chord diagrams)
|
||||||
|
- Geographic visualisations with custom projections
|
||||||
|
- Visualisations requiring smooth, choreographed transitions
|
||||||
|
- Publication-quality graphics with fine-grained styling control
|
||||||
|
- Novel chart types not available in standard libraries
|
||||||
|
|
||||||
|
**Consider alternatives for:**
|
||||||
|
- 3D visualisations - use Three.js instead
|
||||||
|
|
||||||
|
## Core workflow
|
||||||
|
|
||||||
|
### 1. Set up d3.js
|
||||||
|
|
||||||
|
Import d3 at the top of your script:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the CDN version (7.x):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
All modules (scales, axes, shapes, transitions, etc.) are accessible through the `d3` namespace.
|
||||||
|
|
||||||
|
### 2. Choose the integration pattern
|
||||||
|
|
||||||
|
**Pattern A: Direct DOM manipulation (recommended for most cases)**
|
||||||
|
Use d3 to select DOM elements and manipulate them imperatively. This works in any JavaScript environment:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawChart(data) {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart'); // Select by ID, class, or DOM element
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
// Set up dimensions
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
|
||||||
|
// Create scales, axes, and draw visualisation
|
||||||
|
// ... d3 code here ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call when data changes
|
||||||
|
drawChart(myData);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern B: Declarative rendering (for frameworks with templating)**
|
||||||
|
Use d3 for data calculations (scales, layouts) but render elements via your framework:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getChartElements(data) {
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.value)])
|
||||||
|
.range([0, 400]);
|
||||||
|
|
||||||
|
return data.map((d, i) => ({
|
||||||
|
x: 50,
|
||||||
|
y: i * 30,
|
||||||
|
width: xScale(d.value),
|
||||||
|
height: 25
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// In React: {getChartElements(data).map((d, i) => <rect key={i} {...d} fill="steelblue" />)}
|
||||||
|
// In Vue: v-for directive over the returned array
|
||||||
|
// In vanilla JS: Create elements manually from the returned data
|
||||||
|
```
|
||||||
|
|
||||||
|
Use Pattern A for complex visualisations with transitions, interactions, or when leveraging d3's full capabilities. Use Pattern B for simpler visualisations or when your framework prefers declarative rendering.
|
||||||
|
|
||||||
|
### 3. Structure the visualisation code
|
||||||
|
|
||||||
|
Follow this standard structure in your drawing function:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawVisualization(data) {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart'); // Or pass a selector/element
|
||||||
|
svg.selectAll("*").remove(); // Clear previous render
|
||||||
|
|
||||||
|
// 1. Define dimensions
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// 2. Create main group with margins
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
// 3. Create scales
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.x)])
|
||||||
|
.range([0, innerWidth]);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.y)])
|
||||||
|
.range([innerHeight, 0]); // Note: inverted for SVG coordinates
|
||||||
|
|
||||||
|
// 4. Create and append axes
|
||||||
|
const xAxis = d3.axisBottom(xScale);
|
||||||
|
const yAxis = d3.axisLeft(yScale);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(xAxis);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(yAxis);
|
||||||
|
|
||||||
|
// 5. Bind data and create visual elements
|
||||||
|
g.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", 5)
|
||||||
|
.attr("fill", "steelblue");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call when data changes
|
||||||
|
drawVisualization(myData);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Implement responsive sizing
|
||||||
|
|
||||||
|
Make visualisations responsive to container size:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function setupResponsiveChart(containerId, data) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const svg = d3.select(`#${containerId}`).append('svg');
|
||||||
|
|
||||||
|
function updateChart() {
|
||||||
|
const { width, height } = container.getBoundingClientRect();
|
||||||
|
svg.attr('width', width).attr('height', height);
|
||||||
|
|
||||||
|
// Redraw visualisation with new dimensions
|
||||||
|
drawChart(data, svg, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on initial load
|
||||||
|
updateChart();
|
||||||
|
|
||||||
|
// Update on window resize
|
||||||
|
window.addEventListener('resize', updateChart);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => window.removeEventListener('resize', updateChart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
// const cleanup = setupResponsiveChart('chart-container', myData);
|
||||||
|
// cleanup(); // Call when component unmounts or element removed
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use ResizeObserver for more direct container monitoring:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function setupResponsiveChartWithObserver(svgElement, data) {
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const { width, height } = svgElement.getBoundingClientRect();
|
||||||
|
d3.select(svgElement)
|
||||||
|
.attr('width', width)
|
||||||
|
.attr('height', height);
|
||||||
|
|
||||||
|
// Redraw visualisation
|
||||||
|
drawChart(data, d3.select(svgElement), width, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(svgElement.parentElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common visualisation patterns
|
||||||
|
|
||||||
|
### Bar chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawBarChart(data, svgElement) {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgElement);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const xScale = d3.scaleBand()
|
||||||
|
.domain(data.map(d => d.category))
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.value)])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(xScale));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale));
|
||||||
|
|
||||||
|
g.selectAll("rect")
|
||||||
|
.data(data)
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => xScale(d.category))
|
||||||
|
.attr("y", d => yScale(d.value))
|
||||||
|
.attr("width", xScale.bandwidth())
|
||||||
|
.attr("height", d => innerHeight - yScale(d.value))
|
||||||
|
.attr("fill", "steelblue");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
// drawBarChart(myData, document.getElementById('chart'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Line chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const line = d3.line()
|
||||||
|
.x(d => xScale(d.date))
|
||||||
|
.y(d => yScale(d.value))
|
||||||
|
.curve(d3.curveMonotoneX); // Smooth curve
|
||||||
|
|
||||||
|
g.append("path")
|
||||||
|
.datum(data)
|
||||||
|
.attr("fill", "none")
|
||||||
|
.attr("stroke", "steelblue")
|
||||||
|
.attr("stroke-width", 2)
|
||||||
|
.attr("d", line);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scatter plot
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
g.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", d => sizeScale(d.size)) // Optional: size encoding
|
||||||
|
.attr("fill", d => colourScale(d.category)) // Optional: colour encoding
|
||||||
|
.attr("opacity", 0.7);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chord diagram
|
||||||
|
|
||||||
|
A chord diagram shows relationships between entities in a circular layout, with ribbons representing flows between them:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawChordDiagram(data) {
|
||||||
|
// data format: array of objects with source, target, and value
|
||||||
|
// Example: [{ source: 'A', target: 'B', value: 10 }, ...]
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart');
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 600;
|
||||||
|
const height = 600;
|
||||||
|
const innerRadius = Math.min(width, height) * 0.3;
|
||||||
|
const outerRadius = innerRadius + 30;
|
||||||
|
|
||||||
|
// Create matrix from data
|
||||||
|
const nodes = Array.from(new Set(data.flatMap(d => [d.source, d.target])));
|
||||||
|
const matrix = Array.from({ length: nodes.length }, () => Array(nodes.length).fill(0));
|
||||||
|
|
||||||
|
data.forEach(d => {
|
||||||
|
const i = nodes.indexOf(d.source);
|
||||||
|
const j = nodes.indexOf(d.target);
|
||||||
|
matrix[i][j] += d.value;
|
||||||
|
matrix[j][i] += d.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create chord layout
|
||||||
|
const chord = d3.chord()
|
||||||
|
.padAngle(0.05)
|
||||||
|
.sortSubgroups(d3.descending);
|
||||||
|
|
||||||
|
const arc = d3.arc()
|
||||||
|
.innerRadius(innerRadius)
|
||||||
|
.outerRadius(outerRadius);
|
||||||
|
|
||||||
|
const ribbon = d3.ribbon()
|
||||||
|
.source(d => d.source)
|
||||||
|
.target(d => d.target);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10)
|
||||||
|
.domain(nodes);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width / 2},${height / 2})`);
|
||||||
|
|
||||||
|
const chords = chord(matrix);
|
||||||
|
|
||||||
|
// Draw ribbons
|
||||||
|
g.append("g")
|
||||||
|
.attr("fill-opacity", 0.67)
|
||||||
|
.selectAll("path")
|
||||||
|
.data(chords)
|
||||||
|
.join("path")
|
||||||
|
.attr("d", ribbon)
|
||||||
|
.attr("fill", d => colourScale(nodes[d.source.index]))
|
||||||
|
.attr("stroke", d => d3.rgb(colourScale(nodes[d.source.index])).darker());
|
||||||
|
|
||||||
|
// Draw groups (arcs)
|
||||||
|
const group = g.append("g")
|
||||||
|
.selectAll("g")
|
||||||
|
.data(chords.groups)
|
||||||
|
.join("g");
|
||||||
|
|
||||||
|
group.append("path")
|
||||||
|
.attr("d", arc)
|
||||||
|
.attr("fill", d => colourScale(nodes[d.index]))
|
||||||
|
.attr("stroke", d => d3.rgb(colourScale(nodes[d.index])).darker());
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
group.append("text")
|
||||||
|
.each(d => { d.angle = (d.startAngle + d.endAngle) / 2; })
|
||||||
|
.attr("dy", "0.31em")
|
||||||
|
.attr("transform", d => `rotate(${(d.angle * 180 / Math.PI) - 90})translate(${outerRadius + 30})${d.angle > Math.PI ? "rotate(180)" : ""}`)
|
||||||
|
.attr("text-anchor", d => d.angle > Math.PI ? "end" : null)
|
||||||
|
.text((d, i) => nodes[i])
|
||||||
|
.style("font-size", "12px");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heatmap
|
||||||
|
|
||||||
|
A heatmap uses colour to encode values in a two-dimensional grid, useful for showing patterns across categories:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawHeatmap(data) {
|
||||||
|
// data format: array of objects with row, column, and value
|
||||||
|
// Example: [{ row: 'A', column: 'X', value: 10 }, ...]
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart');
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
const margin = { top: 100, right: 30, bottom: 30, left: 100 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Get unique rows and columns
|
||||||
|
const rows = Array.from(new Set(data.map(d => d.row)));
|
||||||
|
const columns = Array.from(new Set(data.map(d => d.column)));
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
// Create scales
|
||||||
|
const xScale = d3.scaleBand()
|
||||||
|
.domain(columns)
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.01);
|
||||||
|
|
||||||
|
const yScale = d3.scaleBand()
|
||||||
|
.domain(rows)
|
||||||
|
.range([0, innerHeight])
|
||||||
|
.padding(0.01);
|
||||||
|
|
||||||
|
// Colour scale for values
|
||||||
|
const colourScale = d3.scaleSequential(d3.interpolateYlOrRd)
|
||||||
|
.domain([0, d3.max(data, d => d.value)]);
|
||||||
|
|
||||||
|
// Draw rectangles
|
||||||
|
g.selectAll("rect")
|
||||||
|
.data(data)
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => xScale(d.column))
|
||||||
|
.attr("y", d => yScale(d.row))
|
||||||
|
.attr("width", xScale.bandwidth())
|
||||||
|
.attr("height", yScale.bandwidth())
|
||||||
|
.attr("fill", d => colourScale(d.value));
|
||||||
|
|
||||||
|
// Add x-axis labels
|
||||||
|
svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(columns)
|
||||||
|
.join("text")
|
||||||
|
.attr("x", d => xScale(d) + xScale.bandwidth() / 2)
|
||||||
|
.attr("y", -10)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.text(d => d)
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
// Add y-axis labels
|
||||||
|
svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(rows)
|
||||||
|
.join("text")
|
||||||
|
.attr("x", -10)
|
||||||
|
.attr("y", d => yScale(d) + yScale.bandwidth() / 2)
|
||||||
|
.attr("dy", "0.35em")
|
||||||
|
.attr("text-anchor", "end")
|
||||||
|
.text(d => d)
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
// Add colour legend
|
||||||
|
const legendWidth = 20;
|
||||||
|
const legendHeight = 200;
|
||||||
|
const legend = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width - 60},${margin.top})`);
|
||||||
|
|
||||||
|
const legendScale = d3.scaleLinear()
|
||||||
|
.domain(colourScale.domain())
|
||||||
|
.range([legendHeight, 0]);
|
||||||
|
|
||||||
|
const legendAxis = d3.axisRight(legendScale)
|
||||||
|
.ticks(5);
|
||||||
|
|
||||||
|
// Draw colour gradient in legend
|
||||||
|
for (let i = 0; i < legendHeight; i++) {
|
||||||
|
legend.append("rect")
|
||||||
|
.attr("y", i)
|
||||||
|
.attr("width", legendWidth)
|
||||||
|
.attr("height", 1)
|
||||||
|
.attr("fill", colourScale(legendScale.invert(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
legend.append("g")
|
||||||
|
.attr("transform", `translate(${legendWidth},0)`)
|
||||||
|
.call(legendAxis);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pie chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const pie = d3.pie()
|
||||||
|
.value(d => d.value)
|
||||||
|
.sort(null);
|
||||||
|
|
||||||
|
const arc = d3.arc()
|
||||||
|
.innerRadius(0)
|
||||||
|
.outerRadius(Math.min(width, height) / 2 - 20);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width / 2},${height / 2})`);
|
||||||
|
|
||||||
|
g.selectAll("path")
|
||||||
|
.data(pie(data))
|
||||||
|
.join("path")
|
||||||
|
.attr("d", arc)
|
||||||
|
.attr("fill", (d, i) => colourScale(i))
|
||||||
|
.attr("stroke", "white")
|
||||||
|
.attr("stroke-width", 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force-directed network
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const simulation = d3.forceSimulation(nodes)
|
||||||
|
.force("link", d3.forceLink(links).id(d => d.id).distance(100))
|
||||||
|
.force("charge", d3.forceManyBody().strength(-300))
|
||||||
|
.force("center", d3.forceCenter(width / 2, height / 2));
|
||||||
|
|
||||||
|
const link = g.selectAll("line")
|
||||||
|
.data(links)
|
||||||
|
.join("line")
|
||||||
|
.attr("stroke", "#999")
|
||||||
|
.attr("stroke-width", 1);
|
||||||
|
|
||||||
|
const node = g.selectAll("circle")
|
||||||
|
.data(nodes)
|
||||||
|
.join("circle")
|
||||||
|
.attr("r", 8)
|
||||||
|
.attr("fill", "steelblue")
|
||||||
|
.call(d3.drag()
|
||||||
|
.on("start", dragstarted)
|
||||||
|
.on("drag", dragged)
|
||||||
|
.on("end", dragended));
|
||||||
|
|
||||||
|
simulation.on("tick", () => {
|
||||||
|
link
|
||||||
|
.attr("x1", d => d.source.x)
|
||||||
|
.attr("y1", d => d.source.y)
|
||||||
|
.attr("x2", d => d.target.x)
|
||||||
|
.attr("y2", d => d.target.y);
|
||||||
|
|
||||||
|
node
|
||||||
|
.attr("cx", d => d.x)
|
||||||
|
.attr("cy", d => d.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
function dragstarted(event) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
|
event.subject.fx = event.subject.x;
|
||||||
|
event.subject.fy = event.subject.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragged(event) {
|
||||||
|
event.subject.fx = event.x;
|
||||||
|
event.subject.fy = event.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragended(event) {
|
||||||
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
event.subject.fx = null;
|
||||||
|
event.subject.fy = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding interactivity
|
||||||
|
|
||||||
|
### Tooltips
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create tooltip div (outside SVG)
|
||||||
|
const tooltip = d3.select("body").append("div")
|
||||||
|
.attr("class", "tooltip")
|
||||||
|
.style("position", "absolute")
|
||||||
|
.style("visibility", "hidden")
|
||||||
|
.style("background-color", "white")
|
||||||
|
.style("border", "1px solid #ddd")
|
||||||
|
.style("padding", "10px")
|
||||||
|
.style("border-radius", "4px")
|
||||||
|
.style("pointer-events", "none");
|
||||||
|
|
||||||
|
// Add to elements
|
||||||
|
circles
|
||||||
|
.on("mouseover", function(event, d) {
|
||||||
|
d3.select(this).attr("opacity", 1);
|
||||||
|
tooltip
|
||||||
|
.style("visibility", "visible")
|
||||||
|
.html(`<strong>${d.label}</strong><br/>Value: ${d.value}`);
|
||||||
|
})
|
||||||
|
.on("mousemove", function(event) {
|
||||||
|
tooltip
|
||||||
|
.style("top", (event.pageY - 10) + "px")
|
||||||
|
.style("left", (event.pageX + 10) + "px");
|
||||||
|
})
|
||||||
|
.on("mouseout", function() {
|
||||||
|
d3.select(this).attr("opacity", 0.7);
|
||||||
|
tooltip.style("visibility", "hidden");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zoom and pan
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.5, 10])
|
||||||
|
.on("zoom", (event) => {
|
||||||
|
g.attr("transform", event.transform);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.call(zoom);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Click interactions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
circles
|
||||||
|
.on("click", function(event, d) {
|
||||||
|
// Handle click (dispatch event, update app state, etc.)
|
||||||
|
console.log("Clicked:", d);
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
d3.selectAll("circle").attr("fill", "steelblue");
|
||||||
|
d3.select(this).attr("fill", "orange");
|
||||||
|
|
||||||
|
// Optional: dispatch custom event for your framework/app to listen to
|
||||||
|
// window.dispatchEvent(new CustomEvent('chartClick', { detail: d }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transitions and animations
|
||||||
|
|
||||||
|
Add smooth transitions to visual changes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Basic transition
|
||||||
|
circles
|
||||||
|
.transition()
|
||||||
|
.duration(750)
|
||||||
|
.attr("r", 10);
|
||||||
|
|
||||||
|
// Chained transitions
|
||||||
|
circles
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.attr("fill", "orange")
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.attr("r", 15);
|
||||||
|
|
||||||
|
// Staggered transitions
|
||||||
|
circles
|
||||||
|
.transition()
|
||||||
|
.delay((d, i) => i * 50)
|
||||||
|
.duration(500)
|
||||||
|
.attr("cy", d => yScale(d.value));
|
||||||
|
|
||||||
|
// Custom easing
|
||||||
|
circles
|
||||||
|
.transition()
|
||||||
|
.duration(1000)
|
||||||
|
.ease(d3.easeBounceOut)
|
||||||
|
.attr("r", 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scales reference
|
||||||
|
|
||||||
|
### Quantitative scales
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Linear scale
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
// Log scale (for exponential data)
|
||||||
|
const logScale = d3.scaleLog()
|
||||||
|
.domain([1, 1000])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
// Power scale
|
||||||
|
const powScale = d3.scalePow()
|
||||||
|
.exponent(2)
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
// Time scale
|
||||||
|
const timeScale = d3.scaleTime()
|
||||||
|
.domain([new Date(2020, 0, 1), new Date(2024, 0, 1)])
|
||||||
|
.range([0, 500]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ordinal scales
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Band scale (for bar charts)
|
||||||
|
const bandScale = d3.scaleBand()
|
||||||
|
.domain(['A', 'B', 'C', 'D'])
|
||||||
|
.range([0, 400])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
// Point scale (for line/scatter categories)
|
||||||
|
const pointScale = d3.scalePoint()
|
||||||
|
.domain(['A', 'B', 'C', 'D'])
|
||||||
|
.range([0, 400]);
|
||||||
|
|
||||||
|
// Ordinal scale (for colours)
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequential scales
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Sequential colour scale
|
||||||
|
const colourScale = d3.scaleSequential(d3.interpolateBlues)
|
||||||
|
.domain([0, 100]);
|
||||||
|
|
||||||
|
// Diverging colour scale
|
||||||
|
const divScale = d3.scaleDiverging(d3.interpolateRdBu)
|
||||||
|
.domain([-10, 0, 10]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
### Data preparation
|
||||||
|
|
||||||
|
Always validate and prepare data before visualisation:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Filter invalid values
|
||||||
|
const cleanData = data.filter(d => d.value != null && !isNaN(d.value));
|
||||||
|
|
||||||
|
// Sort data if order matters
|
||||||
|
const sortedData = [...data].sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Parse dates
|
||||||
|
const parsedData = data.map(d => ({
|
||||||
|
...d,
|
||||||
|
date: d3.timeParse("%Y-%m-%d")(d.date)
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance optimisation
|
||||||
|
|
||||||
|
For large datasets (>1000 elements):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Use canvas instead of SVG for many elements
|
||||||
|
// Use quadtree for collision detection
|
||||||
|
// Simplify paths with d3.line().curve(d3.curveStep)
|
||||||
|
// Implement virtual scrolling for large lists
|
||||||
|
// Use requestAnimationFrame for custom animations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
Make visualisations accessible:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add ARIA labels
|
||||||
|
svg.attr("role", "img")
|
||||||
|
.attr("aria-label", "Bar chart showing quarterly revenue");
|
||||||
|
|
||||||
|
// Add title and description
|
||||||
|
svg.append("title").text("Quarterly Revenue 2024");
|
||||||
|
svg.append("desc").text("Bar chart showing revenue growth across four quarters");
|
||||||
|
|
||||||
|
// Ensure sufficient colour contrast
|
||||||
|
// Provide keyboard navigation for interactive elements
|
||||||
|
// Include data table alternative
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
Use consistent, professional styling:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Define colour palettes upfront
|
||||||
|
const colours = {
|
||||||
|
primary: '#4A90E2',
|
||||||
|
secondary: '#7B68EE',
|
||||||
|
background: '#F5F7FA',
|
||||||
|
text: '#333333',
|
||||||
|
gridLines: '#E0E0E0'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply consistent typography
|
||||||
|
svg.selectAll("text")
|
||||||
|
.style("font-family", "Inter, sans-serif")
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
// Use subtle grid lines
|
||||||
|
g.selectAll(".tick line")
|
||||||
|
.attr("stroke", colours.gridLines)
|
||||||
|
.attr("stroke-dasharray", "2,2");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common issues and solutions
|
||||||
|
|
||||||
|
**Issue**: Axes not appearing
|
||||||
|
- Ensure scales have valid domains (check for NaN values)
|
||||||
|
- Verify axis is appended to correct group
|
||||||
|
- Check transform translations are correct
|
||||||
|
|
||||||
|
**Issue**: Transitions not working
|
||||||
|
- Call `.transition()` before attribute changes
|
||||||
|
- Ensure elements have unique keys for proper data binding
|
||||||
|
- Check that useEffect dependencies include all changing data
|
||||||
|
|
||||||
|
**Issue**: Responsive sizing not working
|
||||||
|
- Use ResizeObserver or window resize listener
|
||||||
|
- Update dimensions in state to trigger re-render
|
||||||
|
- Ensure SVG has width/height attributes or viewBox
|
||||||
|
|
||||||
|
**Issue**: Performance problems
|
||||||
|
- Limit number of DOM elements (consider canvas for >1000 items)
|
||||||
|
- Debounce resize handlers
|
||||||
|
- Use `.join()` instead of separate enter/update/exit selections
|
||||||
|
- Avoid unnecessary re-renders by checking dependencies
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
### references/
|
||||||
|
Contains detailed reference materials:
|
||||||
|
- `d3-patterns.md` - Comprehensive collection of visualisation patterns and code examples
|
||||||
|
- `scale-reference.md` - Complete guide to d3 scales with examples
|
||||||
|
- `colour-schemes.md` - D3 colour schemes and palette recommendations
|
||||||
|
|
||||||
|
### assets/
|
||||||
|
|
||||||
|
Contains boilerplate templates:
|
||||||
|
|
||||||
|
- `chart-template.js` - Starter template for basic chart
|
||||||
|
- `interactive-template.js` - Template with tooltips, zoom, and interactions
|
||||||
|
- `sample-data.json` - Example datasets for testing
|
||||||
|
|
||||||
|
These templates work with vanilla JavaScript, React, Vue, Svelte, or any other JavaScript environment. Adapt them as needed for your specific framework.
|
||||||
|
|
||||||
|
To use these resources, read the relevant files when detailed guidance is needed for specific visualisation types or patterns.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
|
||||||
|
function BasicChart({ data }) {
|
||||||
|
const svgRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
// Select SVG element
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove(); // Clear previous content
|
||||||
|
|
||||||
|
// Define dimensions and margins
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Create main group with margins
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
// Create scales
|
||||||
|
const xScale = d3.scaleBand()
|
||||||
|
.domain(data.map(d => d.label))
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.value)])
|
||||||
|
.range([innerHeight, 0])
|
||||||
|
.nice();
|
||||||
|
|
||||||
|
// Create and append axes
|
||||||
|
const xAxis = d3.axisBottom(xScale);
|
||||||
|
const yAxis = d3.axisLeft(yScale);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("class", "x-axis")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(xAxis);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("class", "y-axis")
|
||||||
|
.call(yAxis);
|
||||||
|
|
||||||
|
// Bind data and create visual elements (bars in this example)
|
||||||
|
g.selectAll("rect")
|
||||||
|
.data(data)
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => xScale(d.label))
|
||||||
|
.attr("y", d => yScale(d.value))
|
||||||
|
.attr("width", xScale.bandwidth())
|
||||||
|
.attr("height", d => innerHeight - yScale(d.value))
|
||||||
|
.attr("fill", "steelblue");
|
||||||
|
|
||||||
|
// Optional: Add axis labels
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "axis-label")
|
||||||
|
.attr("x", innerWidth / 2)
|
||||||
|
.attr("y", innerHeight + margin.bottom - 5)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.text("Category");
|
||||||
|
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "axis-label")
|
||||||
|
.attr("transform", "rotate(-90)")
|
||||||
|
.attr("x", -innerHeight / 2)
|
||||||
|
.attr("y", -margin.left + 15)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.text("Value");
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-container">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width="800"
|
||||||
|
height="400"
|
||||||
|
style={{ border: '1px solid #ddd' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
export default function App() {
|
||||||
|
const sampleData = [
|
||||||
|
{ label: 'A', value: 30 },
|
||||||
|
{ label: 'B', value: 80 },
|
||||||
|
{ label: 'C', value: 45 },
|
||||||
|
{ label: 'D', value: 60 },
|
||||||
|
{ label: 'E', value: 20 },
|
||||||
|
{ label: 'F', value: 90 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Basic D3.js Chart</h1>
|
||||||
|
<BasicChart data={sampleData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
|
||||||
|
function InteractiveChart({ data }) {
|
||||||
|
const svgRef = useRef();
|
||||||
|
const tooltipRef = useRef();
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const width = 800;
|
||||||
|
const height = 500;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Create main group
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
// Scales
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.x)])
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.nice();
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.y)])
|
||||||
|
.range([innerHeight, 0])
|
||||||
|
.nice();
|
||||||
|
|
||||||
|
const sizeScale = d3.scaleSqrt()
|
||||||
|
.domain([0, d3.max(data, d => d.size || 10)])
|
||||||
|
.range([3, 20]);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
// Add zoom behaviour
|
||||||
|
const zoom = d3.zoom()
|
||||||
|
.scaleExtent([0.5, 10])
|
||||||
|
.on("zoom", (event) => {
|
||||||
|
g.attr("transform", `translate(${margin.left + event.transform.x},${margin.top + event.transform.y}) scale(${event.transform.k})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
// Axes
|
||||||
|
const xAxis = d3.axisBottom(xScale);
|
||||||
|
const yAxis = d3.axisLeft(yScale);
|
||||||
|
|
||||||
|
const xAxisGroup = g.append("g")
|
||||||
|
.attr("class", "x-axis")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(xAxis);
|
||||||
|
|
||||||
|
const yAxisGroup = g.append("g")
|
||||||
|
.attr("class", "y-axis")
|
||||||
|
.call(yAxis);
|
||||||
|
|
||||||
|
// Grid lines
|
||||||
|
g.append("g")
|
||||||
|
.attr("class", "grid")
|
||||||
|
.attr("opacity", 0.1)
|
||||||
|
.call(d3.axisLeft(yScale)
|
||||||
|
.tickSize(-innerWidth)
|
||||||
|
.tickFormat(""));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("class", "grid")
|
||||||
|
.attr("opacity", 0.1)
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(xScale)
|
||||||
|
.tickSize(-innerHeight)
|
||||||
|
.tickFormat(""));
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
const tooltip = d3.select(tooltipRef.current);
|
||||||
|
|
||||||
|
// Data points
|
||||||
|
const circles = g.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", d => sizeScale(d.size || 10))
|
||||||
|
.attr("fill", d => colourScale(d.category || 'default'))
|
||||||
|
.attr("stroke", "#fff")
|
||||||
|
.attr("stroke-width", 2)
|
||||||
|
.attr("opacity", 0.7)
|
||||||
|
.style("cursor", "pointer");
|
||||||
|
|
||||||
|
// Hover interactions
|
||||||
|
circles
|
||||||
|
.on("mouseover", function(event, d) {
|
||||||
|
// Enlarge circle
|
||||||
|
d3.select(this)
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr("opacity", 1)
|
||||||
|
.attr("stroke-width", 3);
|
||||||
|
|
||||||
|
// Show tooltip
|
||||||
|
tooltip
|
||||||
|
.style("display", "block")
|
||||||
|
.style("left", (event.pageX + 10) + "px")
|
||||||
|
.style("top", (event.pageY - 10) + "px")
|
||||||
|
.html(`
|
||||||
|
<strong>${d.label || 'Point'}</strong><br/>
|
||||||
|
X: ${d.x.toFixed(2)}<br/>
|
||||||
|
Y: ${d.y.toFixed(2)}<br/>
|
||||||
|
${d.category ? `Category: ${d.category}<br/>` : ''}
|
||||||
|
${d.size ? `Size: ${d.size.toFixed(2)}` : ''}
|
||||||
|
`);
|
||||||
|
})
|
||||||
|
.on("mousemove", function(event) {
|
||||||
|
tooltip
|
||||||
|
.style("left", (event.pageX + 10) + "px")
|
||||||
|
.style("top", (event.pageY - 10) + "px");
|
||||||
|
})
|
||||||
|
.on("mouseout", function() {
|
||||||
|
// Restore circle
|
||||||
|
d3.select(this)
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr("opacity", 0.7)
|
||||||
|
.attr("stroke-width", 2);
|
||||||
|
|
||||||
|
// Hide tooltip
|
||||||
|
tooltip.style("display", "none");
|
||||||
|
})
|
||||||
|
.on("click", function(event, d) {
|
||||||
|
// Highlight selected point
|
||||||
|
circles.attr("stroke", "#fff").attr("stroke-width", 2);
|
||||||
|
d3.select(this)
|
||||||
|
.attr("stroke", "#000")
|
||||||
|
.attr("stroke-width", 3);
|
||||||
|
|
||||||
|
setSelectedPoint(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add transition on initial render
|
||||||
|
circles
|
||||||
|
.attr("r", 0)
|
||||||
|
.transition()
|
||||||
|
.duration(800)
|
||||||
|
.delay((d, i) => i * 20)
|
||||||
|
.attr("r", d => sizeScale(d.size || 10));
|
||||||
|
|
||||||
|
// Axis labels
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "axis-label")
|
||||||
|
.attr("x", innerWidth / 2)
|
||||||
|
.attr("y", innerHeight + margin.bottom - 5)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.style("font-size", "14px")
|
||||||
|
.text("X Axis");
|
||||||
|
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "axis-label")
|
||||||
|
.attr("transform", "rotate(-90)")
|
||||||
|
.attr("x", -innerHeight / 2)
|
||||||
|
.attr("y", -margin.left + 15)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.style("font-size", "14px")
|
||||||
|
.text("Y Axis");
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width="800"
|
||||||
|
height="500"
|
||||||
|
style={{ border: '1px solid #ddd', cursor: 'grab' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'none',
|
||||||
|
padding: '10px',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
fontSize: '13px',
|
||||||
|
zIndex: 1000
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedPoint && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 rounded border border-blue-200">
|
||||||
|
<h3 className="font-bold mb-2">Selected Point</h3>
|
||||||
|
<pre className="text-sm">{JSON.stringify(selectedPoint, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage
|
||||||
|
export default function App() {
|
||||||
|
const sampleData = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
label: `Point ${i + 1}`,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
y: Math.random() * 100,
|
||||||
|
size: Math.random() * 30 + 5,
|
||||||
|
category: ['A', 'B', 'C', 'D'][Math.floor(Math.random() * 4)]
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Interactive D3.js Chart</h1>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Hover over points for details. Click to select. Scroll to zoom. Drag to pan.
|
||||||
|
</p>
|
||||||
|
<InteractiveChart data={sampleData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"timeSeries": [
|
||||||
|
{ "date": "2024-01-01", "value": 120, "category": "A" },
|
||||||
|
{ "date": "2024-02-01", "value": 135, "category": "A" },
|
||||||
|
{ "date": "2024-03-01", "value": 128, "category": "A" },
|
||||||
|
{ "date": "2024-04-01", "value": 145, "category": "A" },
|
||||||
|
{ "date": "2024-05-01", "value": 152, "category": "A" },
|
||||||
|
{ "date": "2024-06-01", "value": 168, "category": "A" },
|
||||||
|
{ "date": "2024-07-01", "value": 175, "category": "A" },
|
||||||
|
{ "date": "2024-08-01", "value": 182, "category": "A" },
|
||||||
|
{ "date": "2024-09-01", "value": 190, "category": "A" },
|
||||||
|
{ "date": "2024-10-01", "value": 185, "category": "A" },
|
||||||
|
{ "date": "2024-11-01", "value": 195, "category": "A" },
|
||||||
|
{ "date": "2024-12-01", "value": 210, "category": "A" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"categorical": [
|
||||||
|
{ "label": "Product A", "value": 450, "category": "Electronics" },
|
||||||
|
{ "label": "Product B", "value": 320, "category": "Electronics" },
|
||||||
|
{ "label": "Product C", "value": 580, "category": "Clothing" },
|
||||||
|
{ "label": "Product D", "value": 290, "category": "Clothing" },
|
||||||
|
{ "label": "Product E", "value": 410, "category": "Food" },
|
||||||
|
{ "label": "Product F", "value": 370, "category": "Food" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"scatterData": [
|
||||||
|
{ "x": 12, "y": 45, "size": 25, "category": "Group A", "label": "Point 1" },
|
||||||
|
{ "x": 25, "y": 62, "size": 35, "category": "Group A", "label": "Point 2" },
|
||||||
|
{ "x": 38, "y": 55, "size": 20, "category": "Group B", "label": "Point 3" },
|
||||||
|
{ "x": 45, "y": 78, "size": 40, "category": "Group B", "label": "Point 4" },
|
||||||
|
{ "x": 52, "y": 68, "size": 30, "category": "Group C", "label": "Point 5" },
|
||||||
|
{ "x": 65, "y": 85, "size": 45, "category": "Group C", "label": "Point 6" },
|
||||||
|
{ "x": 72, "y": 72, "size": 28, "category": "Group A", "label": "Point 7" },
|
||||||
|
{ "x": 85, "y": 92, "size": 50, "category": "Group B", "label": "Point 8" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"hierarchical": {
|
||||||
|
"name": "Root",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"name": "Category 1",
|
||||||
|
"children": [
|
||||||
|
{ "name": "Item 1.1", "value": 100 },
|
||||||
|
{ "name": "Item 1.2", "value": 150 },
|
||||||
|
{ "name": "Item 1.3", "value": 80 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Category 2",
|
||||||
|
"children": [
|
||||||
|
{ "name": "Item 2.1", "value": 200 },
|
||||||
|
{ "name": "Item 2.2", "value": 120 },
|
||||||
|
{ "name": "Item 2.3", "value": 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Category 3",
|
||||||
|
"children": [
|
||||||
|
{ "name": "Item 3.1", "value": 180 },
|
||||||
|
{ "name": "Item 3.2", "value": 140 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"network": {
|
||||||
|
"nodes": [
|
||||||
|
{ "id": "A", "group": 1 },
|
||||||
|
{ "id": "B", "group": 1 },
|
||||||
|
{ "id": "C", "group": 1 },
|
||||||
|
{ "id": "D", "group": 2 },
|
||||||
|
{ "id": "E", "group": 2 },
|
||||||
|
{ "id": "F", "group": 3 },
|
||||||
|
{ "id": "G", "group": 3 },
|
||||||
|
{ "id": "H", "group": 3 }
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{ "source": "A", "target": "B", "value": 1 },
|
||||||
|
{ "source": "A", "target": "C", "value": 2 },
|
||||||
|
{ "source": "B", "target": "C", "value": 1 },
|
||||||
|
{ "source": "C", "target": "D", "value": 3 },
|
||||||
|
{ "source": "D", "target": "E", "value": 2 },
|
||||||
|
{ "source": "E", "target": "F", "value": 1 },
|
||||||
|
{ "source": "F", "target": "G", "value": 2 },
|
||||||
|
{ "source": "F", "target": "H", "value": 1 },
|
||||||
|
{ "source": "G", "target": "H", "value": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"stackedData": [
|
||||||
|
{ "group": "Q1", "seriesA": 30, "seriesB": 40, "seriesC": 25 },
|
||||||
|
{ "group": "Q2", "seriesA": 45, "seriesB": 35, "seriesC": 30 },
|
||||||
|
{ "group": "Q3", "seriesA": 40, "seriesB": 50, "seriesC": 35 },
|
||||||
|
{ "group": "Q4", "seriesA": 55, "seriesB": 45, "seriesC": 40 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"geographicPoints": [
|
||||||
|
{ "city": "London", "latitude": 51.5074, "longitude": -0.1278, "value": 8900000 },
|
||||||
|
{ "city": "Paris", "latitude": 48.8566, "longitude": 2.3522, "value": 2140000 },
|
||||||
|
{ "city": "Berlin", "latitude": 52.5200, "longitude": 13.4050, "value": 3645000 },
|
||||||
|
{ "city": "Madrid", "latitude": 40.4168, "longitude": -3.7038, "value": 3223000 },
|
||||||
|
{ "city": "Rome", "latitude": 41.9028, "longitude": 12.4964, "value": 2873000 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"divergingData": [
|
||||||
|
{ "category": "Item A", "value": -15 },
|
||||||
|
{ "category": "Item B", "value": 8 },
|
||||||
|
{ "category": "Item C", "value": -22 },
|
||||||
|
{ "category": "Item D", "value": 18 },
|
||||||
|
{ "category": "Item E", "value": -5 },
|
||||||
|
{ "category": "Item F", "value": 25 },
|
||||||
|
{ "category": "Item G", "value": -12 },
|
||||||
|
{ "category": "Item H", "value": 14 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
# D3.js Colour Schemes and Palette Recommendations
|
||||||
|
|
||||||
|
Comprehensive guide to colour selection in data visualisation with d3.js.
|
||||||
|
|
||||||
|
## Built-in categorical colour schemes
|
||||||
|
|
||||||
|
### Category10 (default)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemeCategory10
|
||||||
|
// ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
||||||
|
// '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- 10 distinct colours
|
||||||
|
- Good colour-blind accessibility
|
||||||
|
- Default choice for most categorical data
|
||||||
|
- Balanced saturation and brightness
|
||||||
|
|
||||||
|
**Use cases:** General purpose categorical encoding, legend items, multiple data series
|
||||||
|
|
||||||
|
### Tableau10
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemeTableau10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- 10 colours optimised for data visualisation
|
||||||
|
- Professional appearance
|
||||||
|
- Excellent distinguishability
|
||||||
|
|
||||||
|
**Use cases:** Business dashboards, professional reports, presentations
|
||||||
|
|
||||||
|
### Accent
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemeAccent
|
||||||
|
// 8 colours with high saturation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Bright, vibrant colours
|
||||||
|
- High contrast
|
||||||
|
- Modern aesthetic
|
||||||
|
|
||||||
|
**Use cases:** Highlighting important categories, modern web applications
|
||||||
|
|
||||||
|
### Dark2
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemeDark2
|
||||||
|
// 8 darker, muted colours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Subdued palette
|
||||||
|
- Professional appearance
|
||||||
|
- Good for dark backgrounds
|
||||||
|
|
||||||
|
**Use cases:** Dark mode visualisations, professional contexts
|
||||||
|
|
||||||
|
### Paired
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemePaired
|
||||||
|
// 12 colours in pairs of similar hues
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Pairs of light and dark variants
|
||||||
|
- Useful for nested categories
|
||||||
|
- 12 distinct colours
|
||||||
|
|
||||||
|
**Use cases:** Grouped bar charts, hierarchical categories, before/after comparisons
|
||||||
|
|
||||||
|
### Pastel1 & Pastel2
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemePastel1 // 9 colours
|
||||||
|
d3.schemePastel2 // 8 colours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Soft, low-saturation colours
|
||||||
|
- Gentle appearance
|
||||||
|
- Good for large areas
|
||||||
|
|
||||||
|
**Use cases:** Background colours, subtle categorisation, calming visualisations
|
||||||
|
|
||||||
|
### Set1, Set2, Set3
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.schemeSet1 // 9 colours - vivid
|
||||||
|
d3.schemeSet2 // 8 colours - muted
|
||||||
|
d3.schemeSet3 // 12 colours - pastel
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Set1: High saturation, maximum distinction
|
||||||
|
- Set2: Professional, balanced
|
||||||
|
- Set3: Subtle, many categories
|
||||||
|
|
||||||
|
**Use cases:** Varied based on visual hierarchy needs
|
||||||
|
|
||||||
|
## Sequential colour schemes
|
||||||
|
|
||||||
|
Sequential schemes map continuous data from low to high values using a single hue or gradient.
|
||||||
|
|
||||||
|
### Single-hue sequential
|
||||||
|
|
||||||
|
**Blues:**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateBlues
|
||||||
|
d3.schemeBlues[9] // 9-step discrete version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other single-hue options:**
|
||||||
|
- `d3.interpolateGreens` / `d3.schemeGreens`
|
||||||
|
- `d3.interpolateOranges` / `d3.schemeOranges`
|
||||||
|
- `d3.interpolatePurples` / `d3.schemePurples`
|
||||||
|
- `d3.interpolateReds` / `d3.schemeReds`
|
||||||
|
- `d3.interpolateGreys` / `d3.schemeGreys`
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Simple heat maps
|
||||||
|
- Choropleth maps
|
||||||
|
- Density plots
|
||||||
|
- Single-metric visualisations
|
||||||
|
|
||||||
|
### Multi-hue sequential
|
||||||
|
|
||||||
|
**Viridis (recommended):**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateViridis
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Perceptually uniform
|
||||||
|
- Colour-blind friendly
|
||||||
|
- Print-safe
|
||||||
|
- No visual dead zones
|
||||||
|
- Monotonically increasing perceived lightness
|
||||||
|
|
||||||
|
**Other perceptually-uniform options:**
|
||||||
|
- `d3.interpolatePlasma` - Purple to yellow
|
||||||
|
- `d3.interpolateInferno` - Black to white through red/orange
|
||||||
|
- `d3.interpolateMagma` - Black to white through purple
|
||||||
|
- `d3.interpolateCividis` - Colour-blind optimised
|
||||||
|
|
||||||
|
**Colour-blind accessible:**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateTurbo // Rainbow-like but perceptually uniform
|
||||||
|
d3.interpolateCool // Cyan to magenta
|
||||||
|
d3.interpolateWarm // Orange to yellow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Scientific visualisation
|
||||||
|
- Medical imaging
|
||||||
|
- Any high-precision data visualisation
|
||||||
|
- Accessible visualisations
|
||||||
|
|
||||||
|
### Traditional sequential
|
||||||
|
|
||||||
|
**Yellow-Orange-Red:**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateYlOrRd
|
||||||
|
d3.schemeYlOrRd[9]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Yellow-Green-Blue:**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateYlGnBu
|
||||||
|
d3.schemeYlGnBu[9]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other multi-hue:**
|
||||||
|
- `d3.interpolateBuGn` - Blue to green
|
||||||
|
- `d3.interpolateBuPu` - Blue to purple
|
||||||
|
- `d3.interpolateGnBu` - Green to blue
|
||||||
|
- `d3.interpolateOrRd` - Orange to red
|
||||||
|
- `d3.interpolatePuBu` - Purple to blue
|
||||||
|
- `d3.interpolatePuBuGn` - Purple to blue-green
|
||||||
|
- `d3.interpolatePuRd` - Purple to red
|
||||||
|
- `d3.interpolateRdPu` - Red to purple
|
||||||
|
- `d3.interpolateYlGn` - Yellow to green
|
||||||
|
- `d3.interpolateYlOrBr` - Yellow to orange-brown
|
||||||
|
|
||||||
|
**Use cases:** Traditional data visualisation, familiar colour associations (temperature, vegetation, water)
|
||||||
|
|
||||||
|
## Diverging colour schemes
|
||||||
|
|
||||||
|
Diverging schemes highlight deviations from a central value using two distinct hues.
|
||||||
|
|
||||||
|
### Red-Blue (temperature)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.interpolateRdBu
|
||||||
|
d3.schemeRdBu[11]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Intuitive temperature metaphor
|
||||||
|
- Strong contrast
|
||||||
|
- Clear positive/negative distinction
|
||||||
|
|
||||||
|
**Use cases:** Temperature, profit/loss, above/below average, correlation
|
||||||
|
|
||||||
|
### Red-Yellow-Blue
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
d3.interpolateRdYlBu
|
||||||
|
d3.schemeRdYlBu[11]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Three-colour gradient
|
||||||
|
- Softer transition through yellow
|
||||||
|
- More visual steps
|
||||||
|
|
||||||
|
**Use cases:** When extreme values need emphasis and middle needs visibility
|
||||||
|
|
||||||
|
### Other diverging schemes
|
||||||
|
|
||||||
|
**Traffic light:**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateRdYlGn // Red (bad) to green (good)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Spectral (rainbow):**
|
||||||
|
```javascript
|
||||||
|
d3.interpolateSpectral // Full spectrum
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other options:**
|
||||||
|
- `d3.interpolateBrBG` - Brown to blue-green
|
||||||
|
- `d3.interpolatePiYG` - Pink to yellow-green
|
||||||
|
- `d3.interpolatePRGn` - Purple to green
|
||||||
|
- `d3.interpolatePuOr` - Purple to orange
|
||||||
|
- `d3.interpolateRdGy` - Red to grey
|
||||||
|
|
||||||
|
**Use cases:** Choose based on semantic meaning and accessibility needs
|
||||||
|
|
||||||
|
## Colour-blind friendly palettes
|
||||||
|
|
||||||
|
### General guidelines
|
||||||
|
|
||||||
|
1. **Avoid red-green combinations** (most common colour blindness)
|
||||||
|
2. **Use blue-orange diverging** instead of red-green
|
||||||
|
3. **Add texture or patterns** as redundant encoding
|
||||||
|
4. **Test with simulation tools**
|
||||||
|
|
||||||
|
### Recommended colour-blind safe schemes
|
||||||
|
|
||||||
|
**Categorical:**
|
||||||
|
```javascript
|
||||||
|
// Okabe-Ito palette (colour-blind safe)
|
||||||
|
const okabePalette = [
|
||||||
|
'#E69F00', // Orange
|
||||||
|
'#56B4E9', // Sky blue
|
||||||
|
'#009E73', // Bluish green
|
||||||
|
'#F0E442', // Yellow
|
||||||
|
'#0072B2', // Blue
|
||||||
|
'#D55E00', // Vermillion
|
||||||
|
'#CC79A7', // Reddish purple
|
||||||
|
'#000000' // Black
|
||||||
|
];
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal()
|
||||||
|
.domain(categories)
|
||||||
|
.range(okabePalette);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sequential:**
|
||||||
|
```javascript
|
||||||
|
// Use Viridis, Cividis, or Blues
|
||||||
|
d3.interpolateViridis // Best overall
|
||||||
|
d3.interpolateCividis // Optimised for CVD
|
||||||
|
d3.interpolateBlues // Simple, safe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diverging:**
|
||||||
|
```javascript
|
||||||
|
// Use blue-orange instead of red-green
|
||||||
|
d3.interpolateBrBG
|
||||||
|
d3.interpolatePuOr
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom colour palettes
|
||||||
|
|
||||||
|
### Creating custom sequential
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const customSequential = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(['#e8f4f8', '#006d9c']) // Light to dark blue
|
||||||
|
.interpolate(d3.interpolateLab); // Perceptually uniform
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating custom diverging
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const customDiverging = d3.scaleLinear()
|
||||||
|
.domain([0, 50, 100])
|
||||||
|
.range(['#ca0020', '#f7f7f7', '#0571b0']) // Red, grey, blue
|
||||||
|
.interpolate(d3.interpolateLab);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating custom categorical
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Brand colours
|
||||||
|
const brandPalette = [
|
||||||
|
'#FF6B6B', // Primary red
|
||||||
|
'#4ECDC4', // Secondary teal
|
||||||
|
'#45B7D1', // Tertiary blue
|
||||||
|
'#FFA07A', // Accent coral
|
||||||
|
'#98D8C8' // Accent mint
|
||||||
|
];
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal()
|
||||||
|
.domain(categories)
|
||||||
|
.range(brandPalette);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Semantic colour associations
|
||||||
|
|
||||||
|
### Universal colour meanings
|
||||||
|
|
||||||
|
**Red:**
|
||||||
|
- Danger, error, negative
|
||||||
|
- High temperature
|
||||||
|
- Debt, loss
|
||||||
|
|
||||||
|
**Green:**
|
||||||
|
- Success, positive
|
||||||
|
- Growth, vegetation
|
||||||
|
- Profit, gain
|
||||||
|
|
||||||
|
**Blue:**
|
||||||
|
- Trust, calm
|
||||||
|
- Water, cold
|
||||||
|
- Information, neutral
|
||||||
|
|
||||||
|
**Yellow/Orange:**
|
||||||
|
- Warning, caution
|
||||||
|
- Energy, warmth
|
||||||
|
- Attention
|
||||||
|
|
||||||
|
**Grey:**
|
||||||
|
- Neutral, inactive
|
||||||
|
- Missing data
|
||||||
|
- Background
|
||||||
|
|
||||||
|
### Context-specific palettes
|
||||||
|
|
||||||
|
**Financial:**
|
||||||
|
```javascript
|
||||||
|
const financialColours = {
|
||||||
|
profit: '#27ae60',
|
||||||
|
loss: '#e74c3c',
|
||||||
|
neutral: '#95a5a6',
|
||||||
|
highlight: '#3498db'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Temperature:**
|
||||||
|
```javascript
|
||||||
|
const temperatureScale = d3.scaleSequential(d3.interpolateRdYlBu)
|
||||||
|
.domain([40, -10]); // Hot to cold (reversed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traffic/Status:**
|
||||||
|
```javascript
|
||||||
|
const statusColours = {
|
||||||
|
success: '#27ae60',
|
||||||
|
warning: '#f39c12',
|
||||||
|
error: '#e74c3c',
|
||||||
|
info: '#3498db',
|
||||||
|
neutral: '#95a5a6'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessibility best practices
|
||||||
|
|
||||||
|
### Contrast ratios
|
||||||
|
|
||||||
|
Ensure sufficient contrast between colours and backgrounds:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Good contrast example
|
||||||
|
const highContrast = {
|
||||||
|
background: '#ffffff',
|
||||||
|
text: '#2c3e50',
|
||||||
|
primary: '#3498db',
|
||||||
|
secondary: '#e74c3c'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**WCAG guidelines:**
|
||||||
|
- Normal text: 4.5:1 minimum
|
||||||
|
- Large text: 3:1 minimum
|
||||||
|
- UI components: 3:1 minimum
|
||||||
|
|
||||||
|
### Redundant encoding
|
||||||
|
|
||||||
|
Never rely solely on colour to convey information:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add patterns or shapes
|
||||||
|
const symbols = ['circle', 'square', 'triangle', 'diamond'];
|
||||||
|
|
||||||
|
// Add text labels
|
||||||
|
// Use line styles (solid, dashed, dotted)
|
||||||
|
// Use size encoding
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Test visualisations for colour blindness:
|
||||||
|
- Chrome DevTools (Rendering > Emulate vision deficiencies)
|
||||||
|
- Colour Oracle (free desktop application)
|
||||||
|
- Coblis (online simulator)
|
||||||
|
|
||||||
|
## Professional colour recommendations
|
||||||
|
|
||||||
|
### Data journalism
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Guardian style
|
||||||
|
const guardianPalette = [
|
||||||
|
'#005689', // Guardian blue
|
||||||
|
'#c70000', // Guardian red
|
||||||
|
'#7d0068', // Guardian pink
|
||||||
|
'#951c75', // Guardian purple
|
||||||
|
];
|
||||||
|
|
||||||
|
// FT style
|
||||||
|
const ftPalette = [
|
||||||
|
'#0f5499', // FT blue
|
||||||
|
'#990f3d', // FT red
|
||||||
|
'#593380', // FT purple
|
||||||
|
'#262a33', // FT black
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Academic/Scientific
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Nature journal style
|
||||||
|
const naturePalette = [
|
||||||
|
'#0071b2', // Blue
|
||||||
|
'#d55e00', // Vermillion
|
||||||
|
'#009e73', // Green
|
||||||
|
'#f0e442', // Yellow
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use Viridis for continuous data
|
||||||
|
const scientificScale = d3.scaleSequential(d3.interpolateViridis);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Corporate/Business
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Professional, conservative
|
||||||
|
const corporatePalette = [
|
||||||
|
'#003f5c', // Dark blue
|
||||||
|
'#58508d', // Purple
|
||||||
|
'#bc5090', // Magenta
|
||||||
|
'#ff6361', // Coral
|
||||||
|
'#ffa600' // Orange
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic colour selection
|
||||||
|
|
||||||
|
### Based on data range
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function selectColourScheme(data) {
|
||||||
|
const extent = d3.extent(data);
|
||||||
|
const hasNegative = extent[0] < 0;
|
||||||
|
const hasPositive = extent[1] > 0;
|
||||||
|
|
||||||
|
if (hasNegative && hasPositive) {
|
||||||
|
// Diverging: data crosses zero
|
||||||
|
return d3.scaleSequentialSymlog(d3.interpolateRdBu)
|
||||||
|
.domain([extent[0], 0, extent[1]]);
|
||||||
|
} else {
|
||||||
|
// Sequential: all positive or all negative
|
||||||
|
return d3.scaleSequential(d3.interpolateViridis)
|
||||||
|
.domain(extent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Based on category count
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function selectCategoricalScheme(categories) {
|
||||||
|
const n = categories.length;
|
||||||
|
|
||||||
|
if (n <= 10) {
|
||||||
|
return d3.scaleOrdinal(d3.schemeTableau10);
|
||||||
|
} else if (n <= 12) {
|
||||||
|
return d3.scaleOrdinal(d3.schemePaired);
|
||||||
|
} else {
|
||||||
|
// For many categories, use sequential with quantize
|
||||||
|
return d3.scaleQuantize()
|
||||||
|
.domain([0, n - 1])
|
||||||
|
.range(d3.quantize(d3.interpolateRainbow, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common colour mistakes to avoid
|
||||||
|
|
||||||
|
1. **Rainbow gradients for sequential data**
|
||||||
|
- Problem: Not perceptually uniform, hard to read
|
||||||
|
- Solution: Use Viridis, Blues, or other uniform schemes
|
||||||
|
|
||||||
|
2. **Red-green for diverging (colour blindness)**
|
||||||
|
- Problem: 8% of males can't distinguish
|
||||||
|
- Solution: Use blue-orange or purple-green
|
||||||
|
|
||||||
|
3. **Too many categorical colours**
|
||||||
|
- Problem: Hard to distinguish and remember
|
||||||
|
- Solution: Limit to 5-8 categories, use grouping
|
||||||
|
|
||||||
|
4. **Insufficient contrast**
|
||||||
|
- Problem: Poor readability
|
||||||
|
- Solution: Test contrast ratios, use darker colours on light backgrounds
|
||||||
|
|
||||||
|
5. **Culturally inconsistent colours**
|
||||||
|
- Problem: Confusing semantic meaning
|
||||||
|
- Solution: Research colour associations for target audience
|
||||||
|
|
||||||
|
6. **Inverted temperature scales**
|
||||||
|
- Problem: Counterintuitive (red = cold)
|
||||||
|
- Solution: Red/orange = hot, blue = cold
|
||||||
|
|
||||||
|
## Quick reference guide
|
||||||
|
|
||||||
|
**Need to show...**
|
||||||
|
|
||||||
|
- **Categories (≤10):** `d3.schemeCategory10` or `d3.schemeTableau10`
|
||||||
|
- **Categories (>10):** `d3.schemePaired` or group categories
|
||||||
|
- **Sequential (general):** `d3.interpolateViridis`
|
||||||
|
- **Sequential (scientific):** `d3.interpolateViridis` or `d3.interpolatePlasma`
|
||||||
|
- **Sequential (temperature):** `d3.interpolateRdYlBu` (inverted)
|
||||||
|
- **Diverging (zero):** `d3.interpolateRdBu` or `d3.interpolateBrBG`
|
||||||
|
- **Diverging (good/bad):** `d3.interpolateRdYlGn` (inverted)
|
||||||
|
- **Colour-blind safe (categorical):** Okabe-Ito palette (shown above)
|
||||||
|
- **Colour-blind safe (sequential):** `d3.interpolateCividis` or `d3.interpolateBlues`
|
||||||
|
- **Colour-blind safe (diverging):** `d3.interpolatePuOr` or `d3.interpolateBrBG`
|
||||||
|
|
||||||
|
**Always remember:**
|
||||||
|
1. Test for colour-blindness
|
||||||
|
2. Ensure sufficient contrast
|
||||||
|
3. Use semantic colours appropriately
|
||||||
|
4. Add redundant encoding (patterns, labels)
|
||||||
|
5. Keep it simple (fewer colours = clearer visualisation)
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
# D3.js Visualisation Patterns
|
||||||
|
|
||||||
|
This reference provides detailed code patterns for common d3.js visualisation types.
|
||||||
|
|
||||||
|
## Hierarchical visualisations
|
||||||
|
|
||||||
|
### Tree diagram
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
const tree = d3.tree().size([height - 100, width - 200]);
|
||||||
|
|
||||||
|
const root = d3.hierarchy(data);
|
||||||
|
tree(root);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", "translate(100,50)");
|
||||||
|
|
||||||
|
// Links
|
||||||
|
g.selectAll("path")
|
||||||
|
.data(root.links())
|
||||||
|
.join("path")
|
||||||
|
.attr("d", d3.linkHorizontal()
|
||||||
|
.x(d => d.y)
|
||||||
|
.y(d => d.x))
|
||||||
|
.attr("fill", "none")
|
||||||
|
.attr("stroke", "#555")
|
||||||
|
.attr("stroke-width", 2);
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
const node = g.selectAll("g")
|
||||||
|
.data(root.descendants())
|
||||||
|
.join("g")
|
||||||
|
.attr("transform", d => `translate(${d.y},${d.x})`);
|
||||||
|
|
||||||
|
node.append("circle")
|
||||||
|
.attr("r", 6)
|
||||||
|
.attr("fill", d => d.children ? "#555" : "#999");
|
||||||
|
|
||||||
|
node.append("text")
|
||||||
|
.attr("dy", "0.31em")
|
||||||
|
.attr("x", d => d.children ? -8 : 8)
|
||||||
|
.attr("text-anchor", d => d.children ? "end" : "start")
|
||||||
|
.text(d => d.data.name)
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Treemap
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
const root = d3.hierarchy(data)
|
||||||
|
.sum(d => d.value)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
d3.treemap()
|
||||||
|
.size([width, height])
|
||||||
|
.padding(2)
|
||||||
|
.round(true)(root);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
const cell = svg.selectAll("g")
|
||||||
|
.data(root.leaves())
|
||||||
|
.join("g")
|
||||||
|
.attr("transform", d => `translate(${d.x0},${d.y0})`);
|
||||||
|
|
||||||
|
cell.append("rect")
|
||||||
|
.attr("width", d => d.x1 - d.x0)
|
||||||
|
.attr("height", d => d.y1 - d.y0)
|
||||||
|
.attr("fill", d => colourScale(d.parent.data.name))
|
||||||
|
.attr("stroke", "white")
|
||||||
|
.attr("stroke-width", 2);
|
||||||
|
|
||||||
|
cell.append("text")
|
||||||
|
.attr("x", 4)
|
||||||
|
.attr("y", 16)
|
||||||
|
.text(d => d.data.name)
|
||||||
|
.style("font-size", "12px")
|
||||||
|
.style("fill", "white");
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sunburst diagram
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 600;
|
||||||
|
const height = 600;
|
||||||
|
const radius = Math.min(width, height) / 2;
|
||||||
|
|
||||||
|
const root = d3.hierarchy(data)
|
||||||
|
.sum(d => d.value)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const partition = d3.partition()
|
||||||
|
.size([2 * Math.PI, radius]);
|
||||||
|
|
||||||
|
partition(root);
|
||||||
|
|
||||||
|
const arc = d3.arc()
|
||||||
|
.startAngle(d => d.x0)
|
||||||
|
.endAngle(d => d.x1)
|
||||||
|
.innerRadius(d => d.y0)
|
||||||
|
.outerRadius(d => d.y1);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width / 2},${height / 2})`);
|
||||||
|
|
||||||
|
g.selectAll("path")
|
||||||
|
.data(root.descendants())
|
||||||
|
.join("path")
|
||||||
|
.attr("d", arc)
|
||||||
|
.attr("fill", d => colourScale(d.depth))
|
||||||
|
.attr("stroke", "white")
|
||||||
|
.attr("stroke-width", 1);
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chord diagram
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawChordDiagram(data) {
|
||||||
|
// data format: array of objects with source, target, and value
|
||||||
|
// Example: [{ source: 'A', target: 'B', value: 10 }, ...]
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart');
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 600;
|
||||||
|
const height = 600;
|
||||||
|
const innerRadius = Math.min(width, height) * 0.3;
|
||||||
|
const outerRadius = innerRadius + 30;
|
||||||
|
|
||||||
|
// Create matrix from data
|
||||||
|
const nodes = Array.from(new Set(data.flatMap(d => [d.source, d.target])));
|
||||||
|
const matrix = Array.from({ length: nodes.length }, () => Array(nodes.length).fill(0));
|
||||||
|
|
||||||
|
data.forEach(d => {
|
||||||
|
const i = nodes.indexOf(d.source);
|
||||||
|
const j = nodes.indexOf(d.target);
|
||||||
|
matrix[i][j] += d.value;
|
||||||
|
matrix[j][i] += d.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create chord layout
|
||||||
|
const chord = d3.chord()
|
||||||
|
.padAngle(0.05)
|
||||||
|
.sortSubgroups(d3.descending);
|
||||||
|
|
||||||
|
const arc = d3.arc()
|
||||||
|
.innerRadius(innerRadius)
|
||||||
|
.outerRadius(outerRadius);
|
||||||
|
|
||||||
|
const ribbon = d3.ribbon()
|
||||||
|
.source(d => d.source)
|
||||||
|
.target(d => d.target);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10)
|
||||||
|
.domain(nodes);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width / 2},${height / 2})`);
|
||||||
|
|
||||||
|
const chords = chord(matrix);
|
||||||
|
|
||||||
|
// Draw ribbons
|
||||||
|
g.append("g")
|
||||||
|
.attr("fill-opacity", 0.67)
|
||||||
|
.selectAll("path")
|
||||||
|
.data(chords)
|
||||||
|
.join("path")
|
||||||
|
.attr("d", ribbon)
|
||||||
|
.attr("fill", d => colourScale(nodes[d.source.index]))
|
||||||
|
.attr("stroke", d => d3.rgb(colourScale(nodes[d.source.index])).darker());
|
||||||
|
|
||||||
|
// Draw groups (arcs)
|
||||||
|
const group = g.append("g")
|
||||||
|
.selectAll("g")
|
||||||
|
.data(chords.groups)
|
||||||
|
.join("g");
|
||||||
|
|
||||||
|
group.append("path")
|
||||||
|
.attr("d", arc)
|
||||||
|
.attr("fill", d => colourScale(nodes[d.index]))
|
||||||
|
.attr("stroke", d => d3.rgb(colourScale(nodes[d.index])).darker());
|
||||||
|
|
||||||
|
// Add labels
|
||||||
|
group.append("text")
|
||||||
|
.each(d => { d.angle = (d.startAngle + d.endAngle) / 2; })
|
||||||
|
.attr("dy", "0.31em")
|
||||||
|
.attr("transform", d => `rotate(${(d.angle * 180 / Math.PI) - 90})translate(${outerRadius + 30})${d.angle > Math.PI ? "rotate(180)" : ""}`)
|
||||||
|
.attr("text-anchor", d => d.angle > Math.PI ? "end" : null)
|
||||||
|
.text((d, i) => nodes[i])
|
||||||
|
.style("font-size", "12px");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data format example:
|
||||||
|
// const data = [
|
||||||
|
// { source: 'Category A', target: 'Category B', value: 100 },
|
||||||
|
// { source: 'Category A', target: 'Category C', value: 50 },
|
||||||
|
// { source: 'Category B', target: 'Category C', value: 75 }
|
||||||
|
// ];
|
||||||
|
// drawChordDiagram(data);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced chart types
|
||||||
|
|
||||||
|
### Heatmap
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function drawHeatmap(data) {
|
||||||
|
// data format: array of objects with row, column, and value
|
||||||
|
// Example: [{ row: 'A', column: 'X', value: 10 }, ...]
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select('#chart');
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
const margin = { top: 100, right: 30, bottom: 30, left: 100 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Get unique rows and columns
|
||||||
|
const rows = Array.from(new Set(data.map(d => d.row)));
|
||||||
|
const columns = Array.from(new Set(data.map(d => d.column)));
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
// Create scales
|
||||||
|
const xScale = d3.scaleBand()
|
||||||
|
.domain(columns)
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.01);
|
||||||
|
|
||||||
|
const yScale = d3.scaleBand()
|
||||||
|
.domain(rows)
|
||||||
|
.range([0, innerHeight])
|
||||||
|
.padding(0.01);
|
||||||
|
|
||||||
|
// Colour scale for values (sequential from light to dark red)
|
||||||
|
const colourScale = d3.scaleSequential(d3.interpolateYlOrRd)
|
||||||
|
.domain([0, d3.max(data, d => d.value)]);
|
||||||
|
|
||||||
|
// Draw rectangles
|
||||||
|
g.selectAll("rect")
|
||||||
|
.data(data)
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => xScale(d.column))
|
||||||
|
.attr("y", d => yScale(d.row))
|
||||||
|
.attr("width", xScale.bandwidth())
|
||||||
|
.attr("height", yScale.bandwidth())
|
||||||
|
.attr("fill", d => colourScale(d.value));
|
||||||
|
|
||||||
|
// Add x-axis labels
|
||||||
|
svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(columns)
|
||||||
|
.join("text")
|
||||||
|
.attr("x", d => xScale(d) + xScale.bandwidth() / 2)
|
||||||
|
.attr("y", -10)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.text(d => d)
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
// Add y-axis labels
|
||||||
|
svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(rows)
|
||||||
|
.join("text")
|
||||||
|
.attr("x", -10)
|
||||||
|
.attr("y", d => yScale(d) + yScale.bandwidth() / 2)
|
||||||
|
.attr("dy", "0.35em")
|
||||||
|
.attr("text-anchor", "end")
|
||||||
|
.text(d => d)
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
|
// Add colour legend
|
||||||
|
const legendWidth = 20;
|
||||||
|
const legendHeight = 200;
|
||||||
|
const legend = svg.append("g")
|
||||||
|
.attr("transform", `translate(${width - 60},${margin.top})`);
|
||||||
|
|
||||||
|
const legendScale = d3.scaleLinear()
|
||||||
|
.domain(colourScale.domain())
|
||||||
|
.range([legendHeight, 0]);
|
||||||
|
|
||||||
|
const legendAxis = d3.axisRight(legendScale).ticks(5);
|
||||||
|
|
||||||
|
// Draw colour gradient in legend
|
||||||
|
for (let i = 0; i < legendHeight; i++) {
|
||||||
|
legend.append("rect")
|
||||||
|
.attr("y", i)
|
||||||
|
.attr("width", legendWidth)
|
||||||
|
.attr("height", 1)
|
||||||
|
.attr("fill", colourScale(legendScale.invert(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
legend.append("g")
|
||||||
|
.attr("transform", `translate(${legendWidth},0)`)
|
||||||
|
.call(legendAxis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data format example:
|
||||||
|
// const data = [
|
||||||
|
// { row: 'Monday', column: 'Morning', value: 42 },
|
||||||
|
// { row: 'Monday', column: 'Afternoon', value: 78 },
|
||||||
|
// { row: 'Tuesday', column: 'Morning', value: 65 },
|
||||||
|
// { row: 'Tuesday', column: 'Afternoon', value: 55 }
|
||||||
|
// ];
|
||||||
|
// drawHeatmap(data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Area chart with gradient
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
// Define gradient
|
||||||
|
const defs = svg.append("defs");
|
||||||
|
const gradient = defs.append("linearGradient")
|
||||||
|
.attr("id", "areaGradient")
|
||||||
|
.attr("x1", "0%")
|
||||||
|
.attr("x2", "0%")
|
||||||
|
.attr("y1", "0%")
|
||||||
|
.attr("y2", "100%");
|
||||||
|
|
||||||
|
gradient.append("stop")
|
||||||
|
.attr("offset", "0%")
|
||||||
|
.attr("stop-color", "steelblue")
|
||||||
|
.attr("stop-opacity", 0.8);
|
||||||
|
|
||||||
|
gradient.append("stop")
|
||||||
|
.attr("offset", "100%")
|
||||||
|
.attr("stop-color", "steelblue")
|
||||||
|
.attr("stop-opacity", 0.1);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const xScale = d3.scaleTime()
|
||||||
|
.domain(d3.extent(data, d => d.date))
|
||||||
|
.range([0, innerWidth]);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.value)])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
const area = d3.area()
|
||||||
|
.x(d => xScale(d.date))
|
||||||
|
.y0(innerHeight)
|
||||||
|
.y1(d => yScale(d.value))
|
||||||
|
.curve(d3.curveMonotoneX);
|
||||||
|
|
||||||
|
g.append("path")
|
||||||
|
.datum(data)
|
||||||
|
.attr("fill", "url(#areaGradient)")
|
||||||
|
.attr("d", area);
|
||||||
|
|
||||||
|
const line = d3.line()
|
||||||
|
.x(d => xScale(d.date))
|
||||||
|
.y(d => yScale(d.value))
|
||||||
|
.curve(d3.curveMonotoneX);
|
||||||
|
|
||||||
|
g.append("path")
|
||||||
|
.datum(data)
|
||||||
|
.attr("fill", "none")
|
||||||
|
.attr("stroke", "steelblue")
|
||||||
|
.attr("stroke-width", 2)
|
||||||
|
.attr("d", line);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(xScale));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale));
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stacked bar chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const categories = Object.keys(data[0]).filter(k => k !== 'group');
|
||||||
|
const stackedData = d3.stack().keys(categories)(data);
|
||||||
|
|
||||||
|
const xScale = d3.scaleBand()
|
||||||
|
.domain(data.map(d => d.group))
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(stackedData[stackedData.length - 1], d => d[1])])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
g.selectAll("g")
|
||||||
|
.data(stackedData)
|
||||||
|
.join("g")
|
||||||
|
.attr("fill", (d, i) => colourScale(i))
|
||||||
|
.selectAll("rect")
|
||||||
|
.data(d => d)
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => xScale(d.data.group))
|
||||||
|
.attr("y", d => yScale(d[1]))
|
||||||
|
.attr("height", d => yScale(d[0]) - yScale(d[1]))
|
||||||
|
.attr("width", xScale.bandwidth());
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(xScale));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale));
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grouped bar chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const categories = Object.keys(data[0]).filter(k => k !== 'group');
|
||||||
|
|
||||||
|
const x0Scale = d3.scaleBand()
|
||||||
|
.domain(data.map(d => d.group))
|
||||||
|
.range([0, innerWidth])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
const x1Scale = d3.scaleBand()
|
||||||
|
.domain(categories)
|
||||||
|
.range([0, x0Scale.bandwidth()])
|
||||||
|
.padding(0.05);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => Math.max(...categories.map(c => d[c])))])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
const group = g.selectAll("g")
|
||||||
|
.data(data)
|
||||||
|
.join("g")
|
||||||
|
.attr("transform", d => `translate(${x0Scale(d.group)},0)`);
|
||||||
|
|
||||||
|
group.selectAll("rect")
|
||||||
|
.data(d => categories.map(key => ({ key, value: d[key] })))
|
||||||
|
.join("rect")
|
||||||
|
.attr("x", d => x1Scale(d.key))
|
||||||
|
.attr("y", d => yScale(d.value))
|
||||||
|
.attr("width", x1Scale.bandwidth())
|
||||||
|
.attr("height", d => innerHeight - yScale(d.value))
|
||||||
|
.attr("fill", d => colourScale(d.key));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(x0Scale));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale));
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bubble chart
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.x)])
|
||||||
|
.range([0, innerWidth]);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.y)])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
const sizeScale = d3.scaleSqrt()
|
||||||
|
.domain([0, d3.max(data, d => d.size)])
|
||||||
|
.range([0, 50]);
|
||||||
|
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
g.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", d => sizeScale(d.size))
|
||||||
|
.attr("fill", d => colourScale(d.category))
|
||||||
|
.attr("opacity", 0.6)
|
||||||
|
.attr("stroke", "white")
|
||||||
|
.attr("stroke-width", 2);
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("transform", `translate(0,${innerHeight})`)
|
||||||
|
.call(d3.axisBottom(xScale));
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale));
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geographic visualisations
|
||||||
|
|
||||||
|
### Basic map with points
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geoData || !pointData) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
const projection = d3.geoMercator()
|
||||||
|
.fitSize([width, height], geoData);
|
||||||
|
|
||||||
|
const pathGenerator = d3.geoPath().projection(projection);
|
||||||
|
|
||||||
|
// Draw map
|
||||||
|
svg.selectAll("path")
|
||||||
|
.data(geoData.features)
|
||||||
|
.join("path")
|
||||||
|
.attr("d", pathGenerator)
|
||||||
|
.attr("fill", "#e0e0e0")
|
||||||
|
.attr("stroke", "#999")
|
||||||
|
.attr("stroke-width", 0.5);
|
||||||
|
|
||||||
|
// Draw points
|
||||||
|
svg.selectAll("circle")
|
||||||
|
.data(pointData)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => projection([d.longitude, d.latitude])[0])
|
||||||
|
.attr("cy", d => projection([d.longitude, d.latitude])[1])
|
||||||
|
.attr("r", 5)
|
||||||
|
.attr("fill", "steelblue")
|
||||||
|
.attr("opacity", 0.7);
|
||||||
|
|
||||||
|
}, [geoData, pointData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choropleth map
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geoData || !valueData) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
const projection = d3.geoMercator()
|
||||||
|
.fitSize([width, height], geoData);
|
||||||
|
|
||||||
|
const pathGenerator = d3.geoPath().projection(projection);
|
||||||
|
|
||||||
|
// Create value lookup
|
||||||
|
const valueLookup = new Map(valueData.map(d => [d.id, d.value]));
|
||||||
|
|
||||||
|
// Colour scale
|
||||||
|
const colourScale = d3.scaleSequential(d3.interpolateBlues)
|
||||||
|
.domain([0, d3.max(valueData, d => d.value)]);
|
||||||
|
|
||||||
|
svg.selectAll("path")
|
||||||
|
.data(geoData.features)
|
||||||
|
.join("path")
|
||||||
|
.attr("d", pathGenerator)
|
||||||
|
.attr("fill", d => {
|
||||||
|
const value = valueLookup.get(d.id);
|
||||||
|
return value ? colourScale(value) : "#e0e0e0";
|
||||||
|
})
|
||||||
|
.attr("stroke", "#999")
|
||||||
|
.attr("stroke-width", 0.5);
|
||||||
|
|
||||||
|
}, [geoData, valueData]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced interactions
|
||||||
|
|
||||||
|
### Brush and zoom
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
|
||||||
|
const width = 800;
|
||||||
|
const height = 400;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const xScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.x)])
|
||||||
|
.range([0, innerWidth]);
|
||||||
|
|
||||||
|
const yScale = d3.scaleLinear()
|
||||||
|
.domain([0, d3.max(data, d => d.y)])
|
||||||
|
.range([innerHeight, 0]);
|
||||||
|
|
||||||
|
const g = svg.append("g")
|
||||||
|
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||||
|
|
||||||
|
const circles = g.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", 5)
|
||||||
|
.attr("fill", "steelblue");
|
||||||
|
|
||||||
|
// Add brush
|
||||||
|
const brush = d3.brush()
|
||||||
|
.extent([[0, 0], [innerWidth, innerHeight]])
|
||||||
|
.on("start brush", (event) => {
|
||||||
|
if (!event.selection) return;
|
||||||
|
|
||||||
|
const [[x0, y0], [x1, y1]] = event.selection;
|
||||||
|
|
||||||
|
circles.attr("fill", d => {
|
||||||
|
const cx = xScale(d.x);
|
||||||
|
const cy = yScale(d.y);
|
||||||
|
return (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1)
|
||||||
|
? "orange"
|
||||||
|
: "steelblue";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
g.append("g")
|
||||||
|
.attr("class", "brush")
|
||||||
|
.call(brush);
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linked brushing between charts
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function LinkedCharts({ data }) {
|
||||||
|
const [selectedPoints, setSelectedPoints] = useState(new Set());
|
||||||
|
const svg1Ref = useRef();
|
||||||
|
const svg2Ref = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Chart 1: Scatter plot
|
||||||
|
const svg1 = d3.select(svg1Ref.current);
|
||||||
|
svg1.selectAll("*").remove();
|
||||||
|
|
||||||
|
// ... create first chart ...
|
||||||
|
|
||||||
|
const circles1 = svg1.selectAll("circle")
|
||||||
|
.data(data)
|
||||||
|
.join("circle")
|
||||||
|
.attr("fill", d => selectedPoints.has(d.id) ? "orange" : "steelblue");
|
||||||
|
|
||||||
|
// Chart 2: Bar chart
|
||||||
|
const svg2 = d3.select(svg2Ref.current);
|
||||||
|
svg2.selectAll("*").remove();
|
||||||
|
|
||||||
|
// ... create second chart ...
|
||||||
|
|
||||||
|
const bars = svg2.selectAll("rect")
|
||||||
|
.data(data)
|
||||||
|
.join("rect")
|
||||||
|
.attr("fill", d => selectedPoints.has(d.id) ? "orange" : "steelblue");
|
||||||
|
|
||||||
|
// Add brush to first chart
|
||||||
|
const brush = d3.brush()
|
||||||
|
.on("start brush end", (event) => {
|
||||||
|
if (!event.selection) {
|
||||||
|
setSelectedPoints(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[x0, y0], [x1, y1]] = event.selection;
|
||||||
|
const selected = new Set();
|
||||||
|
|
||||||
|
data.forEach(d => {
|
||||||
|
const x = xScale(d.x);
|
||||||
|
const y = yScale(d.y);
|
||||||
|
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) {
|
||||||
|
selected.add(d.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedPoints(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg1.append("g").call(brush);
|
||||||
|
|
||||||
|
}, [data, selectedPoints]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<svg ref={svg1Ref} width="400" height="300" />
|
||||||
|
<svg ref={svg2Ref} width="400" height="300" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation patterns
|
||||||
|
|
||||||
|
### Enter, update, exit with transitions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
|
||||||
|
const circles = svg.selectAll("circle")
|
||||||
|
.data(data, d => d.id); // Key function for object constancy
|
||||||
|
|
||||||
|
// EXIT: Remove old elements
|
||||||
|
circles.exit()
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.attr("r", 0)
|
||||||
|
.remove();
|
||||||
|
|
||||||
|
// UPDATE: Modify existing elements
|
||||||
|
circles
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("fill", "steelblue");
|
||||||
|
|
||||||
|
// ENTER: Add new elements
|
||||||
|
circles.enter()
|
||||||
|
.append("circle")
|
||||||
|
.attr("cx", d => xScale(d.x))
|
||||||
|
.attr("cy", d => yScale(d.y))
|
||||||
|
.attr("r", 0)
|
||||||
|
.attr("fill", "steelblue")
|
||||||
|
.transition()
|
||||||
|
.duration(500)
|
||||||
|
.attr("r", 5);
|
||||||
|
|
||||||
|
}, [data]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path morphing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data1 || !data2) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
|
||||||
|
const line = d3.line()
|
||||||
|
.x(d => xScale(d.x))
|
||||||
|
.y(d => yScale(d.y))
|
||||||
|
.curve(d3.curveMonotoneX);
|
||||||
|
|
||||||
|
const path = svg.select("path");
|
||||||
|
|
||||||
|
// Morph from data1 to data2
|
||||||
|
path
|
||||||
|
.datum(data1)
|
||||||
|
.attr("d", line)
|
||||||
|
.transition()
|
||||||
|
.duration(1000)
|
||||||
|
.attrTween("d", function() {
|
||||||
|
const previous = d3.select(this).attr("d");
|
||||||
|
const current = line(data2);
|
||||||
|
return d3.interpolatePath(previous, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
}, [data1, data2]);
|
||||||
|
```
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# D3.js Scale Reference
|
||||||
|
|
||||||
|
Comprehensive guide to all d3 scale types with examples and use cases.
|
||||||
|
|
||||||
|
## Continuous scales
|
||||||
|
|
||||||
|
### Linear scale
|
||||||
|
|
||||||
|
Maps continuous input domain to continuous output range with linear interpolation.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
scale(50); // Returns 250
|
||||||
|
scale(0); // Returns 0
|
||||||
|
scale(100); // Returns 500
|
||||||
|
|
||||||
|
// Invert scale (get input from output)
|
||||||
|
scale.invert(250); // Returns 50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Most common scale for quantitative data
|
||||||
|
- Axes, bar lengths, position encoding
|
||||||
|
- Temperature, prices, counts, measurements
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `.domain([min, max])` - Set input domain
|
||||||
|
- `.range([min, max])` - Set output range
|
||||||
|
- `.invert(value)` - Get domain value from range value
|
||||||
|
- `.clamp(true)` - Restrict output to range bounds
|
||||||
|
- `.nice()` - Extend domain to nice round values
|
||||||
|
|
||||||
|
### Power scale
|
||||||
|
|
||||||
|
Maps continuous input to continuous output with exponential transformation.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sqrtScale = d3.scalePow()
|
||||||
|
.exponent(0.5) // Square root
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
const squareScale = d3.scalePow()
|
||||||
|
.exponent(2) // Square
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
// Shorthand for square root
|
||||||
|
const sqrtScale2 = d3.scaleSqrt()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Perceptual scaling (human perception is non-linear)
|
||||||
|
- Area encoding (use square root to map values to circle radii)
|
||||||
|
- Emphasising differences in small or large values
|
||||||
|
|
||||||
|
### Logarithmic scale
|
||||||
|
|
||||||
|
Maps continuous input to continuous output with logarithmic transformation.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const logScale = d3.scaleLog()
|
||||||
|
.domain([1, 1000]) // Must be positive
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
logScale(1); // Returns 0
|
||||||
|
logScale(10); // Returns ~167
|
||||||
|
logScale(100); // Returns ~333
|
||||||
|
logScale(1000); // Returns 500
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Data spanning multiple orders of magnitude
|
||||||
|
- Population, GDP, wealth distributions
|
||||||
|
- Logarithmic axes
|
||||||
|
- Exponential growth visualisations
|
||||||
|
|
||||||
|
**Important:** Domain values must be strictly positive (>0).
|
||||||
|
|
||||||
|
### Time scale
|
||||||
|
|
||||||
|
Specialised linear scale for temporal data.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const timeScale = d3.scaleTime()
|
||||||
|
.domain([new Date(2020, 0, 1), new Date(2024, 0, 1)])
|
||||||
|
.range([0, 800]);
|
||||||
|
|
||||||
|
timeScale(new Date(2022, 0, 1)); // Returns 400
|
||||||
|
|
||||||
|
// Invert to get date
|
||||||
|
timeScale.invert(400); // Returns Date object for mid-2022
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Time series visualisations
|
||||||
|
- Timeline axes
|
||||||
|
- Temporal animations
|
||||||
|
- Date-based interactions
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `.nice()` - Extend domain to nice time intervals
|
||||||
|
- `.ticks(count)` - Generate nicely-spaced tick values
|
||||||
|
- All linear scale methods apply
|
||||||
|
|
||||||
|
### Quantize scale
|
||||||
|
|
||||||
|
Maps continuous input to discrete output buckets.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const quantizeScale = d3.scaleQuantize()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(['low', 'medium', 'high']);
|
||||||
|
|
||||||
|
quantizeScale(25); // Returns 'low'
|
||||||
|
quantizeScale(50); // Returns 'medium'
|
||||||
|
quantizeScale(75); // Returns 'high'
|
||||||
|
|
||||||
|
// Get the threshold values
|
||||||
|
quantizeScale.thresholds(); // Returns [33.33, 66.67]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Binning continuous data
|
||||||
|
- Heat map colours
|
||||||
|
- Risk categories (low/medium/high)
|
||||||
|
- Age groups, income brackets
|
||||||
|
|
||||||
|
### Quantile scale
|
||||||
|
|
||||||
|
Maps continuous input to discrete output based on quantiles.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const quantileScale = d3.scaleQuantile()
|
||||||
|
.domain([3, 6, 7, 8, 8, 10, 13, 15, 16, 20, 24]) // Sample data
|
||||||
|
.range(['low', 'medium', 'high']);
|
||||||
|
|
||||||
|
quantileScale(8); // Returns based on quantile position
|
||||||
|
quantileScale.quantiles(); // Returns quantile thresholds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Equal-size groups regardless of distribution
|
||||||
|
- Percentile-based categorisation
|
||||||
|
- Handling skewed distributions
|
||||||
|
|
||||||
|
### Threshold scale
|
||||||
|
|
||||||
|
Maps continuous input to discrete output with custom thresholds.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const thresholdScale = d3.scaleThreshold()
|
||||||
|
.domain([0, 10, 20])
|
||||||
|
.range(['freezing', 'cold', 'warm', 'hot']);
|
||||||
|
|
||||||
|
thresholdScale(-5); // Returns 'freezing'
|
||||||
|
thresholdScale(5); // Returns 'cold'
|
||||||
|
thresholdScale(15); // Returns 'warm'
|
||||||
|
thresholdScale(25); // Returns 'hot'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Custom breakpoints
|
||||||
|
- Grade boundaries (A, B, C, D, F)
|
||||||
|
- Temperature categories
|
||||||
|
- Air quality indices
|
||||||
|
|
||||||
|
## Sequential scales
|
||||||
|
|
||||||
|
### Sequential colour scale
|
||||||
|
|
||||||
|
Maps continuous input to continuous colour gradient.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const colourScale = d3.scaleSequential(d3.interpolateBlues)
|
||||||
|
.domain([0, 100]);
|
||||||
|
|
||||||
|
colourScale(0); // Returns lightest blue
|
||||||
|
colourScale(50); // Returns mid blue
|
||||||
|
colourScale(100); // Returns darkest blue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available interpolators:**
|
||||||
|
|
||||||
|
**Single hue:**
|
||||||
|
- `d3.interpolateBlues`, `d3.interpolateGreens`, `d3.interpolateReds`
|
||||||
|
- `d3.interpolateOranges`, `d3.interpolatePurples`, `d3.interpolateGreys`
|
||||||
|
|
||||||
|
**Multi-hue:**
|
||||||
|
- `d3.interpolateViridis`, `d3.interpolateInferno`, `d3.interpolateMagma`
|
||||||
|
- `d3.interpolatePlasma`, `d3.interpolateWarm`, `d3.interpolateCool`
|
||||||
|
- `d3.interpolateCubehelixDefault`, `d3.interpolateTurbo`
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Heat maps, choropleth maps
|
||||||
|
- Continuous data visualisation
|
||||||
|
- Temperature, elevation, density
|
||||||
|
|
||||||
|
### Diverging colour scale
|
||||||
|
|
||||||
|
Maps continuous input to diverging colour gradient with a midpoint.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const divergingScale = d3.scaleDiverging(d3.interpolateRdBu)
|
||||||
|
.domain([-10, 0, 10]);
|
||||||
|
|
||||||
|
divergingScale(-10); // Returns red
|
||||||
|
divergingScale(0); // Returns white/neutral
|
||||||
|
divergingScale(10); // Returns blue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available interpolators:**
|
||||||
|
- `d3.interpolateRdBu` - Red to blue
|
||||||
|
- `d3.interpolateRdYlBu` - Red, yellow, blue
|
||||||
|
- `d3.interpolateRdYlGn` - Red, yellow, green
|
||||||
|
- `d3.interpolatePiYG` - Pink, yellow, green
|
||||||
|
- `d3.interpolateBrBG` - Brown, blue-green
|
||||||
|
- `d3.interpolatePRGn` - Purple, green
|
||||||
|
- `d3.interpolatePuOr` - Purple, orange
|
||||||
|
- `d3.interpolateRdGy` - Red, grey
|
||||||
|
- `d3.interpolateSpectral` - Rainbow spectrum
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Data with meaningful midpoint (zero, average, neutral)
|
||||||
|
- Positive/negative values
|
||||||
|
- Above/below comparisons
|
||||||
|
- Correlation matrices
|
||||||
|
|
||||||
|
### Sequential quantile scale
|
||||||
|
|
||||||
|
Combines sequential colour with quantile mapping.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const sequentialQuantileScale = d3.scaleSequentialQuantile(d3.interpolateBlues)
|
||||||
|
.domain([3, 6, 7, 8, 8, 10, 13, 15, 16, 20, 24]);
|
||||||
|
|
||||||
|
// Maps based on quantile position
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Perceptually uniform binning
|
||||||
|
- Handling outliers
|
||||||
|
- Skewed distributions
|
||||||
|
|
||||||
|
## Ordinal scales
|
||||||
|
|
||||||
|
### Band scale
|
||||||
|
|
||||||
|
Maps discrete input to continuous bands (rectangles) with optional padding.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const bandScale = d3.scaleBand()
|
||||||
|
.domain(['A', 'B', 'C', 'D'])
|
||||||
|
.range([0, 400])
|
||||||
|
.padding(0.1);
|
||||||
|
|
||||||
|
bandScale('A'); // Returns start position (e.g., 0)
|
||||||
|
bandScale('B'); // Returns start position (e.g., 110)
|
||||||
|
bandScale.bandwidth(); // Returns width of each band (e.g., 95)
|
||||||
|
bandScale.step(); // Returns total step including padding
|
||||||
|
bandScale.paddingInner(); // Returns inner padding (between bands)
|
||||||
|
bandScale.paddingOuter(); // Returns outer padding (at edges)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Bar charts (most common use case)
|
||||||
|
- Grouped elements
|
||||||
|
- Categorical axes
|
||||||
|
- Heat map cells
|
||||||
|
|
||||||
|
**Padding options:**
|
||||||
|
- `.padding(value)` - Sets both inner and outer padding (0-1)
|
||||||
|
- `.paddingInner(value)` - Padding between bands (0-1)
|
||||||
|
- `.paddingOuter(value)` - Padding at edges (0-1)
|
||||||
|
- `.align(value)` - Alignment of bands (0-1, default 0.5)
|
||||||
|
|
||||||
|
### Point scale
|
||||||
|
|
||||||
|
Maps discrete input to continuous points (no width).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const pointScale = d3.scalePoint()
|
||||||
|
.domain(['A', 'B', 'C', 'D'])
|
||||||
|
.range([0, 400])
|
||||||
|
.padding(0.5);
|
||||||
|
|
||||||
|
pointScale('A'); // Returns position (e.g., 50)
|
||||||
|
pointScale('B'); // Returns position (e.g., 150)
|
||||||
|
pointScale('C'); // Returns position (e.g., 250)
|
||||||
|
pointScale('D'); // Returns position (e.g., 350)
|
||||||
|
pointScale.step(); // Returns distance between points
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Line chart categorical x-axis
|
||||||
|
- Scatter plot with categorical axis
|
||||||
|
- Node positions in network graphs
|
||||||
|
- Any point positioning for categories
|
||||||
|
|
||||||
|
### Ordinal colour scale
|
||||||
|
|
||||||
|
Maps discrete input to discrete output (colours, shapes, etc.).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const colourScale = d3.scaleOrdinal(d3.schemeCategory10);
|
||||||
|
|
||||||
|
colourScale('apples'); // Returns first colour
|
||||||
|
colourScale('oranges'); // Returns second colour
|
||||||
|
colourScale('apples'); // Returns same first colour (consistent)
|
||||||
|
|
||||||
|
// Custom range
|
||||||
|
const customScale = d3.scaleOrdinal()
|
||||||
|
.domain(['cat1', 'cat2', 'cat3'])
|
||||||
|
.range(['#FF6B6B', '#4ECDC4', '#45B7D1']);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Built-in colour schemes:**
|
||||||
|
|
||||||
|
**Categorical:**
|
||||||
|
- `d3.schemeCategory10` - 10 colours
|
||||||
|
- `d3.schemeAccent` - 8 colours
|
||||||
|
- `d3.schemeDark2` - 8 colours
|
||||||
|
- `d3.schemePaired` - 12 colours
|
||||||
|
- `d3.schemePastel1` - 9 colours
|
||||||
|
- `d3.schemePastel2` - 8 colours
|
||||||
|
- `d3.schemeSet1` - 9 colours
|
||||||
|
- `d3.schemeSet2` - 8 colours
|
||||||
|
- `d3.schemeSet3` - 12 colours
|
||||||
|
- `d3.schemeTableau10` - 10 colours
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Category colours
|
||||||
|
- Legend items
|
||||||
|
- Multi-series charts
|
||||||
|
- Network node types
|
||||||
|
|
||||||
|
## Scale utilities
|
||||||
|
|
||||||
|
### Nice domain
|
||||||
|
|
||||||
|
Extend domain to nice round values.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0.201, 0.996])
|
||||||
|
.nice();
|
||||||
|
|
||||||
|
scale.domain(); // Returns [0.2, 1.0]
|
||||||
|
|
||||||
|
// With count (approximate tick count)
|
||||||
|
const scale2 = d3.scaleLinear()
|
||||||
|
.domain([0.201, 0.996])
|
||||||
|
.nice(5);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clamping
|
||||||
|
|
||||||
|
Restrict output to range bounds.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500])
|
||||||
|
.clamp(true);
|
||||||
|
|
||||||
|
scale(-10); // Returns 0 (clamped)
|
||||||
|
scale(150); // Returns 500 (clamped)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Copy scales
|
||||||
|
|
||||||
|
Create independent copies.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale1 = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
const scale2 = scale1.copy();
|
||||||
|
// scale2 is independent of scale1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tick generation
|
||||||
|
|
||||||
|
Generate nice tick values for axes.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range([0, 500]);
|
||||||
|
|
||||||
|
scale.ticks(10); // Generate ~10 ticks
|
||||||
|
scale.tickFormat(10); // Get format function for ticks
|
||||||
|
scale.tickFormat(10, ".2f"); // Custom format (2 decimal places)
|
||||||
|
|
||||||
|
// Time scale ticks
|
||||||
|
const timeScale = d3.scaleTime()
|
||||||
|
.domain([new Date(2020, 0, 1), new Date(2024, 0, 1)]);
|
||||||
|
|
||||||
|
timeScale.ticks(d3.timeYear); // Yearly ticks
|
||||||
|
timeScale.ticks(d3.timeMonth, 3); // Every 3 months
|
||||||
|
timeScale.tickFormat(5, "%Y-%m"); // Format as year-month
|
||||||
|
```
|
||||||
|
|
||||||
|
## Colour spaces and interpolation
|
||||||
|
|
||||||
|
### RGB interpolation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(["blue", "red"]);
|
||||||
|
// Default: RGB interpolation
|
||||||
|
```
|
||||||
|
|
||||||
|
### HSL interpolation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(["blue", "red"])
|
||||||
|
.interpolate(d3.interpolateHsl);
|
||||||
|
// Smoother colour transitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lab interpolation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(["blue", "red"])
|
||||||
|
.interpolate(d3.interpolateLab);
|
||||||
|
// Perceptually uniform
|
||||||
|
```
|
||||||
|
|
||||||
|
### HCL interpolation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 100])
|
||||||
|
.range(["blue", "red"])
|
||||||
|
.interpolate(d3.interpolateHcl);
|
||||||
|
// Perceptually uniform with hue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common patterns
|
||||||
|
|
||||||
|
### Diverging scale with custom midpoint
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([min, midpoint, max])
|
||||||
|
.range(["red", "white", "blue"])
|
||||||
|
.interpolate(d3.interpolateHcl);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-stop gradient scale
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const scale = d3.scaleLinear()
|
||||||
|
.domain([0, 25, 50, 75, 100])
|
||||||
|
.range(["#d53e4f", "#fc8d59", "#fee08b", "#e6f598", "#66c2a5"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Radius scale for circles (perceptual)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const radiusScale = d3.scaleSqrt()
|
||||||
|
.domain([0, d3.max(data, d => d.value)])
|
||||||
|
.range([0, 50]);
|
||||||
|
|
||||||
|
// Use with circles
|
||||||
|
circle.attr("r", d => radiusScale(d.value));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptive scale based on data range
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function createAdaptiveScale(data) {
|
||||||
|
const extent = d3.extent(data);
|
||||||
|
const range = extent[1] - extent[0];
|
||||||
|
|
||||||
|
// Use log scale if data spans >2 orders of magnitude
|
||||||
|
if (extent[1] / extent[0] > 100) {
|
||||||
|
return d3.scaleLog()
|
||||||
|
.domain(extent)
|
||||||
|
.range([0, width]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use linear
|
||||||
|
return d3.scaleLinear()
|
||||||
|
.domain(extent)
|
||||||
|
.range([0, width]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colour scale with explicit categories
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const colourScale = d3.scaleOrdinal()
|
||||||
|
.domain(['Low Risk', 'Medium Risk', 'High Risk'])
|
||||||
|
.range(['#2ecc71', '#f39c12', '#e74c3c'])
|
||||||
|
.unknown('#95a5a6'); // Fallback for unknown values
|
||||||
|
```
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
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`
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
name: prd
|
||||||
|
description: "Generate a Product Requirements Document (PRD) for a new feature. Use when planning a feature, starting a new project, or when asked to create a PRD. Triggers on: create a prd, write prd for, plan this feature, requirements for, spec out."
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# PRD Generator
|
||||||
|
|
||||||
|
Create detailed Product Requirements Documents that are clear, actionable, and suitable for implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Job
|
||||||
|
|
||||||
|
1. Receive a feature description from the user
|
||||||
|
2. Ask 3-5 essential clarifying questions (with lettered options)
|
||||||
|
3. Generate a structured PRD based on answers
|
||||||
|
4. Save to `tasks/prd-[feature-name].md`
|
||||||
|
|
||||||
|
**Important:** Do NOT start implementing. Just create the PRD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Clarifying Questions
|
||||||
|
|
||||||
|
Ask only critical questions where the initial prompt is ambiguous. Focus on:
|
||||||
|
|
||||||
|
- **Problem/Goal:** What problem does this solve?
|
||||||
|
- **Core Functionality:** What are the key actions?
|
||||||
|
- **Scope/Boundaries:** What should it NOT do?
|
||||||
|
- **Success Criteria:** How do we know it's done?
|
||||||
|
|
||||||
|
### Format Questions Like This:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. What is the primary goal of this feature?
|
||||||
|
A. Improve user onboarding experience
|
||||||
|
B. Increase user retention
|
||||||
|
C. Reduce support burden
|
||||||
|
D. Other: [please specify]
|
||||||
|
|
||||||
|
2. Who is the target user?
|
||||||
|
A. New users only
|
||||||
|
B. Existing users only
|
||||||
|
C. All users
|
||||||
|
D. Admin users only
|
||||||
|
|
||||||
|
3. What is the scope?
|
||||||
|
A. Minimal viable version
|
||||||
|
B. Full-featured implementation
|
||||||
|
C. Just the backend/API
|
||||||
|
D. Just the UI
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets users respond with "1A, 2C, 3B" for quick iteration. Remember to indent the options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: PRD Structure
|
||||||
|
|
||||||
|
Generate the PRD with these sections:
|
||||||
|
|
||||||
|
### 1. Introduction/Overview
|
||||||
|
Brief description of the feature and the problem it solves.
|
||||||
|
|
||||||
|
### 2. Goals
|
||||||
|
Specific, measurable objectives (bullet list).
|
||||||
|
|
||||||
|
### 3. User Stories
|
||||||
|
Each story needs:
|
||||||
|
- **Title:** Short descriptive name
|
||||||
|
- **Description:** "As a [user], I want [feature] so that [benefit]"
|
||||||
|
- **Acceptance Criteria:** Verifiable checklist of what "done" means
|
||||||
|
|
||||||
|
Each story should be small enough to implement in one focused session.
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
```markdown
|
||||||
|
### US-001: [Title]
|
||||||
|
**Description:** As a [user], I want [feature] so that [benefit].
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Specific verifiable criterion
|
||||||
|
- [ ] Another criterion
|
||||||
|
- [ ] Typecheck/lint passes
|
||||||
|
- [ ] **[UI stories only]** Verify in browser using dev-browser skill
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- Acceptance criteria must be verifiable, not vague. "Works correctly" is bad. "Button shows confirmation dialog before deleting" is good.
|
||||||
|
- **For any story with UI changes:** Always include "Verify in browser using dev-browser skill" as acceptance criteria. This ensures visual verification of frontend work.
|
||||||
|
|
||||||
|
### 4. Functional Requirements
|
||||||
|
Numbered list of specific functionalities:
|
||||||
|
- "FR-1: The system must allow users to..."
|
||||||
|
- "FR-2: When a user clicks X, the system must..."
|
||||||
|
|
||||||
|
Be explicit and unambiguous.
|
||||||
|
|
||||||
|
### 5. Non-Goals (Out of Scope)
|
||||||
|
What this feature will NOT include. Critical for managing scope.
|
||||||
|
|
||||||
|
### 6. Design Considerations (Optional)
|
||||||
|
- UI/UX requirements
|
||||||
|
- Link to mockups if available
|
||||||
|
- Relevant existing components to reuse
|
||||||
|
|
||||||
|
### 7. Technical Considerations (Optional)
|
||||||
|
- Known constraints or dependencies
|
||||||
|
- Integration points with existing systems
|
||||||
|
- Performance requirements
|
||||||
|
|
||||||
|
### 8. Success Metrics
|
||||||
|
How will success be measured?
|
||||||
|
- "Reduce time to complete X by 50%"
|
||||||
|
- "Increase conversion rate by 10%"
|
||||||
|
|
||||||
|
### 9. Open Questions
|
||||||
|
Remaining questions or areas needing clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing for Junior Developers
|
||||||
|
|
||||||
|
The PRD reader may be a junior developer or AI agent. Therefore:
|
||||||
|
|
||||||
|
- Be explicit and unambiguous
|
||||||
|
- Avoid jargon or explain it
|
||||||
|
- Provide enough detail to understand purpose and core logic
|
||||||
|
- Number requirements for easy reference
|
||||||
|
- Use concrete examples where helpful
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
- **Format:** Markdown (`.md`)
|
||||||
|
- **Location:** `tasks/`
|
||||||
|
- **Filename:** `prd-[feature-name].md` (kebab-case)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example PRD
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# PRD: Task Priority System
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Add priority levels to tasks so users can focus on what matters most. Tasks can be marked as high, medium, or low priority, with visual indicators and filtering to help users manage their workload effectively.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Allow assigning priority (high/medium/low) to any task
|
||||||
|
- Provide clear visual differentiation between priority levels
|
||||||
|
- Enable filtering and sorting by priority
|
||||||
|
- Default new tasks to medium priority
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-001: Add priority field to database
|
||||||
|
**Description:** As a developer, I need to store task priority so it persists across sessions.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Add priority column to tasks table: 'high' | 'medium' | 'low' (default 'medium')
|
||||||
|
- [ ] Generate and run migration successfully
|
||||||
|
- [ ] Typecheck passes
|
||||||
|
|
||||||
|
### US-002: Display priority indicator on task cards
|
||||||
|
**Description:** As a user, I want to see task priority at a glance so I know what needs attention first.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Each task card shows colored priority badge (red=high, yellow=medium, gray=low)
|
||||||
|
- [ ] Priority visible without hovering or clicking
|
||||||
|
- [ ] Typecheck passes
|
||||||
|
- [ ] Verify in browser using dev-browser skill
|
||||||
|
|
||||||
|
### US-003: Add priority selector to task edit
|
||||||
|
**Description:** As a user, I want to change a task's priority when editing it.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Priority dropdown in task edit modal
|
||||||
|
- [ ] Shows current priority as selected
|
||||||
|
- [ ] Saves immediately on selection change
|
||||||
|
- [ ] Typecheck passes
|
||||||
|
- [ ] Verify in browser using dev-browser skill
|
||||||
|
|
||||||
|
### US-004: Filter tasks by priority
|
||||||
|
**Description:** As a user, I want to filter the task list to see only high-priority items when I'm focused.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Filter dropdown with options: All | High | Medium | Low
|
||||||
|
- [ ] Filter persists in URL params
|
||||||
|
- [ ] Empty state message when no tasks match filter
|
||||||
|
- [ ] Typecheck passes
|
||||||
|
- [ ] Verify in browser using dev-browser skill
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
- FR-1: Add `priority` field to tasks table ('high' | 'medium' | 'low', default 'medium')
|
||||||
|
- FR-2: Display colored priority badge on each task card
|
||||||
|
- FR-3: Include priority selector in task edit modal
|
||||||
|
- FR-4: Add priority filter dropdown to task list header
|
||||||
|
- FR-5: Sort by priority within each status column (high to medium to low)
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No priority-based notifications or reminders
|
||||||
|
- No automatic priority assignment based on due date
|
||||||
|
- No priority inheritance for subtasks
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
- Reuse existing badge component with color variants
|
||||||
|
- Filter state managed via URL search params
|
||||||
|
- Priority stored in database, not computed
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- Users can change priority in under 2 clicks
|
||||||
|
- High-priority tasks immediately visible at top of lists
|
||||||
|
- No regression in task list performance
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should priority affect task ordering within a column?
|
||||||
|
- Should we add keyboard shortcuts for priority changes?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Before saving the PRD:
|
||||||
|
|
||||||
|
- [ ] Asked clarifying questions with lettered options
|
||||||
|
- [ ] Incorporated user's answers
|
||||||
|
- [ ] User stories are small and specific
|
||||||
|
- [ ] Functional requirements are numbered and unambiguous
|
||||||
|
- [ ] Non-goals section defines clear boundaries
|
||||||
|
- [ ] Saved to `tasks/prd-[feature-name].md`
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
name: RalphSkill
|
||||||
|
description: >
|
||||||
|
Manage Ralph Wiggum Loop projects -- both creating new ones and continuing/amending
|
||||||
|
completed ones. Use when the user says things like: "set up a new Ralph project",
|
||||||
|
"create a new Ralph loop", "ralph setup", "new Ralph", "start a Ralph", "continue
|
||||||
|
the Ralph loop", "there's a bug in the Ralph output", "amend the Ralph project",
|
||||||
|
"re-run Ralph with changes", "add tasks to the Ralph loop", "fix [something] in the
|
||||||
|
Ralph project", or any reference to modifying a completed Ralph loop to address bugs
|
||||||
|
or feature changes. Supports two templates: Snowflake (NHS PQS query development)
|
||||||
|
and Generic (any software project).
|
||||||
|
---
|
||||||
|
|
||||||
|
# RalphSkill
|
||||||
|
|
||||||
|
Two workflows: **New Project** (setup from template) and **Continue Project** (amend a completed loop).
|
||||||
|
|
||||||
|
Determine which workflow by context:
|
||||||
|
- User mentions setting up, creating, or starting a new project -> **New Project**
|
||||||
|
- User mentions bugs, fixes, changes, continuing, amending, or re-running an existing project -> **Continue Project**
|
||||||
|
- If ambiguous, ask.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow A: New Project
|
||||||
|
|
||||||
|
### A1. Template Selection
|
||||||
|
|
||||||
|
Ask which template:
|
||||||
|
|
||||||
|
| Template | Use when |
|
||||||
|
|----------|----------|
|
||||||
|
| **Snowflake** | Snowflake SQL queries (PQS, analytics, NHS data) |
|
||||||
|
| **Generic** | Standard software development (any language/framework) |
|
||||||
|
|
||||||
|
### A2. Project Directory
|
||||||
|
|
||||||
|
Ask:
|
||||||
|
- Directory name?
|
||||||
|
- Location? (default: `Ralph Local/Tasks/`)
|
||||||
|
|
||||||
|
### A3. Copy Template
|
||||||
|
|
||||||
|
Copy the template directory to target. Do NOT modify `RALPH_PROMPT.md`, `SNOWFLAKE_REFERENCE.md` (Snowflake only), or `README.md`.
|
||||||
|
|
||||||
|
### A4. Intake Interview
|
||||||
|
|
||||||
|
**Snowflake**: Read `INTAKE.md` from the copied template. Follow it exactly -- 10 sections, one at a time, summarise and confirm after each. Probe vague answers for specifics (codes, not just drug names).
|
||||||
|
|
||||||
|
**Generic**: Ask:
|
||||||
|
- **Scope**: What are you building? Language/framework?
|
||||||
|
- **Quality checks**: What commands validate? (`npm test`, `python -m pytest`, etc.)
|
||||||
|
- **Tasks**: Implementation tasks in priority order. Each completable in one iteration (~120k tokens). Split large tasks.
|
||||||
|
- **Config**: Max iterations (default: 10), model (default: sonnet, opus for complex), branch name?
|
||||||
|
- **Known pitfalls**: Gotchas to capture as guardrails?
|
||||||
|
|
||||||
|
If the user's initial message already answers some questions, skip those.
|
||||||
|
|
||||||
|
### A5. Populate Files
|
||||||
|
|
||||||
|
**Snowflake** -- populate with interview answers:
|
||||||
|
- `QUERY_PLAN.md`: All sections filled, HTML comments replaced. Tasks updated per interview. Unknowns as lookup tasks.
|
||||||
|
- `guardrails.md`: Keep standard guardrails. Add project-specific ones below (When/Rule/Why format).
|
||||||
|
- `progress.txt`: Seed Data Patterns and Query Patterns with known info. Iteration Log empty.
|
||||||
|
- `ralph.ps1`: Update `param()` defaults if non-default iterations/model specified.
|
||||||
|
|
||||||
|
**Generic** -- populate with interview answers:
|
||||||
|
- `IMPLEMENTATION_PLAN.md`: Project Overview, Quality Checks, Tasks all filled.
|
||||||
|
- `progress.txt`: Seed Codebase Patterns with known info.
|
||||||
|
- `ralph.ps1`: Update `param()` defaults if needed.
|
||||||
|
- `guardrails.md`: Create only if user mentioned significant pitfalls.
|
||||||
|
|
||||||
|
### A6. Validate and Confirm
|
||||||
|
|
||||||
|
Show summary:
|
||||||
|
```
|
||||||
|
Project: [name]
|
||||||
|
Template: [Snowflake/Generic]
|
||||||
|
Tasks: [count] ([brief titles])
|
||||||
|
Model: [model] | Max iterations: [N]
|
||||||
|
Branch: [branch or "none"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the task list. Ask user to confirm. Adjust if needed.
|
||||||
|
|
||||||
|
Remind how to run:
|
||||||
|
```powershell
|
||||||
|
cd "[project path]"
|
||||||
|
.\ralph.ps1 -BranchName "[branch]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checklist**: Plan fully populated (no HTML comments), tasks reflect real work, unknowns are explicit tasks, validation criteria have numbers (Snowflake), quality checks runnable, progress.txt seeded, ralph.ps1 configured, guardrails added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow B: Continue Project
|
||||||
|
|
||||||
|
For amending a completed (or stalled) Ralph loop to address bugs, missing requirements, or feature changes.
|
||||||
|
|
||||||
|
### B1. Identify the Project
|
||||||
|
|
||||||
|
Ask or infer which project directory to amend. Read the project to understand its current state:
|
||||||
|
|
||||||
|
1. Read the plan file (`QUERY_PLAN.md` or `IMPLEMENTATION_PLAN.md`) -- understand all tasks and their status
|
||||||
|
2. Read `progress.txt` -- understand what was accomplished, key numbers, patterns discovered
|
||||||
|
3. Read `guardrails.md` (if exists) -- understand known failure patterns
|
||||||
|
4. Run `git log --oneline -20` in the project directory -- understand commit history
|
||||||
|
|
||||||
|
### B2. Understand the Change
|
||||||
|
|
||||||
|
Ask the user what needs changing. Common scenarios:
|
||||||
|
|
||||||
|
| Scenario | Example |
|
||||||
|
|----------|---------|
|
||||||
|
| **Bug in output** | "The patient counts are wrong -- it's including deceased patients" |
|
||||||
|
| **Missing requirement** | "We also need to exclude care home residents" |
|
||||||
|
| **Feature change** | "The scoring method changed from deprescribing to threshold-based" |
|
||||||
|
| **New tasks needed** | "We need an indicative score query alongside the official one" |
|
||||||
|
| **Validation failure** | "Cross-validation showed a 30% discrepancy with prescribing data" |
|
||||||
|
|
||||||
|
Probe for specifics:
|
||||||
|
- What exactly is wrong? What's the expected vs actual behaviour?
|
||||||
|
- Which task(s) are affected?
|
||||||
|
- Does this invalidate previously completed work, or is it additive?
|
||||||
|
|
||||||
|
### B3. Assess Impact
|
||||||
|
|
||||||
|
Determine what needs to change:
|
||||||
|
|
||||||
|
**Additive** (completed work is still valid):
|
||||||
|
- New tasks appended to the plan file
|
||||||
|
- Existing `[x]` tasks remain marked complete
|
||||||
|
|
||||||
|
**Corrective** (completed work needs revision):
|
||||||
|
- Affected tasks reset from `[x]` back to `[ ]`
|
||||||
|
- Dependent tasks also reset (e.g. if the cohort CTE is wrong, the aggregation task that uses it must also be redone)
|
||||||
|
- Document clearly WHY tasks were reset
|
||||||
|
|
||||||
|
**Structural** (fundamental change to approach):
|
||||||
|
- Multiple tasks may need rewriting, not just resetting
|
||||||
|
- Plan file sections (cohort, scoring, validation criteria) may need updating
|
||||||
|
- Consider whether it's cleaner to rewrite affected plan sections vs. patching
|
||||||
|
|
||||||
|
Present the impact assessment to the user and confirm before making changes.
|
||||||
|
|
||||||
|
### B4. Update Project Files
|
||||||
|
|
||||||
|
Apply changes based on the assessment:
|
||||||
|
|
||||||
|
**Plan file** (`QUERY_PLAN.md` / `IMPLEMENTATION_PLAN.md`):
|
||||||
|
- Add new tasks as `[ ]` items in appropriate position (respect dependency order)
|
||||||
|
- Reset affected tasks from `[x]` to `[ ]`
|
||||||
|
- Update plan sections if requirements changed (cohort, scoring, dates, validation criteria, etc.)
|
||||||
|
- If tasks are being reset, add a comment: `<!-- Reset: [reason] -->`
|
||||||
|
|
||||||
|
**`guardrails.md`**:
|
||||||
|
- If the bug reveals a failure pattern, add a new guardrail (When/Rule/Why format)
|
||||||
|
- Example: if deceased patients were included, add a guardrail about the registered population filter
|
||||||
|
|
||||||
|
**`progress.txt`**:
|
||||||
|
- Append a **manual intervention entry** to the Iteration Log:
|
||||||
|
```
|
||||||
|
## Manual Intervention -- [YYYY-MM-DD]
|
||||||
|
### Reason: [brief description of what changed]
|
||||||
|
### Changes made:
|
||||||
|
- [List of file changes]
|
||||||
|
### Tasks reset: [list task names that were unchecked]
|
||||||
|
### Tasks added: [list new task names]
|
||||||
|
### Context for next iteration:
|
||||||
|
- [What the next Ralph iteration needs to know about these changes]
|
||||||
|
- [Why previous approach was wrong and what to do differently]
|
||||||
|
### New guardrails added:
|
||||||
|
- [Any new guardrails, or "none"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ralph.ps1`**:
|
||||||
|
- Update `param()` defaults if the user wants different iterations/model for the continuation run
|
||||||
|
|
||||||
|
### B5. Validate and Confirm
|
||||||
|
|
||||||
|
Show the user:
|
||||||
|
1. Tasks that were reset (with reasons)
|
||||||
|
2. New tasks added
|
||||||
|
3. Updated plan sections (if any)
|
||||||
|
4. New guardrails (if any)
|
||||||
|
5. The progress.txt intervention entry
|
||||||
|
|
||||||
|
Ask user to confirm. Adjust if needed.
|
||||||
|
|
||||||
|
Remind how to re-run:
|
||||||
|
```powershell
|
||||||
|
cd "[project path]"
|
||||||
|
.\ralph.ps1 -BranchName "[branch]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Checklist**: All affected tasks reset, new tasks in correct dependency position, plan sections updated if requirements changed, guardrails added for discovered failure patterns, progress.txt has intervention entry with clear context for next iteration, no orphaned dependencies (don't reset task A without also resetting tasks that depend on A).
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
---
|
||||||
|
name: ralph
|
||||||
|
description: "Convert PRDs to prd.json format for the Ralph autonomous agent system. Use when you have an existing PRD and need to convert it to Ralph's JSON format. Triggers on: convert this prd, turn this into ralph format, create prd.json from this, ralph json."
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ralph PRD Converter
|
||||||
|
|
||||||
|
Converts existing PRDs to the prd.json format that Ralph uses for autonomous execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Job
|
||||||
|
|
||||||
|
Take a PRD (markdown file or text) and convert it to `prd.json` in your ralph directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "[Project Name]",
|
||||||
|
"branchName": "ralph/[feature-name-kebab-case]",
|
||||||
|
"description": "[Feature description from PRD title/intro]",
|
||||||
|
"userStories": [
|
||||||
|
{
|
||||||
|
"id": "US-001",
|
||||||
|
"title": "[Story title]",
|
||||||
|
"description": "As a [user], I want [feature] so that [benefit]",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Criterion 1",
|
||||||
|
"Criterion 2",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Story Size: The Number One Rule
|
||||||
|
|
||||||
|
**Each story must be completable in ONE Ralph iteration (one context window).**
|
||||||
|
|
||||||
|
Ralph spawns a fresh Amp instance per iteration with no memory of previous work. If a story is too big, the LLM runs out of context before finishing and produces broken code.
|
||||||
|
|
||||||
|
### Right-sized stories:
|
||||||
|
- Add a database column and migration
|
||||||
|
- Add a UI component to an existing page
|
||||||
|
- Update a server action with new logic
|
||||||
|
- Add a filter dropdown to a list
|
||||||
|
|
||||||
|
### Too big (split these):
|
||||||
|
- "Build the entire dashboard" - Split into: schema, queries, UI components, filters
|
||||||
|
- "Add authentication" - Split into: schema, middleware, login UI, session handling
|
||||||
|
- "Refactor the API" - Split into one story per endpoint or pattern
|
||||||
|
|
||||||
|
**Rule of thumb:** If you cannot describe the change in 2-3 sentences, it is too big.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Story Ordering: Dependencies First
|
||||||
|
|
||||||
|
Stories execute in priority order. Earlier stories must not depend on later ones.
|
||||||
|
|
||||||
|
**Correct order:**
|
||||||
|
1. Schema/database changes (migrations)
|
||||||
|
2. Server actions / backend logic
|
||||||
|
3. UI components that use the backend
|
||||||
|
4. Dashboard/summary views that aggregate data
|
||||||
|
|
||||||
|
**Wrong order:**
|
||||||
|
1. UI component (depends on schema that does not exist yet)
|
||||||
|
2. Schema change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria: Must Be Verifiable
|
||||||
|
|
||||||
|
Each criterion must be something Ralph can CHECK, not something vague.
|
||||||
|
|
||||||
|
### Good criteria (verifiable):
|
||||||
|
- "Add `status` column to tasks table with default 'pending'"
|
||||||
|
- "Filter dropdown has options: All, Active, Completed"
|
||||||
|
- "Clicking delete shows confirmation dialog"
|
||||||
|
- "Typecheck passes"
|
||||||
|
- "Tests pass"
|
||||||
|
|
||||||
|
### Bad criteria (vague):
|
||||||
|
- "Works correctly"
|
||||||
|
- "User can do X easily"
|
||||||
|
- "Good UX"
|
||||||
|
- "Handles edge cases"
|
||||||
|
|
||||||
|
### Always include as final criterion:
|
||||||
|
```
|
||||||
|
"Typecheck passes"
|
||||||
|
```
|
||||||
|
|
||||||
|
For stories with testable logic, also include:
|
||||||
|
```
|
||||||
|
"Tests pass"
|
||||||
|
```
|
||||||
|
|
||||||
|
### For stories that change UI, also include:
|
||||||
|
```
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend stories are NOT complete until visually verified. Ralph will use the dev-browser skill to navigate to the page, interact with the UI, and confirm changes work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversion Rules
|
||||||
|
|
||||||
|
1. **Each user story becomes one JSON entry**
|
||||||
|
2. **IDs**: Sequential (US-001, US-002, etc.)
|
||||||
|
3. **Priority**: Based on dependency order, then document order
|
||||||
|
4. **All stories**: `passes: false` and empty `notes`
|
||||||
|
5. **branchName**: Derive from feature name, kebab-case, prefixed with `ralph/`
|
||||||
|
6. **Always add**: "Typecheck passes" to every story's acceptance criteria
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Splitting Large PRDs
|
||||||
|
|
||||||
|
If a PRD has big features, split them:
|
||||||
|
|
||||||
|
**Original:**
|
||||||
|
> "Add user notification system"
|
||||||
|
|
||||||
|
**Split into:**
|
||||||
|
1. US-001: Add notifications table to database
|
||||||
|
2. US-002: Create notification service for sending notifications
|
||||||
|
3. US-003: Add notification bell icon to header
|
||||||
|
4. US-004: Create notification dropdown panel
|
||||||
|
5. US-005: Add mark-as-read functionality
|
||||||
|
6. US-006: Add notification preferences page
|
||||||
|
|
||||||
|
Each is one focused change that can be completed and verified independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
**Input PRD:**
|
||||||
|
```markdown
|
||||||
|
# Task Status Feature
|
||||||
|
|
||||||
|
Add ability to mark tasks with different statuses.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Toggle between pending/in-progress/done on task list
|
||||||
|
- Filter list by status
|
||||||
|
- Show status badge on each task
|
||||||
|
- Persist status in database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output prd.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "TaskApp",
|
||||||
|
"branchName": "ralph/task-status",
|
||||||
|
"description": "Task Status Feature - Track task progress with status indicators",
|
||||||
|
"userStories": [
|
||||||
|
{
|
||||||
|
"id": "US-001",
|
||||||
|
"title": "Add status field to tasks table",
|
||||||
|
"description": "As a developer, I need to store task status in the database.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Add status column: 'pending' | 'in_progress' | 'done' (default 'pending')",
|
||||||
|
"Generate and run migration successfully",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-002",
|
||||||
|
"title": "Display status badge on task cards",
|
||||||
|
"description": "As a user, I want to see task status at a glance.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Each task card shows colored status badge",
|
||||||
|
"Badge colors: gray=pending, blue=in_progress, green=done",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 2,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-003",
|
||||||
|
"title": "Add status toggle to task list rows",
|
||||||
|
"description": "As a user, I want to change task status directly from the list.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Each row has status dropdown or toggle",
|
||||||
|
"Changing status saves immediately",
|
||||||
|
"UI updates without page refresh",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 3,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-004",
|
||||||
|
"title": "Filter tasks by status",
|
||||||
|
"description": "As a user, I want to filter the list to see only certain statuses.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Filter dropdown: All | Pending | In Progress | Done",
|
||||||
|
"Filter persists in URL params",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 4,
|
||||||
|
"passes": false,
|
||||||
|
"notes": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archiving Previous Runs
|
||||||
|
|
||||||
|
**Before writing a new prd.json, check if there is an existing one from a different feature:**
|
||||||
|
|
||||||
|
1. Read the current `prd.json` if it exists
|
||||||
|
2. Check if `branchName` differs from the new feature's branch name
|
||||||
|
3. If different AND `progress.txt` has content beyond the header:
|
||||||
|
- Create archive folder: `archive/YYYY-MM-DD-feature-name/`
|
||||||
|
- Copy current `prd.json` and `progress.txt` to archive
|
||||||
|
- Reset `progress.txt` with fresh header
|
||||||
|
|
||||||
|
**The ralph.sh script handles this automatically** when you run it, but if you are manually updating prd.json between runs, archive first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Before Saving
|
||||||
|
|
||||||
|
Before writing prd.json, verify:
|
||||||
|
|
||||||
|
- [ ] **Previous run archived** (if prd.json exists with different branchName, archive it first)
|
||||||
|
- [ ] Each story is completable in one iteration (small enough)
|
||||||
|
- [ ] Stories are ordered by dependency (schema to backend to UI)
|
||||||
|
- [ ] Every story has "Typecheck passes" as criterion
|
||||||
|
- [ ] UI stories have "Verify in browser using dev-browser skill" as criterion
|
||||||
|
- [ ] Acceptance criteria are verifiable (not vague)
|
||||||
|
- [ ] No story depends on a later story
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
{
|
||||||
|
"project": "GP Clinical Record — Depth Enhancement",
|
||||||
|
"branchName": "ralph/depth-enhancement",
|
||||||
|
"description": "Add depth, interactivity, and rich content to the GP clinical record dashboard: slide-in detail panels, sub-navigation, expanded skills/KPI data, career constellation D3 visualization, and login refresh. Full spec in Ralph/depth-design.md, requirements in Ralph/depth-requirements.md, workflow in Ralph/workflow_depth.md.",
|
||||||
|
"userStories": [
|
||||||
|
{
|
||||||
|
"id": "US-001",
|
||||||
|
"title": "Clean up unused legacy components and hooks",
|
||||||
|
"description": "As a developer, I need to remove all dead code from the previous PMR interface so the codebase is clean before adding new features. Delete all files listed below and verify no dead imports remain.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Delete src/components/PMRInterface.tsx",
|
||||||
|
"Delete src/components/PatientBanner.tsx",
|
||||||
|
"Delete src/components/ClinicalSidebar.tsx",
|
||||||
|
"Delete src/components/Breadcrumb.tsx",
|
||||||
|
"Delete src/components/MobileBottomNav.tsx",
|
||||||
|
"Delete all files in src/components/views/ directory (SummaryView, ConsultationsView, MedicationsView, ProblemsView, InvestigationsView, DocumentsView, ReferralsView) and remove the views/ directory",
|
||||||
|
"Delete src/components/Contact.tsx, Education.tsx, Experience.tsx, FloatingNav.tsx, Footer.tsx, Hero.tsx, Projects.tsx, Skills.tsx (old portfolio components)",
|
||||||
|
"Delete src/hooks/useScrollCondensation.ts",
|
||||||
|
"Delete src/hooks/useActiveSection.ts (will be recreated in a later story)",
|
||||||
|
"Delete src/hooks/useScrollReveal.ts if unused",
|
||||||
|
"Verify no remaining files import any of the deleted files (fix any dead imports)",
|
||||||
|
"npm run build succeeds with zero errors",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 1,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 1 at 2026-02-13 22:57. Model: opus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-002",
|
||||||
|
"title": "Add new TypeScript types and CSS custom properties for depth features",
|
||||||
|
"description": "As a developer, I need new types and CSS foundations that subsequent stories will use. Add types to src/types/pmr.ts and CSS variables + keyframes to src/index.css. See Ralph/depth-design.md Section 4 for type definitions and Section 9 for CSS.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Add SkillCategory type: \u0027Technical\u0027 | \u0027Domain\u0027 | \u0027Leadership\u0027 to src/types/pmr.ts",
|
||||||
|
"Add KPIStory interface with fields: context (string), role (string), outcomes (string[]), period (string optional) to src/types/pmr.ts",
|
||||||
|
"Add optional story?: KPIStory field to existing KPI interface in src/types/pmr.ts",
|
||||||
|
"Add ConstellationNode interface (id, type: \u0027role\u0027|\u0027skill\u0027, label, shortLabel?, organization?, startYear?, endYear?, orgColor?, domain?) to src/types/pmr.ts",
|
||||||
|
"Add ConstellationLink interface (source, target, strength) to src/types/pmr.ts",
|
||||||
|
"Add DetailPanelContent discriminated union type (kpi | skill | skills-all | consultation | project | education | career-role) to src/types/pmr.ts",
|
||||||
|
"Add EducationExtra interface (documentId, extracurriculars?, researchDescription?, programmeDetail?) to src/types/pmr.ts",
|
||||||
|
"Add CSS custom properties to :root in src/index.css: --subnav-height: 36px, --panel-narrow: 400px, --panel-wide: 60vw, --backdrop-blur: 4px, --backdrop-bg: rgba(26,43,42,0.15)",
|
||||||
|
"Add @keyframes panel-slide-in (translateX 100% to 0), panel-slide-out (reverse), backdrop-fade-in (opacity 0 to 1) to src/index.css",
|
||||||
|
"Add prefers-reduced-motion overrides for all new keyframes (instant, no transform/opacity change)",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 2,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 2 at 2026-02-13 22:59. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-003",
|
||||||
|
"title": "Create DetailPanelContext, DetailPanel component, and useFocusTrap hook",
|
||||||
|
"description": "As a developer, I need the core detail panel infrastructure: a context for managing panel state, the slide-in panel component, and a focus trap hook. Create 3 new files. The panel renders placeholder content for now (real renderers come later). See Ralph/depth-design.md Sections 2.1, 2.2 for full interface specs.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/contexts/DetailPanelContext.tsx with DetailPanelProvider that manages: content (DetailPanelContent | null), openPanel, closePanel, isOpen",
|
||||||
|
"Width mapping is deterministic from content.type: kpi/skill/skills-all/education → \u0027narrow\u0027 (var(--panel-narrow)), consultation/project/career-role → \u0027wide\u0027 (var(--panel-wide))",
|
||||||
|
"Title mapping derives from content data (e.g., kpi → kpi.label, skill → skill.name, consultation → consultation.role)",
|
||||||
|
"Create src/components/DetailPanel.tsx: full-screen backdrop (var(--backdrop-bg) + backdrop-filter: blur(var(--backdrop-blur))) with panel sliding from right",
|
||||||
|
"Panel has header with X close button (lucide X icon), colored dot matching tile, and title text",
|
||||||
|
"Panel body is scrollable and renders placeholder text showing content type",
|
||||||
|
"Close triggers: backdrop click, Escape key, X button",
|
||||||
|
"ARIA: aria-modal=true, role=dialog, aria-labelledby pointing to title",
|
||||||
|
"Mobile (\u003c768px): both narrow and wide become 100vw",
|
||||||
|
"prefers-reduced-motion: instant appear, no slide animation",
|
||||||
|
"Create src/hooks/useFocusTrap.ts: useFocusTrap(containerRef, isActive) traps Tab/Shift+Tab within container when active, returns focus to previous element when deactivated",
|
||||||
|
"DetailPanel uses useFocusTrap when open",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 3,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 3 at 2026-02-13 23:03. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-004",
|
||||||
|
"title": "Create SubNav component and useActiveSection hook",
|
||||||
|
"description": "As a developer, I need a sticky sub-navigation bar below the TopBar for section jumping, plus a hook that tracks which section is visible. Create src/components/SubNav.tsx and src/hooks/useActiveSection.ts (the old one was deleted in cleanup). See Ralph/depth-design.md Section 2.3.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/SubNav.tsx with 5 sections: Overview (patient-summary), Skills (core-skills), Experience (career-activity), Projects (projects), Education (education)",
|
||||||
|
"SubNav is sticky below TopBar (top: 48px, z-index: 99)",
|
||||||
|
"Height 36px, background var(--surface), bottom border var(--border-light)",
|
||||||
|
"Tabs: 13px font, weight 500, gap 24px, centered text",
|
||||||
|
"Active tab: teal underline (2px) with 200ms slide transition, text color var(--accent)",
|
||||||
|
"Inactive tabs: var(--text-secondary)",
|
||||||
|
"Click scrolls smoothly to [data-tile-id=tileId] element",
|
||||||
|
"Create src/hooks/useActiveSection.ts using IntersectionObserver to track visible tile by data-tile-id attribute",
|
||||||
|
"Maps tile IDs to section IDs: patient-summary→overview, core-skills→skills, career-activity→experience, projects→projects, education→education",
|
||||||
|
"SubNav accepts activeSection and onSectionClick props",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 4,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 4 at 2026-02-13 23:06. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-005",
|
||||||
|
"title": "Expand skills data from 5 to ~20 with three categories",
|
||||||
|
"description": "As a developer, I need to expand src/data/skills.ts from 5 skills to ~21 skills across 3 categories. Source content from References/CV_v4.md Core Competencies. Each skill retains the medication metaphor (frequency, status, proficiency). See Ralph/depth-design.md Section 5.1 and Ralph/depth-requirements.md Section 4.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"src/data/skills.ts has ~21 SkillMedication entries",
|
||||||
|
"Technical category (8): Data Analysis, Python, SQL, Power BI, JavaScript/TypeScript, Excel, Algorithm Design, Data Pipelines",
|
||||||
|
"Healthcare Domain category (6): Medicines Optimisation, Population Health, NICE TA Implementation, Health Economics, Clinical Pathways, Controlled Drugs",
|
||||||
|
"Strategic \u0026 Leadership category (7): Budget Management, Stakeholder Engagement, Pharmaceutical Negotiation, Team Development, Change Management, Financial Modelling, Executive Communication",
|
||||||
|
"Each skill has: id (kebab-case), name, frequency (medication-style: Daily, Twice daily, Once weekly, When required, etc.), startYear, yearsOfExperience, proficiency (0-100), category, status (Active/Historical), icon (lucide icon name)",
|
||||||
|
"Frequency and proficiency values are realistic based on CV_v4.md role descriptions",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 5,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 5 at 2026-02-13 23:08. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-006",
|
||||||
|
"title": "Add KPI story data and update 4th KPI",
|
||||||
|
"description": "As a developer, I need to add rich story content to each KPI in src/data/kpis.ts for the detail panel, and change the 4th KPI from \u002712 Team Size Led\u0027 to \u00271.2M Population served\u0027. Source from References/CV_v4.md. See Ralph/depth-design.md Section 5.2.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Change 4th KPI from {id:\u0027team\u0027, value:\u002712\u0027, label:\u0027Team Size Led\u0027} to {id:\u0027population\u0027, value:\u00271.2M\u0027, label:\u0027Population Served\u0027, sub:\u0027Norfolk \u0026 Waveney ICS\u0027, colorVariant:\u0027teal\u0027}",
|
||||||
|
"Add story field (KPIStory) to all 4 KPIs with: context, role, outcomes[], period",
|
||||||
|
"£220M story: context about ICB prescribing budget for 1.2M population, role about forecasting models and ICB board accountability, outcomes about proactive financial planning",
|
||||||
|
"£14.6M story: context about efficiency programme, role about data analysis identification, outcomes about over-target performance",
|
||||||
|
"9+ Years story: context about career span Aug 2016-present, role about progression from community pharmacy to system-level leadership",
|
||||||
|
"1.2M story: context about Norfolk \u0026 Waveney ICS population, role about population health analytics and data-driven decision making",
|
||||||
|
"Add explanation field to 4th KPI matching the story context",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 6,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 6 at 2026-02-13 23:10. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-007",
|
||||||
|
"title": "Create education extras data file",
|
||||||
|
"description": "As a developer, I need src/data/educationExtras.ts with expanded detail for the education detail panel. Source from References/CV_v4.md Education section. See Ralph/depth-design.md Section 5.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/data/educationExtras.ts exporting educationExtras array of EducationExtra objects",
|
||||||
|
"MPharm entry (documentId matching doc-mpharm or equivalent from documents.ts): extracurriculars [\u0027President of UEA Pharmacy Society\u0027, \u0027Secretary \u0026 Vice-President of UEA Ultimate Frisbee\u0027, \u0027Publicity Officer for UEA Alzheimer\\\u0027s Society\u0027], researchDescription about cocrystal formation for drug delivery",
|
||||||
|
"Mary Seacole entry: programmeDetail about NHS leadership qualification, change management, healthcare leadership, system-level thinking",
|
||||||
|
"Document IDs match those used in src/data/documents.ts",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 7,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 7 at 2026-02-13 23:11. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-008",
|
||||||
|
"title": "Restructure DashboardLayout with SubNav, new tile order, and DetailPanel",
|
||||||
|
"description": "As a developer, I need to update DashboardLayout.tsx to: wrap with DetailPanelProvider, add SubNav between TopBar and content, reorder tiles per the new layout, render DetailPanel, and adjust spacing. See Ralph/depth-design.md Section 3.1.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"DashboardLayout (or App.tsx) wraps content with DetailPanelProvider from DetailPanelContext",
|
||||||
|
"SubNav renders between TopBar and the flex container",
|
||||||
|
"Content area marginTop accounts for both TopBar and SubNav: calc(var(--topbar-height) + var(--subnav-height))",
|
||||||
|
"Tile order: PatientSummaryTile (full), LatestResultsTile (half) + ProjectsTile (half) side-by-side, CoreSkillsTile (full), LastConsultationTile (full), CareerActivityTile (full), EducationTile (full)",
|
||||||
|
"DetailPanel component renders alongside CommandPalette",
|
||||||
|
"SubNav activeSection state managed via useActiveSection hook",
|
||||||
|
"All tiles have data-tile-id attributes (Card tileId prop)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 8,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 8 at 2026-02-13 23:15. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-009",
|
||||||
|
"title": "Create constellation data mapping file",
|
||||||
|
"description": "As a developer, I need src/data/constellation.ts defining the role-skill mapping for the D3 career constellation graph. Maps 6 career roles to their associated skills with connection strengths. See Ralph/depth-design.md Section 5.3 and 2.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/data/constellation.ts with RoleSkillMapping interface (roleId: string, skillIds: string[])",
|
||||||
|
"Export roleSkillMappings array mapping 6 roles to skill IDs from skills.ts",
|
||||||
|
"Roles: pre-reg-pharmacist-2015, duty-pharmacy-manager-2016, pharmacy-manager-2017, hcd-pharmacist-2022, deputy-head-2024, interim-head-2025 (IDs should match or reference consultation IDs from consultations.ts)",
|
||||||
|
"Export constellationNodes array of ConstellationNode objects for all role nodes (with organization, startYear, endYear, orgColor) and skill nodes (with domain)",
|
||||||
|
"Export constellationLinks array of ConstellationLink objects connecting skills to roles with strength values (0-1)",
|
||||||
|
"Role orgColors: Paydens gets one color, Tesco another, NHS another (use distinct teal/blue/green tones)",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 9,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 9 at 2026-02-13 23:17. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-010",
|
||||||
|
"title": "Modify LatestResultsTile: remove flip, bigger numbers, panel trigger",
|
||||||
|
"description": "As a developer, I need to redesign the KPI cards in LatestResultsTile.tsx: remove the CSS flip animation, make headline numbers larger and bolder, and make each card clickable to open the detail panel. See Ralph/depth-design.md Section 3.5.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Remove flip card animation entirely (no more .metric-card, .metric-card-inner, .metric-card-front, .metric-card-back CSS classes from index.css if they exist)",
|
||||||
|
"Each KPI renders as a clickable button/card with: value at 28-32px font-size, weight 700, colored by kpi.colorVariant",
|
||||||
|
"Label at 12px, weight 500, color var(--text-primary), marginTop 4px",
|
||||||
|
"Sub-text at 10px, font-family var(--font-geist-mono), color var(--text-tertiary), marginTop 2px",
|
||||||
|
"Click calls openPanel({ type: \u0027kpi\u0027, kpi }) from DetailPanelContext",
|
||||||
|
"Hover: border color shift + shadow deepens (transition 150ms)",
|
||||||
|
"Keyboard: Enter/Space triggers panel open",
|
||||||
|
"Card styling: padding 16px, background var(--surface), border 1px solid var(--border-light), border-radius var(--radius-sm)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 10,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 10 at 2026-02-13. Model: opus. Manually marked passed (script hung after story-complete signal)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-011",
|
||||||
|
"title": "Modify CoreSkillsTile: full width, categorised groups, panel triggers",
|
||||||
|
"description": "As a developer, I need to redesign CoreSkillsTile.tsx as full-width with skills grouped by 3 categories, showing top 3-4 per category with \u0027view all\u0027 buttons. Individual skills and \u0027view all\u0027 trigger the detail panel. See Ralph/depth-design.md Section 3.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Card uses full prop (spans both grid columns)",
|
||||||
|
"Skills grouped by category: Technical, Healthcare Domain (Domain), Strategic \u0026 Leadership (Leadership)",
|
||||||
|
"Each category has a header: thin divider line with category label (styled like sidebar section dividers: 10px, uppercase, var(--text-tertiary))",
|
||||||
|
"Show top 3-4 skills per category on the dashboard tile (sorted by proficiency or relevance)",
|
||||||
|
"Each skill row is clickable → openPanel({ type: \u0027skill\u0027, skill }) from DetailPanelContext",
|
||||||
|
"Each category with \u003e4 skills shows a \u0027View all (N)\u0027 button → openPanel({ type: \u0027skills-all\u0027, category })",
|
||||||
|
"Retain medication metaphor display (frequency, status badge)",
|
||||||
|
"Remove old single-expand accordion for skills (replaced by panel)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 11,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 1 at 2026-02-13 23:50. Model: opus."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-012",
|
||||||
|
"title": "Modify ProjectsTile: half width, compact card grid, panel trigger",
|
||||||
|
"description": "As a developer, I need to change ProjectsTile.tsx from full-width to half-width (positioned alongside LatestResultsTile by the layout reorder in US-008). Compact cards with click to open detail panel. See Ralph/depth-design.md Section 3.6.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Remove full prop from Card (half-width, single grid column)",
|
||||||
|
"Compact project cards: status dot + name + year (right-aligned) per row",
|
||||||
|
"Tech stack shown as small inline tags",
|
||||||
|
"Each project card clickable → openPanel({ type: \u0027project\u0027, investigation }) from DetailPanelContext",
|
||||||
|
"Remove old in-place expansion (replaced by panel)",
|
||||||
|
"Hover: border color shift, shadow deepens",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 12,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 2 at 2026-02-13 23:52. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-013",
|
||||||
|
"title": "Modify LastConsultationTile: add panel trigger",
|
||||||
|
"description": "As a developer, I need to add a \u0027View full record\u0027 button to LastConsultationTile.tsx that opens the detail panel with full role details. See Ralph/depth-design.md Section 3.9.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Add \u0027View full record\u0027 link/button at the bottom of the tile",
|
||||||
|
"Click → openPanel({ type: \u0027consultation\u0027, consultation }) from DetailPanelContext, passing the first consultation entry",
|
||||||
|
"Make the tile header area also clickable (opens same panel)",
|
||||||
|
"Keep existing inline content (header info row, achievement bullets)",
|
||||||
|
"Hover state on clickable areas",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 13,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 3 at 2026-02-13 23:55. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-014",
|
||||||
|
"title": "Modify CareerActivityTile: panel triggers and hover preview",
|
||||||
|
"description": "As a developer, I need to change CareerActivityTile.tsx so timeline items click to open the detail panel instead of expanding in-place, and add hover previews. See Ralph/depth-design.md Section 3.7.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Role timeline items click → openPanel({ type: \u0027career-role\u0027, consultation }) from DetailPanelContext",
|
||||||
|
"Remove in-place accordion expansion for career items (replaced by panel)",
|
||||||
|
"Hover preview: items lift slightly on hover with shadow deepens, show 1-2 lines of preview text",
|
||||||
|
"Keep color-coded dots and entry type styling (teal roles, amber projects, green certs, purple education)",
|
||||||
|
"Reserve a container/placeholder for CareerConstellation component (will be added later)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 14,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 4 at 2026-02-13 23:58. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-015",
|
||||||
|
"title": "Modify EducationTile: richer content, panel trigger",
|
||||||
|
"description": "As a developer, I need to enhance EducationTile.tsx with richer inline content and click-to-panel interaction. See Ralph/depth-design.md Section 3.8.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Show richer inline content: MPharm research project score (75.1%), OSCE score (80%), A-level grades (A* Maths, B Chemistry, C Politics)",
|
||||||
|
"Each education entry is clickable → openPanel({ type: \u0027education\u0027, document }) from DetailPanelContext",
|
||||||
|
"Hover: border color shift on clickable entries",
|
||||||
|
"Use education extras data from src/data/educationExtras.ts for inline detail where appropriate",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 15,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed iteration 4 at 2026-02-14 00:33. Model: sonnet."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-016",
|
||||||
|
"title": "Modify PatientSummaryTile: structured presentation with highlight strip",
|
||||||
|
"description": "As a developer, I need to improve PatientSummaryTile.tsx with the full CV_v4.md profile text and a visual highlight strip. See Ralph/depth-design.md Section 3.10 and Ralph/depth-requirements.md Section 4.1.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Verify src/data/profile.ts has the complete profile text from References/CV_v4.md (update if needed)",
|
||||||
|
"Add a visual highlight strip showing key stats: e.g. \u00279+ Years Experience\u0027, \u00271.2M Population\u0027, \u0027£220M Budget\u0027 as small styled badges or pills",
|
||||||
|
"Profile text is not a wall of text — use hierarchy: bold key phrases, structured paragraphs if needed",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 16,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-017",
|
||||||
|
"title": "Create KPIDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/KPIDetail.tsx that renders rich KPI story content inside the detail panel. Wire it into DetailPanel so content.type === \u0027kpi\u0027 renders this component. See Ralph/depth-design.md Section 6.1.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/KPIDetail.tsx accepting a KPI prop",
|
||||||
|
"Renders: headline number (large, colored by kpi.colorVariant), context paragraph (story.context), \u0027Your role\u0027 paragraph (story.role), outcome bullets (story.outcomes), period badge (story.period)",
|
||||||
|
"Graceful fallback if story is undefined (show kpi.explanation instead)",
|
||||||
|
"Wire into DetailPanel: when content.type === \u0027kpi\u0027, render \u003cKPIDetail kpi={content.kpi} /\u003e",
|
||||||
|
"Styling matches dashboard design system (fonts, colors, spacing)",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 17,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-018",
|
||||||
|
"title": "Create ConsultationDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/ConsultationDetail.tsx for displaying full role details in the detail panel. Used for both \u0027consultation\u0027 and \u0027career-role\u0027 content types. See Ralph/depth-design.md Section 6.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/ConsultationDetail.tsx accepting a Consultation prop",
|
||||||
|
"Renders: role title + organization + dates, history paragraph (consultation.history), achievement bullets (consultation.examination), plan/outcomes (consultation.plan), coded entries as badges (consultation.codedEntries)",
|
||||||
|
"Wire into DetailPanel: content.type === \u0027consultation\u0027 or \u0027career-role\u0027 renders this component",
|
||||||
|
"Styled consistently with dashboard design system",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 18,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Already implemented by prior iteration. Component exists with full content, wired into DetailPanel for consultation and career-role types."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-019",
|
||||||
|
"title": "Create ProjectDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/ProjectDetail.tsx for displaying full project information in the wide detail panel. See Ralph/depth-design.md Section 6.5.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/ProjectDetail.tsx accepting an Investigation prop",
|
||||||
|
"Renders: project name + year + status badge, methodology description, tech stack as tags, results bullets, external link button (if investigation.externalUrl exists, opens in new tab)",
|
||||||
|
"Wire into DetailPanel: content.type === \u0027project\u0027 renders this component",
|
||||||
|
"External link uses rel=\u0027noopener noreferrer\u0027",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 19,
|
||||||
|
"passes": true,
|
||||||
|
"notes": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-020",
|
||||||
|
"title": "Create SkillDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/SkillDetail.tsx for displaying individual skill detail in the narrow detail panel. See Ralph/depth-design.md Section 6.2.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/SkillDetail.tsx accepting a SkillMedication prop",
|
||||||
|
"Renders: skill name + frequency + status badge, visual proficiency bar (0-100%), years of experience, category label",
|
||||||
|
"If constellation data is available, show \u0027Used in\u0027 section listing roles that used this skill (import from src/data/constellation.ts)",
|
||||||
|
"Wire into DetailPanel: content.type === \u0027skill\u0027 renders this component",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 20,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Component renders skill header with frequency/status badges, category label, proficiency bar (color-coded), years of experience, and 'Used in' section from constellation data."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-021",
|
||||||
|
"title": "Create SkillsAllDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/SkillsAllDetail.tsx showing the full categorised list of all skills. Clicking an individual skill switches the panel to SkillDetail. See Ralph/depth-design.md Section 6.3.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/SkillsAllDetail.tsx",
|
||||||
|
"Shows full list grouped by Technical / Healthcare Domain / Strategic \u0026 Leadership",
|
||||||
|
"Category headers styled consistently with CoreSkillsTile category headers",
|
||||||
|
"Each skill row is clickable → calls openPanel({ type: \u0027skill\u0027, skill }) to switch panel content",
|
||||||
|
"If opened with a category filter (content.category), scroll to or highlight that category",
|
||||||
|
"Wire into DetailPanel: content.type === \u0027skills-all\u0027 renders this component",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 21,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Full categorised skill list with category headers matching CoreSkillsTile style, proficiency mini-bars, click-to-skill-detail navigation, and category scroll/highlight from filter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-022",
|
||||||
|
"title": "Create EducationDetail renderer for detail panel",
|
||||||
|
"description": "As a developer, I need src/components/detail/EducationDetail.tsx for displaying full education details including extras. See Ralph/depth-design.md Section 6.6.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Create src/components/detail/EducationDetail.tsx accepting a Document prop",
|
||||||
|
"Renders: title + institution + dates + classification",
|
||||||
|
"Imports educationExtras from src/data/educationExtras.ts and finds matching extra by document ID",
|
||||||
|
"If MPharm: shows research project description, extracurricular activities list",
|
||||||
|
"If Mary Seacole: shows programme detail",
|
||||||
|
"Shows notes from document data if present",
|
||||||
|
"Wire into DetailPanel: content.type === \u0027education\u0027 renders this component",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 22,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Renders title + icon + institution + dates + classification badge. Shows research description, OSCE score, extracurriculars (MPharm), programme detail (Mary Seacole), and notes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-023",
|
||||||
|
"title": "Install D3 and scaffold CareerConstellation component",
|
||||||
|
"description": "As a developer, I need to install d3 as a dependency and create a scaffolded CareerConstellation component with an SVG container. See Ralph/depth-design.md Section 2.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Run npm install d3 @types/d3",
|
||||||
|
"Create src/components/CareerConstellation.tsx with props: onRoleClick(id: string), onSkillClick(id: string)",
|
||||||
|
"Component renders a responsive SVG container using useRef\u003cSVGSVGElement\u003e",
|
||||||
|
"Container: full width, height 400px desktop / 300px tablet / 250px mobile (use CSS or media queries)",
|
||||||
|
"SVG has viewBox for responsive scaling",
|
||||||
|
"Import constellation data from src/data/constellation.ts",
|
||||||
|
"Subtle radial gradient background from var(--bg-dashboard) center to var(--surface) edge",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 23,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. D3 + @types/d3 installed. CareerConstellation scaffold with responsive SVG container (400/300/250px), radial gradient bg, ResizeObserver, callbacks ref for future D3 wiring."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-024",
|
||||||
|
"title": "Build D3 force-directed graph rendering in CareerConstellation",
|
||||||
|
"description": "As a developer, I need the D3 force simulation to render role and skill nodes with links in the CareerConstellation component. D3 operates imperatively via useEffect on the SVG ref. See Ralph/depth-design.md Section 2.4 for exact force configuration.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"D3 force simulation with: forceManyBody(-200), forceLink(distance 80, strength from data), forceX chronological (roles positioned left-to-right by startYear), forceY centered, forceCollide(30)",
|
||||||
|
"Role nodes: 24px radius circles, filled with orgColor, white text label",
|
||||||
|
"Skill nodes: 10px radius, color-coded by domain: clinical=var(--success), technical=var(--accent), leadership=var(--amber)",
|
||||||
|
"Links: thin lines (1px), var(--border) color, opacity 0.3",
|
||||||
|
"D3 integration: useEffect on SVG ref, no React state for node positions",
|
||||||
|
"Simulation runs and nodes settle into stable positions",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 24,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. D3 force simulation with forceManyBody(-200), forceLink(dist 80, strength from data), forceX chronological, forceY centered, forceCollide. Role nodes 24px with orgColor + white labels, skill nodes 10px color-coded by domain, links 1px opacity 0.3."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-025",
|
||||||
|
"title": "Add accessibility to CareerConstellation",
|
||||||
|
"description": "As a developer, I need the CareerConstellation to be accessible: keyboard navigable, screen-reader friendly, and respecting reduced motion. See Ralph/depth-design.md Section 2.4 accessibility notes.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"SVG has role=img and aria-label describing the graph (\u0027Career constellation showing roles and skills across career timeline\u0027)",
|
||||||
|
"Screen-reader-only text description of graph structure (hidden visually, available to assistive tech)",
|
||||||
|
"Keyboard navigation: Tab through role nodes, Enter/Space opens detail panel for focused node",
|
||||||
|
"Focus indicators visible on keyboard-focused nodes",
|
||||||
|
"prefers-reduced-motion: disable force simulation animation, render nodes at calculated static positions immediately",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 25,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. SR-only description with role-skill mappings, hidden focusable buttons for keyboard nav (Tab/Enter/Space), focus ring on SVG nodes, prefers-reduced-motion runs simulation synchronously to static positions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-026",
|
||||||
|
"title": "Add hover and click interactions to CareerConstellation",
|
||||||
|
"description": "As a developer, I need hover highlighting and click-to-panel interactions on the CareerConstellation. This connects the graph to the detail panel system. See Ralph/depth-design.md Section 2.4.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Hover role node: connected skill nodes scale up, links brighten to var(--accent), non-connected nodes fade to 0.15 opacity",
|
||||||
|
"Hover skill node: all connected role nodes highlight, link paths illuminate",
|
||||||
|
"Click role node: calls onRoleClick(id) prop",
|
||||||
|
"Click skill node: calls onSkillClick(id) prop",
|
||||||
|
"Integrate into CareerActivityTile: wire onRoleClick to open ConsultationDetail panel, onSkillClick to open SkillDetail panel",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 26,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. D3 hover: connected nodes stay full opacity, non-connected fade to 0.15, links brighten to teal. Click: role→onRoleClick, skill→onSkillClick. Wired into CareerActivityTile replacing placeholder, connected to detail panel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-027",
|
||||||
|
"title": "Restyle LoginScreen with teal accents",
|
||||||
|
"description": "As a developer, I need to visually refresh the LoginScreen with teal accents replacing the current blue. See Ralph/depth-design.md Section 3.3 and Ralph/depth-requirements.md Section 5.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Replace #005EB8 with #0D6E6E throughout LoginScreen (shield icon bg, active field border, cursor, button)",
|
||||||
|
"Replace #004D9F with #0A8080 (button hover state)",
|
||||||
|
"Replace #004494 with #085858 (button pressed state)",
|
||||||
|
"Background color: keep #1E293B or change to #1A2B2A",
|
||||||
|
"Login card feels cohesive with the dashboard teal palette",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 27,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Replaced #005EB8→#0D6E6E, #004D9F→#0A8080, #004494→#085858, background #1E293B→#1A2B2A, shield rgba updated."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-028",
|
||||||
|
"title": "Change login username to a.recruiter and add connection status indicator",
|
||||||
|
"description": "As a developer, I need to change the typed username from a.charlwood to a.recruiter and add a connection status indicator below the login button. See Ralph/depth-design.md Section 3.3.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Username typed in login animation is \u0027a.recruiter\u0027 (not \u0027A.CHARLWOOD\u0027 or similar)",
|
||||||
|
"Connection status indicator appears below the login button: 6px dot + text",
|
||||||
|
"Initial state: red/alert dot + \u0027Awaiting secure connection...\u0027 (var(--alert) color)",
|
||||||
|
"After ~2000ms: dot transitions to green + \u0027Secure connection established\u0027 (var(--success) color, 300ms transition)",
|
||||||
|
"Text: 10px, font-family var(--font-geist-mono), color var(--text-tertiary)",
|
||||||
|
"Login button disabled until BOTH typing is complete AND connectionState === \u0027connected\u0027",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 28,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Username changed to a.recruiter, connection status indicator with red→green 300ms transition, button disabled until typing complete AND connected."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-029",
|
||||||
|
"title": "Add post-login loading state and update TopBar session name",
|
||||||
|
"description": "As a developer, I need a brief loading state after clicking the login button before the dashboard appears, and the TopBar should show A.RECRUITER as the session user. See Ralph/depth-design.md Sections 3.3 and 3.2.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"On login button click: isLoading=true, card content replaced with spinner + \u0027Loading clinical records...\u0027 text",
|
||||||
|
"Loading state lasts ~600ms, then calls onComplete() to transition to dashboard",
|
||||||
|
"Spinner is a CSS-animated spinner (not a GIF), styled with var(--accent) or similar",
|
||||||
|
"Loading text: 12px, color var(--text-secondary)",
|
||||||
|
"In TopBar.tsx: change session display name from \u0027Dr. A.CHARLWOOD\u0027 (or current value) to \u0027A.RECRUITER\u0027",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 29,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Loading state with CSS spinner replaces card content on login click (~600ms), TopBar shows A.RECRUITER, prefers-reduced-motion skips spinner animation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-030",
|
||||||
|
"title": "Update CommandPalette for expanded content and panel actions",
|
||||||
|
"description": "As a developer, I need to update the CommandPalette search index and actions to work with the expanded skills data (~20 skills) and add actions that open the detail panel directly. See Ralph/depth-design.md Section 10, Phase 6.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"Search index in src/lib/search.ts includes all ~21 skills (not just the original 5)",
|
||||||
|
"Selecting a skill result opens the detail panel for that skill (openPanel call or dispatch event)",
|
||||||
|
"Selecting a KPI result opens the KPI detail panel",
|
||||||
|
"Selecting a project result opens the project detail panel",
|
||||||
|
"Ensure DashboardLayout handlePaletteAction supports a new \u0027panel\u0027 action type or adapts existing types to trigger detail panel",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 30,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. All 21 skills in search index, panel action type added. Skills/KPIs/projects open detail panel directly from command palette."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-031",
|
||||||
|
"title": "Responsive testing and fixes for all new components",
|
||||||
|
"description": "As a developer, I need to verify and fix responsive behavior for the detail panel, sub-nav, constellation, and restructured layout at all breakpoints.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"DetailPanel: both narrow and wide render as 100vw on mobile (\u003c768px)",
|
||||||
|
"SubNav: works on tablet/mobile (horizontal scroll if needed, no overflow)",
|
||||||
|
"CareerConstellation: renders at 300px height on tablet, 250px on mobile",
|
||||||
|
"Projects + KPIs: stack vertically on mobile when grid falls to single column",
|
||||||
|
"CoreSkillsTile: full-width layout works on all breakpoints",
|
||||||
|
"All interactive elements have touch targets \u003e= 44px on mobile",
|
||||||
|
"No horizontal overflow at 375px viewport width",
|
||||||
|
"Typecheck passes",
|
||||||
|
"Verify in browser using dev-browser skill"
|
||||||
|
],
|
||||||
|
"priority": 31,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. SubNav horizontal scroll with hidden scrollbar, 44px min touch targets on all interactive elements, DetailPanel close button enlarged to 44px."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "US-032",
|
||||||
|
"title": "Reduced motion audit, final cleanup, and visual review",
|
||||||
|
"description": "As a developer, I need to verify all new animations respect prefers-reduced-motion, remove any dead code introduced during development, and do a final build verification.",
|
||||||
|
"acceptanceCriteria": [
|
||||||
|
"DetailPanel slide animation: instant appear with prefers-reduced-motion",
|
||||||
|
"Backdrop fade: instant with prefers-reduced-motion",
|
||||||
|
"SubNav underline transition: instant with prefers-reduced-motion",
|
||||||
|
"CareerConstellation: static layout (no force simulation animation) with prefers-reduced-motion",
|
||||||
|
"Connection status dot transition: instant with prefers-reduced-motion",
|
||||||
|
"Post-login spinner: static indicator with prefers-reduced-motion",
|
||||||
|
"No dead imports across all files",
|
||||||
|
"Remove any unused flip card CSS (.metric-card-inner etc.) if still present in index.css",
|
||||||
|
"npm run build succeeds cleanly",
|
||||||
|
"npm run typecheck passes with zero errors",
|
||||||
|
"npm run lint passes (pre-existing AccessibilityContext warning OK)",
|
||||||
|
"Typecheck passes"
|
||||||
|
],
|
||||||
|
"priority": 32,
|
||||||
|
"passes": true,
|
||||||
|
"notes": "Completed. Reduced motion overrides for SubNav, connection status, smooth scroll. Created ProjectDetail renderer. Removed unused files (useBreakpoint.ts, profile.ts), legacy PMR CSS variables, placeholder fallback. Build/typecheck/lint all clean."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Ralph Progress — GP Clinical Record Depth Enhancement
|
||||||
|
|
||||||
|
Branch: ralph/depth-enhancement
|
||||||
|
Stories: 32 (US-001 through US-032)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
No iterations completed yet.
|
||||||
|
2026-02-13 22:57 | PASS | US-001: Clean up unused legacy components and hooks | model=opus elapsed=01:58 tools=18
|
||||||
|
2026-02-13 22:59 | PASS | US-002: Add new TypeScript types and CSS custom properties for depth features | model=sonnet elapsed=01:54 tools=11
|
||||||
|
2026-02-13 23:03 | PASS | US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook | model=sonnet elapsed=03:39 tools=22
|
||||||
|
2026-02-13 23:06 | PASS | US-004: Create SubNav component and useActiveSection hook | model=sonnet elapsed=02:54 tools=18
|
||||||
|
2026-02-13 23:08 | PASS | US-005: Expand skills data from 5 to ~20 with three categories | model=sonnet elapsed=01:58 tools=11
|
||||||
|
2026-02-13 23:10 | PASS | US-006: Add KPI story data and update 4th KPI | model=sonnet elapsed=01:59 tools=9
|
||||||
|
2026-02-13 23:11 | PASS | US-007: Create education extras data file | model=sonnet elapsed=01:25 tools=10
|
||||||
|
2026-02-13 23:15 | PASS | US-008: Restructure DashboardLayout with SubNav, new tile order, and DetailPanel | model=sonnet elapsed=03:10 tools=27
|
||||||
|
2026-02-13 23:17 | PASS | US-009: Create constellation data mapping file | model=sonnet elapsed=02:20 tools=10
|
||||||
|
2026-02-13 23:50 | PASS | US-011: Modify CoreSkillsTile: full width, categorised groups, panel triggers | model=opus elapsed=02:54 tools=22
|
||||||
|
2026-02-13 23:52 | PASS | US-012: Modify ProjectsTile: half width, compact card grid, panel trigger | model=sonnet elapsed=02:16 tools=11
|
||||||
|
2026-02-13 23:55 | PASS | US-013: Modify LastConsultationTile: add panel trigger | model=sonnet elapsed=02:20 tools=15
|
||||||
|
2026-02-13 23:58 | PASS | US-014: Modify CareerActivityTile: panel triggers and hover preview | model=sonnet elapsed=02:49 tools=14
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Ralph Wiggum Loop - PRD-driven variant.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||||
|
invocation for each story. Memory persists via filesystem only: git commits,
|
||||||
|
prd.json (passes field), and progress.txt.
|
||||||
|
|
||||||
|
Each iteration works on ONE user story (in priority order).
|
||||||
|
When all stories pass, the loop completes.
|
||||||
|
|
||||||
|
Circuit breakers prevent runaway costs:
|
||||||
|
- No git changes for N consecutive iterations (stalled)
|
||||||
|
- Same error repeated N consecutive iterations (stuck)
|
||||||
|
|
||||||
|
.PARAMETER Model
|
||||||
|
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||||
|
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||||
|
|
||||||
|
.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.
|
||||||
|
|
||||||
|
.PARAMETER StartFrom
|
||||||
|
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Model = "opus",
|
||||||
|
[int]$MaxNoProgress = 3,
|
||||||
|
[int]$MaxSameError = 3,
|
||||||
|
[string]$StartFrom = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$prdFile = Join-Path $scriptDir "prd.json"
|
||||||
|
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||||
|
$logDir = Join-Path $scriptDir "logs"
|
||||||
|
|
||||||
|
# --- Find project root (git repo root) ---
|
||||||
|
|
||||||
|
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||||
|
if (-not $projectRoot) {
|
||||||
|
Write-Error "Not inside a git repository. Run from the project directory."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
if (-not (Test-Path $prdFile)) {
|
||||||
|
Write-Error "prd.json not found at $prdFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure logs directory exists
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||||
|
Write-Host "Created logs directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- PRD Read/Write ---
|
||||||
|
|
||||||
|
function Read-Prd {
|
||||||
|
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Prd {
|
||||||
|
param($prdObj)
|
||||||
|
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# --- Git Setup ---
|
||||||
|
|
||||||
|
$BranchName = $prd.branchName
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||||
|
|
||||||
|
if ($StartFrom) {
|
||||||
|
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||||
|
$skippedCount = 0
|
||||||
|
foreach ($story in $prd.userStories) {
|
||||||
|
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||||
|
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||||
|
$story.passes = $true
|
||||||
|
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||||
|
$skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skippedCount -gt 0) {
|
||||||
|
Save-Prd $prd
|
||||||
|
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker State ---
|
||||||
|
|
||||||
|
$noProgressCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
$sameErrorCount = 0
|
||||||
|
|
||||||
|
# --- Prompt Generation ---
|
||||||
|
|
||||||
|
function Build-StoryPrompt {
|
||||||
|
param(
|
||||||
|
$story,
|
||||||
|
$prdObj,
|
||||||
|
[array]$completedStories
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build completed list
|
||||||
|
$completedSection = ""
|
||||||
|
if ($completedStories.Count -gt 0) {
|
||||||
|
$completedLines = ($completedStories | ForEach-Object {
|
||||||
|
"- $($_.id): $($_.title)"
|
||||||
|
}) -join "`n"
|
||||||
|
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build criteria list
|
||||||
|
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||||
|
|
||||||
|
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||||
|
$sid = $story.id
|
||||||
|
$stitle = $story.title
|
||||||
|
$sdesc = $story.description
|
||||||
|
$pdesc = $prdObj.description
|
||||||
|
|
||||||
|
$prompt = @(
|
||||||
|
"# Ralph Iteration: $sid - $stitle"
|
||||||
|
""
|
||||||
|
"## Project"
|
||||||
|
"$pdesc"
|
||||||
|
""
|
||||||
|
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||||
|
""
|
||||||
|
"## Your Task"
|
||||||
|
""
|
||||||
|
"**${sid}: $stitle**"
|
||||||
|
""
|
||||||
|
"$sdesc"
|
||||||
|
""
|
||||||
|
"## Acceptance Criteria"
|
||||||
|
""
|
||||||
|
"$criteriaLines"
|
||||||
|
""
|
||||||
|
"## Reference Documents"
|
||||||
|
""
|
||||||
|
"Read these as needed for implementation detail:"
|
||||||
|
""
|
||||||
|
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||||
|
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||||
|
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||||
|
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||||
|
"$completedSection"
|
||||||
|
"## Workflow"
|
||||||
|
""
|
||||||
|
"1. Read CLAUDE.md to understand project conventions"
|
||||||
|
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||||
|
"3. Read existing source files you will modify to understand current patterns"
|
||||||
|
"4. Implement ALL acceptance criteria"
|
||||||
|
"5. Run npm run typecheck - fix any type errors"
|
||||||
|
"6. Run npm run build - fix any build errors"
|
||||||
|
"7. Stage and commit your changes:"
|
||||||
|
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||||
|
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||||
|
""
|
||||||
|
"## Rules"
|
||||||
|
""
|
||||||
|
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||||
|
"- Read files before modifying them."
|
||||||
|
"- Follow existing patterns and conventions in the codebase."
|
||||||
|
"- Use lucide-react for icons, never unicode symbols."
|
||||||
|
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||||
|
"- Commit specific files, not git add -A."
|
||||||
|
"- Do NOT start a dev server (npm run dev). One is already running on port $devServerPort. Do NOT run any background tasks."
|
||||||
|
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||||
|
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||||
|
) -join "`n"
|
||||||
|
|
||||||
|
return $prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Banner ---
|
||||||
|
|
||||||
|
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$totalCount = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||||
|
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||||
|
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Dev server port (assumed to be running externally)
|
||||||
|
$devServerPort = 5173
|
||||||
|
Write-Host "Dev server assumed running on port $devServerPort" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Story Loop ---
|
||||||
|
|
||||||
|
$iterationCount = 0
|
||||||
|
$originalDir = Get-Location
|
||||||
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# Partition stories
|
||||||
|
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||||
|
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||||
|
|
||||||
|
# Check if all done
|
||||||
|
if ($pendingStories.Count -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||||
|
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||||
|
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentStory = $pendingStories[0]
|
||||||
|
$iterationCount++
|
||||||
|
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||||
|
|
||||||
|
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||||
|
$pctStr = "${pctComplete}%"
|
||||||
|
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||||
|
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# Record HEAD before this iteration
|
||||||
|
$headBefore = git rev-parse HEAD 2>$null
|
||||||
|
|
||||||
|
$iterStart = Get-Date
|
||||||
|
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate prompt for this story
|
||||||
|
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||||
|
|
||||||
|
# --- Spawn Claude ---
|
||||||
|
|
||||||
|
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||||
|
$rawLogFile = Join-Path $logDir "$($currentStory.id).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
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Spawn Claude via Process.Start for clean shutdown control ---
|
||||||
|
# Using Process.Start instead of pipeline so we can break on the result
|
||||||
|
# event and force-kill the process tree. The pipeline approach hangs when
|
||||||
|
# Claude spawns background tasks (e.g. npm run dev) that keep stdout open.
|
||||||
|
|
||||||
|
$promptTempFile = Join-Path $logDir "$($currentStory.id).prompt.tmp"
|
||||||
|
$promptContent | Set-Content -Path $promptTempFile -Encoding UTF8
|
||||||
|
|
||||||
|
$claudeArgs = "--print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json"
|
||||||
|
$psi = [System.Diagnostics.ProcessStartInfo]::new()
|
||||||
|
$psi.FileName = "cmd.exe"
|
||||||
|
$psi.Arguments = "/c type `"$promptTempFile`" | claude $claudeArgs"
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.RedirectStandardOutput = $true
|
||||||
|
$psi.RedirectStandardError = $true
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$psi.WorkingDirectory = $projectRoot
|
||||||
|
|
||||||
|
$claudeProc = [System.Diagnostics.Process]::Start($psi)
|
||||||
|
|
||||||
|
# Drain stderr async to prevent buffer deadlock
|
||||||
|
$claudeProc.add_ErrorDataReceived({ param($s,$e) })
|
||||||
|
$claudeProc.BeginErrorReadLine()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($null -ne ($line = $claudeProc.StandardOutput.ReadLine())) {
|
||||||
|
$line = $line.Trim()
|
||||||
|
if (-not $line) { continue }
|
||||||
|
|
||||||
|
# Save raw event for debugging
|
||||||
|
try {
|
||||||
|
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
$isResultEvent = $false
|
||||||
|
try {
|
||||||
|
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
|
||||||
|
# --- Tool use start ---
|
||||||
|
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
|
||||||
|
}
|
||||||
|
# --- Streaming text ---
|
||||||
|
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 (terminal — stop reading after this) ---
|
||||||
|
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) {
|
||||||
|
[void]$textBuilder.AppendLine($evt.result)
|
||||||
|
}
|
||||||
|
$isResultEvent = $true
|
||||||
|
}
|
||||||
|
# --- Message-level content ---
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if ($line -and $line -notmatch '^\s*["\{\[\}\]]') {
|
||||||
|
Write-Host $line -ForegroundColor DarkYellow
|
||||||
|
[void]$textBuilder.AppendLine($line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Result is always the final stream event — stop reading
|
||||||
|
if ($isResultEvent) { break }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
# Kill the Claude process tree to prevent orphaned cmd.exe/node processes
|
||||||
|
if ($claudeProc -and -not $claudeProc.HasExited) {
|
||||||
|
try {
|
||||||
|
taskkill /T /F /PID $claudeProc.Id 2>$null | Out-Null
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
Remove-Item -Path $promptTempFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
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..." -ForegroundColor Green
|
||||||
|
|
||||||
|
$apiOverloaded = $true
|
||||||
|
}
|
||||||
|
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||||
|
|
||||||
|
# Save log
|
||||||
|
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||||
|
|
||||||
|
# Show elapsed time
|
||||||
|
$elapsed = (Get-Date) - $iterStart
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --- Detect signals ---
|
||||||
|
|
||||||
|
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||||
|
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||||
|
$headAfter = git rev-parse HEAD 2>$null
|
||||||
|
$hasGitChanges = $headAfter -ne $headBefore
|
||||||
|
|
||||||
|
# --- Update story status ---
|
||||||
|
|
||||||
|
if ($storyComplete) {
|
||||||
|
# Mark story as passed in prd.json
|
||||||
|
$prd = Read-Prd
|
||||||
|
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||||
|
if ($storyToUpdate) {
|
||||||
|
$alreadyDone = if (-not $hasGitChanges) { " (already committed)" } else { "" }
|
||||||
|
$storyToUpdate.passes = $true
|
||||||
|
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model.$alreadyDone"
|
||||||
|
}
|
||||||
|
Save-Prd $prd
|
||||||
|
|
||||||
|
# Append to progress.txt
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$el = $elapsed.ToString('mm\:ss')
|
||||||
|
$tag = if ($hasGitChanges) { "PASS" } else { "PASS (no new commits)" }
|
||||||
|
$progressEntry = "$ts | $tag | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||||
|
if (-not $hasGitChanges) {
|
||||||
|
Write-Host " (Work was already committed)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
$noProgressCount = 0
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
elseif ($storyBlocked) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||||
|
# Blocked counts as no progress
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# No completion signal
|
||||||
|
if ($hasGitChanges) {
|
||||||
|
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
$noProgressCount = 0
|
||||||
|
} else {
|
||||||
|
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: No Progress ---
|
||||||
|
|
||||||
|
if ($noProgressCount -ge $MaxNoProgress) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||||
|
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||||
|
Write-Host "Stuck on: $($currentStory.id) - $($currentStory.title)" -ForegroundColor Red
|
||||||
|
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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 for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||||
|
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} elseif ($currentErrorSignature) {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
}
|
||||||
|
$lastErrorSignature = $currentErrorSignature
|
||||||
|
} else {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dynamic Model Selection ---
|
||||||
|
|
||||||
|
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||||
|
$nextModel = $Matches[1]
|
||||||
|
if ($nextModel -ne $Model) {
|
||||||
|
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||||
|
$Model = $nextModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brief pause between iterations
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
Set-Location $originalDir
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Final Summary ---
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$finalTotal = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||||
|
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||||
|
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($finalPassed -eq $finalTotal) {
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Ralph Wiggum Loop — PRD-driven variant.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||||
|
invocation for each story. Memory persists via filesystem only: git commits,
|
||||||
|
prd.json (passes field), and progress.txt.
|
||||||
|
|
||||||
|
Each iteration works on ONE user story (in priority order).
|
||||||
|
When all stories pass, the loop completes.
|
||||||
|
|
||||||
|
Circuit breakers prevent runaway costs:
|
||||||
|
- No git changes for N consecutive iterations (stalled)
|
||||||
|
- Same error repeated N consecutive iterations (stuck)
|
||||||
|
|
||||||
|
.PARAMETER Model
|
||||||
|
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||||
|
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||||
|
|
||||||
|
.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.
|
||||||
|
|
||||||
|
.PARAMETER StartFrom
|
||||||
|
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||||
|
|
||||||
|
.PARAMETER SkipVerify
|
||||||
|
Skip post-iteration typecheck verification. Faster but less safe.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Model = "opus",
|
||||||
|
[int]$MaxNoProgress = 3,
|
||||||
|
[int]$MaxSameError = 3,
|
||||||
|
[string]$StartFrom = "",
|
||||||
|
[switch]$SkipVerify
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$prdFile = Join-Path $scriptDir "prd.json"
|
||||||
|
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||||
|
$logDir = Join-Path $scriptDir "logs"
|
||||||
|
|
||||||
|
# --- Find project root (git repo root) ---
|
||||||
|
|
||||||
|
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||||
|
if (-not $projectRoot) {
|
||||||
|
Write-Error "Not inside a git repository. Run from the project directory."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
if (-not (Test-Path $prdFile)) {
|
||||||
|
Write-Error "prd.json not found at $prdFile"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure logs directory exists
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||||
|
Write-Host "Created logs directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- PRD Read/Write ---
|
||||||
|
|
||||||
|
function Read-Prd {
|
||||||
|
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-Prd {
|
||||||
|
param($prdObj)
|
||||||
|
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# --- Git Setup ---
|
||||||
|
|
||||||
|
$BranchName = $prd.branchName
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||||
|
|
||||||
|
if ($StartFrom) {
|
||||||
|
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||||
|
$skippedCount = 0
|
||||||
|
foreach ($story in $prd.userStories) {
|
||||||
|
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||||
|
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||||
|
$story.passes = $true
|
||||||
|
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||||
|
$skippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skippedCount -gt 0) {
|
||||||
|
Save-Prd $prd
|
||||||
|
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker State ---
|
||||||
|
|
||||||
|
$noProgressCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
$sameErrorCount = 0
|
||||||
|
|
||||||
|
# --- Prompt Generation ---
|
||||||
|
|
||||||
|
function Build-StoryPrompt {
|
||||||
|
param(
|
||||||
|
$story,
|
||||||
|
$prdObj,
|
||||||
|
[array]$completedStories
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build completed list
|
||||||
|
$completedSection = ""
|
||||||
|
if ($completedStories.Count -gt 0) {
|
||||||
|
$completedLines = ($completedStories | ForEach-Object {
|
||||||
|
"- $($_.id): $($_.title)"
|
||||||
|
}) -join "`n"
|
||||||
|
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build criteria list
|
||||||
|
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||||
|
|
||||||
|
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||||
|
$sid = $story.id
|
||||||
|
$stitle = $story.title
|
||||||
|
$sdesc = $story.description
|
||||||
|
$pdesc = $prdObj.description
|
||||||
|
|
||||||
|
$prompt = @(
|
||||||
|
"# Ralph Iteration: $sid - $stitle"
|
||||||
|
""
|
||||||
|
"## Project"
|
||||||
|
"$pdesc"
|
||||||
|
""
|
||||||
|
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||||
|
""
|
||||||
|
"## Your Task"
|
||||||
|
""
|
||||||
|
"**${sid}: $stitle**"
|
||||||
|
""
|
||||||
|
"$sdesc"
|
||||||
|
""
|
||||||
|
"## Acceptance Criteria"
|
||||||
|
""
|
||||||
|
"$criteriaLines"
|
||||||
|
""
|
||||||
|
"## Reference Documents"
|
||||||
|
""
|
||||||
|
"Read these as needed for implementation detail:"
|
||||||
|
""
|
||||||
|
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||||
|
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||||
|
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||||
|
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||||
|
"$completedSection"
|
||||||
|
"## Workflow"
|
||||||
|
""
|
||||||
|
"1. Read CLAUDE.md to understand project conventions"
|
||||||
|
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||||
|
"3. Read existing source files you will modify to understand current patterns"
|
||||||
|
"4. Implement ALL acceptance criteria"
|
||||||
|
"5. Run npm run typecheck - fix any type errors"
|
||||||
|
"6. Run npm run build - fix any build errors"
|
||||||
|
"7. Stage and commit your changes:"
|
||||||
|
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||||
|
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||||
|
""
|
||||||
|
"## Rules"
|
||||||
|
""
|
||||||
|
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||||
|
"- Read files before modifying them."
|
||||||
|
"- Follow existing patterns and conventions in the codebase."
|
||||||
|
"- Use lucide-react for icons, never unicode symbols."
|
||||||
|
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||||
|
"- Commit specific files, not git add -A."
|
||||||
|
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||||
|
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||||
|
) -join "`n"
|
||||||
|
|
||||||
|
return $prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Banner ---
|
||||||
|
|
||||||
|
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$totalCount = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||||
|
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||||
|
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||||
|
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||||
|
if (-not $SkipVerify) { Write-Host "Post-iteration typecheck verification: ON" -ForegroundColor Cyan }
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Dev Server ---
|
||||||
|
|
||||||
|
$devServerPort = 5173
|
||||||
|
$devServerPid = $null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
|
||||||
|
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $projectRoot -PassThru -WindowStyle Minimized
|
||||||
|
$devServerPid = $devProc.Id
|
||||||
|
|
||||||
|
for ($w = 1; $w -le 20; $w++) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||||
|
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
if ($w -eq 20) {
|
||||||
|
Write-Warning "Dev server may not be ready — visual review steps may fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Story Loop ---
|
||||||
|
|
||||||
|
$iterationCount = 0
|
||||||
|
$originalDir = Get-Location
|
||||||
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||||
|
$prd = Read-Prd
|
||||||
|
|
||||||
|
# Partition stories
|
||||||
|
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||||
|
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||||
|
|
||||||
|
# Check if all done
|
||||||
|
if ($pendingStories.Count -eq 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||||
|
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||||
|
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentStory = $pendingStories[0]
|
||||||
|
$iterationCount++
|
||||||
|
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||||
|
|
||||||
|
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||||
|
$pctStr = "${pctComplete}%"
|
||||||
|
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||||
|
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# Record HEAD before this iteration
|
||||||
|
$headBefore = git rev-parse HEAD 2>$null
|
||||||
|
|
||||||
|
$iterStart = Get-Date
|
||||||
|
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Generate prompt for this story
|
||||||
|
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||||
|
|
||||||
|
# --- Spawn Claude ---
|
||||||
|
|
||||||
|
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||||
|
$rawLogFile = Join-Path $logDir "$($currentStory.id).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
|
||||||
|
try {
|
||||||
|
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
|
||||||
|
# --- Tool use start ---
|
||||||
|
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
|
||||||
|
}
|
||||||
|
# --- Streaming text ---
|
||||||
|
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 ---
|
||||||
|
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) {
|
||||||
|
[void]$textBuilder.AppendLine($evt.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# --- Message-level content ---
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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
|
||||||
|
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..." -ForegroundColor Green
|
||||||
|
|
||||||
|
$apiOverloaded = $true
|
||||||
|
}
|
||||||
|
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||||
|
|
||||||
|
# Save log
|
||||||
|
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||||
|
|
||||||
|
# Show elapsed time
|
||||||
|
$elapsed = (Get-Date) - $iterStart
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
# --- Detect signals ---
|
||||||
|
|
||||||
|
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||||
|
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||||
|
$headAfter = git rev-parse HEAD 2>$null
|
||||||
|
$hasGitChanges = $headAfter -ne $headBefore
|
||||||
|
|
||||||
|
# --- Post-iteration typecheck verification ---
|
||||||
|
|
||||||
|
$typecheckPassed = $true
|
||||||
|
if ($storyComplete -and $hasGitChanges -and -not $SkipVerify) {
|
||||||
|
Write-Host " Verifying typecheck..." -ForegroundColor DarkGray
|
||||||
|
$typecheckOutput = npm run typecheck 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [VERIFY FAIL] Typecheck failed after completion signal. Not marking as passed." -ForegroundColor Red
|
||||||
|
$typecheckPassed = $false
|
||||||
|
} else {
|
||||||
|
Write-Host " [VERIFY OK] Typecheck passed." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Update story status ---
|
||||||
|
|
||||||
|
if ($storyComplete -and $hasGitChanges -and $typecheckPassed) {
|
||||||
|
# Mark story as passed in prd.json
|
||||||
|
$prd = Read-Prd
|
||||||
|
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||||
|
if ($storyToUpdate) {
|
||||||
|
$storyToUpdate.passes = $true
|
||||||
|
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model."
|
||||||
|
}
|
||||||
|
Save-Prd $prd
|
||||||
|
|
||||||
|
# Append to progress.txt
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$el = $elapsed.ToString('mm\:ss')
|
||||||
|
$progressEntry = "$ts | PASS | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||||
|
$noProgressCount = 0
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
elseif ($storyBlocked) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||||
|
# Blocked counts as no progress
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
elseif ($storyComplete -and -not $hasGitChanges) {
|
||||||
|
Write-Host " [WARNING] Completion signaled but no git commits. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
elseif ($storyComplete -and -not $typecheckPassed) {
|
||||||
|
Write-Host " [WARNING] Completion signaled but typecheck failed. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | TYPECHECK_FAIL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
# Has git changes, so not stalled — but not passed either
|
||||||
|
$noProgressCount = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# No completion signal
|
||||||
|
if ($hasGitChanges) {
|
||||||
|
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||||
|
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||||
|
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||||
|
$noProgressCount = 0
|
||||||
|
} else {
|
||||||
|
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||||
|
$noProgressCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Circuit Breaker: No Progress ---
|
||||||
|
|
||||||
|
if ($noProgressCount -ge $MaxNoProgress) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||||
|
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||||
|
Write-Host "Stuck on: $($currentStory.id) — $($currentStory.title)" -ForegroundColor Red
|
||||||
|
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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 for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||||
|
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} elseif ($currentErrorSignature) {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
}
|
||||||
|
$lastErrorSignature = $currentErrorSignature
|
||||||
|
} else {
|
||||||
|
$sameErrorCount = 0
|
||||||
|
$lastErrorSignature = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dynamic Model Selection ---
|
||||||
|
|
||||||
|
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||||
|
$nextModel = $Matches[1]
|
||||||
|
if ($nextModel -ne $Model) {
|
||||||
|
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||||
|
$Model = $nextModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Brief pause between iterations
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
# Cleanup: restore directory, kill dev server
|
||||||
|
Set-Location $originalDir
|
||||||
|
if ($devServerPid) {
|
||||||
|
Write-Host "Stopping dev server (PID $devServerPid)..." -ForegroundColor DarkGray
|
||||||
|
taskkill /T /F /PID $devServerPid 2>$null | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Final Summary ---
|
||||||
|
|
||||||
|
$prd = Read-Prd
|
||||||
|
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||||
|
$finalTotal = $prd.userStories.Count
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||||
|
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||||
|
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||||
|
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||||
|
Write-Host "===========================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($finalPassed -eq $finalTotal) {
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Session Handoff
|
||||||
|
|
||||||
|
_Generated: 2026-02-16 10:43:45 UTC_
|
||||||
|
|
||||||
|
## Git Context
|
||||||
|
|
||||||
|
- **Branch:** `codex/kpi`
|
||||||
|
- **HEAD:** 24ffe03: chore: auto-commit before merge (loop primary)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
|
- [x] Compact Latest Results KPI section
|
||||||
|
- [x] Validate KPI objective and close loop
|
||||||
|
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
Recently modified:
|
||||||
|
|
||||||
|
- `.ralph/agent/handoff.md`
|
||||||
|
- `.ralph/agent/memories.md`
|
||||||
|
- `.ralph/agent/memories.md.lock`
|
||||||
|
- `.ralph/agent/scratchpad.md`
|
||||||
|
- `.ralph/agent/summary.md`
|
||||||
|
- `.ralph/agent/tasks.jsonl`
|
||||||
|
- `.ralph/agent/tasks.jsonl.lock`
|
||||||
|
- `.ralph/current-events`
|
||||||
|
- `.ralph/current-loop-id`
|
||||||
|
- `.ralph/events-20260216-103430.jsonl`
|
||||||
|
|
||||||
|
## Next Session
|
||||||
|
|
||||||
|
Session completed successfully. No pending work.
|
||||||
|
|
||||||
|
**Original objective:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ralph/PROMPT.md
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Memories
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### mem-1771238197-12d0
|
||||||
|
> Latest Results KPI tile now uses a dedicated responsive grid class: mobile defaults to 1 column and md+ forces 4 columns; coachmark/pulse behavior removed from PatientSummaryTile and related CSS.
|
||||||
|
<!-- tags: ui, layout, kpi | created: 2026-02-16 -->
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
### mem-1771238608-ecff
|
||||||
|
> failure: cmd=git commit -m 'chore: document KPI objective verification', exit=128, error=.git/index.lock exists due concurrent git operations, next=run git commands sequentially and remove stale lock after confirming no active git process
|
||||||
|
<!-- tags: tooling, error-handling, git | created: 2026-02-16 -->
|
||||||
|
|
||||||
|
## Context
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## 2026-02-16T10:43:30Z
|
||||||
|
Started new loop iteration for `Ralph/PROMPT.md` objective (Latest Results KPI compaction). Reviewed objective, handoff, summary, and current implementation.
|
||||||
|
|
||||||
|
Observation: implementation in `src/components/tiles/PatientSummaryTile.tsx` and `src/index.css` already appears to satisfy the requested changes (coachmark removed, helper text moved into header row, responsive 1-column mobile and 4-column md+ grid, compact metric card spacing).
|
||||||
|
|
||||||
|
Plan for this iteration:
|
||||||
|
1) Create one runtime task to validate objective state and close loop.
|
||||||
|
2) Run required verification commands (`npm run typecheck`, `npm run lint`, `npm run build`).
|
||||||
|
3) If checks pass, close task and emit `LOOP_COMPLETE`.
|
||||||
|
|
||||||
|
Verification run completed in this iteration:
|
||||||
|
- `npm run typecheck`: pass
|
||||||
|
- `npm run lint`: pass (2 existing warnings in context files only, no errors)
|
||||||
|
- `npm run build`: pass (existing bundle-size warning only)
|
||||||
|
|
||||||
|
Decision: treat objective as complete because acceptance criteria are already present in source and validation is green.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Loop Summary
|
||||||
|
|
||||||
|
**Status:** Completed successfully
|
||||||
|
**Iterations:** 1
|
||||||
|
**Duration:** 1m 35s
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
_No scratchpad found._
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
_No events recorded._
|
||||||
|
|
||||||
|
## Final Commit
|
||||||
|
|
||||||
|
e5c7d9b: chore: document KPI objective verification
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"id":"task-1771238094-7dc9","title":"Compact Latest Results KPI section","description":"Remove coachmark/pulse, move instruction text to heading row right area, enforce 1x4 mobile and 4x1 md+ KPI layout, reduce KPI card whitespace in PatientSummaryTile while preserving content/interactions.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-103430","created":"2026-02-16T10:34:54.490955020+00:00","closed":"2026-02-16T10:36:37.836478822+00:00"}
|
||||||
|
{"id":"task-1771238560-5ec5","title":"Validate KPI objective and close loop","description":"Run typecheck/lint/build and confirm Latest Results KPI compaction acceptance criteria remain satisfied before LOOP_COMPLETE event.","status":"closed","priority":1,"blocked_by":[],"loop_id":"primary-20260216-104201","created":"2026-02-16T10:42:40.351948381+00:00","closed":"2026-02-16T10:43:32.976626807+00:00"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.ralph/events-20260216-104201.jsonl
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
primary-20260216-104201
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"ts":"2026-02-16T10:34:30.364645124+00:00","iteration":0,"hat":"loop","topic":"task.start","triggered":"planner","payload":"Ralph/PROMPT.md"}
|
||||||
|
{"payload":"Objective complete: Latest Results KPI compaction delivered in commit ab80d65; checks passed (typecheck, lint, build).","topic":"LOOP_COMPLETE","ts":"2026-02-16T10:36:42.467538791+00:00"}
|
||||||
|
{"ts":"2026-02-16T10:36:47.668446198+00:00","iteration":1,"hat":"loop","topic":"loop.terminate","payload":"## Reason\ncompleted\n\n## Status\nAll tasks completed successfully.\n\n## Summary\n- Iterations: 1\n- Duration: 2m 5s\n- Exit code: 0"}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"ts":"2026-02-16T10:42:01.113818157+00:00","iteration":0,"hat":"loop","topic":"task.start","triggered":"planner","payload":"Ralph/PROMPT.md"}
|
||||||
|
{"payload":"objective verified complete; typecheck/lint/build pass; task-1771238560-5ec5 closed; commit e5c7d9b","topic":"LOOP_COMPLETE","ts":"2026-02-16T10:43:39.323746394+00:00"}
|
||||||
|
{"ts":"2026-02-16T10:43:44.923284944+00:00","iteration":1,"hat":"loop","topic":"loop.terminate","payload":"## Reason\ncompleted\n\n## Status\nAll tasks completed successfully.\n\n## Summary\n- Iterations: 1\n- Duration: 1m 35s\n- Exit code: 0"}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"ts":"2026-02-16T10:34:30.465886881Z","type":{"kind":"loop_started","prompt":"Ralph/PROMPT.md"}}
|
||||||
|
{"ts":"2026-02-16T10:36:47.670503849Z","type":{"kind":"loop_completed","reason":"completion_promise"}}
|
||||||
|
{"ts":"2026-02-16T10:42:01.215892851Z","type":{"kind":"loop_started","prompt":"Ralph/PROMPT.md"}}
|
||||||
|
{"ts":"2026-02-16T10:43:44.925586089Z","type":{"kind":"loop_completed","reason":"completion_promise"}}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
<<<<<<< HEAD
|
||||||
"pid": 864891,
|
"pid": 864891,
|
||||||
"started": "2026-02-16T10:14:58.914587907Z",
|
"started": "2026-02-16T10:14:58.914587907Z",
|
||||||
"prompt": "[no prompt]"
|
"prompt": "[no prompt]"
|
||||||
|
=======
|
||||||
|
"pid": 883596,
|
||||||
|
"started": "2026-02-16T10:42:01.108766214Z",
|
||||||
|
"prompt": "Ralph/PROMPT.md"
|
||||||
|
>>>>>>> codex/kpi
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"loops": []
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,201 +0,0 @@
|
|||||||
# Implementation Plan — GP System Dashboard Overhaul
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Replace the "CareerRecord PMR" sidebar-nav + view-switching interface with a tile-based GP System dashboard called "CVMIS" Reference design: `References/GPSystemconcept.html`.
|
|
||||||
|
|
||||||
## Quality Checks
|
|
||||||
|
|
||||||
- `npm run typecheck` — zero errors
|
|
||||||
- `npm run lint` — pass (pre-existing AccessibilityContext warning OK)
|
|
||||||
- `npm run build` — must succeed
|
|
||||||
|
|
||||||
## Important
|
|
||||||
|
|
||||||
**This file is for progress tracking only.** For implementation detail on any task, read the referenced file in `Ralph/refs/`. Do NOT bloat this file with implementation notes — keep it lean.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### Phase 0: Foundation
|
|
||||||
|
|
||||||
#### Task 1: Update design tokens and Tailwind config
|
|
||||||
> Detail: `Ralph/refs/ref-01-design-tokens.md`
|
|
||||||
- [x] Update CSS custom properties in `src/index.css` (new palette, shadows, layout vars)
|
|
||||||
- [x] Update `tailwind.config.js` (colors, shadows, borders, radius)
|
|
||||||
- [x] Keep boot/ECG/login tokens unchanged
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 2: Create new data files and update types
|
|
||||||
> Detail: `Ralph/refs/ref-02-data-types.md`
|
|
||||||
- [x] Create `src/data/profile.ts` (personal statement)
|
|
||||||
- [x] Create `src/data/tags.ts` (sidebar tags)
|
|
||||||
- [x] Create `src/data/alerts.ts` (sidebar alert flags)
|
|
||||||
- [x] Create `src/data/kpis.ts` (Latest Results metrics)
|
|
||||||
- [x] Create `src/data/skills.ts` (skills with medication frequency + years)
|
|
||||||
- [x] Update `src/types/pmr.ts` (new interfaces)
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 3: Update CLAUDE.md for new architecture
|
|
||||||
- [x] Already completed during project setup (manual intervention 2026-02-13)
|
|
||||||
|
|
||||||
### Phase 1: Core Layout
|
|
||||||
|
|
||||||
#### Task 4: Build TopBar component
|
|
||||||
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (TopBar section)
|
|
||||||
- [x] Create `src/components/TopBar.tsx`
|
|
||||||
- [x] Brand section (icon + name + version tag)
|
|
||||||
- [x] Search bar (triggers command palette, not inline search)
|
|
||||||
- [x] Session info (mono font, pill badge)
|
|
||||||
- [x] Fixed position, 48px height, white bg, bottom border
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 5: Build new Sidebar — PersonHeader
|
|
||||||
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Sidebar PersonHeader section)
|
|
||||||
- [x] Create `src/components/Sidebar.tsx`
|
|
||||||
- [x] Avatar circle (52px, teal gradient, initials)
|
|
||||||
- [x] Name, title, status badge with pulse dot
|
|
||||||
- [x] Details grid (GPhC, Education, Location, Phone, Email, Registered)
|
|
||||||
- [x] 272px width, light background, right border
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 6: Build new Sidebar — Tags + Alerts
|
|
||||||
> Detail: `Ralph/refs/ref-03-topbar-sidebar.md` (Tags and Alerts section)
|
|
||||||
- [x] Section title component (uppercase, divider line)
|
|
||||||
- [x] Tags section (flex wrap pills, color variants)
|
|
||||||
- [x] Alerts section (colored flag items with icons)
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 7: Build DashboardLayout and wire up App.tsx
|
|
||||||
> Detail: `Ralph/refs/ref-04-dashboard-layout.md`
|
|
||||||
- [x] Create `src/components/DashboardLayout.tsx`
|
|
||||||
- [x] Three-zone layout: TopBar (fixed) + Sidebar (fixed) + Main (scrollable card grid)
|
|
||||||
- [x] Card grid: 2 columns desktop, 1 column <900px
|
|
||||||
- [x] Framer Motion entrance animations (topbar → sidebar → content)
|
|
||||||
- [x] Update App.tsx: replace PMRInterface with DashboardLayout in PMR phase
|
|
||||||
- [x] Verify boot → ECG → login → dashboard transition works
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
### Phase 2: Dashboard Tiles
|
|
||||||
|
|
||||||
#### Task 8: Build reusable Card component
|
|
||||||
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (Card section)
|
|
||||||
- [x] Create `src/components/Card.tsx`
|
|
||||||
- [x] Base card styling (white, border, radius 8px, shadow-sm, hover shadow-md)
|
|
||||||
- [x] `full` variant (spans both grid columns)
|
|
||||||
- [x] CardHeader sub-component (dot + title + optional right text)
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 9: Build PatientSummary tile
|
|
||||||
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (PatientSummary section)
|
|
||||||
- [x] Create `src/components/tiles/PatientSummaryTile.tsx`
|
|
||||||
- [x] Full-width card, first in grid
|
|
||||||
- [x] Personal statement from `src/data/profile.ts`
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 10: Build LatestResults tile
|
|
||||||
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (LatestResults section)
|
|
||||||
- [x] Create `src/components/tiles/LatestResultsTile.tsx`
|
|
||||||
- [x] Half-width card, 2x2 metric grid
|
|
||||||
- [x] Four KPI metric cards with colored values
|
|
||||||
- [x] Data from `src/data/kpis.ts`
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 11: Build CoreSkills tile ("Repeat Medications")
|
|
||||||
> Detail: `Ralph/refs/ref-05-card-and-top-tiles.md` (CoreSkills section)
|
|
||||||
- [x] Create `src/components/tiles/CoreSkillsTile.tsx`
|
|
||||||
- [x] Half-width card, next to LatestResults
|
|
||||||
- [x] Skills listed as medications with frequency + years
|
|
||||||
- [x] Data from `src/data/skills.ts`
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 12: Build LastConsultation tile
|
|
||||||
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (LastConsultation section)
|
|
||||||
- [x] Create `src/components/tiles/LastConsultationTile.tsx`
|
|
||||||
- [x] Full-width card
|
|
||||||
- [x] Header info row (Date, Org, Type, Band)
|
|
||||||
- [x] Role title + achievement bullet list
|
|
||||||
- [x] Data from first entry in `src/data/consultations.ts`
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 13: Build CareerActivity tile
|
|
||||||
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (CareerActivity section)
|
|
||||||
- [x] Create `src/components/tiles/CareerActivityTile.tsx`
|
|
||||||
- [x] Full-width card, two-column activity grid
|
|
||||||
- [x] Merge roles + projects + certs + education into timeline
|
|
||||||
- [x] Color-coded dots by entry type
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 14: Build Education tile
|
|
||||||
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Education section)
|
|
||||||
- [x] Create `src/components/tiles/EducationTile.tsx`
|
|
||||||
- [x] Full-width card, below Career Activity
|
|
||||||
- [x] Education entries from documents data
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 15: Build Projects tile
|
|
||||||
> Detail: `Ralph/refs/ref-06-bottom-tiles.md` (Projects section)
|
|
||||||
- [x] Create `src/components/tiles/ProjectsTile.tsx`
|
|
||||||
- [x] Full-width card, prominent presentation
|
|
||||||
- [x] Status badges, project names, years, descriptions
|
|
||||||
- [x] Data from `src/data/investigations.ts`
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
### Phase 3: Interactions
|
|
||||||
|
|
||||||
#### Task 16: Tile expansion system
|
|
||||||
> Detail: `Ralph/refs/ref-07-interactions.md` (Tile Expansion section)
|
|
||||||
- [x] CareerActivity items expand to show full role detail
|
|
||||||
- [x] Projects items expand to show methodology, tech stack, results
|
|
||||||
- [x] CoreSkills items expand to show prescribing history
|
|
||||||
- [x] Height-only animation (200ms, no opacity fade)
|
|
||||||
- [x] Single-expand accordion
|
|
||||||
- [x] Keyboard: Enter/Space to expand, Escape to collapse
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 17: KPI flip card interaction
|
|
||||||
> Detail: `Ralph/refs/ref-07-interactions.md` (KPI Flip section)
|
|
||||||
- [x] LatestResults metrics flip on click
|
|
||||||
- [x] Front: value + label. Back: explanation text
|
|
||||||
- [x] CSS perspective flip (400ms) or instant swap with reduced motion
|
|
||||||
- [x] One card flipped at a time
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 18: Build Command Palette
|
|
||||||
> Detail: `Ralph/refs/ref-07-interactions.md` (Command Palette section)
|
|
||||||
- [x] Create `src/components/CommandPalette.tsx`
|
|
||||||
- [x] Ctrl+K trigger + search bar click trigger
|
|
||||||
- [x] Overlay with backdrop blur, ESC to close
|
|
||||||
- [x] Fuzzy search via fuse.js (adapt `src/lib/search.ts`)
|
|
||||||
- [x] Grouped results by section + Quick Actions
|
|
||||||
- [x] Keyboard navigation (arrows, Enter, Escape)
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
### Phase 4: Polish
|
|
||||||
|
|
||||||
#### Task 19: Responsive design
|
|
||||||
> Detail: `Ralph/refs/ref-08-polish.md` (Responsive section)
|
|
||||||
- [x] Desktop (>1024px): full sidebar + 2-column grid
|
|
||||||
- [x] Tablet (768–1024px): collapsed/hidden sidebar + adapted grid
|
|
||||||
- [x] Mobile (<768px): no sidebar, single-column tiles, simplified topbar
|
|
||||||
- [x] Touch-friendly targets (48px+)
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 20: Accessibility audit
|
|
||||||
> Detail: `Ralph/refs/ref-08-polish.md` (Accessibility section)
|
|
||||||
- [x] Semantic HTML (header, nav, main, article, section)
|
|
||||||
- [x] Keyboard navigation (Tab, Enter/Space, Escape, Ctrl+K, arrows)
|
|
||||||
- [x] ARIA (expanded, controls, labels, live regions, dialog)
|
|
||||||
- [x] Focus management (trap in palette, visible rings, return focus)
|
|
||||||
- [x] `prefers-reduced-motion` on all animations
|
|
||||||
- [x] Color contrast verification
|
|
||||||
- [x] Run quality checks
|
|
||||||
|
|
||||||
#### Task 21: Clean up and final polish
|
|
||||||
> Detail: `Ralph/refs/ref-08-polish.md` (Cleanup section)
|
|
||||||
- [ ] Remove unused old components (PatientBanner, ClinicalSidebar, Breadcrumb, etc.)
|
|
||||||
- [ ] Remove unused hooks (useScrollCondensation if unused)
|
|
||||||
- [ ] Verify no dead imports
|
|
||||||
- [ ] Final visual review against concept HTML
|
|
||||||
- [ ] Run quality checks (clean build)
|
|
||||||
@@ -1,145 +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 **a GP System Dashboard** — a tile-based clinical record interface that presents Andy's CV as a GP surgery would display a patient record. The clinical metaphor lives in the structure (tiles as record sections, skills as "medications" with frequency, alerts, KPI metrics, career timeline) while the visual execution is modern and premium.
|
|
||||||
|
|
||||||
**The Concept:**
|
|
||||||
The "patient" is Andy's career. After a theatrical boot → ECG → login sequence, users see a dashboard with a light sidebar (person details, tags, alert flags) and a scrollable grid of tiles (Patient Summary, Latest Results, Repeat Medications/Skills, Last Consultation, Career Activity, Education, Projects). Tiles can be expanded for detail. A command palette (Ctrl+K) provides search. The reference design is `References/GPSystemconcept.html`.
|
|
||||||
|
|
||||||
## Your Task This Iteration
|
|
||||||
|
|
||||||
1. **Read the reference file for your task** (REQUIRED): Each task in `IMPLEMENTATION_PLAN.md` references a detail file in `Ralph/refs/`. You MUST read this file before writing code — it provides the full specification, CSS values, data sources, and component structure.
|
|
||||||
|
|
||||||
2. **Read the plan**: Open `IMPLEMENTATION_PLAN.md` and find the highest-priority unchecked item (`- [ ]`). Items are listed in dependency order — pick the first unchecked one. **The plan is for tracking only** — all implementation detail is in the referenced `Ralph/refs/` file.
|
|
||||||
|
|
||||||
3. **Read accumulated learnings**: Open `progress.txt` and read the "Codebase Patterns" section AND the most recent manual intervention entry. These contain critical context about the architecture, established patterns, and decisions from previous iterations.
|
|
||||||
|
|
||||||
4. **Read guardrails**: Open `guardrails.md` and read ALL guardrails. These are hard rules you MUST follow. Key guardrails include:
|
|
||||||
- Light-mode only
|
|
||||||
- Teal accent `#0D6E6E` (not NHS Blue) for interactive elements
|
|
||||||
- 8px border-radius for cards (not 4px)
|
|
||||||
- Three-tier shadow system (sm/md/lg)
|
|
||||||
- Height-only tile expansion (no opacity fade)
|
|
||||||
- Skills frequency: user-specified values (Data Analysis="Twice daily", etc.)
|
|
||||||
- Sidebar contains ONLY PersonHeader + Tags + Alerts
|
|
||||||
- Elvaro Grotesque font (not DM Sans, Inter, or Roboto)
|
|
||||||
- Geist Mono for data/timestamps (not Fira Code in dashboard)
|
|
||||||
|
|
||||||
5. **Implement the item**: Complete the single task you selected. Keep changes focused — one task per iteration. Write production-quality React/TypeScript code.
|
|
||||||
|
|
||||||
**IMPORTANT — Ref files are the source of truth.** If existing code contradicts the ref file, rebuild from the ref spec.
|
|
||||||
|
|
||||||
6. **Run quality checks**: Execute `npm run typecheck`, `npm run lint`, `npm run build`. Fix any issues before proceeding.
|
|
||||||
|
|
||||||
7. **Visual Review** (for visual tasks): After quality checks pass, verify your work in the browser using Playwright MCP:
|
|
||||||
a. Navigate to `http://localhost:5173` using `mcp__playwright__browser_navigate`.
|
|
||||||
b. **First load only**: The app plays boot→ECG→login (~15s). Use `mcp__playwright__browser_wait_for` with `time: 15`, then click the Log In button to reach the dashboard. On subsequent navigations, the app stays in dashboard phase.
|
|
||||||
c. Take a screenshot and compare against `References/GPSystemconcept.html` (open it in a separate tab if needed).
|
|
||||||
d. Check: colors match spec, correct font, proper spacing, borders, shadows, layout alignment, teal accent.
|
|
||||||
e. Fix discrepancies, re-run quality checks, re-screenshot.
|
|
||||||
f. Note the visual review outcome in progress.txt.
|
|
||||||
|
|
||||||
8. **Commit your changes**: Stage and commit all changes with a descriptive message referencing the task.
|
|
||||||
|
|
||||||
9. **Mark the item complete**: In `IMPLEMENTATION_PLAN.md`, change the item from `- [ ]` to `- [x]`.
|
|
||||||
|
|
||||||
10. **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 too)
|
|
||||||
- Any issues encountered
|
|
||||||
- Design decisions made
|
|
||||||
- Visual review outcome
|
|
||||||
|
|
||||||
11. **Commit the progress update**: Stage and commit the updated `IMPLEMENTATION_PLAN.md` and `progress.txt`.
|
|
||||||
|
|
||||||
12. **Recommend model for next iteration**: Look at the NEXT unchecked task. Output a model recommendation:
|
|
||||||
|
|
||||||
```
|
|
||||||
<next-model>sonnet</next-model>
|
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```
|
|
||||||
<next-model>opus</next-model>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Decision framework:**
|
|
||||||
- **Use `sonnet`** for: configuration tasks, data files, simple wiring, accessibility audits, tasks with very prescriptive specs
|
|
||||||
- **Use `opus`** for: visual component builds, complex animation work, tasks requiring aesthetic judgment, command palette, interaction design
|
|
||||||
- **Default to `sonnet`** if unsure
|
|
||||||
|
|
||||||
13. **Determine if another iteration is needed**: The project needs another iteration if ANY task is unchecked, quality checks fail, or there are uncommitted changes.
|
|
||||||
|
|
||||||
14. **Send completion signal ONLY if truly complete**: If ALL tasks are verified done, quality checks pass, and no further work is needed:
|
|
||||||
|
|
||||||
```
|
|
||||||
<promise>COMPLETE</promise>
|
|
||||||
```
|
|
||||||
|
|
||||||
DO NOT output this string if there's any chance another iteration is needed.
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
- **ALWAYS read the ref file for your task before writing code**
|
|
||||||
- **Only work on ONE task per iteration**
|
|
||||||
- **Always read progress.txt AND guardrails.md before starting**
|
|
||||||
- **Ref files are the spec — existing code is not**
|
|
||||||
- **The plan file is for tracking only** — do not add detail to it
|
|
||||||
- **Use TypeScript strictly** — no `any` types, proper interfaces
|
|
||||||
- **Follow project structure** — components in `src/components/`, tiles in `src/components/tiles/`, data in `src/data/`
|
|
||||||
- **Respect prefers-reduced-motion** — all animations must have instant fallbacks
|
|
||||||
- **Keep commits atomic and well-described**
|
|
||||||
- **If quality checks fail, fix before committing**
|
|
||||||
- **If a task is blocked**, document why in progress.txt and move to next
|
|
||||||
|
|
||||||
## Reference Files
|
|
||||||
|
|
||||||
Each task references a specific detail file in `Ralph/refs/`:
|
|
||||||
|
|
||||||
| Tasks | Reference File |
|
|
||||||
|-------|---------------|
|
|
||||||
| Task 1 | `Ralph/refs/ref-01-design-tokens.md` |
|
|
||||||
| Task 2 | `Ralph/refs/ref-02-data-types.md` |
|
|
||||||
| Tasks 4-6 | `Ralph/refs/ref-03-topbar-sidebar.md` |
|
|
||||||
| Task 7 | `Ralph/refs/ref-04-dashboard-layout.md` |
|
|
||||||
| Tasks 8-11 | `Ralph/refs/ref-05-card-and-top-tiles.md` |
|
|
||||||
| Tasks 12-15 | `Ralph/refs/ref-06-bottom-tiles.md` |
|
|
||||||
| Tasks 16-18 | `Ralph/refs/ref-07-interactions.md` |
|
|
||||||
| Tasks 19-21 | `Ralph/refs/ref-08-polish.md` |
|
|
||||||
|
|
||||||
Also reference:
|
|
||||||
- `References/GPSystemconcept.html` — Visual/structural target for the dashboard
|
|
||||||
- `References/CV_v4.md` — Source CV content (roles, achievements, numbers, dates)
|
|
||||||
- `CLAUDE.md` — Project architecture, design direction, styling conventions
|
|
||||||
|
|
||||||
Read ONLY the referenced file(s) for your current task. Do not read all ref files at once.
|
|
||||||
|
|
||||||
## Design Highlights
|
|
||||||
|
|
||||||
**Color Palette (Light-mode only):**
|
|
||||||
- Background: `#F0F5F4` (warm sage)
|
|
||||||
- Surface/cards: `#FFFFFF`
|
|
||||||
- Sidebar: `#F7FAFA` (very light)
|
|
||||||
- Accent: `#0D6E6E` (teal)
|
|
||||||
- Borders: `#D4E0DE` (structural), `#E4EDEB` (cards)
|
|
||||||
- Text: `#1A2B2A` (primary), `#5B7A78` (secondary), `#8DA8A5` (tertiary)
|
|
||||||
- Status: `#059669` (success), `#D97706` (amber), `#DC2626` (alert)
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- Elvaro Grotesque (`font-ui`) for UI text
|
|
||||||
- Geist Mono (`font-geist`) for data, timestamps, coded entries
|
|
||||||
- Fira Code for boot/ECG terminal only
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
- TopBar: fixed, 48px, white, bottom border
|
|
||||||
- Sidebar: 272px, light, person header + tags + alerts
|
|
||||||
- Main: scrollable card grid, 2 columns desktop, 1 column mobile
|
|
||||||
- Cards: 8px radius, shadow-sm, border-light
|
|
||||||
|
|
||||||
**Key Interactions:**
|
|
||||||
- Tile expansion: height-only animation, 200ms, accordion
|
|
||||||
- KPI flip: CSS perspective 400ms, click to flip/unflip
|
|
||||||
- Command palette: Ctrl+K, fuzzy search, keyboard navigation
|
|
||||||
- Entrance: staggered topbar → sidebar → content
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# Guardrails
|
|
||||||
|
|
||||||
Hard rules that MUST be followed in every iteration. Violating these will produce incorrect output.
|
|
||||||
|
|
||||||
## Design Direction
|
|
||||||
|
|
||||||
### When: Making ANY aesthetic decision
|
|
||||||
**Rule:** The direction is **GP System Dashboard** — a tile-based clinical record system with a light, modern aesthetic. Teal accent (#0D6E6E), light sidebar (#F7FAFA), warm sage background (#F0F5F4), white card surfaces. The clinical metaphor lives in the STRUCTURE (tiles as "record sections", status indicators, medication-style skill entries, coded entries) — not in dark chrome or heavy clinical styling.
|
|
||||||
**Why:** The previous dark-sidebar PMR interface is being replaced with the lighter, tile-based GP System concept (`References/GPSystemconcept.html`).
|
|
||||||
|
|
||||||
## Design System Guardrails
|
|
||||||
|
|
||||||
### When: Writing ANY visual component
|
|
||||||
**Rule:** Light-mode only. Do NOT add dark mode classes, `dark:` prefixes, or theme toggles.
|
|
||||||
**Why:** The design direction is light-mode only.
|
|
||||||
|
|
||||||
### When: Setting border-radius on cards and tiles
|
|
||||||
**Rule:** Use 8px border-radius (var(--radius)) for cards and tiles. Use 6px (var(--radius-sm)) for inner elements (metric cards, activity items, tags). The only exception is the LoginScreen card which uses 12px, and the command palette which uses 12px.
|
|
||||||
**Why:** The GP System concept uses 8px radius, slightly more rounded than the old 4px clinical style, reflecting the lighter aesthetic.
|
|
||||||
|
|
||||||
### When: Using monospace/code font
|
|
||||||
**Rule:** Use Geist Mono (`font-family: 'Geist Mono', monospace`) for timestamps, session info, dates, GPhC number, and coded data values. Fira Code is used in boot/ECG phases only.
|
|
||||||
**Why:** Geist Mono is the specified monospace font for the dashboard interface.
|
|
||||||
|
|
||||||
### When: Choosing the UI text font
|
|
||||||
**Rule:** Use Elvaro Grotesque (font-ui) as primary, Blumir (font-ui-alt) as alternative. Do NOT use Inter, Roboto, or system defaults. DM Sans appears in the concept HTML but is NOT the production font — use Elvaro Grotesque.
|
|
||||||
**Why:** Premium typography is the primary vehicle for the luxury feel. The concept HTML uses DM Sans as a placeholder; the production build uses the licensed premium fonts.
|
|
||||||
|
|
||||||
### When: Adding shadows to cards or tiles
|
|
||||||
**Rule:** Use the three-tier shadow system:
|
|
||||||
- `--shadow-sm`: `0 1px 2px rgba(26,43,42,0.05)` (default card state)
|
|
||||||
- `--shadow-md`: `0 2px 8px rgba(26,43,42,0.08)` (hover / interactive)
|
|
||||||
- `--shadow-lg`: `0 8px 32px rgba(26,43,42,0.12)` (command palette, overlays)
|
|
||||||
**Why:** Shadows create depth hierarchy. sm=resting, md=interactive, lg=overlay.
|
|
||||||
|
|
||||||
### When: Styling borders
|
|
||||||
**Rule:** Use `1px solid var(--border-light)` (#E4EDEB) for card and tile borders. Use `1px solid var(--border)` (#D4E0DE) for structural borders (sidebar right edge, topbar bottom, section dividers).
|
|
||||||
**Why:** Two-tier border system: lighter for cards, slightly stronger for structural elements.
|
|
||||||
|
|
||||||
### When: Choosing accent/interactive colors
|
|
||||||
**Rule:** Use teal `#0D6E6E` (var(--accent)) for interactive elements: links, active states, avatar gradient, dots, hover highlights. Hover: `#0A8080`. Accent-light: `rgba(10,128,128,0.08)` for subtle backgrounds.
|
|
||||||
**Why:** Teal is the primary accent in the GP System concept. It replaces NHS Blue as the interactive color.
|
|
||||||
|
|
||||||
### When: Using status colors
|
|
||||||
**Rule:** Status colors: success=`#059669`, amber=`#D97706`, alert=`#DC2626`, purple=`#7C3AED` (education). Each with matching light/border variants. Always pair colored indicators with text labels.
|
|
||||||
**Why:** Traffic light convention. Color-only indicators violate WCAG.
|
|
||||||
|
|
||||||
## Layout Guardrails
|
|
||||||
|
|
||||||
### When: Building the dashboard layout
|
|
||||||
**Rule:** Three-zone layout: TopBar (fixed, 48px) + Sidebar (fixed left, 272px) + Main content (scrollable card grid). Main content has 24px-28px padding and card grid with 16px gap. Grid: 2 columns on desktop, 1 column below 900px.
|
|
||||||
**Why:** Matches the GP System concept layout structure.
|
|
||||||
|
|
||||||
### When: Ordering tiles in the card grid
|
|
||||||
**Rule:** Tile order: Patient Summary (full) → Latest Results (half) + Repeat Medications (half) → Last Consultation (full) → Career Activity (full) → Education (full) → Projects (full). Full-width tiles span both columns.
|
|
||||||
**Why:** This ordering follows the concept layout with the user's addition of Patient Summary at the top.
|
|
||||||
|
|
||||||
## Sidebar Guardrails
|
|
||||||
|
|
||||||
### When: Building the sidebar
|
|
||||||
**Rule:** Sidebar contains ONLY: PersonHeader (avatar, name, title, status, details) → Tags → Alerts/Highlights. Active Projects, Core Skills, and Education are in the MAIN CONTENT as tiles, NOT in the sidebar.
|
|
||||||
**Why:** User explicitly requested moving Projects, Skills, and Education from sidebar to main dashboard tiles.
|
|
||||||
|
|
||||||
## Interaction Guardrails
|
|
||||||
|
|
||||||
### When: Expanding/collapsing tile content
|
|
||||||
**Rule:** Height animation ONLY (200ms, ease-out). Do NOT fade opacity on content. Single-expand accordion — only one item expanded at a time within a tile.
|
|
||||||
**Why:** Consistent expand/collapse behavior. Opacity fade was explicitly prohibited.
|
|
||||||
|
|
||||||
### When: Building the command palette
|
|
||||||
**Rule:** Trigger via Ctrl+K or search bar click. Overlay with backdrop blur. ESC to close. Arrow key navigation. Fuzzy search via fuse.js.
|
|
||||||
**Why:** Matches concept interaction pattern.
|
|
||||||
|
|
||||||
### When: Building KPI flip cards
|
|
||||||
**Rule:** Click to flip metric card (front=value, back=explanation). 400ms CSS perspective flip or instant swap with reduced motion. Only one card flipped at a time.
|
|
||||||
**Why:** User requested interactive KPI exploration with explanation text.
|
|
||||||
|
|
||||||
## Login Screen Guardrails
|
|
||||||
|
|
||||||
### When: Building the login typing animation
|
|
||||||
**Rule:** Username types at 80ms/char. Password dots at 60ms/dot. After typing completes, the "Log In" button becomes interactive — the user clicks it. It is NOT auto-triggered.
|
|
||||||
**Why:** The natural pace lets users absorb what's happening. The interactive button creates a moment of user agency.
|
|
||||||
|
|
||||||
## Component Guardrails
|
|
||||||
|
|
||||||
### When: Displaying traffic light status indicators
|
|
||||||
**Rule:** Colored dots must ALWAYS have text labels. Never use color as the sole indicator.
|
|
||||||
**Why:** WCAG — color cannot be the only means of communicating information.
|
|
||||||
|
|
||||||
### When: Writing table or list markup inside tiles
|
|
||||||
**Rule:** Use semantic markup. Tables use `<table>`, `<thead>`, `<th scope="col">`, `<tbody>`, `<tr>`, `<td>`. Lists use `<ul>`/`<ol>` with `<li>`. No div-based tables.
|
|
||||||
**Why:** Screen readers require native semantics.
|
|
||||||
|
|
||||||
### When: Using icons
|
|
||||||
**Rule:** Use `lucide-react` icons only. No unicode symbols, no inline SVG copied from external sources. Exception: the concept's SVG icons should be converted to their lucide-react equivalents (e.g., concept's house icon → `Home` from lucide-react).
|
|
||||||
**Why:** Consistent icon system, tree-shakeable, accessible.
|
|
||||||
|
|
||||||
## Data Guardrails
|
|
||||||
|
|
||||||
### When: Displaying CV content
|
|
||||||
**Rule:** All data must come from `src/data/*.ts` files. Do NOT hardcode content in components or change any numbers/dates. New data files (profile.ts, tags.ts, alerts.ts, kpis.ts, skills.ts) must be accurate to CV_v4.md.
|
|
||||||
**Why:** Data has been validated against CV_v4.md. Single source of truth.
|
|
||||||
|
|
||||||
### When: Building the "Repeat Medications" (skills) tile
|
|
||||||
**Rule:** Use the exact frequencies specified by the user: Data Analysis="Twice daily", Power BI="Once weekly", Python="Daily", SQL="Daily", JavaScript/TypeScript="When required". Include "years of experience" like "length of time on medication".
|
|
||||||
**Why:** User explicitly specified these frequency values for the medication metaphor.
|
|
||||||
|
|
||||||
## Visual Review Guardrails
|
|
||||||
|
|
||||||
### When: Completing any visual task
|
|
||||||
**Rule:** After quality checks, open `http://localhost:5173` via Playwright MCP tools, take a screenshot, and compare against `References/GPSystemconcept.html`. Fix visual discrepancies. If browser tools are unavailable, note in progress.txt and proceed.
|
|
||||||
**Why:** Code review alone cannot catch visual issues.
|
|
||||||
|
|
||||||
### When: Browser tools fail
|
|
||||||
**Rule:** Skip visual review, note it in progress.txt, continue. Do NOT retry more than twice.
|
|
||||||
**Why:** Visual review is valuable but not blocking.
|
|
||||||
|
|
||||||
## Technical Guardrails
|
|
||||||
|
|
||||||
### When: Writing TypeScript
|
|
||||||
**Rule:** No `any` types. All props must have typed interfaces.
|
|
||||||
**Why:** Strict typing prevents runtime errors.
|
|
||||||
|
|
||||||
### When: Adding animations
|
|
||||||
**Rule:** All animations must respect `prefers-reduced-motion`. With reduced motion: all animations skip to final state instantly.
|
|
||||||
**Why:** Accessibility requirement.
|
|
||||||
|
|
||||||
### When: Running quality checks
|
|
||||||
**Rule:** Run `npm run typecheck`, `npm run lint`, and `npm run build` after EVERY task. Fix all errors before committing.
|
|
||||||
**Why:** Build failures compound across iterations.
|
|
||||||
|
|
||||||
### When: Referencing the concept design
|
|
||||||
**Rule:** The reference design is `References/GPSystemconcept.html`. Open it in a browser or read the HTML to understand the visual target. The concept is the LAYOUT reference; production fonts and some colors differ (see font and color guardrails).
|
|
||||||
**Why:** The concept HTML is the single source of truth for layout and spatial composition.
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Progress Log — Career Constellation Refinement
|
|
||||||
# Branch: ralph/constellation-refinement
|
|
||||||
# Started: 2026-02-16
|
|
||||||
|
|
||||||
## Codebase Patterns
|
|
||||||
- CareerConstellation.tsx (~868 lines) is a D3 force-directed graph with React overlay buttons for accessibility
|
|
||||||
- D3 simulation uses forceSimulation with charge, link, x, y, and collide forces
|
|
||||||
- Module-level window.matchMedia reads for prefersReducedMotion and supportsCoarsePointer
|
|
||||||
- DashboardLayout manages constellation state: highlightedNodeId, pinnedNodeId via callbacks
|
|
||||||
- Work experience data in src/data/consultations.ts, constellation-specific data in src/data/constellation.ts
|
|
||||||
- CSS layout: .pathway-columns grid — first column is .chronology-stream (work experience), second is .pathway-graph-sticky (constellation graph)
|
|
||||||
- Current grid: minmax(0, 1.85fr) minmax(0, 1fr) at desktop — work experience ~65%, graph ~35%
|
|
||||||
- containerHeight prop drives graph height on desktop; on mobile (viewport < 1024px) uses MOBILE_FALLBACK_HEIGHT (360px)
|
|
||||||
- Use window.innerWidth for breakpoint checks, not container.clientWidth — the SVG container overflows on mobile
|
|
||||||
- Design tokens in index.css :root — use var(--accent), var(--border-light), var(--text-tertiary), etc.
|
|
||||||
- SVG shadows: use <filter> with <feDropShadow> in <defs>, apply to <g> groups via .attr('filter', 'url(#filter-id)')
|
|
||||||
- Role nodes are pill-shaped rects (ROLE_WIDTH=104, ROLE_HEIGHT=32, ROLE_RX=16) with orgColor badge styling
|
|
||||||
- Skill nodes use SKILL_RADIUS_DEFAULT (7) resting, SKILL_RADIUS_ACTIVE (11) highlighted — D3 transitions, not CSS
|
|
||||||
- Link lines are <path> elements with quadratic bezier curves — tick handler sets d attr
|
|
||||||
- Accessibility buttons are React <button> elements overlaid on SVG at opacity 0, container pointerEvents 'none', buttons 'auto'
|
|
||||||
- callbacksRef pattern prevents stale closures — use for all D3→React callbacks
|
|
||||||
- Bidirectional highlighting: highlightedNodeId (timeline→graph) and highlightedRoleId (graph→timeline)
|
|
||||||
- Force simulation: role forceY ~0.98, charge -120/-55, link distance 72, collision ~52-65px roles
|
|
||||||
- applyGraphHighlight is the single source of truth for all visual states (resting, highlighted, dimmed)
|
|
||||||
- Resting state values (US-003): skill fill-opacity 0.35, skill label opacity 0.5, link stroke-opacity 0.15, dimmed node opacity 0.15, active skill fill-opacity 0.9
|
|
||||||
- Initial D3 rendering values MUST match applyGraphHighlight resting values — initial stroke-opacity, fill-opacity, label opacity are set during node/link creation AND in the highlight function
|
|
||||||
- Viewport-proportional scaling: dimensions state includes { width, height, scaleFactor }. D3 effect uses `const sf = isMobile ? 1 : scaleFactor`. All desktop pixel values scaled via Math.round(value * sf)
|
|
||||||
- scaleFactor formula: Math.max(1, Math.min(1.6, viewportWidth / 1440)) — 1.0x at ≤1440px, 1.6x at ≥2560px. Only active at ≥1024px viewport
|
|
||||||
- Use the d3-viz skill for all D3 rendering stories
|
|
||||||
- Consultation entries ordered reverse-chronologically (newest first) — new entries go at the end of the array
|
|
||||||
- Constellation role nodes, skill mappings, and links are in constellation.ts — adding nodes there automatically extends yScale domain and screen reader description
|
|
||||||
- Mobile accordion (coarse pointer): pinnedNodeId drives both graph highlight AND accordion visibility. Accordion only shows for role-type nodes (not skills)
|
|
||||||
- SVG background rect has class `.bg-rect` — used for "tap elsewhere to close" handler on touch devices
|
|
||||||
- consultation.orgColor is the source of per-employer colour for cards, dots, borders, and coded entries. Use hexToRgba(orgColor, opacity) for tinted variants
|
|
||||||
- hexToRgba(hex, opacity) helper exists in both WorkExperienceSubsection.tsx and DashboardLayout.tsx for converting hex to rgba
|
|
||||||
|
|
||||||
## 2026-02-16 - US-001
|
|
||||||
- Added Duty Pharmacy Manager (2016-2017, Tesco PLC) and Pre-Registration Pharmacist (2015-2016, Paydens Pharmacy) role nodes to constellation.ts
|
|
||||||
- Added roleSkillMappings entries for both new roles (5 skills for Duty Pharm Mgr, 3 for Pre-Reg)
|
|
||||||
- Added constellationLinks with strength values for both new roles
|
|
||||||
- Added consultation entries for both new roles to consultations.ts with examination, plan, and codedEntries
|
|
||||||
- Fixed Pharmacy Manager orgColor from '#00897B' (teal) to '#E53935' (Tesco red) in both constellation.ts and consultations.ts
|
|
||||||
- Updated role count comment from "4 roles" to "6 roles"
|
|
||||||
- Files changed: src/data/constellation.ts, src/data/consultations.ts
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- buildScreenReaderDescription() iterates constellationNodes dynamically — no manual update needed when adding roles
|
|
||||||
- The #00897B teal colour in index.css (:root --teal) is a generic design token, NOT the Tesco-specific colour — don't change it
|
|
||||||
- Consultation.orgColor must match the constellation node orgColor for visual consistency between graph and cards
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-002
|
|
||||||
- Added UEA MPharm (2011-2015, University of East Anglia, orgColor #7B2D8E) education node to constellation.ts
|
|
||||||
- Added Highworth A-Levels (2009-2011, Highworth Grammar School, orgColor #9C27B0) education node to constellation.ts
|
|
||||||
- Added roleSkillMappings: UEA → medicines-optimisation + data-analysis; Highworth → data-analysis
|
|
||||||
- Added constellationLinks with strength values (0.5, 0.3 for UEA; 0.2 for Highworth)
|
|
||||||
- Added consultation entries for both education entries to consultations.ts (at end of array, maintaining reverse-chronological order)
|
|
||||||
- Education nodes use type 'role' — treated identically by the constellation layout engine
|
|
||||||
- Updated role count comment to "6 roles + Education nodes (2)"
|
|
||||||
- Files changed: src/data/constellation.ts, src/data/consultations.ts
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Education entries use type 'role' and follow exact same data shape — no special handling needed
|
|
||||||
- yScale domain auto-extends from min/max startYear of role-type nodes, so adding 2009 entries extends the timeline automatically
|
|
||||||
- Education entries have deliberately few skill connections (2 for UEA, 1 for Highworth) per design to keep lower timeline clean
|
|
||||||
- Consultation entries go at end of array (reverse-chronological: newest first → oldest last)
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-003
|
|
||||||
- Increased default skill node fill-opacity from 0.2 to 0.35 (initial render + applyGraphHighlight resting state)
|
|
||||||
- Increased default skill label opacity from 0 to 0.5 (labels now partially visible at rest)
|
|
||||||
- Increased default link stroke-opacity from 0.08 to 0.15
|
|
||||||
- Increased active/highlighted skill fill-opacity from 0.85 to 0.9
|
|
||||||
- Changed unconnected node dimming from 0.06 to 0.15 opacity
|
|
||||||
- Updated non-active link stroke-opacity in highlighted branch from 0.08 to 0.15
|
|
||||||
- Changed .pathway-columns desktop grid from 'minmax(0, 1.15fr) minmax(0, 1.5fr)' to 'minmax(0, 1.85fr) minmax(0, 1fr)' — work experience column now ~65%, constellation ~35%
|
|
||||||
- Files changed: src/components/CareerConstellation.tsx, src/index.css
|
|
||||||
- Browser verified: skills recognisable at a glance without hovering; work experience column visibly wider; constellation adapts to narrower container without clipping
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Initial D3 rendering attributes (set during node/link creation) must stay in sync with applyGraphHighlight resting values — there are TWO places to update for each visual property
|
|
||||||
- The highlighted branch also has a fallback opacity for non-active links/labels — remember to update those too (3 places total: initial render, resting branch, highlighted branch fallback)
|
|
||||||
- The constellation ResizeObserver + containerHeight system handles narrower columns automatically — no explicit graph resize code needed
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-004
|
|
||||||
- Added viewport-proportional scaling: scaleFactor = Math.max(1, Math.min(1.6, viewportWidth / 1440))
|
|
||||||
- scaleFactor stored in dimensions state alongside width/height, computed in resize useEffect
|
|
||||||
- Created local `sf` variable in D3 effect (isMobile ? 1 : scaleFactor) to bypass scaling on mobile
|
|
||||||
- Scaled node sizes: ROLE_WIDTH (104→~166), ROLE_HEIGHT (32→~51), ROLE_RX (16→~26), SKILL_RADIUS_DEFAULT (7→~11), SKILL_RADIUS_ACTIVE (11→~18)
|
|
||||||
- Scaled font sizes: year labels (10→11 base, scales to ~18), role labels (11→12 base, scales to ~19), skill labels (10→11 base, scales to ~18)
|
|
||||||
- Scaled spacing: topPadding, bottomPadding, sidePadding, timelineX, roleGap, skillGap, centroid offsets, seeding radius, rightMargin, skillBottomPadding, label dy offset
|
|
||||||
- Scaled force simulation: charge (-120→~-192 role, -55→~-88 skill), link distance (72→~115), collision radius offset (10→~16 role, 16→~26 skill)
|
|
||||||
- Scaled accessibility button sizes to match scaled SVG nodes
|
|
||||||
- Mobile (< 640px) completely bypasses scaling (sf=1), uses MOBILE_ constants unchanged
|
|
||||||
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
|
|
||||||
- Browser verified at 1440px (sf=1.0, identical to pre-change) and 2560px (sf=1.6, all elements clearly larger and well-proportioned)
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- Store scaleFactor in the dimensions state object, not a separate ref — keeps it synced with width/height changes
|
|
||||||
- Use `const sf = isMobile ? 1 : scaleFactor` at top of D3 effect to avoid repeating the mobile guard everywhere
|
|
||||||
- Every hardcoded pixel value in the D3 effect that relates to element sizing, spacing, or force params needs sf multiplication on desktop path
|
|
||||||
- Math.round() wraps all scaled values to avoid sub-pixel rendering artifacts
|
|
||||||
- Accessibility overlay buttons in the React JSX also need scaling — they use base constants directly, not the D3-scoped variables
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-005
|
|
||||||
- Changed mouseenter handler: on desktop (supportsCoarsePointer === false), calls applyGraphHighlight(d.id) + onNodeHover(d.id) for hover-to-highlight
|
|
||||||
- Changed mouseleave handler: resets to highlightedNodeId ?? null (external timeline state or resting), NOT pinnedNodeId
|
|
||||||
- Changed click handler: desktop clicks only fire detail callbacks (onRoleClick/onSkillClick), no pin toggle
|
|
||||||
- Touch (coarse pointer) retains tap-to-pin toggle unchanged inside click handler
|
|
||||||
- pinnedNodeId state only set/cleared for touch interactions
|
|
||||||
- Files changed: src/components/CareerConstellation.tsx
|
|
||||||
- Browser verified: hover on "Interim Head" → 12 connected skills at fill-opacity 0.9, 9 dimmed at opacity 0.15; hover off → all reset to resting (fill-opacity 0.35, label opacity 0.5); desktop click → no pin state
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- D3 mouseenter/mouseleave events require dispatchEvent() in Playwright headless — native page.hover() on SVG <g> elements doesn't reliably trigger D3 handlers
|
|
||||||
- Role rect fill-opacity 0.12 IS the resting state (initialized at line 384), not a dimmed state — don't confuse with skill resting at 0.35
|
|
||||||
- mouseleave should reset to highlightedNodeId (external prop) not pinnedNodeId — on desktop there is no pin, so fallback is null (resting)
|
|
||||||
- The supportsCoarsePointer guard at top of each handler cleanly separates desktop/touch paths without duplicating the handler
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-006
|
|
||||||
- Added mobile accordion expansion below constellation SVG for role details on tap
|
|
||||||
- Accordion shows role title, organisation, duration, and top 3 examination items by default
|
|
||||||
- "Show more" button reveals full examination and plan arrays (only appears when >3 examination items)
|
|
||||||
- Tapping a different role switches accordion content and auto-collapses "show more" (via useEffect on pinnedNodeId)
|
|
||||||
- Tapping the same role again or tapping empty SVG background collapses accordion and resets highlights
|
|
||||||
- Added click handler on SVG background rect (`.bg-rect`) to clear pinnedNodeId on coarse pointer
|
|
||||||
- Accordion uses Framer Motion AnimatePresence with height 0→auto, 200ms ease-out (matches tile expansion pattern)
|
|
||||||
- Accordion hidden entirely on desktop (fine pointer) via supportsCoarsePointer guard
|
|
||||||
- Skill node taps do not open accordion — only role nodes (filtered by `n.type === 'role'`)
|
|
||||||
- Legend hint text changes to "Tap to explore connections" on coarse pointer devices
|
|
||||||
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- The SVG background rect must have a class (`.bg-rect`) for later selection — D3 event handlers on SVG elements created early in the useEffect can reference functions defined later by selecting the element after the function is defined
|
|
||||||
- pinnedNodeId is local to CareerConstellation — it's not passed to DashboardLayout. The accordion relies on this internal state
|
|
||||||
- Framer Motion `key` prop on motion.div enables smooth exit→enter transitions when switching between different roles (AnimatePresence exits the old key, enters the new)
|
|
||||||
- `accordionShowMore` state must reset on pinnedNodeId change to auto-collapse "show more" when switching roles
|
|
||||||
- Not all consultations have >3 examination items — the "Show more" button only renders conditionally, and plan items are only shown when expanded
|
|
||||||
- Browser testing for coarse pointer features requires touch emulation — Playwright's default Chromium reports fine pointer, so the accordion won't appear without explicit touch device emulation
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-007
|
|
||||||
- Created hexToRgba(hex, opacity) helper function in both WorkExperienceSubsection.tsx and DashboardLayout.tsx
|
|
||||||
- WorkExperienceSubsection.tsx: replaced all hardcoded teal/accent colour references with consultation.orgColor:
|
|
||||||
- Dot indicator: '#0D6E6E' → consultation.orgColor
|
|
||||||
- Highlight background: 'rgba(10,128,128,0.03)' → hexToRgba(orgColor, 0.03)
|
|
||||||
- Expanded/highlighted border: 'var(--accent-border)' → hexToRgba(orgColor, 0.2)
|
|
||||||
- Hover border: 'var(--accent-border)' → hexToRgba(orgColor, 0.2)
|
|
||||||
- Left border on expanded detail: 'var(--accent)' → orgColor
|
|
||||||
- Bullet dots: 'var(--accent)' → orgColor at 0.5 opacity
|
|
||||||
- Coded entry tags: bg hexToRgba(orgColor, 0.08), text orgColor, border hexToRgba(orgColor, 0.2)
|
|
||||||
- "View full record" link: 'var(--accent)' → orgColor, hover uses opacity 0.7 instead of accent-hover
|
|
||||||
- DashboardLayout.tsx LastConsultationSubsection: same pattern applied:
|
|
||||||
- Highlight border/bg, hover bg, role title, bullet dots, "View full record" link all use consultation.orgColor
|
|
||||||
- CardHeader dot for "WORK EXPERIENCE" section title remains teal (unchanged)
|
|
||||||
- Files changed: src/components/WorkExperienceSubsection.tsx, src/components/DashboardLayout.tsx, Ralph/prd.json, Ralph/progress.txt
|
|
||||||
- Browser verified: NHS roles show blue dots/borders, Tesco roles show red, Paydens shows green, education shows purple. Expanded Tesco card shows red left border, red bullet dots, and red-tinted coded entries
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- consultation.orgColor exists on every Consultation object — it's the single source for per-employer colour throughout the UI
|
|
||||||
- hexToRgba(hex, opacity) is needed in both WorkExperienceSubsection.tsx and DashboardLayout.tsx — not extracted to a shared utility since it's a small helper and only used in two files
|
|
||||||
- For hover effects on org-coloured links, use opacity change (0.7) instead of a separate --accent-hover variable, since each employer has a different base colour
|
|
||||||
- The hover mouseenter/mouseleave pattern using parentElement!.style is used for border/shadow effects — it directly mutates the parent wrapper's inline styles
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-02-16 - US-008
|
|
||||||
- Re-tuned force simulation parameters for 8 entries (6 roles + 2 education) spanning 2009-2025 in ~35% column
|
|
||||||
- Increased MOBILE_FALLBACK_HEIGHT from 380 to 520 — 8 entries over 17 years need more vertical space on mobile
|
|
||||||
- Reduced desktop sidePadding from 56*sf to 36*sf — frees horizontal space for skill nodes in narrow column
|
|
||||||
- Reduced desktop roleGap from 80*sf to 56*sf — roles sit closer to timeline, more room for skills
|
|
||||||
- Reduced desktop skillGap from 40*sf to 28*sf — skills start sooner after role pills
|
|
||||||
- Reduced skill centroid offset from 60*sf to 40*sf — skills pulled closer to avoid right-edge overflow
|
|
||||||
- Reduced skill seed radius from 50*sf to 35*sf — tighter initial positioning
|
|
||||||
- Increased mobile charge: roles -80→-100, skills -35→-45 — stronger repulsion for better separation
|
|
||||||
- Increased mobile link distance from 48 to 56 — more space between connected nodes
|
|
||||||
- Increased mobile collision padding: roles 6→8, skills 10→14 — better overlap prevention
|
|
||||||
- Increased collision iterations from 2 to 3 — more passes for cleaner overlap resolution
|
|
||||||
- Increased skill forceX strength from 0.18 to 0.25 — pulls skills more towards center of available space
|
|
||||||
- Increased desktop rightMargin from 40*sf to 32*sf — moderate boundary for skill labels
|
|
||||||
- Added width-aware skill label truncation: maxLen 12 when SVG width < 500px (vs 16 at wider)
|
|
||||||
- Increased mobile topPadding 32→36, bottomPadding 32→40 — breathing room at edges
|
|
||||||
- Files changed: src/components/CareerConstellation.tsx, Ralph/prd.json, Ralph/progress.txt
|
|
||||||
- Browser verified at 375px: all 8 entries visible, correct chronological order, acceptable overlap for mobile
|
|
||||||
- Browser verified at 430px: better horizontal distribution, roles well-positioned
|
|
||||||
- Browser verified at 1440px: roles cleanly positioned along timeline, skill labels slightly clipped at right edge (container overflow:hidden), circles fully visible
|
|
||||||
- Browser verified at 2560px: excellent distribution, all labels visible, education nodes cleanly isolated at bottom
|
|
||||||
- **Learnings for future iterations:**
|
|
||||||
- MOBILE_FALLBACK_HEIGHT must scale with the number of timeline entries — 380px was adequate for 4 entries but not for 8
|
|
||||||
- At 1440px, the ~340px column is fundamentally narrow for 21 skill nodes + labels. Some label clipping via overflow:hidden is an acceptable trade-off — circles are visible and labels show fully on hover
|
|
||||||
- Mobile role positioning drifts 1-2 years from exact position due to collision forces pushing close entries apart (2015-2017 has 3 entries). Chronological order is maintained, which is the priority
|
|
||||||
- collision.iterations(3) significantly improves overlap prevention over iterations(2) with 29 total nodes
|
|
||||||
- Skill forceX strength 0.25 (up from 0.18) keeps skills more centred in available space without over-constraining them
|
|
||||||
- The width < 500 check for skill label truncation targets the narrow desktop column specifically — mobile already uses its own 12-char max
|
|
||||||
---
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Significant Interventions Carousel (Ralph Prompt)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Replace the current one-column **Active Projects** list with a **Significant Interventions** carousel that supports thumbnail cards and auto-scroll behavior (Embla-based), while preserving panel-open behavior on card click.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Rename all relevant UI/content references from **Active Projects** to **Significant Interventions**.
|
||||||
|
- Replace `ProjectsTile` list layout with an Embla carousel.
|
||||||
|
- Use auto-scroll as the default carousel behavior.
|
||||||
|
- Keep room for thumbnails now; real thumbnail assets will be added later.
|
||||||
|
|
||||||
|
## Implementation Task List
|
||||||
|
|
||||||
|
- [ ] Install carousel dependencies:
|
||||||
|
- `embla-carousel-react`
|
||||||
|
- `embla-carousel-autoplay`
|
||||||
|
- [ ] Update tile heading in `src/components/tiles/ProjectsTile.tsx`:
|
||||||
|
- `ACTIVE PROJECTS` -> `SIGNIFICANT INTERVENTIONS`
|
||||||
|
- [ ] Refactor `ProjectsTile` in `src/components/tiles/ProjectsTile.tsx`:
|
||||||
|
- Replace vertical list container with Embla viewport/container/slides
|
||||||
|
- Convert each project item to a carousel slide card
|
||||||
|
- Add thumbnail region in each slide (use placeholder block/image container for now)
|
||||||
|
- Keep keyboard activation (`Enter`/`Space`) and click-to-open detail panel
|
||||||
|
- [ ] Implement auto-scroll behavior:
|
||||||
|
- Use Embla autoplay plugin with sensible defaults (continuous feel, pauses on hover/focus)
|
||||||
|
- Respect reduced motion (`prefers-reduced-motion`) by disabling autoplay
|
||||||
|
- [ ] Responsive behavior:
|
||||||
|
- Mobile: single-card view
|
||||||
|
- Tablet/Desktop: multi-card visible area (based on available width)
|
||||||
|
- Ensure overflow clipping and smooth transitions
|
||||||
|
- [ ] Update navigation/search labels to match naming:
|
||||||
|
- `src/components/SubNav.tsx`: `Projects` -> `Significant Interventions`
|
||||||
|
- `src/lib/search.ts`: `Active Projects` -> `Significant Interventions` (section type and related labels/comments)
|
||||||
|
- [ ] Keep detail panel integration unchanged:
|
||||||
|
- Clicking a carousel card still calls `openPanel({ type: 'project', investigation: project })`
|
||||||
|
- [ ] Styling pass:
|
||||||
|
- Align with current dashboard tokens (`--surface`, `--border-light`, `--accent`, etc.)
|
||||||
|
- Ensure cards remain readable without thumbnails
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- The dashboard section title displays **Significant Interventions**.
|
||||||
|
- The old one-column projects list is replaced by a working carousel.
|
||||||
|
- Carousel auto-scrolls by default and pauses appropriately on interaction.
|
||||||
|
- In reduced-motion environments, carousel does not auto-scroll.
|
||||||
|
- Clicking or keyboard-activating a card opens the existing project detail panel.
|
||||||
|
- Layout works on mobile and desktop without overflow bugs.
|
||||||
|
- Search/navigation language no longer references **Active Projects**.
|
||||||
|
|
||||||
|
## Notes for Implementation
|
||||||
|
|
||||||
|
- Thumbnail assets are intentionally deferred; implement with placeholders now.
|
||||||
|
- Keep the component name `ProjectsTile` for this pass to minimize refactor risk; rename component/file in a later cleanup task if desired.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React from 'react'
|
||||||
import { FileText, ChevronRight } from 'lucide-react'
|
import { FileText, ChevronRight } from 'lucide-react'
|
||||||
import { CardHeader } from '../Card'
|
import { CardHeader } from '../Card'
|
||||||
import { ParentSection } from '../ParentSection'
|
import { ParentSection } from '../ParentSection'
|
||||||
@@ -12,26 +12,20 @@ const colorMap: Record<KPI['colorVariant'], string> = {
|
|||||||
teal: '#0D6E6E',
|
teal: '#0D6E6E',
|
||||||
}
|
}
|
||||||
|
|
||||||
const KPI_COACHMARK_KEY = 'kpi-evidence-coachmark-dismissed-v1'
|
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
kpi: KPI
|
kpi: KPI
|
||||||
showCoachmark?: boolean
|
|
||||||
onOpen: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ kpi, showCoachmark = false, onOpen }: MetricCardProps) {
|
function MetricCard({ kpi }: MetricCardProps) {
|
||||||
const { openPanel } = useDetailPanel()
|
const { openPanel } = useDetailPanel()
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
onOpen()
|
|
||||||
openPanel({ type: 'kpi', kpi })
|
openPanel({ type: 'kpi', kpi })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onOpen()
|
|
||||||
openPanel({ type: 'kpi', kpi })
|
openPanel({ type: 'kpi', kpi })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +33,7 @@ function MetricCard({ kpi, showCoachmark = false, onOpen }: MetricCardProps) {
|
|||||||
const buttonStyles: React.CSSProperties = {
|
const buttonStyles: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
padding: '20px',
|
padding: '16px 16px 14px',
|
||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
border: '1px solid var(--border-light)',
|
border: '1px solid var(--border-light)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
@@ -48,11 +42,11 @@ function MetricCard({ kpi, showCoachmark = false, onOpen }: MetricCardProps) {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '2px',
|
gap: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueStyles: React.CSSProperties = {
|
const valueStyles: React.CSSProperties = {
|
||||||
fontSize: '34px',
|
fontSize: '30px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
@@ -60,85 +54,62 @@ function MetricCard({ kpi, showCoachmark = false, onOpen }: MetricCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labelStyles: React.CSSProperties = {
|
const labelStyles: React.CSSProperties = {
|
||||||
fontSize: '14px',
|
fontSize: '13px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
marginTop: '4px',
|
marginTop: '4px',
|
||||||
}
|
}
|
||||||
|
|
||||||
const subStyles: React.CSSProperties = {
|
const subStyles: React.CSSProperties = {
|
||||||
fontSize: '12px',
|
fontSize: '11px',
|
||||||
color: 'var(--text-tertiary)',
|
color: 'var(--text-tertiary)',
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
marginTop: '2px',
|
marginTop: '2px',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={showCoachmark ? 'kpi-card-coachmark-target' : undefined} style={{ position: 'relative' }}>
|
<button
|
||||||
{showCoachmark && (
|
onClick={handleClick}
|
||||||
<div className="kpi-coachmark" role="status" aria-live="polite">
|
onKeyDown={handleKeyDown}
|
||||||
Open any metric to see evidence
|
style={buttonStyles}
|
||||||
</div>
|
className="metric-card"
|
||||||
)}
|
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
|
||||||
<button
|
>
|
||||||
onClick={handleClick}
|
<div
|
||||||
onKeyDown={handleKeyDown}
|
style={{
|
||||||
style={buttonStyles}
|
position: 'absolute',
|
||||||
className={`metric-card ${showCoachmark ? 'metric-card-pulse' : ''}`}
|
top: '12px',
|
||||||
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
|
right: '12px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<div
|
<FileText size={13} />
|
||||||
style={{
|
</div>
|
||||||
position: 'absolute',
|
<div style={valueStyles}>{kpi.value}</div>
|
||||||
top: '14px',
|
<div style={labelStyles}>{kpi.label}</div>
|
||||||
right: '14px',
|
<div style={subStyles}>{kpi.sub}</div>
|
||||||
color: 'var(--accent)',
|
<div
|
||||||
opacity: 0.85,
|
style={{
|
||||||
}}
|
marginTop: '8px',
|
||||||
aria-hidden="true"
|
display: 'inline-flex',
|
||||||
>
|
alignItems: 'center',
|
||||||
<FileText size={14} />
|
gap: '5px',
|
||||||
</div>
|
fontSize: '11px',
|
||||||
<div style={valueStyles}>{kpi.value}</div>
|
fontWeight: 600,
|
||||||
<div style={labelStyles}>{kpi.label}</div>
|
color: 'var(--accent)',
|
||||||
<div style={subStyles}>{kpi.sub}</div>
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
<div
|
}}
|
||||||
style={{
|
>
|
||||||
marginTop: '10px',
|
Click to view evidence
|
||||||
display: 'inline-flex',
|
<ChevronRight size={12} />
|
||||||
alignItems: 'center',
|
</div>
|
||||||
gap: '6px',
|
</button>
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--accent)',
|
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Click to view evidence
|
|
||||||
<ChevronRight size={13} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientSummaryTile() {
|
export function PatientSummaryTile() {
|
||||||
const [showCoachmark, setShowCoachmark] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
const hasDismissed = window.localStorage.getItem(KPI_COACHMARK_KEY) === '1'
|
|
||||||
if (!hasDismissed) {
|
|
||||||
setShowCoachmark(true)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleMetricOpen = () => {
|
|
||||||
if (!showCoachmark) return
|
|
||||||
setShowCoachmark(false)
|
|
||||||
window.localStorage.setItem(KPI_COACHMARK_KEY, '1')
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileTextStyles: React.CSSProperties = {
|
const profileTextStyles: React.CSSProperties = {
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
lineHeight: '1.65',
|
lineHeight: '1.65',
|
||||||
@@ -147,7 +118,8 @@ export function PatientSummaryTile() {
|
|||||||
|
|
||||||
const kpiGridStyles: React.CSSProperties = {
|
const kpiGridStyles: React.CSSProperties = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '16px',
|
gap: '10px',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -165,20 +137,22 @@ export function PatientSummaryTile() {
|
|||||||
|
|
||||||
{/* Latest Results subsection */}
|
{/* Latest Results subsection */}
|
||||||
<div style={{ marginTop: '28px' }}>
|
<div style={{ marginTop: '28px' }}>
|
||||||
<CardHeader dotColor="teal" title="LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)" rightText="Updated May 2025" />
|
<div className="latest-results-header">
|
||||||
<p
|
<CardHeader dotColor="teal" title="LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)" rightText="Updated May 2025" />
|
||||||
style={{
|
<p
|
||||||
margin: '0 0 12px 0',
|
style={{
|
||||||
fontSize: '12px',
|
margin: 0,
|
||||||
color: 'var(--text-secondary)',
|
fontSize: '12px',
|
||||||
fontFamily: 'var(--font-geist-mono)',
|
color: 'var(--text-secondary)',
|
||||||
}}
|
fontFamily: 'var(--font-geist-mono)',
|
||||||
>
|
}}
|
||||||
Select a metric to inspect methodology, impact, and outcomes.
|
>
|
||||||
</p>
|
Select a metric to inspect methodology, impact, and outcomes.
|
||||||
<div className="grid-cols-1 xs:grid-cols-2" style={kpiGridStyles}>
|
</p>
|
||||||
{kpis.map((kpi, index) => (
|
</div>
|
||||||
<MetricCard key={kpi.id} kpi={kpi} onOpen={handleMetricOpen} showCoachmark={showCoachmark && index === 0} />
|
<div className="latest-results-grid" style={kpiGridStyles}>
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<MetricCard key={kpi.id} kpi={kpi} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+22
-39
@@ -204,26 +204,6 @@ body {
|
|||||||
animation: fadeIn 200ms ease-out forwards;
|
animation: fadeIn 200ms ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes kpiPulse {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(10, 128, 128, 0.12);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 0 8px rgba(10, 128, 128, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes coachmarkIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -314,29 +294,32 @@ html {
|
|||||||
transform: translateY(0) scale(0.992);
|
transform: translateY(0) scale(0.992);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card-pulse {
|
.latest-results-header {
|
||||||
animation: kpiPulse 1.8s ease-out infinite;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card-coachmark-target {
|
.latest-results-header > div {
|
||||||
margin-top: 24px;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-coachmark {
|
.latest-results-grid {
|
||||||
position: absolute;
|
margin-top: 12px;
|
||||||
top: -28px;
|
}
|
||||||
left: 0;
|
|
||||||
z-index: 2;
|
@media (min-width: 768px) {
|
||||||
padding: 4px 8px;
|
.latest-results-header {
|
||||||
border-radius: 999px;
|
flex-direction: row;
|
||||||
font-size: 11px;
|
align-items: center;
|
||||||
font-weight: 600;
|
justify-content: space-between;
|
||||||
letter-spacing: 0.02em;
|
gap: 12px;
|
||||||
font-family: var(--font-geist-mono);
|
}
|
||||||
color: var(--accent);
|
|
||||||
background: rgba(10, 128, 128, 0.1);
|
.latest-results-grid {
|
||||||
border: 1px solid var(--accent-border);
|
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||||
animation: coachmarkIn 180ms ease-out;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dashboard card grid responsive — mobile-first */
|
/* Dashboard card grid responsive — mobile-first */
|
||||||
|
|||||||
Reference in New Issue
Block a user