Files
design-system/docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
hsiegeln 9afe626a58 docs: update composable sidebar spec with clarified decisions
Add searchValue prop for controlled input, SidebarContext for
collapsed state propagation, LayoutShell migration plan, and
icon-rail simultaneous callback behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:42:09 +02:00

16 KiB

Composable Sidebar Refactor

Date: 2026-04-02 Upstream issue: cameleer3-server #112

Why

The current Sidebar component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (buildAppTreeNodes, buildRouteTreeNodes, buildAgentTreeNodes). The consuming application can only pass SidebarApp[] data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.

This blocks two features the consuming application needs:

  1. Admin accordion — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
  2. Icon-rail collapse — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)

Goal

Refactor Sidebar into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.

Current Exports (to be replaced)

// Current — monolithic
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'

New Exports

// New — composable
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'

SidebarApp, SidebarRoute, SidebarAgent types are removed — they are application-domain types that move to the consuming app.

Compound Component API

<Sidebar>

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

<Sidebar
  collapsed={false}
  onCollapseToggle={() => {}}
  searchValue=""
  onSearchChange={(query) => {}}
  className=""
>
  <Sidebar.Header ... />
  <Sidebar.Section ... />
  <Sidebar.Section ... />
  <Sidebar.Footer ... />
</Sidebar>
Prop Type Default Description
collapsed boolean false Render as ~48px icon rail
onCollapseToggle () => void - Collapse/expand toggle clicked
onSearchChange (query: string) => void - Search input changed. Omit to hide search.
searchValue string '' Controlled value for the search input
children ReactNode - Sidebar.Header, Sidebar.Section, Sidebar.Footer
className string - Additional CSS class

Search state ownership: The DS renders the search input as a dumb controlled input and calls onSearchChange on every keystroke. The consuming application owns the search state and passes it to each SidebarTree as filterQuery. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.

Rendering rules:

  • Expanded: full width (~260px), all content visible
  • Collapsed: ~48px wide, only icons visible, tooltips on hover
  • Width transition: transition: width 200ms ease
  • Collapse toggle button (<< / >> chevron) in top-right corner
  • Search input hidden when collapsed

<Sidebar.Header>

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

<Sidebar.Header
  logo={<img src="..." />}
  title="cameleer"
  version="v3.2.1"
/>
Prop Type Default Description
logo ReactNode - Logo element
title string - App name (hidden when collapsed)
version string - Version text (hidden when collapsed)

<Sidebar.Section>

An accordion section with a collapsible header and content area.

<Sidebar.Section
  label="APPLICATIONS"
  icon={<Box size={14} />}
  collapsed={false}
  onToggle={() => {}}
  active={false}
>
  <SidebarTree nodes={nodes} ... />
</Sidebar.Section>
Prop Type Default Description
label string - Section header text (rendered uppercase via CSS)
icon ReactNode - Icon for header and collapsed rail
collapsed boolean false Whether children are hidden
onToggle () => void - Header clicked
children ReactNode - Content when expanded
active boolean - Override active highlight. If omitted, not highlighted.

Expanded rendering:

v [icon] APPLICATIONS
  (children rendered here)

Collapsed rendering:

> [icon] APPLICATIONS

In sidebar icon-rail mode:

[icon]     <- centered, tooltip shows label on hover

Header has: chevron (left), icon, label. Chevron rotates on collapse/expand. Active section gets the amber left-border accent (existing pattern). Clicking the header calls onToggle.

Implementation detail: Sidebar.Section and Sidebar.Header need to know the parent's collapsed state to switch between expanded and icon-rail rendering. The <Sidebar> component provides collapsed and onCollapseToggle via React context (SidebarContext). Sub-components read from context — no prop drilling needed.

Icon-rail click behavior: In collapsed mode, clicking a section icon fires both onCollapseToggle and onToggle simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.

<Sidebar.Footer>

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

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

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

A single bottom link.

Prop Type Default Description
icon ReactNode - Link icon
label string - Link text (hidden when collapsed, shown as tooltip)
onClick () => void - Click handler
active boolean false Active state highlight

<SidebarTree> (no changes, newly exported)

Already exists at Sidebar/SidebarTree.tsx. No modifications needed — it already accepts all data via props. Just export it from the package.

Current props (unchanged):

Prop Type Description
nodes SidebarTreeNode[] Tree data
selectedPath string Currently active path for highlighting
filterQuery string Search filter text
onNavigate (path: string) => void Navigation callback
persistKey string localStorage key for expand state
autoRevealPath string | null Path to auto-expand to
isStarred (id: string) => boolean Star state checker
onToggleStar (id: string) => void Star toggle callback

useStarred hook (no changes, newly exported)

Already exists at Sidebar/useStarred.ts. Export as-is.

Returns: { starredIds, isStarred, toggleStar }

What Gets Removed

