# Composable Sidebar Refactor — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace the monolithic `Sidebar` component with a composable compound component (`Sidebar`, `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`), export `SidebarTree` and `useStarred` publicly, and migrate the mock app to a single `LayoutShell` using the new API.

**Architecture:** The DS `Sidebar` becomes a composable shell providing frame, search input, and collapse toggle via React context. Application-specific logic (tree building, starred grouping, section ordering) moves into `LayoutShell.tsx` in the mock app. React Router layout routes replace per-page `<AppShell sidebar={...}>` wrappers.

**Tech Stack:** React 18, TypeScript, CSS Modules, React Router v6 (layout routes + `<Outlet />`), Vitest + React Testing Library

**Spec:** `docs/superpowers/specs/2026-04-02-composable-sidebar-design.md`

---

## File Structure

### Design System (create/modify)

| File | Action | Purpose |
|------|--------|---------|
| `src/design-system/layout/Sidebar/SidebarContext.ts` | Create | React context for `collapsed` + `onCollapseToggle` |
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Rewrite | Compound component shell with sub-components |
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Remove app-specific styles, add section/footer/collapsed/tooltip styles |
| `src/design-system/layout/Sidebar/SidebarTree.tsx` | No change | Already data-driven, just newly exported |
| `src/design-system/layout/Sidebar/useStarred.ts` | No change | Already standalone, just newly exported |
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Rewrite | Tests for compound component API |
| `src/design-system/layout/index.ts` | Modify | Export `SidebarTree`, `SidebarTreeNode`, `useStarred`; remove `SidebarApp`/`SidebarRoute`/`SidebarAgent` |

### Mock App (create/modify)

| File | Action | Purpose |
|------|--------|---------|
| `src/layout/LayoutShell.tsx` | Create | Single sidebar composition with compound API + `<Outlet />` |
| `src/App.tsx` | Modify | Layout route wrapping all pages |
| `src/pages/Dashboard/Dashboard.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
| `src/pages/Routes/Routes.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
| `src/pages/ExchangeDetail/ExchangeDetail.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
| `src/pages/AgentHealth/AgentHealth.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
| `src/pages/AgentInstance/AgentInstance.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper (two return sites) |
| `src/pages/Admin/Admin.tsx` | Modify | `AdminLayout` drops `<AppShell sidebar={...}>`, becomes content-only |
| `src/pages/ApiDocs/ApiDocs.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
| `src/pages/AppDetail/AppDetail.tsx` | Modify | Remove `<AppShell sidebar={...}>` wrapper |
| `src/pages/Inventory/sections/LayoutSection.tsx` | Modify | Update Sidebar demo to use compound API |

---

## Task 1: SidebarContext

**Files:**
- Create: `src/design-system/layout/Sidebar/SidebarContext.ts`

- [ ] **Step 1: Create the context file**

```ts
// src/design-system/layout/Sidebar/SidebarContext.ts
import { createContext, useContext } from 'react'

export interface SidebarContextValue {
  collapsed: boolean
  onCollapseToggle?: () => void
}

export const SidebarContext = createContext<SidebarContextValue>({
  collapsed: false,
})

export function useSidebarContext(): SidebarContextValue {
  return useContext(SidebarContext)
}
```

- [ ] **Step 2: Commit**

```bash
git add src/design-system/layout/Sidebar/SidebarContext.ts
git commit -m "feat(sidebar): add SidebarContext for collapsed state"
```

---

## Task 2: Rewrite Sidebar as compound component

**Files:**
- Rewrite: `src/design-system/layout/Sidebar/Sidebar.tsx`

This replaces the entire file. The old monolithic component (~560 lines) becomes ~150 lines of compound component shell. All application-specific code (type definitions, tree builders, starred logic, hardcoded sections) is deleted.

- [ ] **Step 1: Replace Sidebar.tsx with compound component**

