La resaca del vibe coding
Abriste Cursor, escribiste un prompt y viste cómo Claude Code generaba una UI completa de React Native en segundos. Layouts con flex, botones redondeados, temas de color: todo funcionando al primer intento. Lo enviaste a TestFlight. Los usuarios empezaron a descargarlo. Eso fue la primera semana.
Para la sexta semana, necesitabas cambiar un único color de marca y te llevó tres horas de buscar y reemplazar en cuarenta y siete archivos.
Este es el coste oculto del vibe coding sin un contrato de estilos. Cuando cada componente hardcodea clases utility de Tailwind, tu motor de estilos se convierte en una dependencia que se filtra a cada pantalla, cada hook y cada route guard. Cambiar A rompe B, C y D, no porque tu lógica esté mal, sino porque tus estilos no tienen boundaries.
Por qué NativeWind se convierte en una trampa
Esto es lo que Cursor genera cuando le pides un botón. Se ve limpio. También es una pesadilla de mantenimiento:
// 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>
El problema no es el código. Es el patrón. Cuando cada archivo contiene lógica de estilos raw, no puedes cambiar tu design system sin tocar todos los archivos. La IA lanza rápido, pero también lanza acoplamiento. Tu codebase “funcional” en realidad es un castillo de naipes.
La solución: un contrato de tema
Tu app debería hablar con un theme contract, no con un motor de estilos. El contrato vive en un archivo. Las primitivas viven en una carpeta. Todo lo demás solo usa las primitivas.
// 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
La palabra clave satisfies es el arma secreta. TypeScript comprueba el contrato, pero conserva los tipos literales. Tienes autocomplete sobre theme.spacing.md. Claude Code obtiene un esquema que puede seguir. Todos ganan.
Primitivas semánticas: tu vocabulario de UI
Las primitivas semánticas son los únicos archivos que tocan StyleSheet. Un Button conoce variantes y tamaños. Un Stack conoce gap y alignment. Ninguna pantalla escribe 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>
)
}
Cambia un archivo y se actualizan todos los botones. Genera nuevas pantallas contra el contrato y tu arquitectura sigue intacta.
El gate de ESLint
Los contratos sin enforcement son solo sugerencias. Añade una regla que rechace className fuera del seam de primitivas:
// 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.'
}]
}
}
El Autotomy Expo Starter Pack trae este guardrail preconfigurado. Obtienes la velocidad de Cursor con una arquitectura que sobrevive a tus primeros mil usuarios.