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 的生成速度,同時也有能撐過前一千位使用者的架構。