# 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

### `<Sidebar>`

The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.

```tsx
<Sidebar
  collapsed={false}
  onCollapseToggle={() => {}}
  searchValue=""
  onSearchChange={(query) => {}}
  className=""
>
  <Sidebar.Header ... />
  <Sidebar.Section ... />
  <Sidebar.Section ... />
  <Sidebar.Footer ... />
</Sidebar>
```

| 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)

### `<Sidebar.Header>`

Logo, title, and version. In collapsed mode, renders only the logo centered.

```tsx
<Sidebar.Header
  logo={<img src="..." />}
  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) |

### `<Sidebar.Section>`

An accordion section with a collapsible header and content area.

```tsx
<Sidebar.Section
  label="APPLICATIONS"
  icon={<Box size={14} />}
  collapsed={false}
  onToggle={() => {}}
  active={false}
>
  <SidebarTree nodes={nodes} ... />
</Sidebar.Section>
```

| 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 `<Sidebar>` 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.

### `<Sidebar.Footer>`

Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.

```tsx
<Sidebar.Footer>
  <Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
</Sidebar.Footer>
```

In collapsed mode, footer links render as centered icons with tooltips.

### `<Sidebar.FooterLink>`

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 |

### `<SidebarTree>` (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]);

<Sidebar
  collapsed={sidebarCollapsed}
  onCollapseToggle={() => setSidebarCollapsed(v => !v)}
  searchValue={filterQuery}
  onSearchChange={setFilterQuery}
>
  <Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />

  {isAdminPage && (
    <Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
      collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
      <SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
    </Sidebar.Section>
  )}

  <Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
    collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
    <SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
  </Sidebar.Section>

  <Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
    collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
    <SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
  </Sidebar.Section>

  <Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
    collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
    <SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
  </Sidebar.Section>

  <Sidebar.Footer>
    <Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
  </Sidebar.Footer>
</Sidebar>
```

## Mock App Migration — LayoutShell

The 11 page files currently duplicating `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>` 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 `<Outlet />`.

```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 (
    <AppShell
      sidebar={
        <Sidebar
          collapsed={sidebarCollapsed}
          onCollapseToggle={() => setSidebarCollapsed(v => !v)}
          searchValue={filterQuery}
          onSearchChange={setFilterQuery}
        >
          <Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
          <Sidebar.Section label="Applications" icon={...}
            collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
            <SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
          </Sidebar.Section>
          <Sidebar.Section label="Agents" icon={...}
            collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
            <SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
          </Sidebar.Section>
          <Sidebar.Section label="Routes" icon={...}
            collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
            <SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
          </Sidebar.Section>
          {/* Starred section built from useStarred + SIDEBAR_APPS */}
          <Sidebar.Footer>
            <Sidebar.FooterLink icon={...} label="Admin" ... />
            <Sidebar.FooterLink icon={...} label="API Docs" ... />
          </Sidebar.Footer>
        </Sidebar>
      }
    >
      <Outlet />
    </AppShell>
  )
}
```

### Route structure change

`App.tsx` switches from per-page `<Route element={<Page />}>` to a layout route:

```tsx
<Route element={<LayoutShell />}>
  <Route path="/apps" element={<Dashboard />} />
  <Route path="/apps/:id" element={<Dashboard />} />
  ...all existing routes...
</Route>
```

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 `<AppShell sidebar={...}>` wrapper and becomes just the page content.

The Inventory page's `LayoutSection` keeps its own inline `<Sidebar>` 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 `<Sidebar apps={[...]} onNavigate={...} />` 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.
