diff --git a/app/src/components/CourseSelection.tsx b/app/src/components/CourseSelection.tsx index 6d32a5f..0a79197 100644 --- a/app/src/components/CourseSelection.tsx +++ b/app/src/components/CourseSelection.tsx @@ -51,11 +51,13 @@ function ElectiveSet({ return (
@@ -78,11 +80,25 @@ function ElectiveSet({ )}
- {isPinned ? ( + {/* Pinned view */} +
{pinnedCourse?.name}
- ) : ( +
+ {/* Course list view */} +
{courses.map((course) => { const ceiling = ceilingMap.get(course.id); @@ -136,7 +152,7 @@ function ElectiveSet({ ); })}
- )} +
); } diff --git a/app/src/components/CreditLegend.tsx b/app/src/components/CreditLegend.tsx index 80258d2..c225e50 100644 --- a/app/src/components/CreditLegend.tsx +++ b/app/src/components/CreditLegend.tsx @@ -14,7 +14,11 @@ export function CreditLegend() { > {open ? '▾ How to read this' : '▸ How to read this'} - {open && ( +
Credit bar: @@ -42,7 +46,7 @@ export function CreditLegend() { Maximum 3 specializations can be achieved (30 total credits ÷ 9 per specialization).
- )} +
); } diff --git a/app/src/components/ModeToggle.tsx b/app/src/components/ModeToggle.tsx index 77f49b0..1b96e23 100644 --- a/app/src/components/ModeToggle.tsx +++ b/app/src/components/ModeToggle.tsx @@ -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 diff --git a/app/src/components/Notifications.tsx b/app/src/components/Notifications.tsx index 88f69ec..52097c2 100644 --- a/app/src/components/Notifications.tsx +++ b/app/src/components/Notifications.tsx @@ -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 (
Mode comparison: {altModeName} achieves {altResult.achieved.length} specialization @@ -32,4 +36,3 @@ export function ModeComparison({
); } - diff --git a/app/src/components/SpecializationRanking.tsx b/app/src/components/SpecializationRanking.tsx index b44e11f..3a41876 100644 --- a/app/src/components/SpecializationRanking.tsx +++ b/app/src/components/SpecializationRanking.tsx @@ -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', }} />
@@ -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} - {isAchieved && isExpanded && ( +
- )} +
); } diff --git a/app/src/index.css b/app/src/index.css index 07cee87..756a469 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -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; + } +} diff --git a/openspec/changes/enhance-ui-animations/.openspec.yaml b/openspec/changes/enhance-ui-animations/.openspec.yaml new file mode 100644 index 0000000..0b4defe --- /dev/null +++ b/openspec/changes/enhance-ui-animations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-01 diff --git a/openspec/changes/enhance-ui-animations/design.md b/openspec/changes/enhance-ui-animations/design.md new file mode 100644 index 0000000..287f1d1 --- /dev/null +++ b/openspec/changes/enhance-ui-animations/design.md @@ -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: ` with a transition. + +**Rationale**: CSS cannot transition elements that are conditionally rendered (`{open &&
...}
`). 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 `
` 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 `` 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. diff --git a/openspec/changes/enhance-ui-animations/proposal.md b/openspec/changes/enhance-ui-animations/proposal.md new file mode 100644 index 0000000..b55005e --- /dev/null +++ b/openspec/changes/enhance-ui-animations/proposal.md @@ -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 + + +## 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 diff --git a/openspec/changes/enhance-ui-animations/specs/ui-transitions/spec.md b/openspec/changes/enhance-ui-animations/specs/ui-transitions/spec.md new file mode 100644 index 0000000..2d5df1d --- /dev/null +++ b/openspec/changes/enhance-ui-animations/specs/ui-transitions/spec.md @@ -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 diff --git a/openspec/changes/enhance-ui-animations/tasks.md b/openspec/changes/enhance-ui-animations/tasks.md new file mode 100644 index 0000000..b71328d --- /dev/null +++ b/openspec/changes/enhance-ui-animations/tasks.md @@ -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