# Composable Sidebar Refactor **Date:** 2026-04-02 **Upstream issue:** cameleer3-server #112 ## Why The current `Sidebar` component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`). The consuming application can only pass `SidebarApp[]` data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package. This blocks two features the consuming application needs: 1. **Admin accordion** — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application 2. **Icon-rail collapse** — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.) ## Goal Refactor `Sidebar` into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content. ## Current Exports (to be replaced) ```typescript // Current — monolithic export { Sidebar } from './Sidebar/Sidebar' export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar' ``` ## New Exports ```typescript // New — composable export { Sidebar } from './Sidebar/Sidebar' export { SidebarTree } from './Sidebar/SidebarTree' export type { SidebarTreeNode } from './Sidebar/SidebarTree' export { useStarred } from './Sidebar/useStarred' ``` `SidebarApp`, `SidebarRoute`, `SidebarAgent` types are removed — they are application-domain types that move to the consuming app. ## Compound Component API ### `` The outer shell. Renders the sidebar frame with an optional search input and collapse toggle. ```tsx {}} searchValue="" onSearchChange={(query) => {}} className="" > ``` | Prop | Type | Default | Description | |------|------|---------|-------------| | `collapsed` | `boolean` | `false` | Render as ~48px icon rail | | `onCollapseToggle` | `() => void` | - | Collapse/expand toggle clicked | | `onSearchChange` | `(query: string) => void` | - | Search input changed. Omit to hide search. | | `searchValue` | `string` | `''` | Controlled value for the search input | | `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer | | `className` | `string` | - | Additional CSS class | **Search state ownership:** The DS renders the search input as a dumb controlled input and calls `onSearchChange` on every keystroke. The consuming application owns the search state and passes it to each `SidebarTree` as `filterQuery`. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally. **Rendering rules:** - Expanded: full width (~260px), all content visible - Collapsed: ~48px wide, only icons visible, tooltips on hover - Width transition: `transition: width 200ms ease` - Collapse toggle button (`<<` / `>>` chevron) in top-right corner - Search input hidden when collapsed - Search input auto-positioned between `Sidebar.Header` and first `Sidebar.Section` (not above Header) ### `` Logo, title, and version. In collapsed mode, renders only the logo centered. ```tsx } title="cameleer" version="v3.2.1" /> ``` | Prop | Type | Default | Description | |------|------|---------|-------------| | `logo` | `ReactNode` | - | Logo element | | `title` | `string` | - | App name (hidden when collapsed) | | `version` | `string` | - | Version text (hidden when collapsed) | ### `` An accordion section with a collapsible header and content area. ```tsx } collapsed={false} onToggle={() => {}} active={false} > ``` | Prop | Type | Default | Description | |------|------|---------|-------------| | `label` | `string` | - | Section header text (rendered uppercase via CSS) | | `icon` | `ReactNode` | - | Icon for header and collapsed rail | | `collapsed` | `boolean` | `false` | Whether children are hidden | | `onToggle` | `() => void` | - | Header clicked | | `children` | `ReactNode` | - | Content when expanded | | `active` | `boolean` | - | Override active highlight. If omitted, not highlighted. | **Expanded rendering:** ``` [icon] APPLICATIONS (children rendered here) ``` **Collapsed rendering:** ``` [icon] APPLICATIONS ``` **In sidebar icon-rail mode:** ``` [icon] <- centered, tooltip shows label on hover ``` Header has: icon and label (no chevron — the entire row is clickable). Active section gets the amber left-border accent (existing pattern). Clicking anywhere on the header row calls `onToggle`. **Implementation detail:** `Sidebar.Section` and `Sidebar.Header` need to know the parent's `collapsed` state to switch between expanded and icon-rail rendering. The `` component provides `collapsed` and `onCollapseToggle` via React context (`SidebarContext`). Sub-components read from context — no prop drilling needed. **Icon-rail click behavior:** In collapsed mode, clicking a section icon fires both `onCollapseToggle` and `onToggle` simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate. ### `` Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items. ```tsx } label="API Docs" onClick={() => {}} /> ``` In collapsed mode, footer links render as centered icons with tooltips. ### `` A single bottom link. | Prop | Type | Default | Description | |------|------|---------|-------------| | `icon` | `ReactNode` | - | Link icon | | `label` | `string` | - | Link text (hidden when collapsed, shown as tooltip) | | `onClick` | `() => void` | - | Click handler | | `active` | `boolean` | `false` | Active state highlight | ### `` (no changes, newly exported) Already exists at `Sidebar/SidebarTree.tsx`. No modifications needed — it already accepts all data via props. Just export it from the package. **Current props (unchanged):** | Prop | Type | Description | |------|------|-------------| | `nodes` | `SidebarTreeNode[]` | Tree data | | `selectedPath` | `string` | Currently active path for highlighting | | `filterQuery` | `string` | Search filter text | | `onNavigate` | `(path: string) => void` | Navigation callback | | `persistKey` | `string` | localStorage key for expand state | | `autoRevealPath` | `string \| null` | Path to auto-expand to | | `isStarred` | `(id: string) => boolean` | Star state checker | | `onToggleStar` | `(id: string) => void` | Star toggle callback | ### `useStarred` hook (no changes, newly exported) Already exists at `Sidebar/useStarred.ts`. Export as-is. **Returns:** `{ starredIds, isStarred, toggleStar }` ## What Gets Removed All of this application-specific logic is deleted from the DS: 1. **`buildAppTreeNodes()`** (~30 lines) — transforms `SidebarApp[]` into `SidebarTreeNode[]` 2. **`buildRouteTreeNodes()`** (~20 lines) — transforms apps into route tree nodes 3. **`buildAgentTreeNodes()`** (~25 lines) — transforms apps into agent tree nodes with live-count badges 4. **`collectStarredItems()`** (~20 lines) — gathers starred items across types 5. **`StarredGroup`** sub-component (~30 lines) — renders grouped starred items 6. **Hardcoded sections** (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence 7. **Hardcoded bottom links** (~30 lines) — Admin and API Docs links 8. **Auto-reveal effect** (~20 lines) — `sidebarRevealPath` effect 9. **`SidebarApp`, `SidebarRoute`, `SidebarAgent` types** — domain types, not DS types 10. **`formatCount()` helper** — number formatting, moves to consuming app Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell. ## CSS Changes ### New styles needed - `.sidebarCollapsed` — narrow width (48px), centered icons - `.collapseToggle` — `<<` / `>>` button positioning - `.sectionIcon` — icon rendering in section headers - `.tooltip` — hover tooltips for collapsed mode - Width transition: `transition: width 200ms ease` on `.sidebar` ### Styles that stay - `.sidebar` (modified: width becomes conditional) - `.searchWrap`, `.searchInput` (unchanged) - `.navArea` (unchanged) - All tree styles in `SidebarTree` (unchanged) ### Styles removed - `.bottom`, `.bottomItem`, `.bottomItemActive` — replaced by `Sidebar.Footer` / `Sidebar.FooterLink` styles - `.starredSection`, `.starredGroup`, `.starredItem`, `.starredRemove` — starred rendering moves to app - `.section` — replaced by `Sidebar.Section` styles ## File Structure After Refactor ``` Sidebar/ ├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header, │ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink ├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed) ├── SidebarTree.tsx # Unchanged ├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css) ├── useStarred.ts # Unchanged ├── useStarred.test.ts # Unchanged └── Sidebar.test.tsx # Updated for new compound API ``` ## Testing Update `Sidebar.test.tsx` to test the compound component API: - Renders Header with logo, title, version - Renders Sections with labels and icons - Section toggle calls `onToggle` - Collapsed sections hide children - Sidebar collapsed mode renders icon rail - Collapse toggle calls `onCollapseToggle` - Footer links render with icons and labels - Collapsed mode hides labels, shows tooltips - Search input calls `onSearchChange` - Search hidden when sidebar collapsed - Section icon click in collapsed mode calls both `onCollapseToggle` and `onToggle` `SidebarTree` tests are unaffected. ## Usage Example (for reference) This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only. ```tsx // In LayoutShell.tsx (consuming app) const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [filterQuery, setFilterQuery] = useState(''); const [appsCollapsed, setAppsCollapsed] = useState(false); const [agentsCollapsed, setAgentsCollapsed] = useState(false); const [routesCollapsed, setRoutesCollapsed] = useState(true); const [adminCollapsed, setAdminCollapsed] = useState(true); // Accordion: entering admin expands admin, collapses others useEffect(() => { if (isAdminPage) { setAdminCollapsed(false); setAppsCollapsed(true); setAgentsCollapsed(true); setRoutesCollapsed(true); } else { setAdminCollapsed(true); // restore previous operational states } }, [isAdminPage]); setSidebarCollapsed(v => !v)} searchValue={filterQuery} onSearchChange={setFilterQuery} > } title="cameleer" version="v3.2.1" /> {isAdminPage && ( } collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}> )} } collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}> } collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}> } collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}> } label="API Docs" onClick={() => nav('/api-docs')} /> ``` ## Mock App Migration — LayoutShell The 11 page files currently duplicating `}>` will be consolidated into a single `LayoutShell` component. ### `src/layout/LayoutShell.tsx` Composes the sidebar once using the new compound API. All page-specific content is rendered via ``. ```tsx // src/layout/LayoutShell.tsx export function LayoutShell() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [filterQuery, setFilterQuery] = useState('') const [appsCollapsed, setAppsCollapsed] = useState(false) const [agentsCollapsed, setAgentsCollapsed] = useState(false) const [routesCollapsed, setRoutesCollapsed] = useState(false) const { starredIds, isStarred, toggleStar } = useStarred() const location = useLocation() // ... build tree nodes from SIDEBAR_APPS, starred section, etc. return ( setSidebarCollapsed(v => !v)} searchValue={filterQuery} onSearchChange={setFilterQuery} > setAppsCollapsed(v => !v)}> setAgentsCollapsed(v => !v)}> setRoutesCollapsed(v => !v)}> {/* Starred section built from useStarred + SIDEBAR_APPS */} } > ) } ``` ### Route structure change `App.tsx` switches from per-page `}>` to a layout route: ```tsx }> } /> } /> ...all existing routes... ``` All tree-building helpers (`buildAppTreeNodes`, `buildRouteTreeNodes`, `buildAgentTreeNodes`), starred section logic (`collectStarredItems`, `StarredGroup`), `formatCount`, and `sidebarRevealPath` handling move from `Sidebar.tsx` into `LayoutShell.tsx`. Each page file loses its `` wrapper and becomes just the page content. The Inventory page's `LayoutSection` keeps its own inline `` demo with `SAMPLE_APPS` data — it's a showcase, not a navigation shell. ## Breaking Change This is a **breaking change** to the `Sidebar` API. The old `` signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle. Coordinate: bump DS version, update server UI, deploy together.