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>
);
}- 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).
Every component MUST include data-testid on its root element for test targeting.
| 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>- 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)
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.
- 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)
# 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:
- 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)
pnpm dev:storybook # Verify stories render correctly
pnpm build:storybook # Verify stories build without errorsMirror 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
<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} />;
}- No
'use client'directive - Can be async, fetch data directly
- Cannot use hooks or browser APIs
- Smaller bundle size
- Add
'use client'at top of file - Required for: hooks, event handlers, browser APIs
- Keep as small as possible
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.
- 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)
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);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.
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';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:webTurbopack 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)
└─ ...
- 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.
| 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- React/Next.js
- External libraries
- Internal aliases (
@/) - Relative imports
- Types
pnpm dlx shadcn@latest add [component-name]- 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
Same pattern as common, in components/layouts/[name]/. Add to components/layouts/index.ts.
- 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
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
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.
pnpm lint:web # Run ESLint
pnpm lint:web:fix # Fix lint issues
pnpm typecheck:web # TypeScript type checkinglint-staged automatically runs on commit:
- ESLint with
--fixon.ts,.tsxfiles - Prettier on all staged files
- TypeScript type checking