Files
design-system/docs/superpowers/plans/2026-04-02-composable-sidebar.md
hsiegeln 3561147b42 docs: add composable sidebar implementation plan
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>
2026-04-02 17:50:56 +02:00

54 KiB
Raw Permalink Blame 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

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

  • .item through .routeArrow (lines 132-174)

  • .starredSection through .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