Component architecture patterns and conventions for the Shep AI web interface.
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)
| 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) |
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
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.
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 |
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>
);
}
'use client' — add only when the component uses hooks, event handlers, or browser APIs. Omit for pure render components.className prop — accept and merge via cn() for composability.data-testid — always add on the root element (see convention below).Every component MUST include data-testid on its root element for test targeting.
kebab-case, scoped to the component| Component | data-testid |
|---|---|
FeatureListItem |
feature-list-item |
FeatureStatusGroup |
feature-status-group |
SidebarCollapseToggle |
sidebar-collapse-toggle |
PageHeader |
page-header |
<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>
ui/: use data-slot instead (shadcn convention)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');
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';
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
Same subfolder pattern as common. Compose ui/ and common/ components into page structures.
Organized by domain bounded context. May contain client components, data fetching logic, and route-specific UI.
components/features/[domain]/
├── [component].tsx
└── index.ts
Every web UI component MUST have a colocated .stories.tsx file. This is a non-negotiable requirement for all component work.
.stories.tsx file in the same commitui/), Tier 1 (common/), Tier 2 (layouts/), and Tier 3 (features/)# 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
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',
},
};
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.
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 | When to use |
|---|---|
centered |
Small, standalone primitives (Button, Badge, Input) |
padded |
Medium composed components (ListItem, Card, Header) |
fullscreen |
Full-width layouts (Sidebar, Dashboard, Page) |
Stories must cover:
() => alert('Clicked!') or fn() from @storybook/test)pnpm dev:storybook # Verify stories render correctly
pnpm build:storybook # Verify stories build without errors
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
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');
});
});
import { SidebarProvider } from '@/components/ui/sidebar';
function renderWithSidebar(ui: React.ReactElement) {
return render(<SidebarProvider>{ui}</SidebarProvider>);
}
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();
});
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
)} />
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',
},
});
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
tabular-nums<span className="tabular-nums">05:30</span>
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" />
Use React hooks for component-local state:
const [isOpen, setIsOpen] = useState(false);
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 };
}
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} />;
}
'use client' directive'use client' at top of fileServer 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.
shep ui) or dev-server (pnpm dev:web) calls initializeContainer() and sets globalThis.__shepContainer = containerresolve() from @/lib/server-containerresolve(token) reads the container from globalThis and calls container.resolve(token)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();
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.tsThe 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 .js to .ts extension mapping the way Node.js/tsx does, so @shepai/core imports 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.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';
.next Cache GotchaIf 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.
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)
└─ ...
resolve() from @/lib/server-container.resolve() only works server-side. Pass data down as props from server components.export const dynamic = 'force-dynamic' to server component pages that call resolve().serverExternalPackages up to date – if a new @shepai/core dependency with native bindings is added, include it in the list.| 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 |
// Prefer aliases over relative paths
import { Button } from '@/components/ui/button'; // Good
import { Button } from '../../../components/ui/button'; // Avoid
@/)pnpm dlx shadcn@latest add [component-name]
components/common/[name]/[name].tsx[name].stories.tsxindex.tscomponents/common/index.tsSame pattern as common, in components/layouts/[name]/. Add to components/layouts/index.ts.
components/features/[domain]/index.tscomponents/features/index.tsBefore considering a component done, verify:
data-testid on root element (and sub-elements where needed)className prop accepted and merged via cn()index.ts) createdcommon/index.ts, etc.)args defined (controls must work)Meta<typeof X> type annotation (not satisfies)Primitives/, Composed/, Layout/, Features/)tests/unit/presentation/web/[tier]/pnpm typecheck:web passespnpm test:single tests/unit/presentation/web passespnpm build:storybook passessatisfies Meta<> — causes TS2742 error. Use explicit type annotation instead.'use client' — required when using useState, useEffect, event handlers.index.ts and tier-level barrel.export const Default: Story.data-testid — every component root must have one.args — controls panel will be empty. Always define args. For nested-data components, reuse the component’s data interface as args type (don’t create a duplicate), add argTypes for control customization, and pass args directly as data.pnpm lint:web # Run ESLint
pnpm lint:web:fix # Fix lint issues
pnpm typecheck:web # TypeScript type checking
lint-staged automatically runs on commit:
--fix on .ts, .tsx files