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.
This commit is contained in:
2026-05-09 17:45:28 -04:00
parent b282709476
commit 2ebfb9d2ec
14 changed files with 1293 additions and 205 deletions
+239 -134
View File
@@ -216,6 +216,7 @@ function ElectiveSet({
scorer,
rankWeight,
mode,
isMobile,
onPin,
onUnpin,
openPopoverId,
@@ -233,6 +234,7 @@ function ElectiveSet({
scorer: (specs: string[]) => number;
rankWeight: (specs: string[]) => number;
mode: OptimizationMode;
isMobile: boolean;
onPin: (courseId: string) => void;
onUnpin: () => void;
openPopoverId: string | null;
@@ -282,15 +284,32 @@ function ElectiveSet({
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
borderStyle: isPinned ? 'solid' : 'dashed',
borderRadius: '8px',
padding: '12px',
padding: isPinned ? '8px 12px' : '12px',
marginBottom: '8px',
background: isPinned ? '#eff6ff' : '#fafafa',
transition: 'border-color 200ms, background-color 200ms',
transition: 'border-color 200ms, background-color 200ms, padding 200ms',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{setName}</span>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: '8px',
marginBottom: isPinned ? 0 : '8px',
}}>
<h4 style={{
fontSize: '13px', margin: 0, color: '#444',
display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap',
minWidth: 0, flex: 1,
}}>
<span style={{ flexShrink: 0 }}>{setName}{isPinned ? ':' : ''}</span>
{isPinned && pinnedCourse && (
<span style={{
fontSize: '13px', fontWeight: 600, color: '#1e40af',
minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{pinnedCourse.name}
</span>
)}
{!isPinned && setSearching && (
<span style={{
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
@@ -302,10 +321,11 @@ function ElectiveSet({
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
)}
</h4>
{!isPinned && (
{!isPinned && isMobile && (
<span style={{
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
textTransform: 'uppercase',
flexShrink: 0,
}}>
top outcome if picked
</span>
@@ -317,23 +337,13 @@ function ElectiveSet({
fontSize: '11px', border: '1px solid #bfdbfe', background: '#eff6ff',
color: '#2563eb', cursor: 'pointer', padding: '3px 10px',
borderRadius: '4px', fontWeight: 500,
flexShrink: 0,
}}
>
Clear
</button>
)}
</div>
{/* Pinned view */}
<div style={{
maxHeight: isPinned ? '40px' : '0',
opacity: isPinned ? 1 : 0,
overflow: 'hidden',
transition: 'max-height 250ms ease-out, opacity 200ms',
}}>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
{pinnedCourse?.name}
</div>
</div>
{/* Course list view */}
<div style={{
maxHeight: isPinned ? '0' : '500px',
@@ -342,7 +352,12 @@ function ElectiveSet({
pointerEvents: isPinned ? 'none' : 'auto',
transition: 'max-height 250ms ease-out, opacity 200ms',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'stretch',
gap: '4px',
}}>
{courses.map((course) => {
const isCancelled = !!course.cancelled;
const isDisabled = disabledCourseIds.has(course.id);
@@ -353,137 +368,224 @@ function ElectiveSet({
const cellSearching = !!ceiling && !ceiling.evaluated;
const isRecommended = recommendedCourseId === course.id;
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
const infoIcon = !isUnavailable && hasInfo ? (
<span
role="button"
tabIndex={0}
aria-label={`Info about ${course.name}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (openPopoverId === course.id) {
onClosePopover();
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onOpenPopover(course.id, rect);
}
}}
onMouseEnter={(e) => {
if (window.matchMedia('(hover: hover)').matches) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onHoverOpen(course.id, rect);
}
}}
onMouseLeave={() => {
if (window.matchMedia('(hover: hover)').matches) {
onHoverLeave();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
if (openPopoverId === course.id) {
onClosePopover();
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onOpenPopover(course.id, rect);
}
}
}}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '16px', height: '16px', borderRadius: '50%',
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
color: '#6366f1', fontSize: '10px', fontWeight: 700,
cursor: 'pointer', flexShrink: 0,
fontStyle: 'normal', textDecoration: 'none',
lineHeight: 1,
}}
>
i
</span>
) : null;
const ceilingTags = !isUnavailable && (showSkeleton || cellSearching) ? (
<span style={{
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
<span style={{
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
}} />
searching
</span>
) : !isUnavailable && ceiling && ceiling.evaluated ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '4px',
flexWrap: 'wrap', justifyContent: isMobile ? 'flex-end' : 'flex-start',
}}>
{ceiling.ceilingSpecs.length === 0 ? (
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
no specs
</span>
) : (
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
)}
</span>
) : null;
if (isMobile) {
return (
<button
key={course.id}
onClick={isUnavailable ? undefined : () => onPin(course.id)}
disabled={isUnavailable}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px',
background: isUnavailable ? '#f5f5f5' : '#fff',
cursor: isUnavailable ? 'default' : 'pointer',
fontSize: '13px',
color: isUnavailable ? '#bbb' : '#333',
pointerEvents: isUnavailable ? 'none' : 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<span style={{
flex: 1,
textDecoration: isCancelled ? 'line-through' : 'none',
fontStyle: isCancelled ? 'italic' : 'normal',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}>
{course.name}
{isCancelled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
(Cancelled)
</span>
)}
{!isCancelled && isDisabled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
(Already selected)
</span>
)}
{isRecommended && !isUnavailable && (
<span
title="Best outcome among the choices in this set"
style={{
fontSize: '10px', color: '#15803d', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: '2px',
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
border: '1px solid #bbf7d0', lineHeight: 1.2,
}}
>
<span aria-hidden="true"></span>
<span>Recommended</span>
</span>
)}
{infoIcon}
</span>
{ceilingTags}
</div>
{reqFor && !isUnavailable && (
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
Required for {reqFor.join(', ')}
</span>
)}
</button>
);
}
// Desktop horizontal button
return (
<button
key={course.id}
onClick={isUnavailable ? undefined : () => onPin(course.id)}
disabled={isUnavailable}
title={course.name}
style={{
flex: '1 1 0',
minWidth: 0,
display: 'flex', flexDirection: 'column', alignItems: 'stretch',
textAlign: 'left', padding: '6px 10px',
border: '1px solid #e5e7eb', borderRadius: '4px',
background: isUnavailable ? '#f5f5f5' : '#fff',
textAlign: 'left', padding: '6px 8px', gap: '4px',
border: isRecommended && !isUnavailable ? '1px solid #bbf7d0' : '1px solid #e5e7eb',
borderRadius: '4px',
background: isUnavailable ? '#f5f5f5' : isRecommended ? '#f0fdf4' : '#fff',
cursor: isUnavailable ? 'default' : 'pointer',
fontSize: '13px',
fontSize: '12px',
color: isUnavailable ? '#bbb' : '#333',
pointerEvents: isUnavailable ? 'none' : 'auto',
font: 'inherit',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<span style={{
flex: 1,
textDecoration: isCancelled ? 'line-through' : 'none',
fontStyle: isCancelled ? 'italic' : 'normal',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}>
{course.name}
{isCancelled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px', fontStyle: 'normal', textDecoration: 'none' }}>
(Cancelled)
</span>
)}
{!isCancelled && isDisabled && (
<span style={{ fontSize: '11px', color: '#999', marginLeft: '6px' }}>
(Already selected)
</span>
)}
{isRecommended && !isUnavailable && (
<span
title="Best outcome among the choices in this set"
style={{
fontSize: '10px', color: '#15803d', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: '2px',
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
border: '1px solid #bbf7d0', lineHeight: 1.2,
}}
>
<span aria-hidden="true"></span>
<span>Recommended</span>
</span>
)}
{!isUnavailable && hasInfo && (
<span
role="button"
tabIndex={0}
aria-label={`Info about ${course.name}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (openPopoverId === course.id) {
onClosePopover();
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onOpenPopover(course.id, rect);
}
}}
onMouseEnter={(e) => {
if (window.matchMedia('(hover: hover)').matches) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onHoverOpen(course.id, rect);
}
}}
onMouseLeave={() => {
if (window.matchMedia('(hover: hover)').matches) {
onHoverLeave();
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
if (openPopoverId === course.id) {
onClosePopover();
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onOpenPopover(course.id, rect);
}
}
}}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: '16px', height: '16px', borderRadius: '50%',
border: '1px solid #cbd5e1', background: openPopoverId === course.id ? '#e0e7ff' : '#f1f5f9',
color: '#6366f1', fontSize: '10px', fontWeight: 700,
cursor: 'pointer', flexShrink: 0,
fontStyle: 'normal', textDecoration: 'none',
lineHeight: 1,
}}
>
i
</span>
)}
</span>
{!isUnavailable && (showSkeleton || cellSearching) ? (
<span style={{
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
<span style={{
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
}} />
searching
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
minHeight: '16px',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>{infoIcon}</span>
{isRecommended && !isUnavailable && (
<span
title="Best outcome among the choices in this set"
aria-label="Recommended"
style={{
fontSize: '12px', color: '#15803d', lineHeight: 1,
}}
>
</span>
) : !isUnavailable && ceiling && ceiling.evaluated ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '4px',
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
}}>
{ceiling.ceilingSpecs.length === 0 ? (
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
no specs
</span>
) : (
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
)}
</span>
) : null}
)}
</div>
<div style={{
fontSize: '12px',
color: isUnavailable ? '#bbb' : '#333',
fontWeight: isRecommended && !isUnavailable ? 600 : 500,
lineHeight: 1.3,
textDecoration: isCancelled ? 'line-through' : 'none',
fontStyle: isCancelled ? 'italic' : 'normal',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
flex: 1,
}}>
{course.name}
</div>
{ceilingTags && (
<div style={{
display: 'flex', flexWrap: 'wrap', gap: '3px',
marginTop: 'auto', minHeight: '18px',
}}>
{ceilingTags}
</div>
)}
{isCancelled && (
<span style={{ fontSize: '10px', color: '#999', fontStyle: 'normal', textDecoration: 'none' }}>
Cancelled
</span>
)}
{!isCancelled && isDisabled && (
<span style={{ fontSize: '10px', color: '#999' }}>
Already selected
</span>
)}
{reqFor && !isUnavailable && (
<span style={{ fontSize: '11px', color: '#92400e', marginTop: '2px' }}>
Required for {reqFor.join(', ')}
<span style={{ fontSize: '10px', color: '#92400e', lineHeight: 1.3 }}>
Req. for {reqFor.join(', ')}
</span>
)}
</button>
@@ -506,6 +608,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
const rankWeight = useMemo(() => makePriorityRankWeight(ranking), [ranking]);
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
const hasPinned = Object.keys(pinnedCourses).length > 0;
const breakpoint = useMediaQuery();
const isMobile = breakpoint === 'mobile';
// Index tree results by setId for O(1) lookup
const treeBySet = new Map(treeResults.map((a) => [a.setId, a]));
@@ -586,6 +690,7 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
scorer={scorer}
rankWeight={rankWeight}
mode={mode}
isMobile={isMobile}
onPin={(courseId) => onPin(set.id, courseId)}
onUnpin={() => onUnpin(set.id)}
openPopoverId={popover?.courseId ?? null}