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 を持てます。