Files
emba-course-solver/openspec/changes/mobile-specializations-floating-banner/design.md
Bill Ballou 7940050196 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).
2026-02-28 22:27:07 -05:00

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: 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.