# 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`:

```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.

```css
.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`:

```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**

```bash
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`:

```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.

```css
.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.

```tsx
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**

```bash
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:

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

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

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

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

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

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

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

```ts
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**

```bash
git add src/design-system/composites/index.ts
git commit -m "feat: export SplitPane and EntityList from composites barrel"
```
