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.