Skip to content

Global Component Library - #199

Open
bnguyen1212 wants to merge 13 commits into
feat-admin-sitefrom
global-component-library
Open

Global Component Library#199
bnguyen1212 wants to merge 13 commits into
feat-admin-sitefrom
global-component-library

Conversation

@bnguyen1212

Copy link
Copy Markdown
Contributor

Summary

@sage/ui package is now the shared source of truth for navigation atoms/shell + reusable primitives.

Documentation

Adoption guidelines documented in packages/ui/ADOPTION_GUIDE.md

Next steps:

Potential CI validation workflow for future site adoption

Closes #190

- new workspace package @sage/ui
- package scaffold
- wired main to package with workspace dependency
- added .turbo to .gitignore
- added shared UI primitives in packages/ui/src
- replaced main app imports from local to @sage/ui
- extracted common dev banner, logos, nav links, user profile
- created routing hook
- update tailwind config to scan package
- created package docs
@bnguyen1212
bnguyen1212 requested a review from joalen April 20, 2026 20:25
@vercel

vercel Bot commented Apr 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sage-site Ready Ready Preview Aug 3, 2026 9:26pm

Request Review

@bnguyen1212 bnguyen1212 linked an issue Apr 20, 2026 that may be closed by this pull request

@joalen joalen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work and a solid job trying to make it globalized!! Code looks good, but some suggestions I have...

  • We should globalize the sidebar; things that would differ when inherited from parent sidebar component: left button content + action, sidebar content, buttons related to collapsed state of sidebar and its actions. Everything else I think can be kept the same as far as I know.
  • UI clippings for certain elements (check below):

(should be above the navbar...check reference image labeled below):
Image

Reference image:
Image

(sage logo not visible...check the reference img labeled below):
Image

Reference image:
Image

@bnguyen1212

Copy link
Copy Markdown
Contributor Author

Requested Fixes

  • fixed z-index interactions
Screenshot 2026-08-03 161857 Screenshot 2026-08-03 161926

packages/ui — Shared Component Library

Sidebar system (new, shared between Planner and ChatBot)

  • SidebarTemplate — expanded/collapsed shell; keeps expanded content mounted to preserve child state during collapse
  • SidebarCollapsedRail — collapsed icon rail with toggle, action buttons, footer slot
  • SidebarCollapseToggle — the collapse/expand chevron button
  • SidebarPrimaryAction — the green primary CTA button

Architecture Optimizations

Chatbot: Single Zustand Store Replaces Triple Hook + Event Bus

Before: Three independent useChatbot() instances ran simultaneously — one each in ChatBot.tsx, ChatBotNavbar.tsx, and ChatSidebarContent.tsx. They were kept in sync via a homegrown chatEventEmitter pub/sub singleton. This caused duplicate API calls on mount, eventually-consistent state, and a conditional hook call (chatHook ?? useChatbot()) that violated React's Rules of Hooks.

After: A single useChatbotStore (Zustand + immer) holds all conversation state. Every component reads directly from the store. The event bus and the hook are deleted.

  • main/src/stores/chatbotStore.ts — new; owns conversations, activeConversationId, loading, error; preserves the 1-hour localStorage TTL cache
  • main/src/hooks/useChatbot.tsxdeleted
  • main/src/utils/chatEventEmitter.tsdeleted
  • ChatBotNavbar.tsx, ChatSidebarContent.tsx, ChatSidebarShell.tsx — rewritten to read from store directly; no prop drilling of hook instances
  • Profile.tsx — navigation to chatbot now works via localStorage write; no emitter needed

Result: One API call on mount (was two), no race conditions, no Rules of Hooks violation.


Planner Sidebar: Deduplication + Mobile Parity

Two sidebar files (Sidebar.tsx, PlannerSidebarContent.tsx) shared ~300 lines of near-identical logic. Mobile was also missing features that desktop had.

Extracted shared pieces:

  • main/src/utils/plannerSidebarUtils.tshasCompletion, filterCategories, collectAllSuggestedCourses
  • main/src/hooks/usePlannerSidebarCategories.ts — shared state + effects: auto-expand on requirements change, focusLabel scroll, highlightedKey scroll+clear
  • main/src/components/planner/PlannerDiscoveryBanner.tsx — the green "Discover Courses" banner, reads stagedCourses from usePlannerStore directly

Renamed for clarity:

  • Sidebar.tsxPlannerSidebarDesktop.tsx
  • PlannerSidebarContent.tsxPlannerSidebarMobile.tsx

