Files
design-system/docs/superpowers/plans/2026-04-15-sidebar-section-layout.md
hsiegeln 96e5f77a14 docs: add sidebar section layout implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:53:53 +02:00

16 KiB
Raw Blame History

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 position and maxHeight to SidebarSectionProps

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 SidebarSection to 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 198240) 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 maxHeight props added for testing

If you added maxHeight="100px" to Applications in Step 3, remove it now.