docs: composable sidebar refactor spec
Compound component API replacing monolithic Sidebar. DS provides shell (Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink) + standalone SidebarTree and useStarred exports. Application controls all content, icons, sections. Adds icon-rail collapse mode. Breaking change — coordinate with server UI migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
317
docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
Normal file
317
docs/superpowers/specs/2026-04-02-composable-sidebar-design.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current — monolithic
|
||||||
|
export { Sidebar } from './Sidebar/Sidebar'
|
||||||
|
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Exports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Sidebar
|
||||||
|
collapsed={false}
|
||||||
|
onCollapseToggle={() => {}}
|
||||||
|
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. |
|
||||||
|
| `children` | `ReactNode` | - | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
|
||||||
|
| `className` | `string` | - | Additional CSS class |
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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`. In icon-rail mode, clicking the icon calls both `onCollapseToggle` (to expand the sidebar) and `onToggle`.
|
||||||
|
|
||||||
|
### `<Sidebar.Footer>`
|
||||||
|
|
||||||
|
Pinned to the bottom of the sidebar. Container for `Sidebar.FooterLink` items.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<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.
|
||||||
|
|
||||||
|
### `<Sidebar.FooterLink>`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 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)}
|
||||||
|
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>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
Reference in New Issue
Block a user