Похмелье после vibe coding
Вы открыли Cursor, написали prompt и увидели, как Claude Code за секунды генерирует полноценный React Native UI. Flex layout, скруглённые кнопки, цветовые темы - всё работает с первой попытки. Вы выкатили сборку в TestFlight. Пользователи начали скачивать. Это была первая неделя.
К шестой неделе вам понадобилось поменять один-единственный brand color, и на это ушло три часа find-and-replace по сорока семи файлам.
Это скрытая цена vibe coding без styling contract. Когда каждый компонент хардкодит Tailwind utility classes, ваш styling engine превращается в зависимость, которая протекает в каждый экран, каждый hook и каждый route guard. Изменение A ломает B, C и D - не потому что у вас плохая бизнес-логика, а потому что у стилей нет границ.
Почему NativeWind становится ловушкой
Вот что Cursor генерирует, когда вы просите кнопку. Выглядит аккуратно. На деле это кошмар для поддержки:
// 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>
Проблема не в самом коде. Проблема в паттерне. Когда каждый файл содержит сырую styling logic, вы не можете поменять дизайн-систему, не потрогав каждый файл. AI шипит быстро, но вместе со скоростью он шипит coupling. Ваша “рабочая” кодовая база на деле оказывается карточным домиком.
Исправление: theme contract
Ваше приложение должно разговаривать с theme contract, а не со styling engine напрямую. Contract живёт в одном файле. Примитивы живут в одной папке. Всё остальное просто использует примитивы.
// 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
Ключевое оружие здесь - satisfies. TypeScript проверяет contract, но сохраняет literal types. Вы получаете autocomplete для theme.spacing.md. Claude Code получает схему, которой можно следовать. Выигрывают все.
Семантические примитивы: словарь вашего UI
Семантические примитивы - единственные файлы, которые вообще трогают StyleSheet. Button знает про variants и sizes. Stack знает про gap и alignment. Ни один экран не пишет 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>
)
}
Меняете один файл - обновляются все кнопки. Генерируете новые экраны поверх contract, и архитектура остаётся целой.
ESLint gate
Contract без принуждения - это просто пожелание. Добавьте правило, которое запрещает className за пределами шва примитивов:
// 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.'
}]
}
}
Autotomy Expo Starter Pack поставляется с этим guardrail уже настроенным. Вы получаете скорость Cursor и архитектуру, которая переживёт первую тысячу пользователей.