Files
design-system/docs/superpowers/plans/2026-03-24-admin-components.md
hsiegeln e664e449c3 docs: add 4 implementation plans for mock deviation cleanup
Plan 1: KpiStrip + StatusText + Card title (metrics)
Plan 2: SplitPane + EntityList (admin)
Plan 3: LogViewer + AgentHealth DataTable refactor (observability)
Plan 4: COMPONENT_GUIDE.md + Inventory updates (documentation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:10:00 +01:00

16 KiB

Admin Components 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 SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs.

Architecture: SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel.

Tech Stack: React, TypeScript, CSS Modules, Vitest, React Testing Library

Spec: docs/superpowers/specs/2026-03-24-mock-deviations-design.md (Sections 2, 2b)


File Map

File Action Responsibility
src/design-system/composites/SplitPane/SplitPane.tsx Create Two-column grid layout with list/detail slots and empty state
src/design-system/composites/SplitPane/SplitPane.module.css Create Grid layout, scrollable panels, empty state styling
src/design-system/composites/SplitPane/SplitPane.test.tsx Create 5 test cases for SplitPane
src/design-system/composites/EntityList/EntityList.tsx Create Generic searchable, selectable list with render props
src/design-system/composites/EntityList/EntityList.module.css Create Header, scrollable list, item hover/selected states
src/design-system/composites/EntityList/EntityList.test.tsx Create 11 test cases for EntityList
src/design-system/composites/index.ts Modify Add SplitPane and EntityList exports

Task 1: SplitPane composite

Files:

  • Create: src/design-system/composites/SplitPane/SplitPane.tsx

  • Create: src/design-system/composites/SplitPane/SplitPane.module.css

  • Create: src/design-system/composites/SplitPane/SplitPane.test.tsx

  • Step 1: Write SplitPane tests

Create src/design-system/composites/SplitPane/SplitPane.test.tsx:

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SplitPane } from './SplitPane'

describe('SplitPane', () => {
  it('renders list and detail content', () => {
    render(
      <SplitPane
        list={<div>User list</div>}
        detail={<div>User detail</div>}
      />,
    )
    expect(screen.getByText('User list')).toBeInTheDocument()
    expect(screen.getByText('User detail')).toBeInTheDocument()
  })

  it('shows default empty message when detail is null', () => {
    render(
      <SplitPane
        list={<div>User list</div>}
        detail={null}
      />,
    )
    expect(screen.getByText('Select an item to view details')).toBeInTheDocument()
  })

  it('shows custom empty message when detail is null', () => {
    render(
      <SplitPane
        list={<div>User list</div>}
        detail={null}
        emptyMessage="Pick a user to see info"
      />,
    )
    expect(screen.getByText('Pick a user to see info')).toBeInTheDocument()
  })

  it('renders with different ratios', () => {
    const { container, rerender } = render(
      <SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="1:1" />,
    )
    const pane = container.firstChild as HTMLElement
    expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr')

    rerender(
      <SplitPane list={<div>List</div>} detail={<div>Detail</div>} ratio="2:3" />,
    )
    expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr')
  })

  it('accepts className', () => {
    const { container } = render(
      <SplitPane
        list={<div>List</div>}
        detail={<div>Detail</div>}
        className="custom"
      />,
    )
    expect(container.firstChild).toHaveClass('custom')
  })
})
  • Step 2: Run tests to verify they fail

Run: npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx Expected: FAIL — module not found

  • Step 3: Create SplitPane CSS module

Create src/design-system/composites/SplitPane/SplitPane.module.css:

CSS extracted from src/pages/Admin/UserManagement/UserManagement.module.css (.splitPane, .listPane, .detailPane, .emptyDetail), generalized with a CSS custom property for the column ratio.

.splitPane {
  display: grid;
  grid-template-columns: var(--split-columns, 1fr 2fr);
  gap: 1px;
  background: var(--border-subtle);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  min-height: 0;
  height: 100%;
  box-shadow: var(--shadow-card);
}

.listPane {
  background: var(--bg-surface);
  display: flex;
  flex-direction: column;
  border-radius: var(--radius-lg) 0 0 var(--radius-lg);
  overflow-y: auto;
}

.detailPane {
  background: var(--bg-raised);
  overflow-y: auto;
  padding: 20px;
  border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}

.emptyDetail {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-faint);
  font-size: 13px;
  font-family: var(--font-body);
  font-style: italic;
}
  • Step 4: Create SplitPane component

Create src/design-system/composites/SplitPane/SplitPane.tsx:

import type { ReactNode } from 'react'
import styles from './SplitPane.module.css'

interface SplitPaneProps {
  list: ReactNode
  detail: ReactNode | null
  emptyMessage?: string
  ratio?: '1:1' | '1:2' | '2:3'
  className?: string
}

const ratioMap: Record<string, string> = {
  '1:1': '1fr 1fr',
  '1:2': '1fr 2fr',
  '2:3': '2fr 3fr',
}

