chore: auto-commit before merge (loop primary)

This commit is contained in:
2026-02-18 00:42:07 +00:00
parent 62c0d2ea19
commit 134e41f4f9
19 changed files with 925 additions and 349 deletions
+424 -237
View File
@@ -1,300 +1,487 @@
# Mobile Responsiveness Fix Plan (320-430px)
# UX Improvements Plan — GP Clinical System Theme Polish
## Overview
At viewport widths 320-430px, the dashboard is broken: sidebar rail steals 64px, padding steals 40px, leaving only 216-326px for content. This plan fixes all issues in priority order, grouped by file.
## Status Key
- [ ] Not started
- [x] Complete
---
## Phase 1: Sidebar → Bottom Nav Bar (Critical)
## Improvement 1: Restructure Profile Summary Text
**Status:** [x] Complete
**File:** `src/components/tiles/PatientSummaryTile.tsx`, `src/data/profile-content.ts`
### 1A. Add `xxs` breakpoint to Tailwind (`tailwind.config.js`)
**Current state:** `PatientSummaryTile` line 129 renders `summaryText` (from `getProfileSummaryText()`) as a single `<div>` — an 80+ word paragraph wall.
**What:** Add a new breakpoint `xxs: '360px'` below the existing `xs: 480px`.
**Plan:**
1. In `PatientSummaryTile.tsx`, replace the single `<div style={profileTextStyles}>{summaryText}</div>` with a structured clinical layout:
- **Presenting Complaint** (12 sentence summary): "Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving 1.2 million."
- **Structured fields** below, rendered as a 2-column grid of label/value pairs:
| Label | Value |
|-------|-------|
| Specialisation | Population Health Analytics & Medicines Optimisation |
| Current System | NHS Norfolk & Waveney ICB |
| Population | 1.2 million |
| Focus Areas | Prescribing analytics, financial modelling, algorithm design, data pipelines |
| Key Achievement | £14.6M+ efficiency programmes identified |
**Why:** Enables Tailwind utility classes for sub-480px styling. Also useful for font/spacing adjustments.
2. **Styling approach:**
- Brief summary: same `profileTextStyles` (15px, line-height 1.65, `--text-primary`)
- Structured fields grid: 2-column CSS grid (`grid-template-columns: auto 1fr`), gap 6px 16px
- Labels: `12px uppercase, letter-spacing 0.06em, color: var(--text-tertiary), font-family: var(--font-geist-mono)` — matching existing `fieldLabelStyle` from LastConsultationCard
- Values: `13px, font-weight 600, color: var(--text-primary)` — matching existing `fieldValueStyle` from LastConsultationCard
- A thin `border-top: 1px solid var(--border-light)` with `padding-top: 14px, margin-top: 14px` separating the summary from the fields
```js
screens: {
'xxs': '360px', // NEW
'xs': '480px',
...
}
```
3. **Data source:** Extract structured fields into `profile-content.ts` as a new `structuredProfile` object within `profileContent.profile`. Keep `patientSummaryNarrative` for backward compatibility but add:
```ts
structuredProfile: {
presentingComplaint: '...',
fields: [
{ label: 'Specialisation', value: '...' },
{ label: 'Current System', value: '...' },
// etc.
]
}
```
### 1B. Create `MobileBottomNav` component (`src/components/MobileBottomNav.tsx`)
4. **Mobile:** Grid goes single-column (`1fr`) at `< 480px`. Use CSS class `profile-fields-grid` with media query.
**What:** New component that renders a bottom navigation bar at viewports <600px.
**Collapsed state (default):**
- Fixed to bottom edge, 56px tall, full width
- Background: `var(--sidebar-bg)` with top border `var(--border)`
- Contains: 3 nav icons (Overview, Experience, Skills) + hamburger/menu icon for drawer
- Icons from existing `navSections` in Sidebar.tsx (reuse `UserRound`, `Workflow`, `Wrench`)
- Active state: teal accent color, same as sidebar
- Touch targets: each icon button is 44x44px minimum
**Expanded state (drawer):**
- Triggered by tapping hamburger icon or swiping up
- Slides up from bottom using Framer Motion `AnimatePresence` + `motion.div`
- Max height: 70vh, scrollable
- Contains: full sidebar content (patient name, details, search, tags, alerts)
- Extract shared content rendering from `Sidebar.tsx` into reusable pieces
- Backdrop overlay: same `rgba(26,43,42,0.28)` as current sidebar
- Close: tap backdrop, tap close button, or swipe down
**Implementation:**
- Use `window.matchMedia('(max-width: 599px)')` to detect mobile
- Accept same props as Sidebar: `activeSection`, `onNavigate`, `onSearchClick`
- Do NOT import from Sidebar — reuse the same data sources (`navSections`, `patient`, `tags`, `alerts`)
### 1C. Modify `Sidebar.tsx`
**What:** Hide the sidebar completely at <600px.
**How:** Add a `useMediaQuery` check or pass an `isMobileNav` prop. When viewport is <600px, return `null` (render nothing). The sidebar rail and overlay are replaced by `MobileBottomNav`.
**Important:** All existing sidebar behavior at >=600px must remain unchanged.
### 1D. Modify `DashboardLayout.tsx`
**What:** Integrate MobileBottomNav and adjust main content area.
**Changes:**
1. Import and render `<MobileBottomNav>` alongside sidebar
2. Add CSS class or style for bottom padding on main content when bottom nav is visible: `paddingBottom: 'calc(56px + env(safe-area-inset-bottom))'`
3. The `dashboard-main` margin-left should be 0 at <600px (since sidebar is hidden)
### 1E. Modify `src/index.css`
**What:** Override `dashboard-main` margin-left at <600px.
```css
@media (max-width: 599px) {
.dashboard-main {
margin-left: 0;
}
}
```
**Verify:** Profile reads as structured clinical data, not a LinkedIn About. Labels match the field label aesthetic used in LastConsultationCard.
---
## Phase 2: Spacing & Padding Reduction (Critical)
## Improvement 2: Surface Impact Metrics on Project Cards
**Status:** [x] Complete
**File:** `src/components/tiles/ProjectsTile.tsx`
### 2A. Reduce main content padding at small viewports (`DashboardLayout.tsx`)
**Current state:** `ProjectItem` renders thumbnail, name, year, tech stack, skills, status pill — but never touches `project.resultSummary`. The `Investigation` type has `resultSummary: string` with data like "14,000 patients identified", "£2.6M savings".
**What:** Change padding from `p-5` (20px) to a smaller value at <480px.
**Plan:**
1. In `ProjectItem` component (around line 170, after the name/year row), add a `resultSummary` display:
```tsx
{project.resultSummary && (
<div style={{
fontSize: '12px',
fontWeight: 700,
fontFamily: 'var(--font-geist-mono)',
color: 'var(--accent)',
letterSpacing: '-0.01em',
lineHeight: 1.3,
}}>
{project.resultSummary}
</div>
)}
```
2. Place it between the name row and the tech stack row — immediately after the `</div>` that wraps project name + year (after line 169).
3. All 6 investigations have `resultSummary`, so it will always show. But the conditional guard is good practice.
**How:** Update className: `p-3 xs:p-5 pb-10 md:p-7 md:pb-12 lg:px-8 lg:pt-7 lg:pb-12`
This gives 12px padding at <480px instead of 20px, recovering 16px of usable width.
### 2B. Reduce Card padding at small viewports (`Card.tsx`)
**What:** Reduce `padding: '24px'` to 16px at small viewports.
**How:** Use inline responsive logic or a CSS class. Since Card uses inline styles, detect viewport width or add a CSS class:
Option: Add `className="card-base"` and define:
```css
.card-base { padding: 24px; }
@media (max-width: 479px) {
.card-base { padding: 16px !important; }
}
```
Or use a custom hook for viewport width and adjust inline.
### 2C. Reduce `chronology-item` padding (`index.css`)
**What:** Reduce `padding: 10px 12px 12px` to tighter values at <480px.
```css
@media (max-width: 479px) {
.chronology-item {
padding: 8px 8px 10px;
}
}
```
**Verify:** Each project card shows a bold stat line. Numbers like "14,000 patients identified" are immediately scannable.
---
## Phase 3: KPI Grid Fix (Critical)
## Improvement 3: Add Prominent Contact/Download CV CTA
**Status:** [x] Complete
**File:** `src/components/tiles/PatientSummaryTile.tsx`
### 3A. Make KPI grid responsive (`PatientSummaryTile.tsx`)
**Current state:** Contact actions only exist in CommandPalette (`Ctrl+K`). `profile-content.ts` has URLs: `mailto:andy@charlwood.xyz`, `linkedin.com/in/andycharlwood`, `github.com/andycharlwood`. Download CV exists as a quick action type `'download'`.
**What:** Change KPI grid from hardcoded 2-column to responsive.
**Plan:**
1. Add a compact action bar below the structured profile fields, above the KPI section. Use a horizontal flex row with 4 buttons: Email, LinkedIn, GitHub, Download CV.
2. **Styling** — match GP system "action buttons" aesthetic:
- Container: `display: flex, gap: 8px, flexWrap: wrap, marginTop: 16px, marginBottom: 4px`
- Each button: `display: inline-flex, alignItems: center, gap: 6px, padding: '6px 12px', fontSize: '12px', fontWeight: 600, fontFamily: 'var(--font-geist-mono)', letterSpacing: '0.03em', textTransform: 'uppercase', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--accent)', cursor: 'pointer', transition: '...', textDecoration: 'none'`
- Hover: `background: var(--accent-light), borderColor: var(--accent-border)`
- Icons: `Mail`, `Linkedin`, `Github`, `Download` from lucide-react, size 13
3. **Links:**
- Email → `mailto:andy@charlwood.xyz`
- LinkedIn → `https://linkedin.com/in/andycharlwood` (target `_blank`, `rel="noopener noreferrer"`)
- GitHub → `https://github.com/andycharlwood` (target `_blank`, `rel="noopener noreferrer"`)
- Download CV → trigger the same download logic as CommandPalette (check what it does — likely opens a PDF URL or triggers a download). For now, link to `/AndrewCharlwood_CV.pdf` or check existing download action. If no PDF exists, use a `mailto:` with subject "CV Request" as fallback, or omit.
4. Render as `<a>` tags styled as buttons (not `<button>`) since they navigate externally.
**How:** Use a CSS class instead of inline `gridTemplateColumns`:
```css
/* Default: 2 columns */
.kpi-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Single column at very narrow viewports */
@media (max-width: 359px) {
.kpi-grid {
grid-template-columns: 1fr;
}
}
```
At 360px+ with 2 columns: each card gets ~160px (after removing sidebar, with 12px padding). That's workable.
At <360px (iPhone SE): single column, full width.
### 3B. Reduce KPI value font size at narrow viewports (`PatientSummaryTile.tsx`)
**What:** Reduce `fontSize: '30px'` on metric values.
**How:** Use `clamp()` or media query: `fontSize: 'clamp(22px, 6vw, 30px)'` — scales from 22px at 320px to 30px at 500px.
**Verify:** Buttons visible without scrolling on desktop. Compact on mobile. GP action button aesthetic maintained.
---
## Phase 4: Project Carousel Fix (Critical)
## Improvement 4: Reduce Boot + Login Sequence Time
**Status:** [x] Complete
**Files:** `src/components/BootSequence.tsx`, `src/components/LoginScreen.tsx`, `src/App.tsx`
### 4A. Use 1 card per view at <480px (`ProjectsTile.tsx`)
**Current state:**
- Boot: `TYPING_SPEED = 2` (line 62) → ~5.6s total (3.3s×2 typing + 0.6s hold + 1.2s loading + 0.5s fade)
- Login: 1500ms start delay + ~1.5s typing + 500ms connect + 600ms dissolve ≈ 4.1s
- Total: ~9.7s before dashboard
- No sessionStorage skip logic
- Skip button appears at 1500ms into boot
**What:** Change `cardsPerView` logic:
**Plan:**
1. **BootSequence.tsx line 62:** Change `TYPING_SPEED = 2` → `TYPING_SPEED = 1.2`
- New typing time: ~3.3s × 1.2 = ~4.0s
- New total boot: ~4.0 + 0.6 + 1.2 + 0.5 = ~6.3s
- But also reduce `holdAfterComplete` from 600 → 300, and `loadingDuration` from 1200 → 800
- New total: ~4.0 + 0.3 + 0.8 + 0.5 = ~5.6s
```js
const cardsPerView = useMemo(() => {
if (viewportWidth < 480) return 1 // NEW: 1 card at small mobile
if (viewportWidth < 768) return 2
return 4
}, [viewportWidth])
```
2. **LoginScreen.tsx line 150:** Reduce start delay from 1500 → 800ms
- Change character typing from 80ms → 55ms (username)
- Change password dots from 60ms → 40ms
- New login total: ~0.8 + (13×0.055) + 0.3 + (8×0.04) + 0.5 + 0.6 ≈ 3.1s
- Combined first-visit: ~5.6 + 3.1 = ~8.7s... still too long.
- Further: reduce boot `TYPING_SPEED = 1.0`, `holdAfterComplete: 200`, `loadingDuration: 600`
- New boot: ~3.3 + 0.2 + 0.6 + 0.5 = ~4.6s
- Combined: ~4.6 + 3.1 = ~7.7s. Getting there.
- Also reduce login dissolve from 600 → 400ms, and startDelay to 600ms.
- New login: ~0.6 + 0.7 + 0.3 + 0.3 + 0.5 + 0.4 ≈ 2.8s
- Combined: ~4.6 + 2.8 = ~7.4s. Under 8s is reasonable for a first-time experience.
- **Final timing targets:**
- Boot TYPING_SPEED: 1.0
- holdAfterComplete: 200
- loadingDuration: 600
- Login startDelay: 600 (from 1500)
- Username char: 55ms (from 80)
- Password dot: 40ms (from 60)
- Login dissolve: 400ms (from 600)
At 320px with no sidebar: usable width ~296px → 1 card at ~296px is great.
3. **App.tsx:** Add `sessionStorage` skip logic:
```tsx
const [phase, setPhase] = useState<Phase>(() => {
if (typeof window !== 'undefined' && sessionStorage.getItem('portfolio-visited')) {
return 'pmr'
}
return 'boot'
})
```
And when transitioning to `'pmr'`:
```tsx
useEffect(() => {
if (phase === 'pmr') {
sessionStorage.setItem('portfolio-visited', '1')
}
}, [phase])
```
This means: first visit in tab → full boot+login. Refresh or navigate back → instant dashboard.
### 4B. Reduce card min-height at <480px (`ProjectsTile.tsx`)
4. **Skip button** in `App.tsx`: Keep appearing at 1500ms (or reduce to 1000ms for faster access). Also show during login phase — currently only shows during boot. Add skip button to login phase too:
```tsx
{(phase === 'boot' || phase === 'login') && (
<SkipButton onSkip={skipToDashboard} />
)}
```
**What:** Add a smaller min-height tier:
```js
if (viewportWidth < 480) return 148
if (viewportWidth < 640) return 168
```
**Verify:** First visit ≤ ~5s total. Return visitor in same session → instant dashboard. Skip button visible within 1s.
---
## Phase 5: Timeline & Text Overflow (Important)
## Improvement 5: Resolve Last Consultation / Timeline Duplication
**Status:** [x] Complete
**Files:** `src/components/LastConsultationCard.tsx`, `src/components/TimelineInterventionsSubsection.tsx`
### 5A. Allow timeline badges to wrap (`TimelineInterventionsSubsection.tsx`)
**Current state:**
- `LastConsultationCard` displays the current role with full examination bullet points (lines 135173) + metadata fields + "View full record" button
- `TimelineInterventionsSubsection` renders all `timelineEntities` including the current role as the first accordion item, also with full details
- Both are rendered in `DashboardLayout.tsx` (lines 315, 319)
**What:** Change the badge container from `flexShrink: 0` to allow wrapping at narrow widths.
**Plan:**
1. **LastConsultationCard.tsx:** Remove the examination bullets list entirely (lines 135173: the `<ul>` and all `<li>` elements). Keep:
- CardHeader "LAST CONSULTATION"
- Metadata fields row (Date, Organisation, Type, Band) — this is the clickable summary
- Role title
- "View full record" button
This makes it a compact summary card.
**How:** Add `flexWrap: 'wrap'` to the badge container and remove `flexShrink: 0`.
2. **TimelineInterventionsSubsection.tsx:** Add a "CURRENT" badge to the first timeline entry (the current role). In `TimelineInterventionItem`, detect if the entity is the current one (`entity.isCurrent === true` or first entity in the sorted list). Add a small pill badge next to the date:
```tsx
{entity.isCurrent && (
<span style={{
fontSize: '9px',
fontWeight: 700,
fontFamily: 'var(--font-geist-mono)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
padding: '2px 7px',
borderRadius: '9999px',
background: 'rgba(34, 197, 94, 0.12)',
color: '#16a34a',
border: '1px solid rgba(34, 197, 94, 0.3)',
}}>
Current
</span>
)}
```
Check if `TimelineEntity` has an `isCurrent` field — if not, use `entity.dateRange.end === null` or compare with the consultation from `timelineConsultations`.
At very narrow widths, badges will wrap below the title instead of forcing overflow.
### 5B. Ensure ExpandableCardShell doesn't clip text (`ExpandableCardShell.tsx`)
**What:** The inner wrapper has `overflow: 'hidden'` which is needed for animation but could clip header text.
**Status:** Currently OK — the `minWidth: 0` on flex children handles text wrapping. The header has `gap: '8px'` and text naturally wraps. No change needed, but monitor.
**Verify:** LastConsultationCard shows a compact summary (no bullets). Timeline accordion first item has "Current" badge. Full details only in the accordion expansion.
---
## Phase 6: Constellation Graph (Important)
## Improvement 6: Fix Text-Tertiary Contrast Ratio
**Status:** [x] Complete
**File:** `src/index.css`
### 6A. Reduce constellation height at <480px (`useForceSimulation.ts`)
**Current state:** Line 106: `--text-tertiary: #8DA8A5` on `--bg-dashboard: #F0F5F4`. Current contrast ≈ 2.8:1 (fails WCAG AA 4.5:1 for normal text).
**What:** Change `getHeight()` to return a smaller height for very narrow viewports:
**Plan:**
1. Change `--text-tertiary: #8DA8A5` → `--text-tertiary: #6B8886`
- `#6B8886` (RGB 107, 136, 134) on `#F0F5F4` (RGB 240, 245, 244) gives contrast ≈ 4.5:1
- Maintains the teal-grey character of the palette
2. This is a single-line CSS change.
```js
function getHeight(width: number, containerHeight?: number | null): number {
if (width < 480) return 380 // NEW: shorter for small phones
if (width < 768) return 520
if (containerHeight && containerHeight > 0) return Math.max(400, containerHeight)
return 400
}
```
520px is disproportionate at 320px wide. 380px keeps it visible without dominating the view.
**Verify:** Check contrast with a WCAG contrast checker. Visually scan: dates in timeline, helper text, mono metadata — all should be clearly readable without looking out of place.
---
## Phase 7: Detail Panel Polish (Minor)
## Improvement 7: Add Mobile Identity Bar
**Status:** [x] Complete
**File:** `src/components/DashboardLayout.tsx`
### 7A. Reduce detail panel body padding at narrow widths (`DetailPanel.tsx`)
**Current state:** On mobile (< lg breakpoint), the sidebar is hidden and replaced by `MobileBottomNav`. No name/identity visible without opening the drawer.
**What:** Change `padding: '24px'` to `padding: '16px'` at <480px.
**Plan:**
1. Add a compact top bar in `DashboardLayout.tsx`, rendered only below `lg` breakpoint (use `useIsMobileNav()` hook that already exists, or a `useMediaQuery` for `max-width: 1023px`).
2. **Structure:**
```tsx
{isMobileNav && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'var(--sidebar-bg)',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
zIndex: 50,
}}>
<div>
<div style={{
fontSize: '14px',
fontWeight: 700,
color: 'var(--text-on-dark)',
letterSpacing: '0.04em',
fontFamily: 'var(--font-ui)',
}}>
CHARLWOOD, Andrew
</div>
<div style={{
fontSize: '11px',
color: 'var(--text-secondary-on-dark)',
fontFamily: 'var(--font-geist-mono)',
letterSpacing: '0.02em',
}}>
Informatics Pharmacist · NHS Norfolk & Waveney ICB
</div>
</div>
</div>
)}
```
3. Looks like a GP system patient banner strip — dark background (sidebar-bg), surname first in caps, role subtitle. Check if `--text-on-dark` and `--text-secondary-on-dark` exist; if not, use appropriate colors from sidebar styles (check Sidebar.tsx for text color patterns).
**How:** Add responsive CSS or inline viewport check:
```css
@media (max-width: 479px) {
.detail-panel .detail-panel-body {
padding: 16px;
}
}
```
Or add a `className` to the body div and use CSS.
### 7B. Reduce detail panel header padding (`DetailPanel.tsx`)
**What:** Change `padding: '20px 24px'` to `padding: '16px'` at <480px.
Same approach as 7A.
**Verify:** On mobile viewport, name and role visible at top without opening drawer. Disappears on desktop (≥ lg).
---
## Phase 8: Medications/Skills Grid (Minor)
## Improvement 8: Simplify KPI Section Header Language
**Status:** [x] Complete
**File:** `src/data/profile-content.ts`
### 8A. Already single-column on mobile (`index.css`)
**Current state:** Line 8: `title: 'LATEST RESULTS (CLICK TO VIEW FULL REFERENCE RANGE)'`
**Status:** `.medications-grid` is already `grid-template-columns: 1fr` at mobile, going to 3 columns at 768px+. No change needed.
**Plan:**
1. Change to: `title: 'KEY METRICS'`
2. The existing `helperText` is already good: `'Select a metric to inspect methodology, impact, and outcomes.'` — keep it.
3. Single-line change.
**Verify:** Header reads "KEY METRICS" with helper text below. No medical jargon confusion.
---
## Improvement 9: Add Detail Panel Exit Animation
**Status:** [x] Complete
**File:** `src/components/DetailPanel.tsx`, `src/contexts/DetailPanelContext.tsx`
**Current state:**
- Entry: `animation: 'panel-slide-in 250ms ease-out'` (line 127)
- Exit: Panel returns `null` when `!isOpen` (line 86) — instant unmount, no exit animation
- CSS has `@keyframes panel-slide-out` defined (index.css line 564) but unused
- Backdrop has `backdrop-fade-in` but no `backdrop-fade-out`
**Plan — Use a closing state pattern** (simpler than AnimatePresence since we're not using Framer Motion here):
1. **DetailPanelContext.tsx:** Add a `isClosing` state:
```tsx
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<number>()
const closePanel = useCallback(() => {
setIsClosing(true)
closeTimerRef.current = window.setTimeout(() => {
setIsClosing(false)
setIsOpen(false)
setContent(null)
}, 250) // match panel-slide-out duration
}, [])
```
Expose `isClosing` in the context value.
2. **DetailPanel.tsx:**
- Change guard: `if ((!isOpen && !isClosing) || !content) return null`
- Panel animation: `animation: isClosing ? 'panel-slide-out 250ms ease-in forwards' : 'panel-slide-in 250ms ease-out'`
- Backdrop: add `opacity: isClosing ? 0 : 1, transition: 'opacity 200ms ease-out'`
3. Clean up timer on unmount in the context provider.
**Verify:** Panel slides out smoothly before disappearing. Backdrop fades. Escape key triggers exit animation. Reduced motion users get instant close (CSS already overrides the keyframes).
---
## Improvement 10: Fix marginBottom Typo
**Status:** [x] Complete
**File:** `src/components/LastConsultationCard.tsx`
**Current state:** Line 89: `marginBottom: '1=px'` — typo. Surrounding context: this is on the metadata fields row div which also has `paddingBottom: '14px'`, `borderBottom: '1px solid var(--border-light)'`, and `margin: '-8px -8px 14px -8px'`.
**Plan:**
1. The `margin` shorthand on line 95 (`margin: '-8px -8px 14px -8px'`) already sets `marginBottom: 14px`, so the `marginBottom: '1=px'` on line 89 is being overridden anyway.
2. Change `marginBottom: '1=px'` → remove it entirely (the margin shorthand handles it), or change to `marginBottom: '10px'` if the intent was spacing before the bottom border. Looking at the layout: the `margin` shorthand on line 95 already handles bottom margin (14px), so the `marginBottom` on line 89 is redundant and was likely a typo of `'10px'` but is overridden.
3. Simplest fix: change `'1=px'` → `'10px'` to fix the typo. Even though it's overridden, fix the intent so the code is correct.
**Verify:** No visual regression. The metadata row spacing is unchanged (margin shorthand dominates).
---
## Improvement 11: Add Arrow Navigation to Desktop Projects Carousel
**Status:** [x] Complete
**File:** `src/components/tiles/ProjectsTile.tsx` — `ContinuousScrollCarousel` (lines 381505)
**Current state:** Auto-scrolling via `requestAnimationFrame` at 24px/s. Pauses on hover/focus. No manual navigation buttons.
**Plan:**
1. **Import** `ChevronLeft, ChevronRight` from `lucide-react` (already have `lucide-react` in the file).
2. **Add a resume timeout ref** and **transition helper** inside `ContinuousScrollCarousel`:
```tsx
const resumeTimeoutRef = useRef<number>(0)
const jumpByCards = useCallback((direction: 1 | -1) => {
const trackEl = trackRef.current
const firstSetEl = firstSetRef.current
if (!trackEl || !firstSetEl) return
const gap = 12
const cardsPerView = 4
const totalGap = (cardsPerView - 1) * gap
const cardWidth = (viewportWidth - totalGap) / cardsPerView
const jumpPx = cardWidth + gap
// Pause auto-scroll
isPausedRef.current = true
window.clearTimeout(resumeTimeoutRef.current)
// Apply CSS transition for smooth jump
if (!prefersReducedMotion) {
trackEl.style.transition = 'transform 0.4s ease'
}
// Calculate new offset
const setWidth = firstSetEl.offsetWidth
let newOffset = offsetRef.current + (direction * jumpPx)
if (setWidth > 0) {
newOffset = ((newOffset % setWidth) + setWidth) % setWidth
}
offsetRef.current = newOffset
trackEl.style.transform = `translate3d(-${newOffset}px, 0, 0)`
// Remove transition after completion so rAF loop isn't fighting CSS
const transitionEnd = () => {
trackEl.style.transition = ''
trackEl.removeEventListener('transitionend', transitionEnd)
}
if (!prefersReducedMotion) {
trackEl.addEventListener('transitionend', transitionEnd, { once: true })
}
// Resume auto-scroll after 6s
resumeTimeoutRef.current = window.setTimeout(() => {
isPausedRef.current = false
}, 6000)
}, [viewportWidth, prefersReducedMotion])
```
3. **Clean up** the resume timeout on unmount (add to the rAF effect cleanup or a separate effect).
4. **Render arrows** — wrap the existing viewport div in a relative container:
```tsx
<div style={{ position: 'relative' }}>
{/* Existing viewport div */}
<div ref={viewportRef} style={{ overflow: 'hidden' }} ...>
...
</div>
{/* Left arrow */}
<button
onClick={() => jumpByCards(-1)}
aria-label="Previous project"
style={{
position: 'absolute',
left: '-4px',
top: '50%',
transform: 'translateY(-50%)',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: '50%',
cursor: 'pointer',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
color: 'var(--text-secondary)',
transition: 'opacity 150ms, background-color 150ms',
zIndex: 2,
}}
>
<ChevronLeft size={16} />
</button>
{/* Right arrow */}
<button
onClick={() => jumpByCards(1)}
aria-label="Next project"
style={{ /* mirror of left, but right: '-4px' */ }}
>
<ChevronRight size={16} />
</button>
</div>
```
5. **Hover effect** on arrows: `opacity 0.7 → 1` on hover, match the existing `FullscreenButton` pattern.
6. **Existing hover pause** still works — `onMouseEnter/Leave` on the viewport div pauses the rAF loop. Arrow clicks set `isPausedRef = true` with their own 6s resume timer. If user hovers viewport area after clicking arrow, hover pause takes over. On mouse leave, if the 6s timer hasn't elapsed, the arrow's timer still holds the pause.
- Need to handle interaction: when `setPaused(false)` fires from `onMouseLeave`, only unpause if the arrow timer has elapsed. Solution: track `arrowPausedUntil` timestamp. `setPaused` checks if `Date.now() < arrowPausedUntil`. Actually simpler: just let the arrow timeout set `isPausedRef = false` after 6s regardless. The hover handlers already set it. The last writer wins. This is fine — if user hovers after clicking, hover sets `true`. When they leave, `false`. If 6s timer fires while hovering, it sets `false` but hover immediately sets `true` again via the rAF check. Actually the hover sets it on enter/leave events, not continuously. So: mouse leaves → sets false → auto-scroll resumes. That's OK. The 6s pause only matters if the user clicks an arrow and then doesn't hover the carousel.
7. **Reduced motion:** Arrows still work (instant jump, no CSS transition). Auto-scroll stays disabled per existing logic.
**Verify:** Arrows visible at left/right edges of carousel. Click jumps one card smoothly. Auto-scroll pauses for 6s after click. Reduced motion: instant jump. Rapid clicks work without jank.
---
## Implementation Order
1. **Phase 1** (Sidebar → Bottom Nav) — Most impactful, recovers 64px
2. **Phase 2** (Spacing) — Recovers 16-32px more
3. **Phase 3** (KPI grid) — Fixes cramped cards
4. **Phase 4** (Carousel) — Fixes tiny project cards
5. **Phase 5** (Timeline) — Fixes potential text overflow
6. **Phase 6** (Constellation) — Better proportions
7. **Phase 7** (Detail panel) — Polish
8. **Phase 8** (Skills grid) — No change needed
Implement in priority order 1→11. Each improvement is atomic and independently verifiable.
## Width Budget After Fixes
**Quality gate after each improvement:** `npm run lint && npm run typecheck && npm run build`
| Viewport | Sidebar | Padding | Usable Width | Before |
|----------|---------|---------|--------------|--------|
| 320px | 0px | 24px | **296px** | 216px |
| 360px | 0px | 24px | **336px** | 256px |
| 375px | 0px | 24px | **351px** | 271px |
| 400px | 0px | 24px | **376px** | 296px |
| 430px | 0px | 24px | **406px** | 326px |
## Files Modified (Summary)
*At <480px: 12px padding each side = 24px total. Card padding: 16px each side = 32px total. Content area inside card: 232-374px.*
## Files Modified
| File | Changes |
|------|---------|
| `tailwind.config.js` | Add `xxs: 360px` breakpoint |
| `src/components/MobileBottomNav.tsx` | **NEW** — bottom nav bar + drawer |
| `src/components/Sidebar.tsx` | Hide at <600px |
| `src/components/DashboardLayout.tsx` | Integrate bottom nav, adjust padding |
| `src/index.css` | Add <600px and <480px media queries |
| `src/components/Card.tsx` | Responsive padding |
| `src/components/tiles/PatientSummaryTile.tsx` | KPI grid class, font size clamp |
| `src/components/tiles/ProjectsTile.tsx` | 1 card per view at <480px |
| `src/components/TimelineInterventionsSubsection.tsx` | Badge wrapping |
| `src/hooks/useForceSimulation.ts` | Shorter constellation at <480px |
| `src/components/DetailPanel.tsx` | Responsive padding |
## Constraints Respected
- No new npm dependencies (Framer Motion already available)
- No changes to boot/ECG/login screens
- No D3 simulation logic changes (only container sizing)
- Desktop/tablet (768px+) completely unchanged
- PMR aesthetic maintained
| # | Files |
|---|-------|
| 1 | `PatientSummaryTile.tsx`, `profile-content.ts`, `types/profile-content.ts` |
| 2 | `ProjectsTile.tsx` |
| 3 | `PatientSummaryTile.tsx` |
| 4 | `BootSequence.tsx`, `LoginScreen.tsx`, `App.tsx` |
| 5 | `LastConsultationCard.tsx`, `TimelineInterventionsSubsection.tsx` |
| 6 | `index.css` |
| 7 | `DashboardLayout.tsx` |
| 8 | `profile-content.ts` |
| 9 | `DetailPanel.tsx`, `DetailPanelContext.tsx` |
| 10 | `LastConsultationCard.tsx` |
| 11 | `ProjectsTile.tsx` |