Files
design-system/docs/superpowers/plans/2026-04-02-composable-sidebar.md

1610 lines
54 KiB
Markdown
Raw Normal View History

# 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 ────────────────────────────────<E29480><E29480>─────────────────────────
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 ───────────────────────────────────────────────────────<E29480><E29480><EFBFBD>
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) ──────────────────────────<E29480><E29480><EFBFBD>───────────── */
.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) ─────────────────<E29480><E29480>──── */
.sectionIcon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--sidebar-muted);
width: 16px;
}
/* ── Icon-rail section item ───────────────────────────<E29480><E29480><EFBFBD>─────────────────── */
.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) ────────────────────────<E29480><E29480><EFBFBD>────────────── */
.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 ────────────────────────────────<E29480><E29480>────────────────────────
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 ─────────────────────<E29480><E29480><EFBFBD>────────────────────────
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` |