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).
5.2 KiB
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: 85vhscroll 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.,
onTransitionEndcleanup). 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: fixedelement: 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.