Mobile brought to parity with desktop:

  • Pipe | category name splitting (was desktop-only)
  • prereq_blocked deduplication against suggested courses (was desktop-only)

react-router-dom: Moved to peerDependencies

react-router-dom was in dependencies of packages/ui, meaning the library could ship its own copy separate from main/. Since React Router uses React context internally, two copies in the same page would break Link routing. Moved to peerDependencies (consumer provides it) and devDependencies (for tsc type resolution during build).


CSS: Tailwind Preset Extracted into packages/ui

Before: packages/ui components used tokens like text-textdark, bg-accent, and font-dmsans — but those token definitions lived only in main/tailwind.config.js. Any consumer other than main/ would need to duplicate the full Tailwind theme.

After: Design tokens are defined once in packages/ui/tailwind.preset.js and consumed by main/tailwind.config.js via presets: [uiPreset]. main/ retains only the shadcn HSL-variable tokens that are specific to it. The preset is the single source of truth for the SAGE design system.


PlannerNavbar: Prop Drilling Eliminated

Before: PlannerNavbar had a 16-prop interface, every prop a pass-through to PlannerSidebarMobile inside its sidebarContent render prop. The navbar itself used none of them.

After: Planner.tsx composes <PlannerSidebarMobile> directly and passes it to <PlannerNavbar sidebarContent={...} />. PlannerNavbar now has a single prop and no knowledge of planner-specific data. It is a reusable layout shell.


@bnguyen1212
bnguyen1212 requested a review from joalen August 3, 2026 21:31

@joalen joalen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, check it out:

  1. mobile planner sidebar now shows different categories than before
    hasCompletion used to differ between mobile and desktop, where mobile ignored suggested/prereq_blocked when deciding whether to show an AND/OR category, desktop didn't.

This unification means mobile will now render categories that were previously filtered out (any category whose only "completion" is having suggested or prereq-blocked courses, with no actual progress). That's probably the right behavior, but it's a visible UI change on mobile that isn't mentioned in the PR description

Can you check if this was tested on mobile and is intentional, not just an artifact of the merge?

  1. These
Image

^ feels a bit awkward, could we match it to how it was on the original chatbot site? Basically the three dots (screenshot cut off but the idea there) are needed for each of the convos in the list + the adjustable sidebar here.

Image

Also it looks like the sidebar height is variable rather than static from before so maybe we can keep that behavior or this might happen in which it clips to the bottom

Image

You prob might need to merge from dev into the feat-admin site branch to propagate some of those new UI elements that planner has (like this):

Image

