Der Vibe-Coding-Kater

Du hast Cursor geöffnet, einen Prompt eingegeben und Claude Code in Sekunden ein komplettes React-Native-UI generieren sehen. Flex-Layouts, runde Buttons, Farbthemen - alles hat beim ersten Versuch funktioniert. Du hast nach TestFlight ausgeliefert. Nutzer haben angefangen herunterzuladen. Das war Woche eins.

In Woche sechs musstest du eine einzige Brand Color ändern, und plötzlich warst du drei Stunden mit Find-and-Replace über siebenundvierzig Dateien beschäftigt.

Das ist der versteckte Preis von Vibe Coding ohne Styling-Contract. Wenn jede Komponente Tailwind-Utility-Classes fest verdrahtet, wird deine Styling Engine zu einer Dependency, die in jeden Screen, jeden Hook und jeden Route Guard hineinleckt. Änderung A zerbricht B, C und D - nicht, weil deine Logik falsch ist, sondern weil dein Styling keine Grenzen hat.

Warum NativeWind zur Falle wird

Das hier generiert Cursor, wenn du nach einem Button fragst. Es sieht sauber aus. Es ist trotzdem ein Wartungsalbtraum:

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

Das Problem ist nicht der Code. Das Problem ist das Pattern. Wenn jede Datei rohe Styling-Logik enthält, kannst du dein Design System nicht ändern, ohne jede Datei anzufassen. Die KI shippt schnell, aber sie shippt Coupling. Deine “funktionierende” Codebase ist in Wahrheit ein Kartenhaus.

Die Lösung: ein Theme Contract

Deine App sollte mit einem Theme Contract sprechen, nicht direkt mit einer Styling Engine. Der Contract lebt in genau einer Datei. Die Primitives leben in genau einem Ordner. Alles andere verwendet nur die 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

Das satisfies-Keyword ist die Geheimwaffe. TypeScript prüft den Contract, behält aber die Literal Types. Du bekommst Autocomplete auf theme.spacing.md. Claude Code bekommt ein Schema, dem es folgen kann. Alle gewinnen.

Semantic Primitives: dein UI-Vokabular

Semantic Primitives sind die einzigen Dateien, die StyleSheet direkt anfassen. Ein Button kennt Varianten und Größen. Ein Stack kennt Gap und Alignment. Kein Screen schreibt 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>
  )
}

Ändere eine Datei, und jeder Button wird aktualisiert. Generiere neue Screens gegen den Contract, und deine Architektur bleibt intakt.

Das ESLint-Gate

Contracts ohne Durchsetzung sind nur Vorschläge. Ergänze eine Regel, die className außerhalb des Primitive-Seams ablehnt:

// 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.'
    }]
  }
}

Das Autotomy Expo Starter Pack bringt dieses Guardrail bereits vorkonfiguriert mit. Du bekommst Cursor-Geschwindigkeit mit einer Architektur, die auch deine ersten tausend Nutzer überlebt.