The Vibe Coding Hangover
You opened Cursor, typed a prompt, and watched Claude Code generate a full React Native UI in seconds. Flex layouts, rounded buttons, color themes — all working on the first try. You shipped to TestFlight. Users started downloading. That was week one.
By week six, you needed to change a single brand color and it took three hours of find-and-replace across forty-seven files.
This is the hidden cost of vibe coding without a styling contract. When every component hardcodes Tailwind utility classes, your styling engine becomes a dependency that leaks into every screen, every hook, every route guard. Change A breaks B, C, and D — not because your logic is wrong, but because your styling has no boundaries.
Why NativeWind Becomes a Trap
Here’s what Cursor generates when you ask for a button. It looks clean. It’s also a maintenance nightmare:
// This is what Claude Code gives you. It works until it doesn't.
<View className='p-4 flex-row justify-between'>
<Text className='text-lg font-bold text-slate-800'>
Title
</Text>
<Button className='bg-blue-500 px-4 py-2 rounded-lg' />
</View>
The problem isn’t the code. It’s the pattern. When every file contains raw styling logic, you can’t change your design system without touching every file. The AI ships fast, but it ships coupling. Your “working” codebase is actually a house of cards.
The Fix: A Theme Contract
Your app should talk to a theme contract, not to a styling engine. The contract lives in one file. The primitives live in one folder. Everything else just uses the primitives.
// src/constants/theme.ts
// The ONLY file that defines your app's visual identity.
// Claude Code reads this. Cursor references it.
export interface ThemeContract {
colors: {
background: string
foreground: string
surface: string
primary: string
danger: string
}
spacing: { xs: number; sm: number; md: number; lg: number; xl: number }
radius: { sm: number; md: number; lg: number; full: number }
typography: { title: number; body: number; caption: number }
}
export const theme = {
colors: {
background: '#0B0F19',
foreground: '#F8FAFC',
surface: '#111827',
primary: '#2563EB',
danger: '#DC2626',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 8, md: 12, lg: 20, full: 999 },
typography: { title: 28, body: 16, caption: 13 },
} as const satisfies ThemeContract
The satisfies keyword is the secret weapon. TypeScript checks the contract, but preserves literal types. You get autocomplete on theme.spacing.md. Claude Code gets a schema it can follow. Everyone wins.
Semantic Primitives: Your UI Vocabulary
Semantic primitives are the only files that touch StyleSheet. A Button knows variants and sizes. A Stack knows gap and alignment. No screen writes className:
// src/components/ui/button.tsx
// Public API: semantic props only.
import { Pressable, Text } from 'react-native'
import { theme } from '@/constants/theme'
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
onPress: () => void
children: string
}
export function Button({
variant = 'primary',
size = 'md',
loading,
onPress,
children,
}: ButtonProps) {
const bgMap = {
primary: theme.colors.primary,
secondary: theme.colors.surface,
danger: theme.colors.danger,
}
const heightMap = { sm: 36, md: 44, lg: 52 }
return (
<Pressable
onPress={onPress}
disabled={loading}
accessibilityRole='button'
accessibilityState={{ busy: loading }}
style={{
backgroundColor: bgMap[variant],
height: heightMap[size],
paddingHorizontal: theme.spacing.lg,
borderRadius: theme.radius.md,
justifyContent: 'center',
alignItems: 'center',
opacity: loading ? 0.6 : 1,
}}>
<Text style={{ color: '#FFF', fontSize: theme.typography.body }}>
{children}
</Text>
</Pressable>
)
}
Change one file, every button updates. Generate new screens against the contract, and your architecture stays intact.
The ESLint Gate
Contracts without enforcement are just suggestions. Add a rule that rejects className outside the primitive seam:
// eslint.config.js
{
files: ['app/**/*', 'src/**/*'],
ignores: ['src/components/ui/**/*', 'src/components/layout/**/*'],
rules: {
'no-restricted-syntax': ['error', {
selector: 'JSXAttribute[name.name="className"]',
message: 'Use semantic UI primitives instead of className.'
}]
}
}
The Autotomy Expo Starter Pack comes with this guardrail pre-configured. You get Cursor speed with architecture that survives your first thousand users.