Files
Bill 2ebfb9d2ec v1.4.0: Desktop layout redesign + mobile tabs
Specializations move from a 340px left rail to a horizontal 2-row chip
grid at the top (drag L→R to rank). Each chip shows rank, spec-colored
abbreviation tag matching the tags used in plans/schedule, full name on
its own row, status glyph, and a micro credit bar. Hover/tap a chip to
see full status, allocated/threshold credits, and contributing-courses
breakdown in a popover.

The right pane splits into two side-by-side columns on desktop: Top
Plans (left) and Schedule (right), each scrolling independently. The
search progress bar hoists into a global strip below the spec grid so
it stays visible regardless of which column is scrolled.

Schedule blocks render their course choices as a horizontal row of
equal-width buttons (3-5 per set) instead of stacked rows. Pinned sets
collapse to a single line with the course name inline next to the set
title. Term headers (Spring/Summer/Fall) remain as section dividers.

On mobile, the layout becomes a 3-tab segmented control
(Specializations / Plans / Courses) with the search progress strip
above the tabs. The previous floating MobileStatusBanner and
MobileCourseBanner are dropped — tabs replace their navigation
function.
2026-05-09 17:45:28 -04:00

8.8 KiB

1. Layout reflow scaffolding

  • 1.1 In app/src/App.tsx, replace the desktop branch's 2-column grid (340px 1fr) with a vertical flex container holding: header section, mode toggle, mode comparison banner, specialization strip placeholder, progress strip placeholder, and a 1fr 2-column workspace (1fr 1fr) for plans/schedule.
  • 1.2 Ensure each workspace column has overflowY: auto; min-height: 0 and the parent grid has flex: 1; min-height: 0 so independent scroll works inside the existing 100vh container.
  • 1.3 Verify mobile branch JSX is byte-equivalent to today (no regressions to mobile stack, MobileStatusBanner / MobileCourseBanner refs, or scroll-into-view behavior).
  • 1.4 Move the CreditLegend rendering out of the Schedule column. It will be invoked from the spec strip header in step 2.

2. Specialization horizontal strip

  • 2.1 In app/src/components/SpecializationRanking.tsx, branch on useMediaQuery() === 'desktop' (or the existing isMobile derivation if shared) to render a strip variant.
  • 2.2 Implement the desktop strip container: single-row flex (display: flex; gap: 6px; overflowX: auto) so narrow viewports get a horizontal scrollbar on the strip rather than wrap.
  • 2.3 Implement compact Chip element: ~70px wide, vertical stack of {rank number small, abbreviation bold, micro credit bar}. Background color from STATUS_STYLES[status].bg. Use the abbreviation field from SPECIALIZATIONS.
  • 2.4 Reuse CreditBar rendering at smaller dimensions (height ~4px, no tick marks for the compact variant; only the threshold marker).
  • 2.5 Replace the verticalListSortingStrategy import with horizontalListSortingStrategy in the desktop branch (keep vertical for mobile). Reuse the existing DndContext, sensors, and arrayMove handler.
  • 2.6 Render the achievement summary ("X specializations achieved" / "No specializations achieved yet") in a strip header row above the chip strip on desktop. Right-align the [▸ legend] toggle.
  • 2.7 Mount the existing CreditLegend content as the expandable panel triggered by [▸ legend]. The legend toggle and panel can be rendered directly in App.tsx's spec-strip header area, or as a child of SpecializationRanking's strip header — pick one and stay consistent.

3. Specialization chip hover popover

  • 3.1 Build a SpecChipPopover component (can live in SpecializationRanking.tsx) that takes specId, anchorRect, result, and an onClose handler.
  • 3.2 Compute popover position with the same smart-flip logic as CourseInfoPopover (use spaceBelow, spaceAbove, clamp left to viewport).
  • 3.3 Popover content: full spec name (bold), status word ("Achieved" / "Achievable" / "Missing Required" / "Unreachable") with same color as the badge today, allocated/threshold numeric (e.g., "5.5 / 9.0"), and — only when the spec is achieved — a list of contributing courses with their credit amounts (reuse the existing AllocationBreakdown logic).
  • 3.4 Implement hover-open / hover-close with a 150ms close delay so the cursor can move from chip to popover. Mirror the pattern in CourseSelection.tsx (hoverCloseTimer, cancelHoverClose, handleHoverOpen, handleHoverLeave).
  • 3.5 Implement tap-toggle for touch devices: onClick toggles open/closed; clicking outside closes (use the same mousedown outside-click handler the existing popover uses).
  • 3.6 Open popover via onMouseEnter only when window.matchMedia('(hover: hover)').matches (consistent with how the course info popover handles touch).

