Hangover Setelah Vibe Coding
Anda membuka Cursor, mengetik prompt, lalu melihat Claude Code menghasilkan UI React Native lengkap dalam hitungan detik. Flex layout, tombol rounded, color theme - semuanya bekerja pada percobaan pertama. Anda merilis ke TestFlight. Pengguna mulai mengunduh. Itu minggu pertama.
Masuk minggu keenam, Anda perlu mengganti satu brand color dan butuh tiga jam find-and-replace di empat puluh tujuh file.
Itulah biaya tersembunyi dari vibe coding tanpa styling contract. Saat setiap komponen men-hardcode utility class Tailwind, styling engine Anda berubah menjadi dependency yang bocor ke setiap screen, setiap hook, setiap route guard. Perubahan A merusak B, C, dan D - bukan karena logic Anda salah, tetapi karena styling Anda tidak punya batas.
Kenapa NativeWind Menjadi Jebakan
Inilah yang dihasilkan Cursor saat Anda meminta sebuah button. Terlihat rapi. Nyatanya ini mimpi buruk untuk maintenance:
// 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>
Masalahnya bukan pada kodenya. Masalahnya ada pada pattern-nya. Saat setiap file berisi styling logic mentah, Anda tidak bisa mengubah design system tanpa menyentuh setiap file. AI memang mengirim cepat, tapi ia juga mengirim coupling. Codebase Anda yang terlihat “berfungsi” sebenarnya adalah rumah kartu.
Perbaikannya: Theme Contract
Aplikasi Anda harus berbicara dengan theme contract, bukan dengan styling engine. Contract itu hidup di satu file. Primitive components hidup di satu folder. Semua yang lain cukup memakai primitive tersebut.
// 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
Keyword satisfies adalah senjata rahasianya. TypeScript mengecek contract, tapi tetap mempertahankan literal types. Anda mendapat autocomplete pada theme.spacing.md. Claude Code mendapat schema yang bisa diikuti. Semua pihak menang.
Semantic Primitives: Kosakata UI Anda
Semantic primitives adalah satu-satunya file yang menyentuh StyleSheet. Sebuah Button tahu variant dan size. Sebuah Stack tahu gap dan alignment. Tidak ada screen yang menulis 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>
)
}
Ubah satu file, semua button ikut diperbarui. Hasilkan screen baru mengikuti contract yang sama, dan arsitektur Anda tetap utuh.
Gerbang ESLint
Contract tanpa enforcement hanyalah saran. Tambahkan rule yang menolak className di luar 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.'
}]
}
}
Autotomy Expo Starter Pack sudah menyertakan guardrail ini. Anda mendapat kecepatan Cursor dengan arsitektur yang tetap bertahan saat melewati seribu pengguna pertama.