```tsx
// src/design-system/layout/Sidebar/Sidebar.tsx
import { type ReactNode } from 'react'
import { Search, X, ChevronsLeft, ChevronsRight, ChevronRight, ChevronDown } from 'lucide-react'
import styles from './Sidebar.module.css'
import { SidebarContext, useSidebarContext } from './SidebarContext'

// ── Sidebar (shell) ─────────────────────────────────────────────────────────

interface SidebarProps {
  collapsed?: boolean
  onCollapseToggle?: () => void
  searchValue?: string
  onSearchChange?: (query: string) => void
  children: ReactNode
  className?: string
}

function SidebarRoot({
  collapsed = false,
  onCollapseToggle,
  searchValue = '',
  onSearchChange,
  children,
  className,
}: SidebarProps) {
  const showSearch = onSearchChange != null && !collapsed

  return (
    <SidebarContext.Provider value={{ collapsed, onCollapseToggle }}>
      <aside
        className={[
          styles.sidebar,
          collapsed ? styles.sidebarCollapsed : '',
          className ?? '',
        ].filter(Boolean).join(' ')}
      >
        {/* Collapse toggle */}
        {onCollapseToggle && (
          <button
            className={styles.collapseToggle}
            onClick={onCollapseToggle}
            aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
          >
            {collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
          </button>
        )}

        {/* Search */}
        {showSearch && (
          <div className={styles.searchWrap}>
            <div className={styles.searchInner}>
              <span className={styles.searchIcon} aria-hidden="true">
                <Search size={12} />
              </span>
              <input
                className={styles.searchInput}
                type="text"
                placeholder="Filter..."
                value={searchValue}
                onChange={(e) => onSearchChange(e.target.value)}
              />
              {searchValue && (
                <button
                  type="button"
                  className={styles.searchClear}
                  onClick={() => onSearchChange('')}
                  aria-label="Clear search"
                >
                  <X size={12} />
                </button>
              )}
            </div>
          </div>
        )}

        {/* Children — Sidebar.Header, Sidebar.Section(s), Sidebar.Footer.
           Sections are wrapped in navArea (scrollable) by convention.
           Footer uses flex-shrink: 0 + margin-top: auto to pin to bottom.
           We render children directly so Footer sits outside the scroll area. */}
        {children}
      </aside>
    </SidebarContext.Provider>
  )
}

// ── Sidebar.Header ────────────────────────────────��─────────────────────────

interface SidebarHeaderProps {
  logo: ReactNode
  title: string
  version?: string
  onClick?: () => void
}

function SidebarHeader({ logo, title, version, onClick }: SidebarHeaderProps) {
  const { collapsed } = useSidebarContext()

  return (
    <div
      className={styles.logo}
      onClick={onClick}
      style={onClick ? { cursor: 'pointer' } : undefined}
    >
      <span className={styles.logoImg}>{logo}</span>
      {!collapsed && (
        <div>
          <span className={styles.brand}>{title}</span>
          {version && <span className={styles.version}>{version}</span>}
        </div>
      )}
    </div>
  )
}

// ── Sidebar.Section ─────────────────────────────────────────────────────────

interface SidebarSectionProps {
  label: string
  icon?: ReactNode
  collapsed?: boolean
  onToggle?: () => void
  active?: boolean
  children?: ReactNode
}

function SidebarSection({
  label,
  icon,
  collapsed: sectionCollapsed = false,
  onToggle,
  active = false,
  children,
}: SidebarSectionProps) {
  const { collapsed: sidebarCollapsed, onCollapseToggle } = useSidebarContext()

  // Icon-rail mode: render centered icon with tooltip
  if (sidebarCollapsed) {
    return (
      <div
        className={[styles.sectionRailItem, active ? styles.sectionRailItemActive : ''].filter(Boolean).join(' ')}
        onClick={() => {
          onCollapseToggle?.()
          onToggle?.()
        }}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') {
            onCollapseToggle?.()
            onToggle?.()
          }
        }}
        title={label}
      >
        {icon && <span className={styles.sectionIcon}>{icon}</span>}
      </div>
    )
  }

  // Expanded mode: accordion section
  return (
    <div className={[styles.treeSection, active ? styles.treeSectionActive : ''].filter(Boolean).join(' ')}>
      <div className={styles.treeSectionToggle}>
        {onToggle && (
          <button
            className={styles.treeSectionChevronBtn}
            onClick={onToggle}
            aria-expanded={!sectionCollapsed}
            aria-label={sectionCollapsed ? `Expand ${label}` : `Collapse ${label}`}
          >
            {sectionCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
          </button>
        )}
        {icon && <span className={styles.sectionIcon}>{icon}</span>}
        <span
          className={[styles.treeSectionLabel, active ? styles.treeSectionLabelActive : ''].filter(Boolean).join(' ')}
          onClick={onToggle}
          role="button"
          tabIndex={0}
          onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onToggle?.() }}
        >
          {label}
        </span>
      </div>
      {!sectionCollapsed && children}
    </div>
  )
}

// ── Sidebar.Footer ──────────────────────────────────────────────────────────

interface SidebarFooterProps {
  children: ReactNode
}

function SidebarFooter({ children }: SidebarFooterProps) {
  return <div className={styles.bottom} role="group" aria-label="Footer links">{children}</div>
}

// ── Sidebar.FooterLink ──────────────────────────────────────────────────────

interface SidebarFooterLinkProps {
  icon: ReactNode
  label: string
  onClick?: () => void
  active?: boolean
}

function SidebarFooterLink({ icon, label, onClick, active = false }: SidebarFooterLinkProps) {
  const { collapsed } = useSidebarContext()

  return (
    <div
      className={[
        styles.bottomItem,
        active ? styles.bottomItemActive : '',
      ].filter(Boolean).join(' ')}
      onClick={onClick}
      role="button"
      tabIndex={0}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick?.() }}
      title={collapsed ? label : undefined}
    >
      <span className={styles.bottomIcon}>{icon}</span>
      {!collapsed && (
        <div className={styles.itemInfo}>
          <div className={styles.itemName}>{label}</div>
        </div>
      )}
    </div>
  )
}

// ── Compound export ───────────────────────────────────────────────────────���─

export const Sidebar = Object.assign(SidebarRoot, {
  Header: SidebarHeader,
  Section: SidebarSection,
  Footer: SidebarFooter,
  FooterLink: SidebarFooterLink,
})
```

- [ ] **Step 2: Verify TypeScript compiles**

Run: `npx tsc --noEmit 2>&1 | head -30`

This will have errors because consumers still use the old API. That's expected — we fix them in later tasks. The Sidebar file itself should be error-free.

- [ ] **Step 3: Commit**

```bash
git add src/design-system/layout/Sidebar/Sidebar.tsx
git commit -m "feat(sidebar): rewrite as compound component

Replaces monolithic Sidebar with composable API:
Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer,
Sidebar.FooterLink. Application-specific logic removed."
```

---

## Task 3: Update Sidebar.module.css

**Files:**
- Modify: `src/design-system/layout/Sidebar/Sidebar.module.css`

Remove application-specific styles (starred section, hardcoded section headers). Add collapsed/icon-rail styles, section icon slot, and tooltip support. Keep search, tree, and bottom link styles.

- [ ] **Step 1: Replace the CSS file contents**

The file keeps: `.sidebar`, `.logo*`, `.brand`, `.version`, `.searchWrap`, `.searchInput`, `.searchIcon`, `.searchClear`, all `.tree*` styles, `.bottom`, `.bottomItem`, `.bottomItemActive`, `.bottomIcon`, `.itemInfo`, `.itemName`, `.noResults`.

Remove `.navArea` (no longer used — children render directly in the sidebar flex column).

Delete: `.section` (old header), `.starredSection`, `.starredHeader`, `.starredList`, `.starredGroup`, `.starredGroupLabel`, `.starredItem`, `.starredItemInfo`, `.starredItemName`, `.starredItemContext`, `.starredRemove`, `.items`, `.item`, `.item.active`, `.navIcon`, `.routeArrow`.

Add the following new styles **after the existing `.navArea` block** (around line 114):

