Add CSS transitions and animations for smooth UI interactions

Animate course set pin/unpin with cross-fade content swap, credit bar
width changes, status badge color transitions, expand/collapse panels
(CreditLegend, AllocationBreakdown), mode toggle switching, and
ModeComparison banner fade. Specialization rows flash on credit/status
changes. Threshold markers animate position. All animations respect
prefers-reduced-motion.
This commit is contained in:
2026-02-28 22:46:11 -05:00
parent 7940050196
commit 7a8330e205
11 changed files with 318 additions and 19 deletions

View File

@@ -40,6 +40,7 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
transition: 'width 300ms ease-out',
}}
/>
)}
@@ -47,13 +48,14 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
style={{
position: 'absolute', left: 0, top: 0, height: '100%',
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
borderRadius: '3px',
borderRadius: '3px', transition: 'width 300ms ease-out',
}}
/>
<div
style={{
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
width: '2px', height: '10px', background: '#666',
transition: 'left 300ms ease-out',
}}
/>
</div>
@@ -105,22 +107,40 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
setNodeRef,
setActivatorNodeRef,
transform,
transition,
transition: dndTransition,
isDragging,
} = useSortable({ id });
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
const isAchieved = status === 'achieved';
// Track changes and flash on status/credit updates
const prevStatusRef = useRef(status);
const prevAllocatedRef = useRef(allocated);
const [flash, setFlash] = useState(false);
useEffect(() => {
const statusChanged = prevStatusRef.current !== status;
const creditsChanged = prevAllocatedRef.current !== allocated;
prevStatusRef.current = status;
prevAllocatedRef.current = allocated;
if (statusChanged || creditsChanged) {
setFlash(true);
const timer = setTimeout(() => setFlash(false), 400);
return () => clearTimeout(timer);
}
}, [status, allocated]);
const rowStyle: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
transition: [dndTransition, 'background 400ms ease-out, box-shadow 400ms ease-out'].filter(Boolean).join(', '),
opacity: isDragging ? 0.5 : 1,
marginBottom: '4px',
borderRadius: '6px',
background: isDragging ? '#e8e8e8' : style.bg,
background: isDragging ? '#e8e8e8' : flash ? '#fef9c3' : style.bg,
border: '1px solid #ddd',
padding: '6px 10px',
boxShadow: flash ? '0 0 0 2px #facc15' : 'none',
};
const arrowBtn: React.CSSProperties = {
@@ -159,16 +179,20 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
style={{
fontSize: '11px', padding: '2px 6px', borderRadius: '10px',
background: style.color + '20', color: style.color, fontWeight: 600,
whiteSpace: 'nowrap',
whiteSpace: 'nowrap', transition: 'background 200ms, color 200ms',
}}
>
{style.label}
</span>
</div>
<CreditBar allocated={allocated} potential={potential} threshold={9} />
{isAchieved && isExpanded && (
<div style={{
maxHeight: isAchieved && isExpanded ? '200px' : '0',
overflow: 'hidden',
transition: 'max-height 200ms ease-out',
}}>
<AllocationBreakdown specId={id} allocations={allocations} />
)}
</div>
</div>
);
}