Use when creating, modifying, or reviewing web UI components. Triggers include "new component", "add component", "create UI", "build a widget", "update component", working with files in src/presentation/web/components/, or when the user asks to build any React component for the web UI. Part of the Shep autonomous SDLC platform — https://shep.bot
Build React components following the four-tier architecture, with mandatory Storybook stories, data-testid attributes, and unit tests.
Tier 0: ui/ -> shadcn/ui primitives (managed by CLI, rarely hand-edited)
Tier 1: common/ -> Reusable composed components (combine ui/ primitives)
Tier 2: layouts/ -> Page shells, structural wrappers (use ui/ + common/)
Tier 3: features/ -> Domain-specific views bound to routes (use all tiers)Import rule: A tier may only import from lower tiers, never upward.
features/ -> layouts/, common/, ui/
layouts/ -> common/, ui/
common/ -> ui/
ui/ -> external packages onlycomponents/ui/
button.tsx
button.stories.tsxcomponents/common/feature-list-item/
feature-list-item.tsx # Implementation
feature-list-item.stories.tsx # Storybook stories (MANDATORY)
index.ts # Barrel exportBarrel export template:
export { FeatureListItem } from './feature-list-item';
export type { FeatureListItemProps } from './feature-list-item';After creating any Tier 1-3 component, add it to the tier-level barrel:
components/common/index.tscomponents/layouts/index.tscomponents/features/index.ts'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.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');Every component MUST have a colocated .stories.tsx file. This is non-negotiable.
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 below
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',
},
};| Tier | Prefix | Example |
|---|---|---|
ui/ | Primitives/ | Primitives/Button |
common/ | Composed/ | Composed/FeatureListItem |
layouts/ | Layout/ | Layout/AppSidebar |
features/ | Features/ | Features/VersionPage |
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>
),
],
};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 that prevents using component directly, use the existing data interface as the args type. Storybook auto-infers controls from the args values — no argTypes needed. Do NOT create a duplicate args interface.
import type { FeatureNodeData } from './feature-node-state-config';
// 1. Use the component's own data interface — controls auto-inferred from args
const meta: Meta<FeatureNodeData> = {
title: 'Composed/FeatureNode',
args: { name: 'Auth Module', state: 'running', progress: 45, featureId: '#f1', lifecycle: 'requirements' },
};
type Story = StoryObj<FeatureNodeData>;
// 2. Pass args directly as data — no mapping function needed
export const Default: Story = {
render: (args) => <FeatureNode id="n1" data={args} type="featureNode" />,
};
// 3. Stories needing callbacks pass them via story-level args
export const WithAction: Story = {
args: { onAction: () => undefined, onSettings: () => undefined },
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 instead of number input, 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.
Stories must cover:
() => alert('Clicked!') or fn() from @storybook/test)| 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) |
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/ tierimport { 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 elementstabular-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" />Before 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.e86d11c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.