```css
/* ── Collapsed sidebar (icon rail) ──────────────────────────���───────────── */

.sidebarCollapsed {
  width: 48px;
}

.sidebar {
  transition: width 200ms ease;
}

/* Collapse toggle button */
.collapseToggle {
  position: absolute;
  top: 12px;
  right: 8px;
  z-index: 1;
  background: none;
  border: none;
  padding: 4px;
  margin: 0;
  color: var(--sidebar-muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-sm);
  transition: color 0.12s, background 0.12s;
}

.collapseToggle:hover {
  color: var(--sidebar-text);
  background: rgba(255, 255, 255, 0.08);
}

/* ── Section icon (in expanded header and rail mode) ─────────────────��──── */

.sectionIcon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  color: var(--sidebar-muted);
  width: 16px;
}

/* ── Icon-rail section item ───────────────────────────���─────────────────── */

.sectionRailItem {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 10px 0;
  cursor: pointer;
  border-left: 3px solid transparent;
  transition: background 0.12s;
}

.sectionRailItem:hover {
  background: var(--sidebar-hover);
}

.sectionRailItemActive {
  border-left-color: var(--amber);
}

.sectionRailItemActive .sectionIcon {
  color: var(--amber);
}

/* ── Active section (expanded mode) ────────────────────────���────────────── */

.treeSectionActive {
  border-left-color: var(--amber);
}
```

Also modify the existing `.sidebar` rule to add `position: relative` and `overflow-y` (children are rendered directly in the flex column; sections scroll naturally):

Change line 1-8 from:
```css
.sidebar {
  width: 260px;
  flex-shrink: 0;
  background: var(--sidebar-bg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
```
to:
```css
.sidebar {
  width: 260px;
  flex-shrink: 0;
  background: var(--sidebar-bg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  overflow-y: auto;
  position: relative;
  transition: width 200ms ease;
}
```

Update `.bottom` to pin to the bottom of the sidebar:
```css
.bottom {
  border-top: 1px solid rgba(255, 255, 255, 0.06);
  padding: 6px;
  flex-shrink: 0;
  margin-top: auto;
}
```

And modify `.logo` to handle collapsed centering — change padding from `padding: 16px 18px;` to:
```css
.logo {
  padding: 16px 18px;
  display: flex;
  align-items: center;
  gap: 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  flex-shrink: 0;
  overflow: hidden;
}

.sidebarCollapsed .logo {
  padding: 16px 0;
  justify-content: center;
}
```

Remove the following class blocks entirely (starred section, old flat items, old section header):
- `.section` (lines 117-124)
- `.items` (lines 127-129)
- `.item` through `.routeArrow` (lines 132-174)
- `.starredSection` through `.starredRemove:hover` (lines 386-472)

- [ ] **Step 2: Commit**

```bash
git add src/design-system/layout/Sidebar/Sidebar.module.css
git commit -m "style(sidebar): update CSS for compound component + collapsed mode"
```

---

## Task 4: Update layout barrel exports

**Files:**
- Modify: `src/design-system/layout/index.ts`

- [ ] **Step 1: Replace the layout index**

```ts
// src/design-system/layout/index.ts
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
export { TopBar } from './TopBar/TopBar'
```

Note: `SidebarApp`, `SidebarRoute`, `SidebarAgent` type exports are intentionally removed — they are application-domain types.

- [ ] **Step 2: Commit**

```bash
git add src/design-system/layout/index.ts
git commit -m "feat(sidebar): export SidebarTree, SidebarTreeNode, useStarred from layout barrel"
```

---

## Task 5: Rewrite Sidebar tests

**Files:**
- Rewrite: `src/design-system/layout/Sidebar/Sidebar.test.tsx`

- [ ] **Step 1: Replace test file with compound component tests**