export function SplitPane({
  list,
  detail,
  emptyMessage = 'Select an item to view details',
  ratio = '1:2',
  className,
}: SplitPaneProps) {
  return (
    <div
      className={`${styles.splitPane} ${className ?? ''}`}
      style={{ '--split-columns': ratioMap[ratio] } as React.CSSProperties}
    >
      <div className={styles.listPane}>{list}</div>
      <div className={styles.detailPane}>
        {detail !== null ? detail : (
          <div className={styles.emptyDetail}>{emptyMessage}</div>
        )}
      </div>
    </div>
  )
}
  • Step 5: Run tests to verify they pass

Run: npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx Expected: 5 tests PASS

  • Step 6: Commit
git add src/design-system/composites/SplitPane/SplitPane.tsx \
        src/design-system/composites/SplitPane/SplitPane.module.css \
        src/design-system/composites/SplitPane/SplitPane.test.tsx
git commit -m "feat: add SplitPane composite for master/detail layouts"

Task 2: EntityList composite

Files:

  • Create: src/design-system/composites/EntityList/EntityList.tsx

  • Create: src/design-system/composites/EntityList/EntityList.module.css

  • Create: src/design-system/composites/EntityList/EntityList.test.tsx

  • Step 1: Write EntityList tests

Create src/design-system/composites/EntityList/EntityList.test.tsx:

import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EntityList } from './EntityList'

interface TestItem {
  id: string
  name: string
}

const items: TestItem[] = [
  { id: '1', name: 'Alice' },
  { id: '2', name: 'Bob' },
  { id: '3', name: 'Charlie' },
]

const defaultProps = {
  items,
  renderItem: (item: TestItem) => <span>{item.name}</span>,
  getItemId: (item: TestItem) => item.id,
}

describe('EntityList', () => {
  it('renders all items', () => {
    render(<EntityList {...defaultProps} />)
    expect(screen.getByText('Alice')).toBeInTheDocument()
    expect(screen.getByText('Bob')).toBeInTheDocument()
    expect(screen.getByText('Charlie')).toBeInTheDocument()
  })

  it('calls onSelect when item clicked', async () => {
    const onSelect = vi.fn()
    const user = userEvent.setup()
    render(<EntityList {...defaultProps} onSelect={onSelect} />)
    await user.click(screen.getByText('Bob'))
    expect(onSelect).toHaveBeenCalledWith('2')
  })

  it('highlights selected item', () => {
    render(<EntityList {...defaultProps} selectedId="2" />)
    const selectedOption = screen.getByText('Bob').closest('[role="option"]')
    expect(selectedOption).toHaveAttribute('aria-selected', 'true')
    expect(selectedOption).toHaveClass(/selected/i)
  })

  it('renders search input when onSearch provided', () => {
    render(<EntityList {...defaultProps} onSearch={vi.fn()} searchPlaceholder="Search users..." />)
    expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument()
  })

  it('calls onSearch when typing in search', async () => {
    const onSearch = vi.fn()
    const user = userEvent.setup()
    render(<EntityList {...defaultProps} onSearch={onSearch} />)
    await user.type(screen.getByPlaceholderText('Search...'), 'alice')
    expect(onSearch).toHaveBeenLastCalledWith('alice')
  })

  it('renders add button when onAdd provided', () => {
    render(<EntityList {...defaultProps} onAdd={vi.fn()} addLabel="+ Add user" />)
    expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument()
  })

  it('calls onAdd when add button clicked', async () => {
    const onAdd = vi.fn()
    const user = userEvent.setup()
    render(<EntityList {...defaultProps} onAdd={onAdd} addLabel="+ Add user" />)
    await user.click(screen.getByRole('button', { name: '+ Add user' }))
    expect(onAdd).toHaveBeenCalledOnce()
  })

  it('hides header when no search or add', () => {
    const { container } = render(<EntityList {...defaultProps} />)
    // No header element should be rendered (no search input, no add button)
    expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument()
    expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument()
  })

  it('shows empty message when items is empty', () => {
    render(
      <EntityList
        items={[]}
        renderItem={() => <span />}
        getItemId={() => ''}
      />,
    )
    expect(screen.getByText('No items found')).toBeInTheDocument()
  })

  it('shows custom empty message', () => {
    render(
      <EntityList
        items={[]}
        renderItem={() => <span />}
        getItemId={() => ''}
        emptyMessage="No users match your search"
      />,
    )
    expect(screen.getByText('No users match your search')).toBeInTheDocument()
  })

  it('accepts className', () => {
    const { container } = render(<EntityList {...defaultProps} className="custom" />)
    expect(container.firstChild).toHaveClass('custom')
  })
})
  • Step 2: Run tests to verify they fail

Run: npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx Expected: FAIL — module not found

  • Step 3: Create EntityList CSS module

Create src/design-system/composites/EntityList/EntityList.module.css:

