Add mobile floating banners for specialization status and course selection progress

On mobile, the single-column layout makes it easy to lose context when
scrolling between the specializations and course selection panels. This adds
two floating banners that appear via IntersectionObserver:

- Top banner: summarizes specialization statuses (achieved/achievable/missing/unreachable)
- Bottom banner: shows course selection progress (N/12 selected)

Both slide in/out with CSS transitions and scroll to their respective
sections on tap. Only rendered on mobile viewports (max-width: 639px).
This commit is contained in:
2026-02-28 22:27:07 -05:00
parent 969d4ff5a9
commit 7940050196
14 changed files with 527 additions and 4 deletions

View File

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

View File

@@ -0,0 +1,73 @@
## Context
On mobile (max-width: 639px), the app stacks the specializations panel above the course selection panel in a single scrolling page. The user loses sight of specialization statuses when scrolling down to pick courses. There are no existing sticky/fixed elements in the codebase — all positioning is static or component-relative.
The app uses inline `React.CSSProperties` exclusively (no CSS framework), React 19, and the `useMediaQuery` hook for responsive breakpoints.
## Goals / Non-Goals
**Goals:**
- Show a compact, fixed-position banner at the top of the viewport on mobile when the specializations section scrolls out of view
- Summarize all four status categories with counts and color-coded badges matching `STATUS_STYLES`
- Allow tapping the banner to scroll back to the specializations section
- Animate the banner in/out smoothly
**Non-Goals:**
- No banner on tablet or desktop — those layouts already keep specializations visible via the two-column grid with `maxHeight: 85vh` scroll containers
- No interactive controls in the banner (no reordering, no expansion) — it's read-only summary
- No persistence of banner visibility preference
## Decisions
### 1. Visibility trigger: IntersectionObserver on a sentinel ref
**Decision**: Attach a `ref` to the specializations wrapper `<div>` in `App.tsx`. Use an `IntersectionObserver` to detect when this element leaves the viewport. When the element is not intersecting, show the banner.
**Alternatives considered**:
- *Scroll event listener with threshold*: Requires computing offsets, fires frequently, needs throttling. IntersectionObserver is more efficient and declarative.
- *CSS `position: sticky`*: Cannot transform the full specializations panel into a compact summary. Sticky would keep the full panel visible, which is too large on mobile.
### 2. Component placement: Rendered in App.tsx, outside the panel layout
**Decision**: Mount `<MobileStatusBanner>` at the top of the `App` return, before the `<h1>`. It renders as `position: fixed; top: 0` so DOM order doesn't matter for visual placement, but placing it early keeps the component tree logical.
**Rationale**: The banner needs `optimizationResult.statuses` which is already available in `App`. Placing it at the App level avoids prop drilling through the specializations component.
### 3. Data flow: Pass statuses record directly
**Decision**: Pass `result.statuses` (the `Record<string, SpecStatus>`) to the banner component. The component counts each status category internally.
**Alternatives considered**:
- *Pass pre-computed counts*: Would reduce banner's logic but adds computation to App. The count loop over 14 entries is trivial.
- *Pass the full AllocationResult*: Over-exposes data the banner doesn't need.
### 4. Animation: CSS transition on transform
**Decision**: Use `transform: translateY(-100%)` when hidden, `translateY(0)` when visible, with a `transition: transform 200ms ease-out`. The element is always mounted when on mobile; visibility is toggled via transform.
**Alternatives considered**:
- *Conditional rendering with mount/unmount*: Cannot animate exit without additional logic (e.g., `onTransitionEnd` cleanup). Always-mounted with transform is simpler.
- *opacity fade*: Banner would still occupy space or need pointer-events toggling. Transform slide is cleaner — slides up off-screen.
### 5. Scroll-to-top behavior: `scrollIntoView` on the specializations ref
**Decision**: When the banner is tapped, call `specSectionRef.current.scrollIntoView({ behavior: 'smooth' })`. This reuses the same ref used for the IntersectionObserver.
**Rationale**: Simple, built-in browser API. No offset calculations needed since we want the section at the top of the viewport.
### 6. Styling approach: Inline styles, consistent with codebase
**Decision**: Use inline `React.CSSProperties` for the banner, matching the existing project convention. Reuse `STATUS_STYLES` colors from `SpecializationRanking.tsx` by exporting them.
**Alternative**: Could duplicate the color constants in the banner file. But exporting avoids drift if status colors change.
### 7. z-index: 1000
**Decision**: Use `z-index: 1000` for the fixed banner. This is the first fixed element in the app, so any reasonable value works. 1000 leaves room for future layering needs (modals, tooltips) without collision.
## Risks / Trade-offs
- **First `position: fixed` element**: Introduces a new positioning context. If future components also use fixed positioning, z-index conflicts could arise. → Mitigation: Document the z-index value; 1000 leaves headroom.
- **IntersectionObserver on older browsers**: Supported in all modern browsers (Safari 12.1+, Chrome 58+). EMBA students use modern devices. → No polyfill needed.
- **Banner covers page content**: Fixed element at top overlaps scrollable content. → Mitigation: The banner is compact (~40px tall) and only appears when the user has already scrolled past the specializations section, so it replaces information that's already off-screen. The slide-in animation makes the appearance non-jarring.
- **STATUS_STYLES export**: Changing the export surface of `SpecializationRanking.tsx`. → Minimal risk — it's a simple constant export, not a behavioral change.

