Vibe coding 的後遺症

你打開 Cursor,輸入一段 prompt,看著 Claude Code 在幾秒內生成一整套 React Native UI。Flex layout、圓角 button、color theme,全都一次就跑起來。你把它送上 TestFlight。使用者開始下載。這是第一週。

到了第六週,你只是想改一個 brand color,結果要在 47 個檔案裡做三個小時的 find-and-replace。

這就是沒有 styling contract 的 Vibe coding 的隱藏成本。當每個 component 都在硬寫 Tailwind utility class,你的 styling engine 就會變成一個滲進每個 screen、每個 hook、每個 route guard 的 dependency。改 A,B、C、D 就一起壞。不是因為你的 logic 錯了,而是因為你的 styling 根本沒有 boundary。

為什麼 NativeWind 會變成陷阱

下面是你請 Cursor 幫你做一個 button 時,它常常會生成的東西。看起來很乾淨,但維護起來就是惡夢:

// 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>

問題不在這段程式碼本身,而在這個 pattern。當每個檔案裡都塞著 raw styling logic,你就不可能在不碰所有檔案的前提下修改 design system。AI 的確 ship 得很快,但它連 coupling 一起 ship 出去。你眼前這個「能跑」的 codebase,其實就是一棟 house of cards。

解法:Theme Contract

你的 app 應該和 theme contract 對話,而不是直接跟 styling engine 對話。contract 放在一個檔案裡。primitives 放在一個資料夾裡。其他地方只負責使用 primitives。

// 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。你可以對 theme.spacing.md 拿到 autocomplete。Claude Code 也能得到它可以遵守的 schema。所有人都受益。

Semantic primitives:你的 UI 詞彙

Semantic primitives 是唯一能直接碰 StyleSheet 的檔案。Button 知道 variant 和 size。Stack 知道 gap 和 alignment。沒有任何一個 screen 需要自己寫 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>
  )
}

改一個檔案,所有 button 一起更新。新 screen 也都對著同一份 contract 生成,你的架構才撐得住。

ESLint 閘門

沒有 enforcement 的 contract,就只是建議而已。加一條規則,禁止在 primitive seam 之外使用 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 已經把這條護欄預先配好。你照樣保有 Cursor 的生成速度,同時也有能撐過前一千位使用者的架構。