Похмелье после 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 и архитектуру, которая переживёт первую тысячу пользователей.