View File

@@ -0,0 +1,28 @@
## Why
On mobile, the specializations panel stacks above the course selection panel and scrolls naturally with the page. Once a user scrolls down to interact with course selections, they lose visibility of the specialization statuses (achieved, achievable, missing required, unreachable). This forces constant scrolling back up to check how course picks affect specialization progress. A floating summary banner solves this by keeping the key status information visible while the user works through their course selections.
## What Changes
- Add a floating banner that appears at the top of the viewport on mobile after the user scrolls past the specializations section
- The banner summarizes specialization statuses: count of achieved, achievable, missing required, and unreachable specializations
- Uses the same color-coded status badges already defined in `STATUS_STYLES`
- The banner animates in/out based on scroll position using an `IntersectionObserver` on the specializations section
- Tapping the banner scrolls back to the full specializations panel
- Only renders on mobile breakpoint (`max-width: 639px`)
## Capabilities
### New Capabilities
- `mobile-status-banner`: A floating UI component that summarizes specialization statuses on mobile when the specializations section is scrolled out of view
### Modified Capabilities
<!-- No existing specs to modify -->
## Impact
- **Components**: New `MobileStatusBanner` component; minor changes to `App.tsx` to mount it and pass specialization data
- **Hooks**: May add a small scroll-detection hook or use `IntersectionObserver` inline
- **Styling**: Introduces the first `position: fixed` element in the codebase; needs appropriate `z-index` to layer above content
- **Dependencies**: No new libraries required — `IntersectionObserver` is a browser API
- **Performance**: Minimal — observer fires only on intersection changes, not on every scroll event

View File