4. Hoist search progress strip

  • 4.1 In app/src/components/TopPlans.tsx, remove the animated progress bar JSX and the percent/iteration sub-line. Keep the static "Search complete · N explored" / "Search incomplete · cap hit at N" status text in the plan header (it's a per-panel summary).
  • 4.2 Adjust TopPlansProps if needed so the parent can still pass progress and loading (only consumed for the static status text now).
  • 4.3 Create app/src/components/SearchProgressStrip.tsx exporting a component that takes progress: { iterations; iterationsTotal } | null and loading: boolean and renders the animated bar + iteration count when loading && progress. Render nothing otherwise (so it collapses to zero height when idle).
  • 4.4 Render SearchProgressStrip in App.tsx's desktop branch between the spec strip and the workspace columns.
  • 4.5 On mobile, render the existing inline-progress-bar JSX inside TopPlans (gate it on isMobile). The desktop strip only renders when !isMobile.

5. Schedule horizontal course buttons

  • 5.1 In app/src/components/CourseSelection.tsx, change ElectiveSet's unpinned branch container from display: flex; flexDirection: column; gap: 4px to display: flex; gap: 4px (no flex direction) on desktop. Keep the column layout on mobile via a useMediaQuery branch.
  • 5.2 Each course button gets flex: '1 1 0' so buttons stretch to equal width within the row. Set min-width: 0 on the button to allow text truncation.
  • 5.3 Restructure the button content into top/middle/bottom rows: top row contains the info icon (left) and the recommended star (right). Middle is the course name with WebkitLineClamp: 3. Bottom is the spec ceiling tag row.
  • 5.4 Add title={course.name} so the full name is available on hover for truncated names.
  • 5.5 Allow the spec-tag row to wrap (flex-wrap: wrap); use flex-grow: 1 on the wrapping container so button heights stretch to match the tallest button in the set.
  • 5.6 Map cancelled and already-selected states to button styling: gray background, strikethrough name (cancelled only), tiny footer label "(Cancelled)" or "(Already selected)" below the spec-tag row.
  • 5.7 Map per-course searching state to a pulsing skeleton band where the spec tags would render (reuse the cell-pulse keyframe).
  • 5.8 Map the required-for-spec note to a small amber footer line below the spec tags.
  • 5.9 Pinned-set branch is unchanged. Term headers (Spring/Summer/Fall) are unchanged.
  • 5.10 Verify the existing CourseInfoPopover invocation (and its hover/click/keyboard handlers) still works inside the new button anatomy — the info icon remains a clickable child element with stopPropagation.

6. Wiring and styles

  • 6.1 Confirm App.tsx passes searchProgress and treeLoading down to SearchProgressStrip and TopPlans correctly with the new responsibilities split.
  • 6.2 Confirm useAppState shape is unchanged (no state-machine edits needed for this refactor).
  • 6.3 Re-check the courseSectionRef and specSectionRef IntersectionObserver hooks in App.tsx: the refs must still anchor to mobile-only sections (the floating MobileStatusBanner / MobileCourseBanner depend on them).

7. Verification

  • 7.1 Type-check (npm run build or tsc --noEmit) clean. Note: vite build passes; tsc has pre-existing errors in feasibility.ts, optimizer.ts, decisionTree.ts, appState.ts, and an unused scorer prop in CourseSelection.tsx — all present at v1.3.3 baseline, none introduced by this change.
  • 7.2 Lint (npm run lint) clean. Note: same pre-existing 11 errors (setState-in-effect in appState.ts/App.tsx/SpecializationRanking.tsx SortableItem, unused vars in solver files, fast-refresh warning on existing STATUS_STYLES export). No new errors from this change.
  • 7.3 Manual desktop check at 1200px+ viewport: spec strip in single row, chip hover opens popover with correct content, drag-to-reorder works left-right, progress strip animates during search, 2 columns scroll independently, schedule blocks render horizontal button rows, all states render correctly (default / cancelled / already-selected / searching / recommended / required-for). Deferred — Playwright browser binary not available in this sandbox; user should verify in their dev environment.
  • 7.4 Manual desktop check at narrower viewport (~1024px): chip strip gets internal horizontal scrollbar without wrapping; column split still readable; schedule buttons still legible at minimum width. Deferred — same as 7.3.
  • 7.5 Manual mobile check (≤768px): vertical specialization list with arrows + drag, vertical-stacked schedule rows, progress bar inside Top Plans (not hoisted), MobileStatusBanner and MobileCourseBanner still appear when their respective sections scroll out of view. Deferred — same as 7.3.
  • 7.6 Existing unit tests pass (npm test). Verified: 84 tests pass across 6 test files.

8. Release

  • 8.1 Bump version to 1.4.0 in app/vite.config.ts.
  • 8.2 Add a 1.4.0 entry to CHANGELOG.md summarizing the desktop redesign (horizontal spec strip, hoisted progress, 2-col workspace, horizontal schedule buttons, mobile unchanged).
  • 8.3 Build (npm run build) and verify the production bundle. Verified via npx vite build: 403 KB JS bundle, 0.5 KB CSS, built in ~1.3s.