UI Architecture
March 10, 2026 · View on GitHub
Component architecture patterns and conventions for the Shep AI web interface.
Four-Tier Component Hierarchy
Components are organized into four tiers with strict dependency direction. Higher tiers may import from lower tiers, but never the reverse.
Tier 0: ui/ → shadcn/ui primitives (CLI-managed, no business logic)
Tier 1: common/ → Cross-feature composed components (combine ui/ primitives)
Tier 2: layouts/ → Page shells, structural wrappers (use ui/ + common/)
Tier 3: features/ → Domain-specific UI bound to routes/data (use all lower tiers)
Import Rules
| Tier | Can Import From | Cannot Import From |
|---|---|---|
ui/ | External packages only | common/, layouts/, features/ |
common/ | ui/, hooks, external packages | layouts/, features/ |
layouts/ | ui/, common/, hooks | features/ |
features/ | ui/, common/, layouts/, hooks | (no restrictions) |
Directory Structure
components/
├── ui/ # Tier 0: shadcn/ui primitives (CLI-managed)
│ ├── accordion.tsx # ~28 primitives including:
│ ├── alert-dialog.tsx # badge, button, card, checkbox, dialog,
│ ├── badge.tsx # drawer, input, label, popover, select,
│ ├── button.tsx # sidebar, skeleton, sonner, spinner, tabs,
│ ├── comet-spinner.tsx # textarea, tooltip, etc.
│ └── ...
├── common/ # Tier 1: Cross-feature composed components
│ ├── index.ts # Tier-level barrel
│ ├── feature-node/ # React Flow feature node
│ ├── repository-node/ # React Flow repository node
│ ├── base-drawer/ # Foundation drawer component
│ ├── feature-drawer/ # Feature detail drawer
│ ├── feature-drawer-tabs/ # Drawer tab navigation
│ ├── feature-create-drawer/ # Create feature form drawer
│ ├── control-center-drawer/ # Drawer orchestrator
│ ├── feature-list-item/ # Sidebar feature list item
│ ├── feature-status-badges/ # Status badge components
│ ├── feature-status-group/ # Grouped features by status
│ ├── page-header/ # Page header with title/actions
│ ├── empty-state/ # Empty content placeholder
│ ├── loading-skeleton/ # Loading placeholders
│ ├── theme-toggle/ # Light/dark/system toggle
│ ├── sidebar-nav-item/ # Sidebar navigation item
│ ├── sidebar-collapse-toggle/ # Sidebar collapse button
│ ├── sidebar-section-header/ # Sidebar section header
│ ├── shep-logo/ # Branding logo
│ ├── version-badge/ # Version display badge
│ ├── elapsed-time/ # Live elapsed time
│ ├── action-button/ # Styled action button
│ ├── ci-status-badge/ # CI pipeline status
│ ├── deployment-status-badge/ # Deployment status
│ ├── delete-feature-dialog/ # Delete confirmation
│ ├── reject-feedback-dialog/ # Rejection feedback
│ ├── drawer-action-bar/ # Drawer action buttons
│ ├── drawer-revision-input/ # Revision text input
│ ├── prd-questionnaire/ # PRD questionnaire form
│ ├── merge-review/ # Merge review UI
│ ├── task-progress-view/ # Task progress display
│ ├── server-log-viewer/ # Log viewer
│ ├── sound-toggle/ # Sound on/off toggle
│ └── ... # ~40 components total
├── layouts/ # Tier 2: Page shells, structural wrappers
│ ├── index.ts # Tier-level barrel
│ ├── app-shell/ # Top-level app wrapper
│ ├── app-sidebar/ # Application sidebar
│ ├── dashboard-layout/ # Dashboard page shell
│ ├── header/ # Top navigation bar
│ └── sidebar/ # Base sidebar layout
└── features/ # Tier 3: Domain-specific UI
├── index.ts # Tier-level barrel
├── control-center/ # Dashboard control center + state
├── features-canvas/ # React Flow canvas + custom edges
├── settings/ # Settings page sections + pickers
├── skills/ # Skills page + cards + drawer
├── tools/ # Tools page + cards + drawer
└── version/ # Version display page
Export Pattern
Each component directory uses a per-component index.ts barrel export. Additionally, each tier has a tier-level barrel file that re-exports all components in that tier.
// Per-component barrel (e.g., common/page-header/index.ts)
export { PageHeader } from './page-header';
export type { PageHeaderProps } from './page-header';
// Tier-level barrel (e.g., common/index.ts)
export { PageHeader } from './page-header';
export { ThemeToggle } from './theme-toggle';
// Import from the component directory (preferred)
import { PageHeader } from '@/components/common/page-header';
// Or import from the tier-level barrel
import { PageHeader } from '@/components/common';
When adding a new component to Tier 1-3, update both the component index.ts and the tier-level barrel.
Storybook Categories
Stories are organized to mirror the tier structure:
| Storybook Title Prefix | Component Tier | Description |
|---|---|---|
Primitives/ | ui/ | shadcn/ui base components |
Composed/ | common/ | Shared composed components |
Layout/ | layouts/ | Page shells and structural wrappers |
Features/ | features/ | Domain-specific components |
Component Template
Standard pattern for all Tier 1-3 components:
'use client'; // Only if the component uses hooks, event handlers, or browser APIs
import { cn } from '@/lib/utils';
export interface MyComponentProps {
/** Brief prop description. */
label: string;
className?: string;
}
export function MyComponent({ label, className }: MyComponentProps) {
return (
<div
data-testid="my-component"
className={cn('base-classes', className)}
>
{label}
</div>
);
}
Component Rules
- Named exports only — no default exports for components.
'use client'— add only when the component uses hooks, event handlers, or browser APIs. Omit for pure render components.classNameprop — accept and merge viacn()for composability.- Props interface — always export the interface alongside the component.
data-testid— always add on the root element (see convention below).
data-testid Convention
Every component MUST include data-testid on its root element for test targeting.
Naming scheme: kebab-case, scoped to the component
| Component | data-testid |
|---|---|
FeatureListItem | feature-list-item |
FeatureStatusGroup | feature-status-group |
SidebarCollapseToggle | sidebar-collapse-toggle |
PageHeader | page-header |
Sub-elements: append a suffix
<div data-testid="feature-list-item">
<span data-testid="feature-list-item-label">{name}</span>
<span data-testid="feature-list-item-meta">{duration}</span>
</div>
When to add data-testid
- Root element of every component: always
- Sub-elements: only when tests need to target them specifically (labels, meta, actions)
- Primitives in
ui/: usedata-slotinstead (shadcn convention)
In tests, prefer data-testid queries
screen.getByTestId('feature-list-item');
screen.getByTestId('feature-list-item-meta');
Fall back to role/text queries when data-testid is not set:
screen.getByRole('button', { name: /submit/i });
screen.getByText('Auth Module');
Component Patterns
UI Components (Tier 0)
shadcn/ui primitives managed by the CLI. Do not manually modify these -- use pnpm dlx shadcn@latest add [name] to add new ones.
// components/ui/button.tsx - CVA-based variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
Common Components (Tier 1)
Each component in its own subfolder with colocated stories and barrel export:
components/common/[name]/
├── [name].tsx # Component implementation
├── [name].stories.tsx # Storybook stories
└── index.ts # Re-export for clean imports
Layout Components (Tier 2)
Same subfolder pattern as common. Compose ui/ and common/ components into page structures.
Feature Components (Tier 3)
Organized by domain bounded context. May contain client components, data fetching logic, and route-specific UI.
components/features/[domain]/
├── [component].tsx
└── index.ts
Mandatory Storybook Stories
Every web UI component MUST have a colocated .stories.tsx file. This is a non-negotiable requirement for all component work.
Rules
- New components: Must include a
.stories.tsxfile in the same commit - Modified components: If behavior or props change, stories must be updated
- All tiers apply: Tier 0 (
ui/), Tier 1 (common/), Tier 2 (layouts/), and Tier 3 (features/) - Story coverage: Stories should cover all variants, sizes, and key states (default, hover, disabled, loading)
Story File Pattern
# Tier 0 (ui/) - flat colocated
components/ui/
├── button.tsx
└── button.stories.tsx
# Tier 1-3 - subfolder colocated
components/common/page-header/
├── page-header.tsx
├── page-header.stories.tsx
└── index.ts
Story Template
import type { Meta, StoryObj } from '@storybook/react';
import { MyComponent } from './my-component';
// IMPORTANT: Use explicit type annotation, NOT `satisfies Meta<>`
const meta: Meta<typeof MyComponent> = {
title: 'Composed/MyComponent', // See title prefixes in Storybook Categories
component: MyComponent,
parameters: {
layout: 'padded', // 'centered' | 'padded' | 'fullscreen'
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Example',
},
};
Args and Controls
Storybook controls only appear when stories define args. Never use hardcoded render-only stories — always define args so the Controls panel works.
Standard components (flat props): Use component in meta and args in stories. Controls are auto-generated.
const meta: Meta<typeof MyComponent> = {
component: MyComponent,
args: {
label: 'Default label',
variant: 'primary',
},
};
export const Default: Story = {
args: {
label: 'Example',
},
};
Wrapped/nested-data components (e.g. React Flow nodes): When a component receives data through a nested object (like { data }) or requires wrapper context, use the existing data interface as the args type. Do NOT create a duplicate args interface.
import type { FeatureNodeData } from './feature-node-state-config';
const meta: Meta<FeatureNodeData> = {
title: 'Composed/FeatureNode',
args: { name: 'Auth Module', state: 'running', progress: 45, featureId: '#f1', lifecycle: 'requirements' },
};
type Story = StoryObj<FeatureNodeData>;
export const Default: Story = {
render: (args) => <FeatureNode id="n1" data={args} type="featureNode" />,
};
Only add argTypes when you need to override defaults (e.g. select dropdown instead of free text, range slider, or { table: { disable: true } } to hide a field).
Gallery/showcase stories (AllStates, AllLifecycles) may use hardcoded render without args — controls are not useful when showing all variants at once. But the Default story must always have args.
Decorators for Context-Dependent Components
If the component requires a React context (e.g. SidebarProvider), wrap it:
const meta: Meta<typeof SidebarNavItem> = {
// ...
decorators: [
(Story) => (
<SidebarProvider>
<SidebarMenu>
<Story />
</SidebarMenu>
</SidebarProvider>
),
],
};
Story-level decorator overrides (e.g. for alternate states):
export const Collapsed: Story = {
args: { /* ... */ },
decorators: [
(Story) => (
<SidebarProvider defaultOpen={false}>
<Story />
</SidebarProvider>
),
],
};
Layout Parameter Guidelines
| Layout | When to use |
|---|---|
centered | Small, standalone primitives (Button, Badge, Input) |
padded | Medium composed components (ListItem, Card, Header) |
fullscreen | Full-width layouts (Sidebar, Dashboard, Page) |
Story Coverage Requirements
Stories must cover:
- Default state — component with typical props
- All meaningful variants — each status, size, or visual variant
- Edge cases — empty content, long text, missing optional props
- Interactive states — with click handlers (use
() => alert('Clicked!')orfn()from@storybook/test)
Verification
pnpm dev:storybook # Verify stories render correctly
pnpm build:storybook # Verify stories build without errors
Unit Test Patterns
File Location
Mirror the component tier structure under tests/unit/presentation/web/:
tests/unit/presentation/web/
button.test.tsx # ui/ tier
common/feature-list-item.test.tsx # common/ tier
layouts/app-sidebar.test.tsx # layouts/ tier
features/version-page-client.test.tsx # features/ tier
Test Template
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MyComponent } from '@/components/common/my-component';
describe('MyComponent', () => {
it('renders label text', () => {
render(<MyComponent label="Hello" />);
expect(screen.getByTestId('my-component')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('fires onClick when clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<MyComponent label="Click me" onClick={handleClick} />);
await user.click(screen.getByTestId('my-component'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('applies custom className', () => {
render(<MyComponent label="Styled" className="custom-class" />);
expect(screen.getByTestId('my-component')).toHaveClass('custom-class');
});
});
Context-Dependent Component Helper
import { SidebarProvider } from '@/components/ui/sidebar';
function renderWithSidebar(ui: React.ReactElement) {
return render(<SidebarProvider>{ui}</SidebarProvider>);
}
Timer/Interval Testing
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('updates after 1 second', () => {
vi.setSystemTime(Date.now());
render(<ElapsedTime startedAt={Date.now()} />);
act(() => vi.advanceTimersByTime(1000));
expect(screen.getByText('00:01')).toBeInTheDocument();
});
Styling Patterns
Tailwind + cn() for Conditional Classes
import { cn } from '@/lib/utils';
<div className={cn(
'flex items-center gap-2 rounded-md px-2',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
className
)} />
CVA for Variant-Driven Components
import { cva, type VariantProps } from 'class-variance-authority';
const myVariants = cva('base-classes', {
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
outline: 'border bg-background',
},
size: {
default: 'h-9 px-4',
sm: 'h-7 px-3 text-xs',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
Design Tokens — Use Semantic Color Names
bg-background, text-foreground # Page-level
bg-primary, text-primary-foreground # Brand actions
bg-muted, text-muted-foreground # De-emphasized
bg-sidebar-accent # Sidebar hover/active
text-destructive # Errors
border, bg-input # Form elements
Numeric Display: Always Use tabular-nums
<span className="tabular-nums">05:30</span>
Icons: lucide-react
import { Home, CircleAlert, Loader2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
// As prop type
interface Props {
icon: LucideIcon;
}
// Semantic icon coloring
<CircleAlert className="text-amber-500" />
<Loader2 className="text-blue-500 animate-spin" />
<CircleCheck className="text-emerald-500" />
State Management
Local State
Use React hooks for component-local state:
const [isOpen, setIsOpen] = useState(false);
Shared State
For state shared across components, use custom hooks with context:
// hooks/useTheme.ts
export function useTheme() {
const [theme, setTheme] = useState<Theme>('system');
const resolvedTheme = useResolvedTheme(theme);
return { theme, setTheme, resolvedTheme };
}
Server State
For data fetching, use Next.js Server Components with the DI resolve() helper (see DI Integration below):
// app/features/page.tsx (Server Component)
import { resolve } from '@/lib/server-container';
import { ListFeaturesUseCase } from '@shepai/core/application/use-cases/features/list-features.use-case';
export const dynamic = 'force-dynamic';
export default async function FeaturesPage() {
const features = await resolve(ListFeaturesUseCase).execute();
return <FeatureList features={features} />;
}
Server vs Client Components
Server Components (Default)
- No
'use client'directive - Can be async, fetch data directly
- Cannot use hooks or browser APIs
- Smaller bundle size
Client Components
- Add
'use client'at top of file - Required for: hooks, event handlers, browser APIs
- Keep as small as possible
DI Integration (Server-Side)
Server components and API routes access the CLI's dependency injection container through a lightweight resolve() helper. The CLI bootstrap (or dev-server) initializes the tsyringe DI container and places it on globalThis.__shepContainer. The web layer reads it back through the helper at lib/server-container.ts.
How It Works
- CLI bootstrap (
shep ui) or dev-server (pnpm dev:web) callsinitializeContainer()and setsglobalThis.__shepContainer = container - Server components / API routes import
resolve()from@/lib/server-container resolve(token)reads the container fromglobalThisand callscontainer.resolve(token)
Usage: Class Tokens (Use Cases)
When resolving a concrete class (typical for use cases), pass the class directly:
import { resolve } from '@/lib/server-container';
import { ListFeaturesUseCase } from '@shepai/core/application/use-cases/features/list-features.use-case';
// In a server component or API route handler:
const features = await resolve(ListFeaturesUseCase).execute();
Usage: String Tokens (Interfaces)
When resolving an interface-based dependency registered with a string token:
import { resolve } from '@/lib/server-container';
import type { IAgentRunRepository } from '@shepai/core/application/ports/output/agents/agent-run-repository.interface';
const repo = resolve<IAgentRunRepository>('IAgentRunRepository');
const run = await repo.findById(runId);
serverExternalPackages in next.config.ts
The following packages are excluded from Turbopack bundling via serverExternalPackages:
serverExternalPackages: ['@shepai/core', 'tsyringe', 'reflect-metadata', 'better-sqlite3'];
This is required because:
@shepai/core-- Turbopack does not perform.jsto.tsextension mapping the way Node.js/tsx does, so@shepai/coreimports would fail if bundled. Marking it external lets Node.js resolve it at runtime.tsyringe/reflect-metadata-- DI metadata relies on runtime reflection that Turbopack cannot bundle correctly.better-sqlite3-- Native Node.js addon (C++ binding) that cannot be bundled.
Force Dynamic Rendering
Server components that call resolve() must opt out of static rendering so the DI container is available at request time:
/** Force request-time rendering so the DI container is available. */
export const dynamic = 'force-dynamic';
Stale .next Cache Gotcha
If changes to next.config.ts (especially serverExternalPackages) are not picked up, delete the Next.js cache directory and restart:
rm -rf src/presentation/web/.next/
pnpm dev:web
Turbopack caches aggressively and stale entries can cause confusing module resolution failures even after config changes.
Architecture Diagram
CLI bootstrap / dev-server
│
├─ initializeContainer() # Opens SQLite, runs migrations, registers deps
└─ globalThis.__shepContainer = container
│
▼
Next.js Server Runtime
│
├─ lib/server-container.ts # resolve<T>(token) → container.resolve(token)
│
├─ app/page.tsx # Server Component: resolve(ListFeaturesUseCase)
├─ app/api/.../route.ts # API Route: resolve(CreateFeatureUseCase)
└─ ...
Rules
- Never import tsyringe directly in web layer code -- always use
resolve()from@/lib/server-container. - Never resolve dependencies in client components --
resolve()only works server-side. Pass data down as props from server components. - Always add
export const dynamic = 'force-dynamic'to server component pages that callresolve(). - Keep
serverExternalPackagesup to date -- if a new@shepai/coredependency with native bindings is added, include it in the list.
File Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Components | kebab-case | theme-toggle.tsx |
| Stories | .stories.tsx suffix | theme-toggle.stories.tsx |
| Tests | .test.tsx suffix | theme-toggle.test.tsx |
| Hooks | use prefix | useTheme.ts |
Import Conventions
Path Aliases
// Prefer aliases over relative paths
import { Button } from '@/components/ui/button'; // Good
import { Button } from '../../../components/ui/button'; // Avoid
Import Order
- React/Next.js
- External libraries
- Internal aliases (
@/) - Relative imports
- Types
Adding New Components
Add shadcn/ui Component (Tier 0)
pnpm dlx shadcn@latest add [component-name]
Create Common Component (Tier 1)
- Create subfolder:
components/common/[name]/ - Add component file:
[name].tsx - Add stories:
[name].stories.tsx - Add barrel export:
index.ts - Add to tier-level barrel:
components/common/index.ts
Create Layout Component (Tier 2)
Same pattern as common, in components/layouts/[name]/. Add to components/layouts/index.ts.
Create Feature Component (Tier 3)
- Create domain subfolder:
components/features/[domain]/ - Add component files as needed
- Add barrel export:
index.ts - Add to tier-level barrel:
components/features/index.ts
Component Checklist
Before considering a component done, verify:
- Component file created with proper structure
-
data-testidon root element (and sub-elements where needed) - Props interface exported
-
classNameprop accepted and merged viacn() - Barrel export (
index.ts) created - Tier-level barrel updated (
common/index.ts, etc.) - Storybook stories colocated with all variants covered
- Default story has
argsdefined (controls must work) - Stories use explicit
Meta<typeof X>type annotation (notsatisfies) - Story title uses correct tier prefix (
Primitives/,Composed/,Layout/,Features/) - Unit test created in
tests/unit/presentation/web/[tier]/ -
pnpm typecheck:webpasses -
pnpm test:single tests/unit/presentation/webpasses -
pnpm build:storybookpasses
Common Mistakes
satisfies Meta<>— causes TS2742 error. Use explicit type annotation instead.- Missing
'use client'— required when usinguseState,useEffect, event handlers. - Forgetting barrel exports — both component-level
index.tsand tier-level barrel. - Inline meta in stories — no default exports, always
export const Default: Story. - Missing
data-testid— every component root must have one. - Magic numbers in styles — use design tokens and Tailwind spacing scale.
- Uppercase in commit subjects — commitlint rejects it; use all-lowercase.
- Render-only stories without
args— controls panel will be empty. Always defineargs. For nested-data components, reuse the component's data interface as args type (don't create a duplicate), addargTypesfor control customization, and passargsdirectly as data.
Code Quality
Linting & Formatting
pnpm lint:web # Run ESLint
pnpm lint:web:fix # Fix lint issues
pnpm typecheck:web # TypeScript type checking
Pre-commit Hooks
lint-staged automatically runs on commit:
- ESLint with
--fixon.ts,.tsxfiles - Prettier on all staged files
- TypeScript type checking