refactor: centralise color maps, org color fallback, and motion-safe transitions

Create src/lib/theme-colors.ts with DOT_COLORS, KPI_COLORS,
PROJECT_STATUS_COLORS, and DEFAULT_ORG_COLOR constants. Add
motionSafeTransition() utility to src/lib/utils.ts.

Removes 6 duplicate color map definitions across Card, DetailPanel,
PatientSummaryTile, KPIDetail, ProjectsTile, and ProjectDetail.
Replaces 9 hardcoded '#0D6E6E' fallbacks and 7 inline motion ternaries.
Fixes project status color inconsistency between ProjectsTile and
ProjectDetail (Ongoing was teal in tile, amber in detail).
This commit is contained in:
2026-02-17 01:58:10 +00:00
parent 296b18f025
commit 8f4ddc454a
16 changed files with 686 additions and 190 deletions
+2 -9
View File
@@ -1,4 +1,5 @@
import React from 'react'
import { DOT_COLORS } from '@/lib/theme-colors'
interface CardProps {
children: React.ReactNode
@@ -43,14 +44,6 @@ export interface CardHeaderProps {
rightText?: string
}
const dotColorMap: Record<CardHeaderProps['dotColor'], string> = {
teal: '#0D6E6E',
amber: '#D97706',
green: '#059669',
alert: '#DC2626',
purple: '#7C3AED',
}
export function CardHeader({ dotColor, title, rightText }: CardHeaderProps) {
const headerStyles: React.CSSProperties = {
display: 'flex',
@@ -63,7 +56,7 @@ export function CardHeader({ dotColor, title, rightText }: CardHeaderProps) {
width: '9px',
height: '9px',
borderRadius: '50%',
backgroundColor: dotColorMap[dotColor],
backgroundColor: DOT_COLORS[dotColor],
flexShrink: 0,
}
+3 -7
View File
@@ -12,7 +12,7 @@ import {
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
import { prefersReducedMotion } from '@/lib/utils'
import { prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
const MAX_HISTORY = 10
@@ -29,9 +29,7 @@ const buttonVariants = {
visible: {
opacity: 1,
y: 0,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, ease: 'easeOut', delay: 1 },
transition: motionSafeTransition(0.3, 'easeOut', 1),
},
}
@@ -42,9 +40,7 @@ const panelVariants = {
visible: {
opacity: 1,
scale: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' },
transition: motionSafeTransition(0.2),
},
exit: prefersReducedMotion
? { opacity: 1, scale: 1 }
+7 -10
View File
@@ -17,16 +17,15 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineConsultations } from '@/data/timeline'
import { skills } from '@/data/skills'
import type { PaletteAction } from '@/lib/search'
import { hexToRgba, prefersReducedMotion } from '@/lib/utils'
import { hexToRgba, prefersReducedMotion, motionSafeTransition } from '@/lib/utils'
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
const sidebarVariants = {
hidden: prefersReducedMotion ? { x: 0, opacity: 1 } : { x: -272, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.25, ease: 'easeOut', delay: 0.05 },
transition: motionSafeTransition(0.25, 'easeOut', 0.05),
},
}
@@ -34,9 +33,7 @@ const contentVariants = {
hidden: prefersReducedMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: prefersReducedMotion
? { duration: 0 }
: { duration: 0.3, delay: 0.15 },
transition: motionSafeTransition(0.3, 'easeOut', 0.15),
},
}
@@ -102,8 +99,8 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
marginTop: '24px',
borderRadius: 'var(--radius-sm)',
border: '1px solid',
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.2) : 'transparent',
background: isHighlighted ? hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.03) : 'transparent',
borderColor: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'transparent',
background: isHighlighted ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'transparent',
transition: 'border-color 150ms ease-out, background-color 150ms ease-out',
padding: '8px',
margin: '-8px',
@@ -130,7 +127,7 @@ function LastConsultationSubsection({ highlightedRoleId }: LastConsultationSubse
transition: 'background-color 150ms ease-out',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.04)
e.currentTarget.style.backgroundColor = hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.04)
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
+2 -10
View File
@@ -10,6 +10,7 @@ import { SkillDetail } from './detail/SkillDetail'
import { SkillsAllDetail } from './detail/SkillsAllDetail'
import { EducationDetail } from './detail/EducationDetail'
import { ProjectDetail } from './detail/ProjectDetail'
import { DOT_COLORS } from '@/lib/theme-colors'
// Width mapping from content type
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
@@ -60,15 +61,6 @@ function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
}
}
// Dot color value map (from Card.tsx)
const dotColorValueMap: Record<CardHeaderProps['dotColor'], string> = {
teal: '#0D6E6E',
amber: '#D97706',
green: '#059669',
alert: '#DC2626',
purple: '#7C3AED',
}
export function DetailPanel() {
const { content, closePanel, isOpen } = useDetailPanel()
const panelRef = useRef<HTMLDivElement>(null)
@@ -96,7 +88,7 @@ export function DetailPanel() {
const width = widthMap[content.type]
const title = getPanelTitle(content)
const dotColor = getDotColor(content)
const dotColorValue = dotColorValueMap[dotColor]
const dotColorValue = DOT_COLORS[dotColor]
return (
<>
@@ -5,7 +5,7 @@ import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { timelineEntities, timelineConsultations } from '@/data/timeline'
import { getExperienceEducationUICopy } from '@/lib/profile-content'
import type { TimelineEntity } from '@/types/pmr'
import { hexToRgba, prefersReducedMotion } from '@/lib/utils'
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
interface TimelineInterventionItemProps {
entity: TimelineEntity
@@ -170,11 +170,7 @@ function TimelineInterventionItem({
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
}
transition={motionSafeTransition(0.2)}
style={{ overflow: 'hidden' }}
>
<div
+9 -12
View File
@@ -4,7 +4,8 @@ import { ChevronRight } from 'lucide-react'
import { CardHeader } from './Card'
import { timelineConsultations } from '@/data/timeline'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { hexToRgba, prefersReducedMotion } from '@/lib/utils'
import { hexToRgba, motionSafeTransition } from '@/lib/utils'
import { DEFAULT_ORG_COLOR } from '@/lib/theme-colors'
interface RoleItemProps {
consultation: typeof timelineConsultations[0]
@@ -33,9 +34,9 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
return (
<div
style={{
background: isHighlightedFromGraph ? hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.03) : 'var(--bg-dashboard)',
background: isHighlightedFromGraph ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.03) : 'var(--bg-dashboard)',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${isExpanded || isHighlightedFromGraph ? hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.2) : 'var(--border-light)'}`,
border: `1px solid ${isExpanded || isHighlightedFromGraph ? hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2) : 'var(--border-light)'}`,
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
overflow: 'hidden',
}}
@@ -60,7 +61,7 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
}}
onMouseEnter={(e) => {
if (!isExpanded) {
e.currentTarget.parentElement!.style.borderColor = hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.2)
e.currentTarget.parentElement!.style.borderColor = hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2)
e.currentTarget.parentElement!.style.boxShadow = 'var(--shadow-md)'
}
}}
@@ -78,7 +79,7 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
width: '9px',
height: '9px',
borderRadius: '50%',
background: consultation.orgColor ?? '#0D6E6E',
background: consultation.orgColor ?? DEFAULT_ORG_COLOR,
flexShrink: 0,
marginTop: '4px',
}}
@@ -137,11 +138,7 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
}
transition={motionSafeTransition(0.2)}
style={{ overflow: 'hidden' }}
>
<div
@@ -210,9 +207,9 @@ function RoleItem({ consultation, isExpanded, isHighlightedFromGraph, onToggle,
fontFamily: 'var(--font-geist-mono)',
padding: '3px 8px',
borderRadius: '4px',
background: hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.08),
background: hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.08),
color: consultation.orgColor ?? 'var(--accent)',
border: `1px solid ${hexToRgba(consultation.orgColor ?? '#0D6E6E', 0.2)}`,
border: `1px solid ${hexToRgba(consultation.orgColor ?? DEFAULT_ORG_COLOR, 0.2)}`,
}}
>
{entry.code}: {entry.description}
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import type { TimelineEntity } from '@/types/pmr'
import { prefersReducedMotion } from './constants'
import { motionSafeTransition } from '@/lib/utils'
interface MobileAccordionProps {
pinnedCareerEntity: TimelineEntity | null
@@ -23,7 +23,7 @@ export const MobileAccordion: React.FC<MobileAccordionProps> = ({ pinnedCareerEn
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
transition={motionSafeTransition(0.2)}
style={{ overflow: 'hidden' }}
>
<div
+3 -9
View File
@@ -1,16 +1,10 @@
import type { KPI } from '@/types/pmr'
import { KPI_COLORS } from '@/lib/theme-colors'
interface KPIDetailProps {
kpi: KPI
}
// Color map for KPI values
const colorMap: Record<KPI['colorVariant'], string> = {
green: '#059669',
amber: '#D97706',
teal: '#0D6E6E',
}
export function KPIDetail({ kpi }: KPIDetailProps) {
// If story exists, render rich content; otherwise fallback to explanation
if (!kpi.story) {
@@ -27,7 +21,7 @@ export function KPIDetail({ kpi }: KPIDetailProps) {
style={{
fontSize: '32px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
color: KPI_COLORS[kpi.colorVariant],
marginBottom: '16px',
}}
>
@@ -55,7 +49,7 @@ export function KPIDetail({ kpi }: KPIDetailProps) {
style={{
fontSize: '48px',
fontWeight: 700,
color: colorMap[kpi.colorVariant],
color: KPI_COLORS[kpi.colorVariant],
lineHeight: '1',
marginBottom: '8px',
}}
+2 -7
View File
@@ -1,16 +1,11 @@
import { ExternalLink } from 'lucide-react'
import type { Investigation } from '@/types/pmr'
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
interface ProjectDetailProps {
investigation: Investigation
}
const statusColorMap: Record<Investigation['status'], string> = {
Complete: '#059669',
Ongoing: '#D97706',
Live: '#0D6E6E',
}
const statusBgMap: Record<Investigation['status'], string> = {
Complete: 'rgba(5,150,105,0.08)',
Ongoing: 'rgba(217,119,6,0.08)',
@@ -18,7 +13,7 @@ const statusBgMap: Record<Investigation['status'], string> = {
}
export function ProjectDetail({ investigation }: ProjectDetailProps) {
const statusColor = statusColorMap[investigation.status]
const statusColor = PROJECT_STATUS_COLORS[investigation.status]
const statusBg = statusBgMap[investigation.status]
return (
+2 -7
View File
@@ -6,12 +6,7 @@ import { kpis } from '@/data/kpis'
import type { KPI } from '@/types/pmr'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import { getLatestResultsCopy, getProfileSectionTitle, getProfileSummaryText } from '@/lib/profile-content'
const colorMap: Record<KPI['colorVariant'], string> = {
green: '#059669',
amber: '#D97706',
teal: '#0D6E6E',
}
import { KPI_COLORS } from '@/lib/theme-colors'
interface MetricCardProps {
kpi: KPI
@@ -52,7 +47,7 @@ function MetricCard({ kpi }: MetricCardProps) {
fontWeight: 700,
letterSpacing: '-0.02em',
lineHeight: 1.2,
color: colorMap[kpi.colorVariant],
color: KPI_COLORS[kpi.colorVariant],
}
const labelStyles: React.CSSProperties = {
+2 -7
View File
@@ -3,12 +3,7 @@ import { investigations } from '@/data/investigations'
import { Card, CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext'
import type { Investigation } from '@/types/pmr'
const statusColorMap: Record<string, string> = {
Complete: '#059669',
Ongoing: '#0D6E6E',
Live: '#059669',
}
import { PROJECT_STATUS_COLORS } from '@/lib/theme-colors'
interface ProjectItemProps {
project: Investigation
@@ -25,7 +20,7 @@ function ProjectItem({
thumbnailHeight,
onClick,
}: ProjectItemProps) {
const dotColor = statusColorMap[project.status] || '#0D6E6E'
const dotColor = PROJECT_STATUS_COLORS[project.status]
const isLive = project.status === 'Live'
const handleKeyDown = useCallback(
+27
View File
@@ -0,0 +1,27 @@
/** Semantic dot/accent colors used across Card, DetailPanel, KPIs */
export const DOT_COLORS = {
teal: '#0D6E6E',
amber: '#D97706',
green: '#059669',
alert: '#DC2626',
purple: '#7C3AED',
} as const
export type DotColorName = keyof typeof DOT_COLORS
/** KPI color variants (subset of DOT_COLORS) */
export const KPI_COLORS: Record<'green' | 'amber' | 'teal', string> = {
green: DOT_COLORS.green,
amber: DOT_COLORS.amber,
teal: DOT_COLORS.teal,
}
/** Project/investigation status colors */
export const PROJECT_STATUS_COLORS: Record<'Complete' | 'Ongoing' | 'Live', string> = {
Complete: '#059669',
Ongoing: '#D97706',
Live: '#0D6E6E',
}
/** Default org color fallback when consultation.orgColor is undefined */
export const DEFAULT_ORG_COLOR = '#0D6E6E'
+10
View File
@@ -15,3 +15,13 @@ export function hexToRgba(hex: string, opacity: number): string {
}
export const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
/** Returns a framer-motion transition that respects prefers-reduced-motion */
export function motionSafeTransition(
duration: number,
ease: string = 'easeOut',
delay: number = 0
): { duration: number; ease?: string; delay?: number } {
if (prefersReducedMotion) return { duration: 0 }
return { duration, ease, ...(delay ? { delay } : {}) }
}