@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Banner appears when specializations scroll out of view
The system SHALL display a floating summary banner fixed to the top of the viewport on mobile when the specializations section is no longer visible in the viewport.
#### Scenario: User scrolls past specializations on mobile
- **WHEN** the user is on a mobile viewport (max-width: 639px) and scrolls down until the specializations section leaves the viewport
- **THEN** a fixed banner SHALL slide in from the top of the screen displaying specialization status counts
#### Scenario: User scrolls back up to specializations on mobile
- **WHEN** the banner is visible and the user scrolls up until the specializations section re-enters the viewport
- **THEN** the banner SHALL slide out of view (upward)
#### Scenario: User is on tablet or desktop
- **WHEN** the viewport width is greater than 639px
- **THEN** the banner SHALL NOT render at all
### Requirement: Banner displays status counts with color-coded badges
The banner SHALL show the count of specializations in each status category using the same color scheme as the specialization list: achieved (green), achievable (blue), missing required (amber), and unreachable (gray).
#### Scenario: Multiple statuses present
- **WHEN** the banner is visible and statuses are: 2 achieved, 5 achievable, 1 missing required, 6 unreachable
- **THEN** the banner SHALL display four badges: "2 Achieved" in green, "5 Achievable" in blue, "1 Missing Req." in amber, "6 Unreachable" in gray
#### Scenario: Zero count for a status category
- **WHEN** a status category has zero specializations (e.g., 0 missing required)
- **THEN** that category's badge SHALL still be displayed with a count of 0
#### Scenario: Statuses update after course selection change
- **WHEN** the user selects or deselects a course while the banner is visible
- **THEN** the banner status counts SHALL update to reflect the new optimization result
### Requirement: Banner animates in and out
The banner SHALL animate its appearance and disappearance using a vertical slide transition.
#### Scenario: Banner slides in
- **WHEN** the specializations section scrolls out of the viewport on mobile
- **THEN** the banner SHALL transition from `translateY(-100%)` to `translateY(0)` over 200ms with ease-out timing
#### Scenario: Banner slides out
- **WHEN** the specializations section scrolls back into the viewport on mobile
- **THEN** the banner SHALL transition from `translateY(0)` to `translateY(-100%)` over 200ms with ease-out timing
### Requirement: Tapping banner scrolls to specializations
The banner SHALL be tappable. Tapping it SHALL smoothly scroll the page so the specializations section is visible at the top of the viewport.
#### Scenario: User taps the banner
- **WHEN** the banner is visible and the user taps anywhere on it
- **THEN** the page SHALL smooth-scroll to bring the specializations section to the top of the viewport
#### Scenario: Banner hides after scroll completes
- **WHEN** the user taps the banner and the page scrolls to the specializations section
- **THEN** the banner SHALL slide out as the specializations section becomes visible (via the standard intersection trigger)
### Requirement: Banner renders above all other content
The banner SHALL use `position: fixed` with `z-index: 1000` to ensure it layers above all other page content.
#### Scenario: Banner overlaps page content
- **WHEN** the banner is visible
- **THEN** it SHALL be rendered at `position: fixed; top: 0` spanning the full viewport width, above all other elements
### Requirement: Banner uses consistent styling
The banner SHALL use inline `React.CSSProperties` consistent with the existing codebase convention, and SHALL reuse the exported `STATUS_STYLES` color definitions from the specialization ranking component.
#### Scenario: Status colors match specialization list
- **WHEN** the banner displays status badges
- **THEN** the badge colors SHALL exactly match the `STATUS_STYLES` used in the specialization ranking rows (achieved: #16a34a, achievable: #2563eb, missing_required: #d97706, unreachable: #9ca3af)

View File

@@ -0,0 +1,29 @@
## 1. Export STATUS_STYLES
- [x] 1.1 Export `STATUS_STYLES` from `SpecializationRanking.tsx` so it can be imported by the banner component
## 2. Create MobileStatusBanner component
- [x] 2.1 Create `app/src/components/MobileStatusBanner.tsx` with props: `statuses: Record<string, SpecStatus>`, `visible: boolean`, `onTap: () => void`
- [x] 2.2 Implement status count logic — loop over the statuses record to count each category (achieved, achievable, missing_required, unreachable)
- [x] 2.3 Render four color-coded badges using `STATUS_STYLES` colors, each showing `"{count} {label}"` (display all categories including zero counts)
- [x] 2.4 Style the banner: `position: fixed`, `top: 0`, `left: 0`, `width: 100%`, `z-index: 1000`, white background with bottom border, compact height (~40px)
- [x] 2.5 Implement slide animation: always mounted, use `transform: translateY(-100%)` when hidden vs `translateY(0)` when visible, `transition: transform 200ms ease-out`
- [x] 2.6 Attach `onClick` handler to the banner root element that calls `onTap`
## 3. Integrate into App.tsx
- [x] 3.1 Add a `ref` to the specializations wrapper `<div>` in `App.tsx` (the div containing `CreditLegend` and `SpecializationRanking`)
- [x] 3.2 Add IntersectionObserver logic: observe the specializations ref, set a `bannerVisible` state to `true` when the section is not intersecting, `false` when it is
- [x] 3.3 Gate the observer and banner on `isMobile` — only create the observer and render the banner when on mobile breakpoint
- [x] 3.4 Mount `<MobileStatusBanner>` at the top of the App return (before `<h1>`), passing `statuses={optimizationResult.statuses}`, `visible={bannerVisible && isMobile}`, and `onTap` that calls `specSectionRef.current.scrollIntoView({ behavior: 'smooth' })`
- [x] 3.5 Clean up the IntersectionObserver in the useEffect return (disconnect on unmount or when isMobile changes)
## 4. Test
- [x] 4.1 Verify banner appears on mobile viewport when scrolling past specializations
- [x] 4.2 Verify banner hides when scrolling back up to specializations
- [x] 4.3 Verify banner does not render on tablet/desktop viewports
- [x] 4.4 Verify tapping the banner scrolls back to the specializations section
- [x] 4.5 Verify status counts update reactively when course selections change while banner is visible
- [x] 4.6 Verify badge colors match the specialization list status badges