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

@@ -51,11 +51,13 @@ function ElectiveSet({
return (
<div
style={{
border: isPinned ? '1px solid #3b82f6' : '1px dashed #ccc',
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
borderStyle: isPinned ? 'solid' : 'dashed',
borderRadius: '8px',
padding: '12px',
marginBottom: '8px',
background: isPinned ? '#eff6ff' : '#fafafa',
transition: 'border-color 200ms, background-color 200ms',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
@@ -78,11 +80,25 @@ function ElectiveSet({
</button>
)}
</div>
{isPinned ? (
{/* Pinned view */}
<div style={{
maxHeight: isPinned ? '40px' : '0',
opacity: isPinned ? 1 : 0,
overflow: 'hidden',
transition: 'max-height 250ms ease-out, opacity 200ms',
}}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
{pinnedCourse?.name}
</div>
) : (
</div>
{/* Course list view */}
<div style={{
maxHeight: isPinned ? '0' : '500px',
opacity: isPinned ? 0 : 1,
overflow: 'hidden',
pointerEvents: isPinned ? 'none' : 'auto',
transition: 'max-height 250ms ease-out, opacity 200ms',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{courses.map((course) => {
const ceiling = ceilingMap.get(course.id);
@@ -136,7 +152,7 @@ function ElectiveSet({
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -14,7 +14,11 @@ export function CreditLegend() {
>
{open ? '▾ How to read this' : '▸ How to read this'}
</button>
{open && (
<div style={{
maxHeight: open ? '300px' : '0',
overflow: 'hidden',
transition: 'max-height 200ms ease-out',
}}>
<div style={{ marginTop: '6px', padding: '10px', background: '#f9fafb', borderRadius: '6px', border: '1px solid #e5e7eb', color: '#555', lineHeight: 1.6 }}>
<div style={{ marginBottom: '8px' }}>
<strong>Credit bar:</strong>
@@ -42,7 +46,7 @@ export function CreditLegend() {
Maximum 3 specializations can be achieved (30 total credits ÷ 9 per specialization).
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
background: mode === 'maximize-count' ? '#fff' : 'transparent',
boxShadow: mode === 'maximize-count' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: mode === 'maximize-count' ? '#111' : '#666',
transition: 'background 150ms, box-shadow 150ms, color 150ms',
}}
>
Maximize Count
@@ -40,6 +41,7 @@ export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
background: mode === 'priority-order' ? '#fff' : 'transparent',
boxShadow: mode === 'priority-order' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
color: mode === 'priority-order' ? '#111' : '#666',
transition: 'background 150ms, box-shadow 150ms, color 150ms',
}}
>
Priority Order

View File

@@ -12,18 +12,22 @@ export function ModeComparison({
const currentSpecs = new Set(result.achieved);
const altSpecs = new Set(altResult.achieved);
if (
const isVisible = !(
currentSpecs.size === altSpecs.size &&
result.achieved.every((s) => altSpecs.has(s))
) {
return null;
}
);
return (
<div
style={{
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
padding: '10px', marginBottom: '8px', fontSize: '12px',
fontSize: '12px',
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
maxHeight: isVisible ? '80px' : '0',
padding: isVisible ? '10px' : '0 10px',
marginBottom: isVisible ? '8px' : '0',
transition: 'opacity 200ms, max-height 200ms ease-out, padding 200ms, margin-bottom 200ms',
}}
>
<strong>Mode comparison:</strong> {altModeName} achieves {altResult.achieved.length} specialization
@@ -32,4 +36,3 @@ export function ModeComparison({
</div>
);
}

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>
);
}

View File

@@ -26,3 +26,10 @@ h1, h2, h3, h4 {
button {
font-family: inherit;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-01

View File

@@ -0,0 +1,93 @@
## Context
The app uses React 19 with inline styles and no CSS framework. Current animations are limited to:
- A `skeleton-pulse` keyframe for loading placeholders in CourseSelection
- `transform` + `transition` on mobile banners (MobileStatusBanner, MobileCourseBanner)
- dnd-kit's built-in drag transform/transition on sortable items
All other state changes (course pin/unpin, credit bar updates, expand/collapse, status badge changes, mode toggle) are instant. The app has no animation library dependency.
## Goals / Non-Goals
**Goals:**
- Smooth transitions for all interactive state changes so users can follow what changed
- Keep bundle size unchanged — CSS-only approach using `transition` and `@keyframes`
- Respect `prefers-reduced-motion` globally
- Maintain existing inline-style pattern (no migration to CSS modules or Tailwind)
**Non-Goals:**
- Page/route transition animations (app is single-page, no routing)
- Drag-and-drop animation improvements (dnd-kit handles this already)
- Loading skeleton animation changes (already animated)
- Mobile banner slide animations (already implemented)
- Scroll animations or parallax effects
## Decisions
### 1. CSS transitions on inline styles, no animation library
**Choice**: Add `transition` properties to existing inline styles.
**Rationale**: The app already uses inline styles everywhere. Adding framer-motion or react-spring would introduce a runtime dependency (15-40 KB) for effects that CSS `transition` handles natively. CSS transitions are GPU-accelerated and zero-JS-overhead.
**Alternative considered**: framer-motion `AnimatePresence` for mount/unmount animations. Rejected because the bundle cost isn't justified — we can approximate enter/exit with max-height or opacity transitions on always-mounted wrappers.
### 2. Expand/collapse via max-height + overflow, not conditional rendering
**Choice**: For CreditLegend and AllocationBreakdown, keep elements mounted but toggle between `max-height: 0; overflow: hidden` and `max-height: <value>` with a transition.
**Rationale**: CSS cannot transition elements that are conditionally rendered (`{open && <div>...}</div>`). To animate open/close, the element must remain in the DOM. Using `max-height` with a generous upper bound (e.g., `500px`) provides a smooth slide without needing to measure actual content height.
**Alternative considered**: `requestAnimationFrame` + `scrollHeight` measurement for precise height animation. Rejected — adds JS complexity for marginal visual improvement. The max-height approach is standard and sufficient.
### 3. Global `prefers-reduced-motion` reset in index.css
**Choice**: Add a single CSS rule in `index.css`:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
**Rationale**: A global rule is simpler and more reliable than adding motion checks to each component. This is the recommended pattern from MDN. Using `0.01ms` instead of `0s` ensures transition events still fire.
### 4. Credit bar transitions via CSS `transition: width`
**Choice**: Add `transition: width 300ms ease-out` to the CreditBar's inner `<div>` elements.
**Rationale**: The bars already use percentage-based widths. Adding a transition property is a one-line change that produces a smooth fill/drain animation as credit allocations change.
### 5. Status badge color transitions
**Choice**: Add `transition: background 200ms, color 200ms` to the status badge `<span>` in SortableItem.
**Rationale**: When a specialization's status changes (e.g., achievable → achieved), the badge colors will cross-fade instead of snapping. Since the badge is always rendered (just with different colors), a simple transition property works.
### 6. Mode toggle sliding indicator
**Choice**: Add `transition: background 150ms, box-shadow 150ms` to both toggle buttons.
**Rationale**: The toggle already swaps `background` and `boxShadow` between active/inactive. Adding transitions creates a smooth switch effect with no structural changes.
### 7. ModeComparison banner enter/exit
**Choice**: Always render the banner div but use `opacity` + `max-height` transitions to animate in/out, with the conditional logic controlling style values rather than mount/unmount.
**Rationale**: Same pattern as expand/collapse. The banner either shows or hides based on whether the two modes differ. Transitioning opacity provides a fade effect.
### 8. Course set pin/unpin transitions
**Choice**: Add `transition: border-color 200ms, background 200ms` to the ElectiveSet container div.
**Rationale**: When a course is pinned, the set's border changes from dashed gray to solid blue and background shifts. Transitioning these properties creates a visual confirmation of the selection.
## Risks / Trade-offs
- **max-height overshoot**: Using a fixed max-height (e.g., 500px) means the transition timing won't perfectly match content height. Short content will appear to "accelerate" at the end. → Mitigation: Use `ease-out` timing and a reasonable max-height close to typical content size.
- **Inline style transitions are slightly verbose**: Each component gets `transition: ...` added to its style object. → Mitigation: This is consistent with the existing pattern. Extracting shared transition strings into constants keeps it DRY.
- **Content flash on expand/collapse refactor**: Changing from conditional render to always-mounted-but-hidden may cause brief layout shifts during the transition. → Mitigation: Use `overflow: hidden` on the collapsible wrapper to clip content during animation.

View File

@@ -0,0 +1,29 @@
## Why
UI state changes (course selection, status transitions, panel expand/collapse) happen instantly, making interactions feel abrupt. Adding subtle CSS transitions and animations will make the app feel more polished and help users track what changed after each action.
## What Changes
- Add CSS transitions to course pin/unpin so the elective set smoothly shifts between its "open" and "pinned" states (border, background color, content swap)
- Animate credit bar width changes so users can visually follow how selecting a course redistributes credits across specializations
- Add expand/collapse transitions to CreditLegend and AllocationBreakdown instead of instant mount/unmount
- Animate status badge changes (e.g., "Achievable" → "Achieved") with a brief highlight or color transition
- Add a smooth transition to the ModeToggle active indicator when switching optimization modes
- Animate the ModeComparison notification banner entrance/exit
- Keep all animations CSS-only (transitions + keyframes) — no new runtime dependencies
## Capabilities
### New Capabilities
- `ui-transitions`: CSS transition utilities and patterns for smooth state changes across all interactive components (course selection, credit bars, expand/collapse, status badges, mode toggle, notification banners)
### Modified Capabilities
<!-- No existing specs to modify -->
## Impact
- **Components affected**: CourseSelection, SpecializationRanking, CreditLegend, ModeToggle, Notifications (ModeComparison)
- **CSS**: New transition declarations added to inline styles; possible new keyframe definitions in index.css
- **Dependencies**: None — CSS-only approach, no new packages
- **Accessibility**: All animations will respect `prefers-reduced-motion` media query
- **Performance**: CSS transitions are GPU-accelerated; no JS animation loops

View File

@@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: Course set pin/unpin transition
The ElectiveSet container SHALL transition its border-color and background-color over 200ms when a course is pinned or unpinned.
#### Scenario: User pins a course
- **WHEN** user clicks a course button in an unpinned elective set
- **THEN** the set's border smoothly transitions from dashed gray to solid blue and background fades from gray to blue-tinted over 200ms
#### Scenario: User unpins a course
- **WHEN** user clicks the "Clear" button on a pinned elective set
- **THEN** the set's border and background smoothly transition back to the unpinned style over 200ms
### Requirement: Credit bar width transition
The CreditBar's allocated and potential bar segments SHALL animate their width changes over 300ms with ease-out timing when credit allocations change.
#### Scenario: Credits increase after pinning a course
- **WHEN** a course is pinned that contributes credits to a specialization
- **THEN** the CreditBar's filled segments smoothly grow to their new width over 300ms
#### Scenario: Credits decrease after unpinning a course
- **WHEN** a course is unpinned that was contributing credits
- **THEN** the CreditBar's filled segments smoothly shrink to their new width over 300ms
### Requirement: Status badge color transition
The specialization status badge SHALL transition its background-color and text color over 200ms when the specialization's status changes.
#### Scenario: Status changes from achievable to achieved
- **WHEN** a course selection causes a specialization to reach 9 credits
- **THEN** the badge colors cross-fade from blue (achievable) to green (achieved) over 200ms
#### Scenario: Status changes from achieved to achievable
- **WHEN** a course is unpinned causing a specialization to drop below 9 credits
- **THEN** the badge colors cross-fade from green back to blue over 200ms
### Requirement: CreditLegend expand/collapse animation
The CreditLegend help panel SHALL animate open and closed with a sliding transition instead of instant mount/unmount.
#### Scenario: User opens the legend
- **WHEN** user clicks "How to read this"
- **THEN** the legend content slides open over 200ms using a max-height transition with overflow hidden
#### Scenario: User closes the legend
- **WHEN** user clicks the toggle again while the legend is open
- **THEN** the legend content slides closed over 200ms
### Requirement: AllocationBreakdown expand/collapse animation
The AllocationBreakdown detail panel in achieved specializations SHALL animate open and closed with a sliding transition.
#### Scenario: User expands an achieved specialization
- **WHEN** user clicks an achieved specialization row to view its allocation breakdown
- **THEN** the breakdown content slides open over 200ms
#### Scenario: User collapses an achieved specialization
- **WHEN** user clicks the row again to hide the breakdown
- **THEN** the breakdown content slides closed over 200ms
### Requirement: Mode toggle transition
The ModeToggle buttons SHALL transition their background, box-shadow, and text color over 150ms when the active mode changes.
#### Scenario: User switches optimization mode
- **WHEN** user clicks the inactive mode button
- **THEN** the active indicator (white background + shadow) smoothly transitions to the newly selected button over 150ms while the previous button fades to the inactive style
### Requirement: ModeComparison banner enter/exit animation
The ModeComparison notification banner SHALL fade in when it appears and fade out when it disappears, instead of instant mount/unmount.
#### Scenario: Banner appears after mode switch reveals a difference
- **WHEN** the comparison banner becomes relevant (modes produce different results)
- **THEN** the banner fades in with an opacity transition over 200ms
#### Scenario: Banner disappears when modes align
- **WHEN** the comparison banner is no longer relevant (modes produce same results)
- **THEN** the banner fades out with an opacity transition over 200ms
### Requirement: Reduced motion accessibility
All transitions and animations SHALL be effectively disabled when the user's system has `prefers-reduced-motion: reduce` enabled.
#### Scenario: User has reduced motion enabled
- **WHEN** the operating system or browser is configured with `prefers-reduced-motion: reduce`
- **THEN** all transition and animation durations are reduced to near-zero (0.01ms) so state changes are instant
#### Scenario: User does not have reduced motion enabled
- **WHEN** no reduced-motion preference is set
- **THEN** all transitions play at their specified durations

View File

@@ -0,0 +1,34 @@
## 1. Global Accessibility Setup
- [x] 1.1 Add `prefers-reduced-motion` media query to `index.css` that sets all animation and transition durations to 0.01ms
## 2. Course Selection Transitions
- [x] 2.1 Add `transition: border-color 200ms, background-color 200ms` to the ElectiveSet container div in `CourseSelection.tsx`
## 3. Credit Bar Transitions
- [x] 3.1 Add `transition: width 300ms ease-out` to both the allocated and potential bar segments in the CreditBar component in `SpecializationRanking.tsx`
## 4. Status Badge Transitions
- [x] 4.1 Add `transition: background 200ms, color 200ms` to the status badge span in the SortableItem component in `SpecializationRanking.tsx`
## 5. Expand/Collapse Animations
- [x] 5.1 Refactor CreditLegend to always mount the content panel and use `max-height` + `overflow: hidden` + `transition: max-height 200ms ease-out` instead of conditional rendering
- [x] 5.2 Refactor AllocationBreakdown in SortableItem to always mount and use `max-height` + `overflow: hidden` + `transition: max-height 200ms ease-out` instead of conditional rendering
## 6. Mode Toggle Transition
- [x] 6.1 Add `transition: background 150ms, box-shadow 150ms, color 150ms` to both mode toggle buttons in `ModeToggle.tsx`
## 7. ModeComparison Banner Animation
- [x] 7.1 Refactor ModeComparison in `Notifications.tsx` to always render the banner div and use `opacity` + `max-height` transitions for enter/exit instead of returning null
## 8. Verification
- [ ] 8.1 Manually verify all transitions play correctly: pin/unpin courses, toggle mode, expand/collapse legend and breakdown, observe credit bars and status badges
- [ ] 8.2 Verify reduced-motion: confirm all animations are effectively instant when `prefers-reduced-motion: reduce` is active
- [x] 8.3 Run existing tests to ensure no regressions from the refactored conditional rendering