CSS extracted from src/pages/Admin/UserManagement/UserManagement.module.css (.listHeader, .listHeaderSearch, .entityList, .entityItem, .entityItemSelected), generalized for reuse.

.entityListRoot {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.listHeader {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px;
  border-bottom: 1px solid var(--border-subtle);
}

.listHeaderSearch {
  flex: 1;
}

.list {
  flex: 1;
  overflow-y: auto;
}

.entityItem {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 12px;
  cursor: pointer;
  transition: background 0.1s;
  border-bottom: 1px solid var(--border-subtle);
}

.entityItem:hover {
  background: var(--bg-hover);
}

.entityItemSelected {
  background: var(--amber-bg);
  border-left: 3px solid var(--amber);
}

.emptyMessage {
  padding: 32px;
  text-align: center;
  color: var(--text-faint);
  font-size: 12px;
  font-family: var(--font-body);
}
  • Step 4: Create EntityList component

Create src/design-system/composites/EntityList/EntityList.tsx:

The component uses role="listbox" / role="option" for accessibility, matching the pattern in UsersTab.tsx. It delegates search input and add button to the existing Input and Button primitives.

import { useState, type ReactNode } from 'react'
import { Input } from '../../primitives/Input/Input'
import { Button } from '../../primitives/Button/Button'
import styles from './EntityList.module.css'

interface EntityListProps<T> {
  items: T[]
  renderItem: (item: T, isSelected: boolean) => ReactNode
  getItemId: (item: T) => string
  selectedId?: string
  onSelect?: (id: string) => void
  searchPlaceholder?: string
  onSearch?: (query: string) => void
  addLabel?: string
  onAdd?: () => void
  emptyMessage?: string
  className?: string
}

export function EntityList<T>({
  items,
  renderItem,
  getItemId,
  selectedId,
  onSelect,
  searchPlaceholder = 'Search...',
  onSearch,
  addLabel,
  onAdd,
  emptyMessage = 'No items found',
  className,
}: EntityListProps<T>) {
  const [searchValue, setSearchValue] = useState('')
  const showHeader = !!onSearch || !!onAdd

  function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value
    setSearchValue(value)
    onSearch?.(value)
  }

  function handleSearchClear() {
    setSearchValue('')
    onSearch?.('')
  }

  return (
    <div className={`${styles.entityListRoot} ${className ?? ''}`}>
      {showHeader && (
        <div className={styles.listHeader}>
          {onSearch && (
            <Input
              placeholder={searchPlaceholder}
              value={searchValue}
              onChange={handleSearchChange}
              onClear={handleSearchClear}
              className={styles.listHeaderSearch}
            />
          )}
          {onAdd && addLabel && (
            <Button size="sm" variant="secondary" onClick={onAdd}>
              {addLabel}
            </Button>
          )}
        </div>
      )}

      <div className={styles.list} role="listbox">
        {items.map((item) => {
          const id = getItemId(item)
          const isSelected = id === selectedId
          return (
            <div
              key={id}
              className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
              onClick={() => onSelect?.(id)}
              role="option"
              tabIndex={0}
              aria-selected={isSelected}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.preventDefault()
                  onSelect?.(id)
                }
              }}
            >
              {renderItem(item, isSelected)}
            </div>
          )
        })}
        {items.length === 0 && (
          <div className={styles.emptyMessage}>{emptyMessage}</div>
        )}
      </div>
    </div>
  )
}
  • Step 5: Run tests to verify they pass

Run: npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx Expected: 11 tests PASS

  • Step 6: Commit
git add src/design-system/composites/EntityList/EntityList.tsx \
        src/design-system/composites/EntityList/EntityList.module.css \
        src/design-system/composites/EntityList/EntityList.test.tsx
git commit -m "feat: add EntityList composite for searchable, selectable lists"

Task 3: Barrel exports & full test suite

Files:

  • Modify: src/design-system/composites/index.ts

  • Step 1: Add exports to barrel

Add these lines to src/design-system/composites/index.ts in alphabetical position.

After the DetailPanel export (line 13), add:

export { EntityList } from './EntityList/EntityList'

After the LineChart export (line 19), before LoginDialog, add:

// (no change needed here — LoginDialog is already present)

After the ShortcutsBar export (line 33), before SegmentedTabs, add:

export { SplitPane } from './SplitPane/SplitPane'

The resulting new lines in index.ts (in their alphabetical positions):

export { EntityList } from './EntityList/EntityList'
export { SplitPane } from './SplitPane/SplitPane'
  • Step 2: Run the full component test suite

Run: npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/ Expected: All 16 tests PASS (5 SplitPane + 11 EntityList)

  • Step 3: Run the full project test suite to check for regressions

Run: npx vitest run Expected: All tests PASS

  • Step 4: Commit
git add src/design-system/composites/index.ts
git commit -m "feat: export SplitPane and EntityList from composites barrel"