Three days before a customer pilot, our PM pinged me: "Can we ship that analytics dashboard?" The design had been sitting in Figma for weeks. I promised I'd have it in production by Friday with AI co-pilot.
By Wednesday morning, the PR was still in draft. Not because the UI was hard—it looked exactly like the mock—but because the AI kept inventing work.
Here's what a typical week produced:
// Monday - inline styles
export const RevenueCard = () => {
return (
<div style={{
background: 'white',
borderRadius: '12px',
padding: '24px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
}}>
<span style={{ color: '#6B7280', fontSize: '14px' }}>Total Revenue</span>
<div style={{ fontSize: '32px', fontWeight: 600 }}>$124,500</div>
</div>
);
};
// Tuesday - MUI (we use Tailwind)
import { DataGrid } from '@mui/x-data-grid';
// Wednesday - CSS modules (since when?)
import styles from './FilterPanel.module.css';
// Thursday - styled-components (not even installed)
import styled from 'styled-components';
Four days. Four completely different approaches. The code worked, technically. But maintaining it? Good luck.
The root cause became obvious: AI doesn't read documentation the way humans do. It pattern-matches. And if your codebase doesn't have clear patterns to match, AI will invent its own—differently every time.
The Lesson
AI reflects your architecture. Chaotic codebase, chaotic output. Structured codebase, structured output.
After months of trial and error, here's what actually works.
1. Separate State From Representation (Smart vs Dumb Components)
AI writes strange things when fetch logic, loading UI, and display live in the same file. Split them.
// Container: owns data fetching
export function RevenueCardContainer() {
const { data, error, isLoading } = useRevenue();
if (isLoading) return <RevenueCardView state="loading" />;
if (error) return <RevenueCardView state="error" message="Revenue unavailable" />;
if (!data) return <RevenueCardView state="empty" message="No revenue yet" />;
return <RevenueCardView state="ready" value={data.value} previousValue={data.previousValue} />;
}
// Presentational: pure UI, tokens only
export function RevenueCardView({ state, value, previousValue, message }: Props) {
if (state === 'loading') return <MetricCard loading label="Revenue" />;
if (state === 'error') return <MetricCard label="Revenue" error message={message} />;
if (state === 'empty') return <MetricCard label="Revenue" empty message={message} />;
return (
<MetricCard
label="Revenue"
value={value}
previousValue={previousValue}
format="currency"
/>
);
}
Storybook becomes the contract AI must honor. Capture the four canonical states—loading, empty, error, ready—so the bot can't invent new ones:
// RevenueCardView.stories.tsx
export const Loading = { args: { state: 'loading' } };
export const Empty = { args: { state: 'empty', message: 'No revenue yet' } };
export const Error = { args: { state: 'error', message: 'Revenue unavailable' } };
export const Ready = { args: { state: 'ready', value: 124500, previousValue: 110600 } };
AI stops rebuilding components that already exist because the stories show the "golden" versions.
2. Adopt Atomic Design
Atomic Design by Brad Frost turns out to be exactly what you need when AI is generating your code.
The core insight: hierarchical composition. Atoms form molecules. Molecules form organisms. Each level has a single responsibility.
Why does this matter for AI? Because AI excels at composition when given well-defined pieces and falls apart when rules are ambiguous.
// Level 1: Atoms - indivisible primitives
export const Text = ({ variant, color, children }: TextProps) => (
<span className={cn(textVariants[variant], textColors[color])}>
{children}
</span>
);
export const Skeleton = ({ variant = 'text' }: SkeletonProps) => (
<div className={cn('animate-pulse bg-muted rounded', skeletonVariants[variant])} />
);
// Level 2: Molecules - atoms with a purpose
export const MetricValue = ({ value, previousValue, format, loading }: Props) => {
if (loading) {
return (
<div className="space-y-2">
<Skeleton variant="heading" />
<Skeleton variant="text" className="w-16" />
</div>
);
}
return (
<div className="space-y-1">
<Text variant="display" color="primary">{formatters[format](value)}</Text>
{previousValue && <TrendIndicator value={calculateChange(value, previousValue)} />}
</div>
);
};
// Level 3: Organisms - complete UI sections
export const MetricCard = ({ label, value, previousValue, format, loading }: Props) => (
<Card variant="elevated" padding="lg">
<MetricLabel label={label} />
<MetricValue value={value} previousValue={previousValue} format={format} loading={loading} />
</Card>
);
Now when I ask for "a revenue metric card," AI composes: <MetricCard label="Revenue" value={revenue} format="currency" />. Consistent every time.
3. Design Tokens as Vocabulary
Components solve structural consistency. Design tokens solve visual consistency.
Named constants for every visual decision—not "blue" but action-primary, not "16px" but spacing-4:
export const colors = {
action: {
primary: '#6366F1',
primaryHover: '#4F46E5',
},
surface: {
page: '#F9FAFB',
card: '#FFFFFF',
},
content: {
primary: '#111827',
secondary: '#4B5563',
muted: '#9CA3AF',
},
} as const;
Wire them through Tailwind config:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
action: {
primary: 'var(--color-action-primary)',
},
surface: {
card: 'var(--color-surface-card)',
},
content: {
primary: 'var(--color-content-primary)',
},
},
},
},
};
Now when AI writes bg-surface-card or text-content-secondary, it's speaking your design language. No hex codes that drift.
4. Scaffold Before You Generate
AI behaves best when you give it a guardrailed sandbox instead of a blank file.
A command like pnpm ui:generate metric-card should create:
MetricCard/
├── MetricCard.tsx # Container
├── MetricCardView.tsx # Presentational
├── MetricCard.stories.tsx
├── MetricCard.test.tsx
└── index.ts
The generated files include TODOs and comments telling AI where to edit and where not to touch. AI fills the blanks instead of rewriting the world. You can also use this mcp to help with scaffolding with the folder structure you liked.
5. Enforce Contracts with Lint and Stories
Static rules catch mistakes before they ship.
// eslint.config.mjs
export default [
{
rules: {
'no-restricted-imports': [
'error',
{
paths: ['styled-components', '@mui/material'],
patterns: [
{ group: ['**/../*'], message: 'Import UI from @/components/ui' },
],
},
],
'tailwindcss/no-custom-classname': [
'error',
{ callees: ['cn'], config: './tailwind.config.js' },
],
},
},
];
- ESLint bans off-piste imports
- Tailwind plugin forces token utilities
- CI fails if stories miss the four states
Mistakes die in CI, not in code review.
Bonus: Composition Over God Components
Don't build this:
// ❌ 60+ props nobody can reason about
<DataTable
data={transactions}
columns={columns}
pagination={true}
paginationPosition="bottom"
sortable={true}
filterable={true}
selectable={true}
// ... 55 more props
/>
Build this:
// ✅ Composition: each piece does one thing well
<DataTable data={transactions} columns={columns}>
<DataTableToolbar>
<DataTableFilter column="status" options={statusOptions} />
<DataTableSearch placeholder="Search..." />
</DataTableToolbar>
<DataTableBody loading={isLoading} emptyState={<Empty />} />
<DataTableFooter>
<DataTablePagination pageSize={10} />
</DataTableFooter>
</DataTable>
Same capabilities, different mental model. When requirements change, you reorganize JSX rather than hunting through props.
Extra Tips
Embed source locations in the DOM:
<div
data-component="MetricCard"
data-source="src/components/MetricCard/MetricCard.tsx"
>
AI can inspect the DOM and jump straight to the file. No guessing.
Sub-agents to save context:
Your main conversation doesn't need the entire component library in memory. Spin up focused agents for specific tasks (UI fixes, story writing, a11y audits)—they load only what they need and return.
Reusable commands:
Build /add-story, /review-component, /fix-ui commands that encode your conventions. AI follows them without you repeating yourself.
The Path Forward
The fix for inconsistent AI output isn't better prompting. It's tighter architecture.
Every atom you add to your library is an atom AI never reinvents. Every design token is a decision that never drifts. Every composition pattern is a template for variations you haven't thought of yet.
Build the rails, the bot stays on track.
Originally published at agiflow.io
Top comments (0)