All of this application-specific logic is deleted from the DS:

  1. buildAppTreeNodes() (~30 lines) — transforms SidebarApp[] into SidebarTreeNode[]
  2. buildRouteTreeNodes() (~20 lines) — transforms apps into route tree nodes
  3. buildAgentTreeNodes() (~25 lines) — transforms apps into agent tree nodes with live-count badges
  4. collectStarredItems() (~20 lines) — gathers starred items across types
  5. StarredGroup sub-component (~30 lines) — renders grouped starred items
  6. Hardcoded sections (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
  7. Hardcoded bottom links (~30 lines) — Admin and API Docs links
  8. Auto-reveal effect (~20 lines) — sidebarRevealPath effect
  9. SidebarApp, SidebarRoute, SidebarAgent types — domain types, not DS types
  10. formatCount() helper — number formatting, moves to consuming app

Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.

CSS Changes

New styles needed

  • .sidebarCollapsed — narrow width (48px), centered icons
  • .collapseToggle<< / >> button positioning
  • .sectionIcon — icon rendering in section headers
  • .tooltip — hover tooltips for collapsed mode
  • Width transition: transition: width 200ms ease on .sidebar

Styles that stay

  • .sidebar (modified: width becomes conditional)
  • .searchWrap, .searchInput (unchanged)
  • .navArea (unchanged)
  • All tree styles in SidebarTree (unchanged)

Styles removed

  • .bottom, .bottomItem, .bottomItemActive — replaced by Sidebar.Footer / Sidebar.FooterLink styles
  • .starredSection, .starredGroup, .starredItem, .starredRemove — starred rendering moves to app
  • .section — replaced by Sidebar.Section styles

File Structure After Refactor

Sidebar/
├── Sidebar.tsx              # Compound component: Sidebar, Sidebar.Header,
│                            # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
├── Sidebar.module.css       # Updated styles (shell + section + footer + collapsed)
├── SidebarTree.tsx           # Unchanged
├── SidebarTree.module.css    # Unchanged (if separate, otherwise stays in Sidebar.module.css)
├── useStarred.ts             # Unchanged
├── useStarred.test.ts        # Unchanged
└── Sidebar.test.tsx          # Updated for new compound API

Testing

Update Sidebar.test.tsx to test the compound component API:

  • Renders Header with logo, title, version
  • Renders Sections with labels and icons
  • Section toggle calls onToggle
  • Collapsed sections hide children
  • Sidebar collapsed mode renders icon rail
  • Collapse toggle calls onCollapseToggle
  • Footer links render with icons and labels
  • Collapsed mode hides labels, shows tooltips
  • Search input calls onSearchChange
  • Search hidden when sidebar collapsed
  • Section icon click in collapsed mode calls both onCollapseToggle and onToggle

SidebarTree tests are unaffected.

Usage Example (for reference)

This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.

// In LayoutShell.tsx (consuming app)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [filterQuery, setFilterQuery] = useState('');
const [appsCollapsed, setAppsCollapsed] = useState(false);
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
const [routesCollapsed, setRoutesCollapsed] = useState(true);
const [adminCollapsed, setAdminCollapsed] = useState(true);

// Accordion: entering admin expands admin, collapses others
useEffect(() => {
  if (isAdminPage) {
    setAdminCollapsed(false);
    setAppsCollapsed(true);
    setAgentsCollapsed(true);
    setRoutesCollapsed(true);
  } else {
    setAdminCollapsed(true);
    // restore previous operational states
  }
}, [isAdminPage]);

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

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

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

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

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

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

Mock App Migration — LayoutShell

The 11 page files currently duplicating <AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> will be consolidated into a single LayoutShell component.

src/layout/LayoutShell.tsx

Composes the sidebar once using the new compound API. All page-specific content is rendered via <Outlet />.

// src/layout/LayoutShell.tsx
export function LayoutShell() {
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
  const [filterQuery, setFilterQuery] = useState('')
  const [appsCollapsed, setAppsCollapsed] = useState(false)
  const [agentsCollapsed, setAgentsCollapsed] = useState(false)
  const [routesCollapsed, setRoutesCollapsed] = useState(false)
  const { starredIds, isStarred, toggleStar } = useStarred()
  const location = useLocation()
  // ... build tree nodes from SIDEBAR_APPS, starred section, etc.

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

Route structure change

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

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

All tree-building helpers (buildAppTreeNodes, buildRouteTreeNodes, buildAgentTreeNodes), starred section logic (collectStarredItems, StarredGroup), formatCount, and sidebarRevealPath handling move from Sidebar.tsx into LayoutShell.tsx. Each page file loses its <AppShell sidebar={...}> wrapper and becomes just the page content.

The Inventory page's LayoutSection keeps its own inline <Sidebar> demo with SAMPLE_APPS data — it's a showcase, not a navigation shell.

Breaking Change

This is a breaking change to the Sidebar API. The old <Sidebar apps={[...]} onNavigate={...} /> signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.

Coordinate: bump DS version, update server UI, deploy together.