Comment on lines +249 to +253
onClick={(e) => {
e.stopPropagation();
startNewChat();
onClose?.();
}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls startNewChat() directly rather than what ChatBot.tsx's handleStartNewChat uses. Desktop saves the current in-progress conversation to the store before clearing activeConversationId so that's good, but the mobile version doesn't get this.

If a user sends a message and taps "new chat" from the mobile drawer before the response lands, that exchange can get dropped.

Can we route this through the same save-then-clear logic, or lift handleStartNewChat somewhere both desktop and mobile can call and retain the same behavior?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To help a bit, check out the logic in this code definition: handleStartNewChat

Comment on lines +8 to +9
onClose?: () => void;
layout?: "mobile" | "template";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can prob add in a property that allows for piping messages in (essentially the same => void) prop for the onClose? but for starting a new chat

className="w-full flex transition-all duration-100 items-center justify-center space-x-2 py-2 px-6 rounded-3xl bg-accent text-textdark hover:text-gray-700"
onClick={(e) => {
e.stopPropagation();
startNewChat();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that new property for the ChatSidebarContentProps will help you out in fixing this issue initially for my startNewChat() comment

Comment on lines 14 to +23
const {
conversations,
conversation_id,
activeConversationId,
error,
loading,
setConversations,
setActiveConversationId,
startNewChat,
deleteConversation,
renameConversation,
setConversationId,
initialLoad
} = useChatbot();
} = useChatbotStore();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing calls that function: initialLoad(user), maybe we can bring back that useEffect?

Comment on lines +283 to +348
renderExpandedContent={
<div
ref={drop}
className={`${isOver ? 'bg-gray-100' : ''}`}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<PlannerDiscoveryBanner onOpenDiscovery={onOpenDiscovery} />

{stagedCourses.length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between px-1 mb-2">
<div className="text-[10px] font-semibold text-purple-500 uppercase tracking-wide flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-purple-400 inline-block" />
Staged · {stagedCourses.length} course{stagedCourses.length !== 1 ? 's' : ''}
</div>
<button
onClick={() => !allStagedPlaced && usePlannerStore.getState().clearStagedCourses()}
className={`text-[10px] transition-colors ${allStagedPlaced ? 'text-gray-300 cursor-not-allowed pointer-events-none' : 'text-gray-400 hover:text-red-400 cursor-pointer'}`}
>
Clear all
</button>
</div>
<CoursesCarousel
courses={stagedCourses}
type="discovered"
onRemove={removeStagedCourse}
coursebookData={coursebookData}
gradesData={gradesData}
coursebookSemester={coursebookSemester}
availableSemesters={[]}
placedSuggestedCourses={placedSuggestedCourses}
/>
</div>
)}

<h2 className="text-xl font-bold text-gray-900 mb-4">Degree Requirements</h2>

<div className="space-y-3 pb-24">
{requirements.map((req, reqIdx) => {
const reqCompletion = semesters ? getCompletionForCategory(req, semesters) : { completed: req.progress, total: req.total, isCreditBased: true };
const reqCreditsBreakdown = reqCompletion.isCreditBased && semesters ? getCreditsBreakdownRecursive(req, semesters) : undefined;
return (
<RequirementCategory
key={reqIdx}
title={req.degree}
completed={reqCompletion.completed}
total={reqCompletion.total}
isExpanded={autoExpandedCategories[reqIdx]}
onToggle={() => setAutoExpandedCategories((prev) => ({ ...prev, [reqIdx]: !prev[reqIdx] }))}
hasSubcategories={req.categories?.length > 0}
isFirstCategory={reqIdx === 0}
creditsBreakdown={reqCreditsBreakdown}
footnote={(req as any).footnote}
rules={(req as any).rules}
>
{req.categories?.length > 0 ? (
renderCategories(req.categories, reqIdx)
) : (
<div className="text-sm text-gray-500">No categories available</div>
)}
</RequirementCategory>
);
})}
</div>
</div>
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useDrop ref no longer covers the header area (the "Edit plans" button + collapse toggle row, now rendered by SidebarTemplate itself outside renderExpandedContent). If a course is dropped near the top of the sidebar it may no longer register.

See if we can do a quick manual drag-and-drop test near the top edge before we merge it to our branch so that our behavior doesn't regress from original.

return (
<button
aria-label={label}
className={className ?? "flex transition-all duration-100 items-center space-x-2 py-2 px-8 rounded-3xl bg-accent text-textdark text-base hover:text-gray-700"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used to be:
className="flex transition-all duration-100 items-center space-x-2 py-2 px-6 rounded-3xl bg-accent text-textdark hover:text-gray-700"

to which the new one there can introduce a visual regression, check that out

Comment on lines +24 to +29
primaryAction={{
label: "Start new chat",
icon: <MessageCirclePlusIcon size={24} aria-hidden="true" />,
onClick: onStartNewChat,
dataTour: "new-chat-expanded",
}}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared default (px-8) matches the old planner "Edit plans" button, not the old chat "Start new chat" button (px-6). Since ChatSidebarShell doesn't pass a className override, the chat sidebar's new-chat button picked up slightly wider horizontal padding than before. Minor, but worth a visual diff check to see if this is needed.

Comment on lines +65 to +67
<div className="w-12 h-12 flex items-center justify-center -translate-y-4">
{footer ?? <PanelLeftDashed size={24} className="stroke-[#bbbbbb] group-hover/sidebar:stroke-[#dddddd] transition-colors duration-150" />}
</div>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group-hover/sidebar:stroke-[#dddddd] has no matching group/sidebar ancestor in either consumer, so this hover effect is effectively a no-op.

Might have been a preexisting issue carried over from the old code, but since it's now baked into a shared package component, worth either adding group/sidebar to SidebarTemplate's root div or dropping the dead class.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so if I read the guide right, it tells future devs that we need to manually copy an (incomplete) list of 8 color tokens, but from what i can see, main/tailwind.config.js just does presets: [uiPreset] and gets everything for free: border, secondary, textsecondary, buttondisabled, which the guide's list omits entirely and which shared components (e.g. SidebarTemplate's border border-border) rely on.

Can you update the guide to recommend presets: [uiPreset] instead of manual token duplication? As written, a team following this guide would skip missing borders/secondary colors.

user={user}
logout={logout}
profilePicture={profilePicture}
triggerClassName={!profilePicture ? "bg-secondary p-2 rounded-full" : "p-2 rounded-full"}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one! Apparently the old code never applied that bg-secondary mark in which it was because someone forgot to backtick that class.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create common UI components shared space

2 participants