16 KiB
Sidebar Section Layout 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: Add position and maxHeight props to Sidebar.Section so sections can stack at the top or bottom of the sidebar with optional scrollable content areas, and style all scrollbars to match the dark sidebar aesthetic.
Architecture: Extend the existing flexbox column layout inside SidebarRoot by partitioning section children into top/bottom groups with a flex spacer between them. Each group wrapper scrolls independently when the viewport is short. SidebarSection gets a content wrapper div that accepts an inline maxHeight and scrolls its children. Custom thin scrollbar styles are applied via CSS.
Tech Stack: React, CSS Modules, Vitest + React Testing Library
File Structure
| File | Action | Responsibility |
|---|---|---|
src/design-system/layout/Sidebar/Sidebar.tsx |
Modify | Add position and maxHeight props to SidebarSectionProps, wrap children in .sectionContent div, partition children in SidebarRoot into top/bottom groups |
src/design-system/layout/Sidebar/Sidebar.module.css |
Modify | Add .sectionGroup, .sectionSpacer, .sectionContent classes, custom scrollbar styles |
src/design-system/layout/Sidebar/Sidebar.test.tsx |
Modify | Add tests for position partitioning, maxHeight content wrapper, spacer rendering, collapsed behavior |
Task 1: Add CSS classes for section groups, spacer, content wrapper, and scrollbars
Files:
-
Modify:
src/design-system/layout/Sidebar/Sidebar.module.css:392(before the bottom links section) -
Step 1: Add
.sectionGroup,.sectionSpacer,.sectionContent, and scrollbar styles
Insert the following block before the /* ── Bottom links */ comment at line 392:
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
.sectionGroup {
flex: 0 1 auto;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionGroup::-webkit-scrollbar {
width: 4px;
}
.sectionGroup::-webkit-scrollbar-track {
background: transparent;
}
.sectionGroup::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionGroup::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.sectionSpacer {
flex: 1 0 0;
}
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
.sectionContent {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionContent::-webkit-scrollbar {
width: 4px;
}
.sectionContent::-webkit-scrollbar-track {
background: transparent;
}
.sectionContent::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionContent::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
- Step 2: Commit
git add src/design-system/layout/Sidebar/Sidebar.module.css
git commit -m "style: add section group, spacer, content, and scrollbar CSS classes"
Task 2: Add maxHeight prop and content wrapper to SidebarSection
Files:
-
Modify:
src/design-system/layout/Sidebar/Sidebar.tsx:21-29(SidebarSectionProps) -
Modify:
src/design-system/layout/Sidebar/Sidebar.tsx:78-131(SidebarSection component) -
Test:
src/design-system/layout/Sidebar/Sidebar.test.tsx -
Step 1: Write the failing tests
Add the following tests to the end of the describe block in Sidebar.test.tsx:
// 17. renders sectionContent wrapper with maxHeight when open
it('renders section content wrapper with maxHeight style when open', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 18. renders sectionContent wrapper without maxHeight when not provided
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 19. does not render sectionContent wrapper when section is closed
it('does not render section content wrapper when section is closed', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open={false}
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).not.toBeInTheDocument()
})
- Step 2: Run tests to verify they fail
Run: npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx
Expected: FAIL — maxHeight prop is not recognized, no .sectionContent wrapper exists.
- Step 3: Add
positionandmaxHeighttoSidebarSectionProps
Update the interface at line 21:
interface SidebarSectionProps {
icon: ReactNode
label: string
open: boolean
onToggle: () => void
active?: boolean
children: ReactNode
className?: string
position?: 'top' | 'bottom'
maxHeight?: string
}
- Step 4: Update
SidebarSectionto destructure new props and wrap children
Update the function signature at line 78 to destructure the new props (they are accepted but position is only used by SidebarRoot):
function SidebarSection({
icon,
label,
open,
onToggle,
active,
children,
className,
position: _position,
maxHeight,
}: SidebarSectionProps) {
Then replace {open && children} (line 128) with:
{open && (
<div
className={styles.sectionContent}
style={maxHeight ? { maxHeight } : undefined}
>
{children}
</div>
)}
- Step 5: Run tests to verify they pass
Run: npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx
Expected: ALL PASS
- Step 6: Commit
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
git commit -m "feat: add maxHeight prop with sectionContent wrapper to SidebarSection"
Task 3: Partition children in SidebarRoot into top/bottom groups
Files:
-
Modify:
src/design-system/layout/Sidebar/Sidebar.tsx:167-244(SidebarRoot component) -
Test:
src/design-system/layout/Sidebar/Sidebar.test.tsx -
Step 1: Write the failing tests
Add the following tests to the end of the describe block in Sidebar.test.tsx:
// 20. renders top sections in sectionGroup wrapper
it('renders sections in top group wrapper by default', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const topGroup = container.querySelector('.sectionGroup')
expect(topGroup).toBeInTheDocument()
expect(topGroup!.textContent).toContain('Apps')
})
// 21. renders bottom sections in separate group with spacer
it('renders bottom sections in a separate group with spacer between', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
<div>routes content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
expect(groups[0].textContent).toContain('Apps')
expect(groups[1].textContent).toContain('Routes')
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).toBeInTheDocument()
})
// 22. does not render spacer when no bottom sections
it('does not render spacer when all sections are top', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
<div>agents content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).not.toBeInTheDocument()
})
// 23. collapsed sidebar renders bottom sections in bottom group
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
const { container } = render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
<div>apps</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
<div>routes</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
// Bottom group should contain the Routes rail item
const bottomGroup = groups[1]
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
})
- Step 2: Run tests to verify they fail
Run: npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx
Expected: FAIL — no .sectionGroup wrappers exist yet, sections render directly in {rest}.
- Step 3: Rewrite the child partitioning logic in
SidebarRoot
Replace the entire IIFE inside the <aside> (lines 198–240) with the following:
{(() => {
const childArray = Children.toArray(children)
// Extract header
const headerIdx = childArray.findIndex(
(child) => isValidElement(child) && child.type === SidebarHeader,
)
const header = headerIdx >= 0 ? childArray[headerIdx] : null
const rest = headerIdx >= 0
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
: childArray
// Extract footer
const footerIdx = rest.findIndex(
(child) => isValidElement(child) && child.type === SidebarFooter,
)
const footer = footerIdx >= 0 ? rest[footerIdx] : null
const sections = footerIdx >= 0
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
: rest
// Partition sections into top/bottom by position prop
const topSections: typeof sections = []
const bottomSections: typeof sections = []
for (const child of sections) {
if (
isValidElement<SidebarSectionProps>(child) &&
child.props.position === 'bottom'
) {
bottomSections.push(child)
} else {
topSections.push(child)
}
}
const hasBottomSections = bottomSections.length > 0
return (
<>
{header}
{onSearchChange && !collapsed && (
<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>
)}
<div className={styles.sectionGroup}>
{topSections}
</div>
{hasBottomSections && <div className={styles.sectionSpacer} />}
{hasBottomSections && (
<div className={styles.sectionGroup}>
{bottomSections}
</div>
)}
{footer}
</>
)
})()}
- Step 4: Run tests to verify they pass
Run: npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx
Expected: ALL PASS
- Step 5: Run the full test suite to check for regressions
Run: npx vitest run
Expected: ALL PASS
- Step 6: Commit
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
git commit -m "feat: partition sidebar sections into top/bottom groups with spacer"
Task 4: Update LayoutShell to use position="bottom" on Routes and Starred sections
Files:
-
Modify:
src/layout/LayoutShell.tsx:378-429(the Routes and Starred section declarations) -
Step 1: Add
position="bottom"to the Routes section
In LayoutShell.tsx, find the Routes <Sidebar.Section> (around line 378) and add position="bottom":
<Sidebar.Section
label="Routes"
icon={<GitBranch size={14} />}
open={!routesCollapsed}
onToggle={toggleRoutesCollapsed}
active={location.pathname.startsWith('/routes')}
position="bottom"
>
- Step 2: Add
position="bottom"to the Starred section
Find the Starred <Sidebar.Section> (around line 396) and add position="bottom":
{hasStarred && (
<Sidebar.Section
label="★ Starred"
icon={<span />}
open={true}
onToggle={() => {}}
active={false}
position="bottom"
>
- Step 3: Run the full test suite
Run: npx vitest run
Expected: ALL PASS
- Step 4: Commit
git add src/layout/LayoutShell.tsx
git commit -m "feat: position Routes and Starred sections at bottom of sidebar"
Task 5: Visual verification in the browser
Files: None (manual verification)
- Step 1: Start the dev server
Run: npm run dev
- Step 2: Verify top/bottom layout
Open the app in a browser. Confirm:
-
Applications and Agents sections are at the top
-
Routes and Starred sections are at the bottom, above the footer
-
A flexible gap separates the two groups
-
Step 3: Verify scrollbar styling
If a section has enough content to overflow (or temporarily add maxHeight="100px" to the Applications section in LayoutShell.tsx), confirm:
-
Content scrolls within the section
-
Scrollbar is thin (4px), with a muted thumb on a transparent track
-
Scrollbar thumb brightens on hover
-
Step 4: Verify collapsed sidebar
Collapse the sidebar and confirm:
-
Top section icons stay at the top
-
Bottom section icons cluster near the footer
-
Spacer separates the two groups in the rail
-
Step 5: Verify short viewport
Resize the browser window very short. Confirm:
-
Both top and bottom groups scroll independently
-
No content is permanently clipped or inaccessible
-
Step 6: Remove any temporary
maxHeightprops added for testing
If you added maxHeight="100px" to Applications in Step 3, remove it now.