10-task plan covering compound component, CSS, exports, tests, LayoutShell, route migration, and page wrapper stripping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
54 KiB
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
// 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
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
// 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
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):
/* ── 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:
.sidebar {
width: 260px;
flex-shrink: 0;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
to:
.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:
.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:
.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) -
.itemthrough.routeArrow(lines 132-174) -
.starredSectionthrough.starredRemove:hover(lines 386-472) -
Step 2: Commit
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
// 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
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
// 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
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
// 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
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
// 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
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:
return (
<AppShell
sidebar={<Sidebar apps={SIDEBAR_APPS} />}
detail={selectedExchange ? (<DetailPanel ...>) : undefined}
>
<TopBar ... />
<div className={styles.content}>...</div>
<ShortcutsBar ... />
</AppShell>
)
to:
return (
<>
<TopBar ... />
<div className={styles.content}>...</div>
<ShortcutsBar ... />
{selectedExchange && (<DetailPanel .../>)}
</>
)
Remove these import lines:
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):
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:
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar ... />
<Tabs ... />
<div className={styles.adminContent}>{children}</div>
</AppShell>
to:
<>
<TopBar ... />
<Tabs ... />
<div className={styles.adminContent}>{children}</div>
</>
- Step 7: ApiDocs.tsx
Remove imports of AppShell, Sidebar, SIDEBAR_APPS.
Return:
<>
<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:
<>
<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
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:
import { Sidebar } from '../../../design-system/layout/Sidebar/Sidebar'
import type { SidebarApp } from '../../../design-system/layout/Sidebar/Sidebar'
Add:
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:
// ── 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:
{/* 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
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
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 |