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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
@@ -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 } : {}) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user