```tsx
// src/design-system/layout/Sidebar/Sidebar.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { ThemeProvider } from '../../providers/ThemeProvider'

function renderSidebar(ui: React.ReactElement) {
  return render(
    <ThemeProvider>
      <MemoryRouter>
        {ui}
      </MemoryRouter>
    </ThemeProvider>,
  )
}

describe('Sidebar compound component', () => {
  it('renders Header with logo, title, and version', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Header logo={<span data-testid="logo">🐪</span>} title="cameleer" version="v1.0" />
      </Sidebar>,
    )
    expect(screen.getByTestId('logo')).toBeInTheDocument()
    expect(screen.getByText('cameleer')).toBeInTheDocument()
    expect(screen.getByText('v1.0')).toBeInTheDocument()
  })

  it('hides Header title and version when collapsed', () => {
    renderSidebar(
      <Sidebar collapsed>
        <Sidebar.Header logo={<span data-testid="logo">🐪</span>} title="cameleer" version="v1.0" />
      </Sidebar>,
    )
    expect(screen.getByTestId('logo')).toBeInTheDocument()
    expect(screen.queryByText('cameleer')).not.toBeInTheDocument()
    expect(screen.queryByText('v1.0')).not.toBeInTheDocument()
  })

  it('renders Section with label', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Section label="Applications">
          <div>section content</div>
        </Sidebar.Section>
      </Sidebar>,
    )
    expect(screen.getByText('Applications')).toBeInTheDocument()
    expect(screen.getByText('section content')).toBeInTheDocument()
  })

  it('hides Section children when section is collapsed', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Section label="Applications" collapsed>
          <div>hidden content</div>
        </Sidebar.Section>
      </Sidebar>,
    )
    expect(screen.getByText('Applications')).toBeInTheDocument()
    expect(screen.queryByText('hidden content')).not.toBeInTheDocument()
  })

  it('calls onToggle when Section header is clicked', async () => {
    const onToggle = vi.fn()
    const user = userEvent.setup()
    renderSidebar(
      <Sidebar>
        <Sidebar.Section label="Agents" onToggle={onToggle}>
          <div>content</div>
        </Sidebar.Section>
      </Sidebar>,
    )
    await user.click(screen.getByText('Agents'))
    expect(onToggle).toHaveBeenCalledOnce()
  })

  it('renders collapse toggle and calls onCollapseToggle', async () => {
    const onCollapseToggle = vi.fn()
    const user = userEvent.setup()
    renderSidebar(
      <Sidebar onCollapseToggle={onCollapseToggle}>
        <Sidebar.Header logo={<span>🐪</span>} title="test" />
      </Sidebar>,
    )
    const toggle = screen.getByLabelText('Collapse sidebar')
    await user.click(toggle)
    expect(onCollapseToggle).toHaveBeenCalledOnce()
  })

  it('renders expand toggle label when collapsed', () => {
    renderSidebar(
      <Sidebar collapsed onCollapseToggle={() => {}}>
        <Sidebar.Header logo={<span>🐪</span>} title="test" />
      </Sidebar>,
    )
    expect(screen.getByLabelText('Expand sidebar')).toBeInTheDocument()
  })

  it('renders search input and calls onSearchChange', async () => {
    const onSearchChange = vi.fn()
    const user = userEvent.setup()
    renderSidebar(
      <Sidebar searchValue="" onSearchChange={onSearchChange}>
        <Sidebar.Header logo={<span>🐪</span>} title="test" />
      </Sidebar>,
    )
    const input = screen.getByPlaceholderText('Filter...')
    await user.type(input, 'a')
    expect(onSearchChange).toHaveBeenCalledWith('a')
  })

  it('hides search input when sidebar is collapsed', () => {
    renderSidebar(
      <Sidebar collapsed searchValue="" onSearchChange={() => {}}>
        <Sidebar.Header logo={<span>🐪</span>} title="test" />
      </Sidebar>,
    )
    expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
  })

  it('hides search when onSearchChange is not provided', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Header logo={<span>🐪</span>} title="test" />
      </Sidebar>,
    )
    expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
  })

  it('renders FooterLinks with icons and labels', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Footer>
          <Sidebar.FooterLink icon={<span data-testid="admin-icon">⚙</span>} label="Admin" />
          <Sidebar.FooterLink icon={<span data-testid="docs-icon">📄</span>} label="API Docs" />
        </Sidebar.Footer>
      </Sidebar>,
    )
    expect(screen.getByText('Admin')).toBeInTheDocument()
    expect(screen.getByText('API Docs')).toBeInTheDocument()
    expect(screen.getByTestId('admin-icon')).toBeInTheDocument()
  })

  it('hides FooterLink labels when collapsed and sets title', () => {
    renderSidebar(
      <Sidebar collapsed>
        <Sidebar.Footer>
          <Sidebar.FooterLink icon={<span data-testid="icon">⚙</span>} label="Admin" />
        </Sidebar.Footer>
      </Sidebar>,
    )
    expect(screen.queryByText('Admin')).not.toBeInTheDocument()
    expect(screen.getByTitle('Admin')).toBeInTheDocument()
  })

  it('calls FooterLink onClick', async () => {
    const onClick = vi.fn()
    const user = userEvent.setup()
    renderSidebar(
      <Sidebar>
        <Sidebar.Footer>
          <Sidebar.FooterLink icon={<span>⚙</span>} label="Admin" onClick={onClick} />
        </Sidebar.Footer>
      </Sidebar>,
    )
    await user.click(screen.getByText('Admin'))
    expect(onClick).toHaveBeenCalledOnce()
  })

  it('renders Section as icon-rail item when sidebar is collapsed', () => {
    renderSidebar(
      <Sidebar collapsed>
        <Sidebar.Section label="Applications" icon={<span data-testid="apps-icon">📦</span>} />
      </Sidebar>,
    )
    expect(screen.getByTestId('apps-icon')).toBeInTheDocument()
    expect(screen.getByTitle('Applications')).toBeInTheDocument()
    // Label text should NOT be rendered as visible text in rail mode
    expect(screen.queryByText('Applications')).not.toBeInTheDocument()
  })

  it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
    const onCollapseToggle = vi.fn()
    const onToggle = vi.fn()
    const user = userEvent.setup()
    renderSidebar(
      <Sidebar collapsed onCollapseToggle={onCollapseToggle}>
        <Sidebar.Section
          label="Applications"
          icon={<span data-testid="apps-icon">📦</span>}
          collapsed
          onToggle={onToggle}
        />
      </Sidebar>,
    )
    await user.click(screen.getByTitle('Applications'))
    expect(onCollapseToggle).toHaveBeenCalledOnce()
    expect(onToggle).toHaveBeenCalledOnce()
  })

  it('applies active highlight to FooterLink', () => {
    renderSidebar(
      <Sidebar>
        <Sidebar.Footer>
          <Sidebar.FooterLink icon={<span>⚙</span>} label="Admin" active />
        </Sidebar.Footer>
      </Sidebar>,
    )
    const adminEl = screen.getByText('Admin').closest('[role="button"]')!
    expect(adminEl.className).toContain('bottomItemActive')
  })
})
```

- [ ] **Step 2: Run tests to verify they pass**

Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`

Expected: All tests pass. If any fail, fix the component or test to match.

- [ ] **Step 3: Commit**

```bash
git add src/design-system/layout/Sidebar/Sidebar.test.tsx
git commit -m "test(sidebar): rewrite tests for compound component API"
```

---

## Task 6: Create LayoutShell

**Files:**
- Create: `src/layout/LayoutShell.tsx`

This is the central migration piece. All application-specific sidebar logic (tree building, starred section, section collapse persistence, sidebarReveal) moves here from the old `Sidebar.tsx`.

- [ ] **Step 1: Create the LayoutShell file**

```tsx
// src/layout/LayoutShell.tsx
import { useState, useEffect, useMemo } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight, X } from 'lucide-react'
import { AppShell } from '../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../design-system/layout/Sidebar/SidebarTree'
import { useStarred } from '../design-system/layout/Sidebar/useStarred'
import { StatusDot } from '../design-system/primitives/StatusDot/StatusDot'
import { SIDEBAR_APPS } from '../mocks/sidebar'
import type { SidebarApp } from '../mocks/sidebar'
import camelLogoUrl from '../assets/camel-logo.svg'

// ── Helpers ──────────────────────────────────────────────────────────────────

function formatCount(n: number): string {
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
  return String(n)
}

// ── Tree node builders ──────────────────────────────────────────────────────

function buildAppTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
  return apps.map((app) => ({
    id: `app:${app.id}`,
    label: app.name,
    icon: <StatusDot variant={app.health} />,
    badge: formatCount(app.exchangeCount),
    path: `/apps/${app.id}`,
    starrable: true,
    starKey: app.id,
    children: app.routes.map((route) => ({
      id: `route:${app.id}:${route.id}`,
      starKey: `${app.id}:${route.id}`,
      label: route.name,
      icon: <ChevronRight size={12} />,
      badge: formatCount(route.exchangeCount),
      path: `/apps/${app.id}/${route.id}`,
      starrable: true,
    })),
  }))
}

function buildRouteTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
  return apps
    .filter((app) => app.routes.length > 0)
    .map((app) => ({
      id: `routes:${app.id}`,
      label: app.name,
      icon: <StatusDot variant={app.health} />,
      badge: `${app.routes.length} routes`,
      path: `/routes/${app.id}`,
      starrable: true,
      starKey: `routes:${app.id}`,
      children: app.routes.map((route) => ({
        id: `routestat:${app.id}:${route.id}`,
        starKey: `routes:${app.id}:${route.id}`,
        label: route.name,
        icon: <ChevronRight size={12} />,
        badge: formatCount(route.exchangeCount),
        path: `/routes/${app.id}/${route.id}`,
        starrable: true,
      })),
    }))
}

function buildAgentTreeNodes(apps: SidebarApp[]): SidebarTreeNode[] {
  return apps
    .filter((app) => app.agents.length > 0)
    .map((app) => {
      const liveCount = app.agents.filter((a) => a.status === 'live').length
      return {
        id: `agents:${app.id}`,
        label: app.name,
        icon: <StatusDot variant={app.health} />,
        badge: `${liveCount}/${app.agents.length} live`,
        path: `/agents/${app.id}`,
        starrable: true,
        starKey: `agents:${app.id}`,
        children: app.agents.map((agent) => ({
          id: `agent:${app.id}:${agent.id}`,
          starKey: `${app.id}:${agent.id}`,
          label: agent.name,
          badge: `${agent.tps.toFixed(1)}/s`,
          path: `/agents/${app.id}/${agent.id}`,
          starrable: true,
        })),
      }
    })
}

// ── Starred section ────────────────────────────────��────────────────────────

interface StarredItem {
  starKey: string
  label: string
  icon?: React.ReactNode
  path: string
  type: 'application' | 'route' | 'agent' | 'routestat'
  parentApp?: string
}

function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): StarredItem[] {
  const items: StarredItem[] = []
  for (const app of apps) {
    if (starredIds.has(app.id)) {
      items.push({ starKey: app.id, label: app.name, icon: <StatusDot variant={app.health} />, path: `/apps/${app.id}`, type: 'application' })
    }
    for (const route of app.routes) {
      const key = `${app.id}:${route.id}`
      if (starredIds.has(key)) {
        items.push({ starKey: key, label: route.name, path: `/apps/${app.id}/${route.id}`, type: 'route', parentApp: app.name })
      }
    }
    const agentsAppKey = `agents:${app.id}`
    if (starredIds.has(agentsAppKey)) {
      items.push({ starKey: agentsAppKey, label: app.name, icon: <StatusDot variant={app.health} />, path: `/agents/${app.id}`, type: 'agent' })
    }
    for (const agent of app.agents) {
      const key = `${app.id}:${agent.id}`
      if (starredIds.has(key)) {
        items.push({ starKey: key, label: agent.name, path: `/agents/${app.id}/${agent.id}`, type: 'agent', parentApp: app.name })
      }
    }
    const routesAppKey = `routes:${app.id}`
    if (starredIds.has(routesAppKey)) {
      items.push({ starKey: routesAppKey, label: app.name, icon: <StatusDot variant={app.health} />, path: `/routes/${app.id}`, type: 'routestat' })
    }
    for (const route of app.routes) {
      const routeKey = `routes:${app.id}:${route.id}`
      if (starredIds.has(routeKey)) {
        items.push({ starKey: routeKey, label: route.name, path: `/routes/${app.id}/${route.id}`, type: 'routestat', parentApp: app.name })
      }
    }
  }
  return items
}

// ── StarredGroup sub-component ──────────────────────────────────────────────

function StarredGroup({ label, items, onNavigate, onRemove }: {
  label: string
  items: StarredItem[]
  onNavigate: (path: string) => void
  onRemove: (starKey: string) => void
}) {
  return (
    <div style={{ marginBottom: 4 }}>
      <div style={{ padding: '4px 12px 2px', fontSize: 10, color: 'var(--sidebar-muted)', fontWeight: 500 }}>{label}</div>
      {items.map((item) => (
        <div
          key={item.starKey}
          style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 12px', borderRadius: 'var(--radius-sm)', color: 'var(--sidebar-text)', fontSize: 12, cursor: 'pointer', userSelect: 'none' }}
          onClick={() => onNavigate(item.path)}
          role="button"
          tabIndex={0}
          onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path) }}
        >
          {item.icon}
          <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
            <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: 500 }}>{item.label}</span>
            {item.parentApp && <span style={{ fontSize: 10, color: 'var(--sidebar-muted)' }}>{item.parentApp}</span>}
          </div>
          <button
            style={{ background: 'none', border: 'none', padding: 2, color: 'var(--sidebar-muted)', cursor: 'pointer', display: 'flex' }}
            onClick={(e) => { e.stopPropagation(); onRemove(item.starKey) }}
            tabIndex={-1}
            aria-label={`Remove ${item.label} from starred`}
          >
            <X size={12} />
          </button>
        </div>
      ))}
    </div>
  )
}

// ── LayoutShell ─────────────────────────────────────────────────────────────

