Vibe coding の二日酔い
Cursor を開いて prompt を打ち、Claude Code が数秒で React Native UI を丸ごと生成するのを見た。Flex layout、角丸 button、color theme。全部一発で動く。TestFlight に出した。ユーザーも入り始めた。そこまでは week one の話です。
week six になると、brand color を 1 つ変えるだけで 47 ファイルに対して 3 時間の 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 は罠になるのか
button を作ってと頼むと Cursor はこういうコードを出してきます。見た目は綺麗です。でも保守の観点では悪夢です。
// 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 です。すべての file に raw styling logic が入ると、design system を変えるたびに全 file を触るしかなくなる。AI は高速に出荷する。でも coupling も一緒に出荷する。いまは「動いている」codebase でも、実態は house of cards です。
解決策: Theme Contract
app は styling engine ではなく theme contract と話すべきです。contract は 1 file に置く。primitive は 1 folder に置く。それ以外は primitive を使うだけにする。
// 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 keyword が secret weapon です。TypeScript は contract を検証しつつ、literal type は維持してくれる。theme.spacing.md の autocomplete も効く。Claude Code には従うべき schema が渡る。全員に得がある。
Semantic Primitives: UI の語彙
StyleSheet に触ってよい file は semantic primitive だけです。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>
)
}
1 file を変えれば、すべての button が更新される。新しい screen を contract に従って生成しても、architecture は崩れない。
ESLint Gate
enforcement のない contract は suggestion にすぎません。primitive seam の外では className を reject する rule を入れる。
// 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 にはこの guardrail が最初から入っています。Cursor の速度はそのままに、最初の 1000 ユーザーを越えても壊れない architecture を持てます。