export function LayoutShell() {
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
  const [filterQuery, setFilterQuery] = useState('')

  // Section collapse state with localStorage persistence
  const [appsCollapsed, _setAppsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:apps-collapsed') === 'true')
  const [agentsCollapsed, _setAgentsCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:agents-collapsed') === 'true')
  const [routesCollapsed, _setRoutesCollapsed] = useState(() => localStorage.getItem('cameleer:sidebar:routes-collapsed') === 'true')

  const setAppsCollapsed = (fn: (v: boolean) => boolean) => {
    _setAppsCollapsed((prev) => {
      const next = fn(prev)
      localStorage.setItem('cameleer:sidebar:apps-collapsed', String(next))
      return next
    })
  }
  const setAgentsCollapsed = (fn: (v: boolean) => boolean) => {
    _setAgentsCollapsed((prev) => {
      const next = fn(prev)
      localStorage.setItem('cameleer:sidebar:agents-collapsed', String(next))
      return next
    })
  }
  const setRoutesCollapsed = (fn: (v: boolean) => boolean) => {
    _setRoutesCollapsed((prev) => {
      const next = fn(prev)
      localStorage.setItem('cameleer:sidebar:routes-collapsed', String(next))
      return next
    })
  }

  const navigate = useNavigate()
  const location = useLocation()
  const { starredIds, isStarred, toggleStar } = useStarred()

  // Build tree data
  const appNodes = useMemo(() => buildAppTreeNodes(SIDEBAR_APPS), [])
  const agentNodes = useMemo(() => buildAgentTreeNodes(SIDEBAR_APPS), [])
  const routeNodes = useMemo(() => buildRouteTreeNodes(SIDEBAR_APPS), [])

  // Sidebar reveal from Cmd-K navigation
  const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null

  useEffect(() => {
    if (!sidebarRevealPath) return
    const matchesAppTree = appNodes.some((node) =>
      node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
    )
    if (matchesAppTree && appsCollapsed) {
      _setAppsCollapsed(false)
      localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
    }
    const matchesAgentTree = agentNodes.some((node) =>
      node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
    )
    if (matchesAgentTree && agentsCollapsed) {
      _setAgentsCollapsed(false)
      localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
    }
  }, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps

  const effectiveSelectedPath = sidebarRevealPath ?? location.pathname

  // Starred items
  const starredItems = useMemo(() => collectStarredItems(SIDEBAR_APPS, starredIds), [starredIds])
  const starredApps = starredItems.filter((i) => i.type === 'application')
  const starredRoutes = starredItems.filter((i) => i.type === 'route')
  const starredAgents = starredItems.filter((i) => i.type === 'agent')
  const starredRouteStats = starredItems.filter((i) => i.type === 'routestat')
  const hasStarred = starredItems.length > 0

  return (
    <AppShell
      sidebar={
        <Sidebar
          collapsed={sidebarCollapsed}
          onCollapseToggle={() => setSidebarCollapsed((v) => !v)}
          searchValue={filterQuery}
          onSearchChange={setFilterQuery}
        >
          <Sidebar.Header
            logo={<img src={camelLogoUrl} alt="" aria-hidden="true" style={{ width: 28, height: 24, filter: 'brightness(0) saturate(100%) invert(76%) sepia(30%) saturate(400%) hue-rotate(5deg) brightness(95%)' }} />}
            title="cameleer"
            version="v3.2.1"
            onClick={() => navigate('/apps')}
          />

          <Sidebar.Section
            label="Applications"
            icon={<Box size={14} />}
            collapsed={appsCollapsed}
            onToggle={() => setAppsCollapsed((v) => !v)}
            active={location.pathname.startsWith('/apps')}
          >
            <SidebarTree
              nodes={appNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:apps"
              autoRevealPath={sidebarRevealPath}
            />
          </Sidebar.Section>

          <Sidebar.Section
            label="Agents"
            icon={<Cpu size={14} />}
            collapsed={agentsCollapsed}
            onToggle={() => setAgentsCollapsed((v) => !v)}
            active={location.pathname.startsWith('/agents')}
          >
            <SidebarTree
              nodes={agentNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:agents"
              autoRevealPath={sidebarRevealPath}
            />
          </Sidebar.Section>

          <Sidebar.Section
            label="Routes"
            icon={<GitBranch size={14} />}
            collapsed={routesCollapsed}
            onToggle={() => setRoutesCollapsed((v) => !v)}
            active={location.pathname.startsWith('/routes')}
          >
            <SidebarTree
              nodes={routeNodes}
              selectedPath={effectiveSelectedPath}
              isStarred={isStarred}
              onToggleStar={toggleStar}
              filterQuery={filterQuery}
              persistKey="cameleer:expanded:routes"
              autoRevealPath={sidebarRevealPath}
            />
          </Sidebar.Section>

          {/* Starred section */}
          {hasStarred && (
            <Sidebar.Section label="★ Starred" collapsed={false}>
              {starredApps.length > 0 && <StarredGroup label="Applications" items={starredApps} onNavigate={navigate} onRemove={toggleStar} />}
              {starredRoutes.length > 0 && <StarredGroup label="Routes" items={starredRoutes} onNavigate={navigate} onRemove={toggleStar} />}
              {starredAgents.length > 0 && <StarredGroup label="Agents" items={starredAgents} onNavigate={navigate} onRemove={toggleStar} />}
              {starredRouteStats.length > 0 && <StarredGroup label="Routes" items={starredRouteStats} onNavigate={navigate} onRemove={toggleStar} />}
            </Sidebar.Section>
          )}

          <Sidebar.Footer>
            <Sidebar.FooterLink
              icon={<Settings size={14} />}
              label="Admin"
              onClick={() => navigate('/admin')}
              active={location.pathname.startsWith('/admin')}
            />
            <Sidebar.FooterLink
              icon={<FileText size={14} />}
              label="API Docs"
              onClick={() => navigate('/api-docs')}
              active={location.pathname === '/api-docs'}
            />
          </Sidebar.Footer>
        </Sidebar>
      }
    >
      <Outlet />
    </AppShell>
  )
}
```

- [ ] **Step 2: Commit**

```bash
git add src/layout/LayoutShell.tsx
git commit -m "feat: create LayoutShell with compound Sidebar and Outlet"
```

---

## Task 7: Update App.tsx to use layout routes

**Files:**
- Modify: `src/App.tsx`

- [ ] **Step 1: Replace App.tsx with layout route structure**

```tsx
// src/App.tsx
import { useMemo, useCallback } from 'react'
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { LayoutShell } from './layout/LayoutShell'
import { Dashboard } from './pages/Dashboard/Dashboard'
import { Routes as RoutesPage } from './pages/Routes/Routes'
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { AgentInstance } from './pages/AgentInstance/AgentInstance'
import { Inventory } from './pages/Inventory/Inventory'
import { AuditLog } from './pages/Admin/AuditLog/AuditLog'
import { OidcConfig } from './pages/Admin/OidcConfig/OidcConfig'
import { UserManagement } from './pages/Admin/UserManagement/UserManagement'
import { ApiDocs } from './pages/ApiDocs/ApiDocs'

import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from './design-system/composites/CommandPalette/types'
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { buildRouteToAppMap } from './mocks/sidebar'

const routeToApp = buildRouteToAppMap()

/** Compute which sidebar path to reveal for a given search result */
function computeSidebarRevealPath(result: SearchResult): string | undefined {
  if (!result.path) return undefined

  if (result.category === 'application') return result.path
  if (result.category === 'route') return result.path
  if (result.category === 'agent') return result.path

  if (result.category === 'exchange') {
    const exchange = exchanges.find((e) => e.id === result.id)
    if (exchange) {
      const appId = routeToApp.get(exchange.route)
      if (appId) return `/apps/${appId}/${exchange.route}`
    }
  }

  return result.path
}

export default function App() {
  const navigate = useNavigate()
  const { open: paletteOpen, setOpen } = useCommandPalette()
  const { isInTimeRange, statusFilters } = useGlobalFilters()

  const filteredSearchData = useMemo(() => {
    let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
    if (statusFilters.size > 0) {
      filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
    }
    return buildSearchData(filteredExchanges, routes, agents)
  }, [isInTimeRange, statusFilters])

  const handleSelect = useCallback(
    (result: SearchResult) => {
      if (result.path) {
        const sidebarReveal = computeSidebarRevealPath(result)
        navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
      }
      setOpen(false)
    },
    [navigate, setOpen],
  )

  return (
    <>
      <Routes>
        <Route element={<LayoutShell />}>
          <Route path="/" element={<Navigate to="/apps" replace />} />
          <Route path="/apps" element={<Dashboard />} />
          <Route path="/apps/:id" element={<Dashboard />} />
          <Route path="/apps/:id/:routeId" element={<Dashboard />} />
          <Route path="/routes" element={<RoutesPage />} />
          <Route path="/routes/:appId" element={<RoutesPage />} />
          <Route path="/routes/:appId/:routeId" element={<RoutesPage />} />
          <Route path="/exchanges/:id" element={<ExchangeDetail />} />
          <Route path="/agents/:appId/:instanceId" element={<AgentInstance />} />
          <Route path="/agents/*" element={<AgentHealth />} />
          <Route path="/admin" element={<Navigate to="/admin/rbac" replace />} />
          <Route path="/admin/audit" element={<AuditLog />} />
          <Route path="/admin/oidc" element={<OidcConfig />} />
          <Route path="/admin/rbac" element={<UserManagement />} />
          <Route path="/api-docs" element={<ApiDocs />} />
        </Route>
        <Route path="/inventory" element={<Inventory />} />
      </Routes>
      <CommandPalette
        open={paletteOpen}
        onClose={() => setOpen(false)}
        onOpen={() => setOpen(true)}
        data={filteredSearchData}
        onSelect={handleSelect}
      />
    </>
  )
}
```

Note: The Inventory route stays **outside** the `LayoutShell` because it has its own layout and renders a standalone Sidebar demo.

- [ ] **Step 2: Commit**

```bash
git add src/App.tsx
git commit -m "refactor: wrap routes in LayoutShell layout route"
```

---

## Task 8: Strip AppShell+Sidebar wrappers from page components

**Files:**
- Modify: `src/pages/Dashboard/Dashboard.tsx`
- Modify: `src/pages/Routes/Routes.tsx`
- Modify: `src/pages/ExchangeDetail/ExchangeDetail.tsx`
- Modify: `src/pages/AgentHealth/AgentHealth.tsx`
- Modify: `src/pages/AgentInstance/AgentInstance.tsx`
- Modify: `src/pages/Admin/Admin.tsx`
- Modify: `src/pages/ApiDocs/ApiDocs.tsx`
- Modify: `src/pages/AppDetail/AppDetail.tsx`

Each page currently wraps its content in `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>`. Since `LayoutShell` now provides the `AppShell` + sidebar, each page must return **only the content** that was previously inside `<AppShell>` (the TopBar + page body). Pages with `DetailPanel` passed as `detail` prop to `AppShell` need special handling — see below.

- [ ] **Step 1: Dashboard.tsx**

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`). Remove the `<AppShell sidebar={...} detail={...}>` wrapper.

The `detail` prop passed to `AppShell` is for `DetailPanel`. Since `AppShell` uses a portal (`<div id="cameleer-detail-panel-root" />`), the `DetailPanel` component already portals itself. The `detail` prop on `AppShell` is deprecated (comment says so). So we just render `DetailPanel` alongside the other content.

Change the return from:
```tsx
return (
  <AppShell
    sidebar={<Sidebar apps={SIDEBAR_APPS} />}
    detail={selectedExchange ? (<DetailPanel ...>) : undefined}
  >
    <TopBar ... />
    <div className={styles.content}>...</div>
    <ShortcutsBar ... />
  </AppShell>
)
```
to:
```tsx
return (
  <>
    <TopBar ... />
    <div className={styles.content}>...</div>
    <ShortcutsBar ... />
    {selectedExchange && (<DetailPanel .../>)}
  </>
)
```

Remove these import lines:
```tsx
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
```
And remove `SIDEBAR_APPS` from the sidebar import (keep `buildRouteToAppMap`):
```tsx
import { buildRouteToAppMap } from '../../mocks/sidebar'
```

- [ ] **Step 2: Routes.tsx**

This file has **two return statements** (route detail view and top-level view). Both wrap in `<AppShell sidebar={...}>`. Strip both.

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`).

Replace each `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>` with a fragment `<>...</>`.

- [ ] **Step 3: ExchangeDetail.tsx**

Has **two return sites** (not found + normal). Strip both.

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS` (keep `buildRouteToAppMap`).

Replace each `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>...</AppShell>` with `<>...</>`.

- [ ] **Step 4: AgentHealth.tsx**

One return with `<AppShell sidebar={...} detail={...}>`.

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.

Replace wrapper with fragment. Render `DetailPanel` alongside content.

- [ ] **Step 5: AgentInstance.tsx**

**Two return sites** (not found + normal).

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.

Replace both wrappers with fragments.

- [ ] **Step 6: Admin.tsx (AdminLayout)**

`AdminLayout` currently wraps in `<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>`. Strip it.

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.

Change `AdminLayout` return from:
```tsx
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
  <TopBar ... />
  <Tabs ... />
  <div className={styles.adminContent}>{children}</div>
</AppShell>
```
to:
```tsx
<>
  <TopBar ... />
  <Tabs ... />
  <div className={styles.adminContent}>{children}</div>
</>
```

- [ ] **Step 7: ApiDocs.tsx**

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.

Return:
```tsx
<>
  <TopBar
    breadcrumb={[{ label: 'API Documentation' }]}
    environment="PRODUCTION"
    user={{ name: 'hendrik' }}
  />
  <EmptyState
    title="API Documentation"
    description="API documentation coming soon."
  />
</>
```

- [ ] **Step 8: AppDetail.tsx**

Remove imports of `AppShell`, `Sidebar`, `SIDEBAR_APPS`.

Return:
```tsx
<>
  <TopBar
    breadcrumb={[
      { label: 'Applications', href: '/apps' },
      { label: id ?? '' },
    ]}
    environment="PRODUCTION"
    user={{ name: 'hendrik' }}
  />
  <EmptyState
    title="Application Detail"
    description="Application detail view coming soon."
  />
</>
```

- [ ] **Step 9: Verify TypeScript compiles**

Run: `npx tsc --noEmit`

Expected: Zero errors. All pages now return content fragments, `LayoutShell` provides the `AppShell` wrapper.

- [ ] **Step 10: Commit**

```bash
git add src/pages/Dashboard/Dashboard.tsx src/pages/Routes/Routes.tsx src/pages/ExchangeDetail/ExchangeDetail.tsx src/pages/AgentHealth/AgentHealth.tsx src/pages/AgentInstance/AgentInstance.tsx src/pages/Admin/Admin.tsx src/pages/ApiDocs/ApiDocs.tsx src/pages/AppDetail/AppDetail.tsx
git commit -m "refactor: strip AppShell+Sidebar wrappers from all page components

Pages now render content only. LayoutShell provides the AppShell
wrapper with compound Sidebar via React Router layout route."
```

---

## Task 9: Update Inventory LayoutSection

**Files:**
- Modify: `src/pages/Inventory/sections/LayoutSection.tsx`

The Inventory page's Sidebar demo needs to use the compound API. This is a standalone showcase, not wrapped by `LayoutShell`.

- [ ] **Step 1: Update LayoutSection to use compound Sidebar API**

Replace the imports and data section:

Remove:
```tsx
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
```

Add:
```tsx
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../../../design-system/layout/Sidebar/SidebarTree'
import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/SidebarTree'
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
import { Box, Cpu, Settings, FileText, ChevronRight } from 'lucide-react'
```

Replace the `SAMPLE_APPS` data and the Sidebar demo card:

```tsx
// ── Sample tree nodes for demo ─────────────────────���────────────────────────

const SAMPLE_APP_NODES: SidebarTreeNode[] = [
  {
    id: 'app:app1',
    label: 'cameleer-prod',
    icon: <StatusDot variant="live" />,
    badge: '14.3k',
    path: '#',
    starrable: true,
    starKey: 'app1',
    children: [
      { id: 'route:app1:r1', label: 'order-ingest', icon: <ChevronRight size={12} />, badge: '5.4k', path: '#', starrable: true, starKey: 'app1:r1' },
      { id: 'route:app1:r2', label: 'payment-validate', icon: <ChevronRight size={12} />, badge: '3.1k', path: '#', starrable: true, starKey: 'app1:r2' },
    ],
  },
  {
    id: 'app:app2',
    label: 'cameleer-staging',
    icon: <StatusDot variant="stale" />,
    badge: '871',
    path: '#',
    starrable: true,
    starKey: 'app2',
    children: [
      { id: 'route:app2:r3', label: 'notify-customer', icon: <ChevronRight size={12} />, badge: '2.2k', path: '#', starrable: true, starKey: 'app2:r3' },
    ],
  },
  {
    id: 'app:app3',
    label: 'cameleer-dev',
    icon: <StatusDot variant="dead" />,
    badge: '42',
    path: '#',
    starrable: true,
    starKey: 'app3',
  },
]
```

Then update the Sidebar DemoCard:

```tsx
{/* 2. Sidebar */}
<DemoCard
  id="sidebar"
  title="Sidebar"
  description="Composable navigation sidebar with sections, tree navigation, and icon-rail collapse mode."
>
  <div className={styles.sidebarPreview}>
    <Sidebar>
      <Sidebar.Header logo={<span style={{ fontSize: 20 }}>🐪</span>} title="cameleer" version="v3.2.1" />
      <Sidebar.Section label="Applications" icon={<Box size={14} />}>
        <SidebarTree
          nodes={SAMPLE_APP_NODES}
          isStarred={() => false}
          onToggleStar={() => {}}
        />
      </Sidebar.Section>
      <Sidebar.Footer>
        <Sidebar.FooterLink icon={<Settings size={14} />} label="Admin" />
        <Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" />
      </Sidebar.Footer>
    </Sidebar>
  </div>
</DemoCard>
```

- [ ] **Step 2: Verify build**

Run: `npx tsc --noEmit`

Expected: Zero errors.

- [ ] **Step 3: Commit**

```bash
git add src/pages/Inventory/sections/LayoutSection.tsx
git commit -m "refactor(inventory): update Sidebar demo to compound API"
```

---

## Task 10: Run full test suite and build

- [ ] **Step 1: Run all tests**

Run: `npx vitest run`

Expected: All tests pass. The `useStarred.test.ts` tests are unchanged and should still pass.

- [ ] **Step 2: Run TypeScript check**

Run: `npx tsc --noEmit`

Expected: Zero errors.

- [ ] **Step 3: Run build**

Run: `npm run build`

Expected: Clean Vite build with no errors.

- [ ] **Step 4: Fix any issues found**

If tests fail or build breaks, fix the specific issue. Common problems:
- Missing imports after removing old Sidebar types
- CSS class references that were removed
- SidebarTree props that changed shape

- [ ] **Step 5: Commit any fixes**

```bash
git add -u
git commit -m "fix: resolve test/build issues from sidebar refactor"
```

---

## Summary of changes

| Area | Before | After |
|------|--------|-------|
| `Sidebar.tsx` | 560 lines, monolithic, hardcoded 3 sections | ~150 lines, compound component shell |
| `SidebarTree.tsx` | Internal, not exported | Exported publicly |
| `useStarred.ts` | Internal, not exported | Exported publicly |
| Layout exports | `Sidebar`, `SidebarApp/Route/Agent` types | `Sidebar`, `SidebarTree`, `SidebarTreeNode`, `useStarred` |
| Page components | Each wraps `<AppShell sidebar={...}>` (11 files) | Return content only, `LayoutShell` provides shell |
| `App.tsx` | Flat route list | Layout route (`<LayoutShell />`) wrapping all pages |
| Application logic | In DS package | In `src/layout/LayoutShell.tsx` |
