Wenn LLMs Ihr Design-System übersteuern: Leitplanken für LLM-taugliche React-Komponenten

Ein Vortrag, der nachwirkt
Anfang der Woche besuchte ich einen Vortrag, dessen Titel zunächst nach eng umrissenem Sprachwissenschafts-Thema klingt — und der in Wahrheit eine Frage verhandelt, die auch in der Softwareentwicklung täglich wiederkehrt. Heejin Do, Forscherin an der ETH Zürich, sprach beim Zurich Language AI Meetup über eine Frage, die harmloser klingt, als sie ist: Warum können Large Language Models nicht einfach Korrektur lesen? Ihr Vortrag hiess Why LLMs Can't Just Proofread – and How to Fix It.
Während Heejin den Failure Mode beschrieb — das stille Übersteuern, das selbstbewusste Umschreiben von Sätzen, die bereits korrekt waren — kam mir etwas aus der Praxis so deutlich bekannt vor, dass ich mich im Sessel aufrichtete. Sie kennen diesen Moment: Ein Coding Agent startet einen Refactor, und nach dreissig Sekunden merken Sie, dass er in ein Rabbithole läuft. Sie möchten eingreifen, manuell nachjustieren, dem Agent signalisieren: stop, dieses Kaninchenloch ist nicht der richtige Weg. Und genau in dem Augenblick, in dem Ihre Hand zur Tastatur wandert, erscheint das Wort, das wir alle so sehr lieben: Compacting…
Dann sitzen Sie da. Mit einem halb fertigen Gedanken und einem Agent, der gerade seinen eigenen Kontext verdichtet, um munter in die falsche Richtung weiterzumarschieren. Auf eine menschliche Weise nachvollziehbar — und, wenn man den Schmerz einen Moment beiseiteschiebt, durchaus mit einem Schmunzeln zu quittieren.
Heejins Vortrag war formal an Language Specialists adressiert. Wie relevant war er für mich? Deutlich relevanter, als es auf den ersten Blick scheint. Denn während Sprachexperten Texte anpassen und Korrektur lesen, machen wir Software-Entwickler im Grunde genau dasselbe: Wir schreiben Code und müssen ihn anschliessend reviewen. In beiden Prozessen — beim Schreiben des Codes und beim Review danach — setzen wir zu Recht auf LLMs, um uns zu unterstützen. Doch diese Unterstützung stösst an ihre Grenzen, sobald wir die Spielregeln für den Agent nicht klar definieren. Genau darum geht es in diesem Beitrag.
Wie Übersteuerung in einem Design-System aussieht
Bevor wir darüber reden, warum das passiert, lohnt es sich, die Diskussion an zehn konkreten Failure Modes zu verankern, die ich in den letzten sechs Monaten in Produktions-Repositories beobachtet habe. Keiner davon ist hypothetisch.
Erstens: Der Agent erfindet das Rad neu, anstatt zu dem zu greifen, was die Bibliothek längst mitliefert. Soll er eine Benutzerverwaltungsseite mit einer sortierbaren, durchsuchbaren Tabelle und Row-Click-to-Edit ergänzen, dann installiert er @tanstack/react-table, verdrahtet useReactTable von Hand, baut ein Column-Definition-Objekt auf, zeichnet die Sortier-Icons von Grund auf — und liefert vierhundert Zeilen später eine funktionierende, aber komplett eigenständige Tabelle ab. Obwohl die Bibliothek längst <DataTable columns={…} rows={…} onRowClick={…} /> exportiert, die genau diese Oberfläche mit dem hauseigenen Styling, der Accessibility, den Loading-Skeletons und Empty States bereits sauber umhüllt. Der Agent hat DataTable nicht importiert. Er hat nicht danach gesucht. Die TanStack-Table-Priors aus seinen Trainingsdaten haben schlicht dominiert — das hauseigene Primitiv blieb unsichtbar. Das Rad neu zu erfinden ist der Default-Modus, sobald die Verankerung schwach ist — und es ist mit Abstand der teuerste Failure Mode, denn die duplizierte Oberfläche muss für immer neben derjenigen gepflegt werden, die ohnehin schon existierte.
Zweitens: Der Agent öffnet eine CVA-Datei und "repariert" etwas, das gar nicht kaputt war. <Button> begrenzt seine size-Variante bewusst auf die abgeschlossene Union 'sm' | 'md' | 'lg' — eine Skala, auf die sich die Design-Leitung nach drei Runden Design-Review festgelegt hat. Für einen Hero-CTA könnte der Agent schlicht eine Utility-Schicht obendrauf legen — className="w-full px-12 text-lg" — und ausliefern. Stattdessen öffnet er Button.cva.ts und hängt still und leise xl: 'h-16 px-8 text-xl' an die size-Variant-Map an. TypeScript ist einverstanden: Einen Wert zu einer Union hinzuzufügen ist eine vollkommen valide Mutation. Der Build bleibt grün. Die Komponente rendert. Und doch ist die bewusste Design-Entscheidung, dass die Skala bei lg endet, klamm und heimlich rückgängig gemacht worden. Es hat sich ein neuer Wert in die Union eingeschlichen, der nie abgesegnet war — und bis das jemandem auffällt, vergeht Zeit. In der Zwischenzeit verbreitet sich der Fehler wie ein Virus: Die nächste Komponente greift den Prior auf, ebenfalls ungefragt, der Refactor danach zementiert ihn, und das xl, das nie auf einem Design-Review-Protokoll stand, ist plötzlich im ganzen Repository verteilt.
Drittens: Halluzinationen auf Ebene der Design-Tokens. Das Design-System ist auf semantischen Farb-Tokens aufgebaut — bg-primary-500, bg-secondary-600, text-success-700, border-danger-400 —, die in tailwind.config.ts auf die Markenpalette des Kunden gemappt sind. Bitte ich den Agenten, "das primäre Gelb" für ein Highlight-Badge zu nehmen, sollte er zu bg-primary-500 greifen. Stattdessen greift er zu bg-amber-500, oder bg-yellow-400, oder text-teal-600. Diese Klassen sind in den Trainingsdaten massiv überrepräsentiert — jedes Tailwind-Tutorial im offenen Internet feiert Amber und Teal —, und das Kundenprojekt verwendet sie gar nicht. Eine von zwei Sachen passiert: Entweder steht die Klasse gar nicht in Tailwinds Safelist, sodass der JIT-Compiler sie nicht emittiert und das Element ohne Styles rendert; oder sie wird im Build-Prozess mit eingepackt, und nun hat sich ein falsches Design-Token klammheimlich in den Code geschlichen, das dem definierten Brand-System widerspricht. Beide Varianten sind teuer — die zweite ist die perfidere, weil sie aussieht, als funktioniere sie. Die Disziplin um die Design-Tokens war die Verankerung. Der Agent hat sie nicht gelesen.
Viertens: Der Agent überschreitet die /libs↔/apps-Grenze und produziert, was ich nur als Spaghetti bezeichnen kann. Das Repository trifft eine bewusste, starke Konvention: /libs ist die Heimat der Atomic-Design-Komponenten — komplexe Primitive, in denen die Investition in .cva.ts-Dateien, BEM-Identifier und geschlossene Variant-Unions sich vielfach auszahlt. /apps dagegen besteht aus Wrappern, Seiten und Business-Layern — hier nutzen wir Tailwind inline direkt am Element, weil das Styling übersichtlich bleibt, wir die volle Ausdruckskraft des Utility-First-Ansatzes behalten und nicht durch fünf Pfade navigieren müssen, nur um eine Klasse zu ändern. Das ist tatsächlich das Beste aus beiden Welten: typsichere Primitive dort, wo Komplexität es rechtfertigt, und agile Applikationen dort, wo sie es nicht tut. Der Agent ignoriert die Grenze routinemässig. Er schreibt Inline-Tailwind innerhalb von /libs und lässt die .cva.ts-Datei verwaist zurück; er scaffoldet eine vollwertige .cva.ts rund um einen Wegwerf-Wrapper in /apps, den niemand mehr gross anfasst. In beiden Richtungen: Spaghetti. Die Konvention in jedem einzelnen Prompt zu wiederholen ist keine tragfähige Antwort — es ist eine Steuer, die das Codebase auf ewig zahlt.
Fünftens: Der Agent over-engineert dort, wo Einfachheit gereicht hätte. In /libs trägt jede Komponente eine types.ts, die konsistente, geteilte Props-Strukturen definiert — ComponentClassNames, um Styling an jedem Slot zu erweitern, ComponentLabels, um jeden sichtbaren String zu übersetzen, dazu klar benannte Variant-Typen, die über ExtractVariantProps aus dem CVA-Wrapper abgeleitet werden. Diese Struktur überall in /libs gleich zu halten, ist kein Selbstzweck: Sie macht das Verhalten lesbar, das Styling erweiterbar und die Internationalisierung vorhersehbar. Genau dafür lohnt sich das Stahlgerüst. In /apps dagegen ist all das Overkill. Die Komponenten, die wir dort bauen, sind fast ausschliesslich Wrapper, die Business-Logik orchestrieren — keine komplexe Styling-, Animations- oder Layout-Frage muss beantwortet werden, die richtig aufwendigen Komponenten stammen ja aus /libs und erledigen das Heavy Lifting. Der Agent ist jedoch notorisch übermotiviert: Er schleppt das Stahlgerüst aus /libs in /apps-Wrapper — legt eine types.ts an, scaffoldet einen ClassNames-Typ, arbeitet ein Labels-Shape aus —, wo eine nicht tragende Trockenbau-Trennwand gereicht hätte. Das ist die Bazooka auf Spatzen. Es ist Overcorrection in Reinform: Wartungsaufwand hoch, Edit-Friction hoch, gelieferter Mehrwert flach bei null — eine Korrektur, die kostet und nichts bringt.
Sechstens: Der Agent kann eine voll ausgestattete Komponente nicht von einer rohen unterscheiden und greift standardmässig zur vollen Variante. Die Bibliothek liefert den Split bewusst — und sie liefert ihn konsequent über die gesamte Eingabe-Oberfläche: Jedes Formular-Primitiv existiert in zwei Ausführungen, einem rohen *Input und einem batteries-included *Field. TextInput neben TextField. DateInput neben DateField. NumberInput neben NumberField. SelectInput neben SelectField. Der *Input ist ein einzelnes Atom, das genau eine Sache tut — das Control rendern, ein onChange emittieren, fertig. Der *Field verdrahtet dasselbe Control mit react-hook-form für das State-Management, hängt es in ein Zod-Schema für die Validierung ein und übernimmt Label, Hint-Text, Error-State und Layout für Sie. Der Split ist nicht kosmetisch. Batteries ist hier die wortwörtliche Bundle-Kosten: Field liefert react-hook-form und zod in das Client-Bundle aus, Input liefert beides nicht.
Sie beantworten verschiedene Fragen. Für ein Einstellungsformular mit fünfzehn validierten Feldern spart TextField pro Feld dreissig Zeilen register/control/errors-Boilerplate und gibt Ihnen ein einzelnes Schema, das gleichzeitig als Vertrag für die Server-Action dient. Für ein Suchfeld in einer Toolbar ist TextInput die ganze Geschichte — ein Element, Controlled State in einem useState, keine Zeremonie, kein Schema, kein Error-Slot, keine Formular-Library im Bundle. Bitten Sie den Agenten, "eine saubere Lösung für eine Toolbar-Suche zu bauen", und — zuverlässig, ohne Ausnahme — bekommen Sie TextField, ein Zod-Schema, react-hook-form und einen Container mit onSubmit — alles für ein einzelnes Suchfeld, das gar nicht ungültig werden kann und nie als Formular abgeschickt werden wird. Und jedes Gramm dieser Maschinerie wird trotzdem in das Client-Bundle ausgeliefert. Gewicht, das Sie bezahlen; Validierung, die Sie nie brauchten; eine Form-Library, die im Arbeitsspeicher gehalten wird für ein Feld, das überhaupt keine Validierungsregeln hat. Die Übersteuerung ist hier keine falsche Antwort. Sie ist die korrekte Antwort auf eine Frage, die nie gestellt wurde — und das ist Overengineering in Reinform: null Mehrwert, messbare Kosten, jedes Mal. Ich weiss ehrlich gesagt nicht, warum wir immer wieder "saubere Lösung" in unsere Prompts tippen. Das ist kein Steuer-Signal, das ist ein Brandbeschleuniger. "Sauber" heisst für einen Coding Agent "die Variante mit mehr Maschinerie"; Sie könnten genauso gut einen atomgetriebenen Dosenöffner bestellen und sich wundern, wenn er in Geschenkpapier eingewickelt ankommt.
Siebtens: Der Agent overkorrigiert bewährte interne Konventionen in die Form des Getting-Started-Guides. In diesem Codebase leben die Column-Definitionen einer Tabelle in einer eigenständigen columns.tsx-Datei neben der Tabelle, und die Validierung eines Formulars in einer eigenständigen schema.ts. Columns werden grep-bar, Schemas werden zwischen Formular und der Server-Action teilbar, die das Formular entgegennimmt, Diffs bleiben klein, Reviews schnell — eine Konvention, die sich in grossen Codebasen vielfach ausgezahlt hat. Die Dokumentation von TanStack Table und React Hook Form zeigt es inline. Jeder Getting-Started-Guide zeigt es inline. Jede Stack-Overflow-Antwort zeigt es inline. Jeder Agent, mit dem ich je gearbeitet habe, "räumt" unsere getrennte Struktur zurück zur Inline-Variante — in der ehrlichen Überzeugung, einen Fehler zu korrigieren. Er korrigiert gar nichts. Er overkorrigiert eine bewährte interne Konvention in Richtung der Form seiner Trainingsdaten. Korrekt ist nicht dasselbe wie konform zu den Docs, und interne Konventionen, die sich ihren Platz auf dem Merit-Weg verdient haben, sollten nicht jedes Mal ausradiert werden, wenn ein Agent den Drang verspürt, zurück zum Starter-Template zu laufen, auf dem er trainiert wurde.
Achtens: Changesets. Ich mag es, Commits in meinen eigenen Händen zu behalten — es ist einer der wenigen Teile des Loops, die ich mir nicht aus der Hand nehmen lasse. Der Agent schreibt die Changesets für mich: Conventional-Commit-Stil in der Kopfzeile, Fliesstext mit den Details darunter, eine Datei pro logischer Änderung. Ich bündele sie in meinem Tempo zu Commits, was mir einen sauberen Human-in-the-Loop-Checkpoint gibt — jedes Changeset ist ein Review-Gate, bevor überhaupt irgendetwas auf main landet. Ein Workflow, den ich richtig zu schätzen gelernt habe. Sich selbst überlassen, öffnet der Agent am nächsten Touchpoint jedoch das zuletzt angelegte Changeset und "verbessert" es an Ort und Stelle, anstatt eine neue Datei anzulegen. Schreiben Sie "Changesets sind unveränderlich, lege eine neue Datei an" in den Prompt, und er gehorcht — dieses Mal. Das Ärgerliche ist nicht, dass er sich nicht steuern liesse. Das Ärgerliche ist, dass man ihn jedes einzelne Mal aufs Neue steuern muss: Ein echtes Codebase produziert viele Changesets pro Tag, jeder neue Thread beginnt bei null und ohne Erinnerung an das, was Sie gestern vereinbart hatten, und die Leitplanke muss in Endlosschleife wiederholt werden. Vergisst man es einmal, ist der Agent sofort zurück im Modus "Historie umschreiben" — leise, selbstbewusst, genau in der Datei, die Sie am wenigsten angefasst sehen wollten. Warum ist Editieren überhaupt der Default? Weil der Agent nicht in Touchpoints denkt und nicht in Human-in-the-Loop-Gates. Er wurde auf eine grosszügigere Annahme trainiert: Jede Changeset-Datei, die noch nicht von changeset version aufgebraucht wurde, sei noch ein veränderliches Work-in-Progress-Dokument, und sie umzuschreiben sei harmloser Ordnungsdienst. In meinem Setup ist diese Annahme schlicht falsch. changeset version läuft bei mir in der CI/CD-Pipeline, nicht auf meinem Laptop — und genau dort gehört es hin: deterministisch, auditierbar, ein einziger Weg in die Produktion über jedes Kunden- und Eigenprojekt hinweg. Die Konsequenz ist scharf. Sobald Sie git pull --rebase origin main ausführen, ist eine Changeset-Datei, die auf Ihrem Rechner noch unschuldig veränderlich aussah, upstream längst aufgebraucht — aus dem Repository entfernt und in die CHANGELOG.md eingearbeitet. Haben Sie das Rebase ausgelassen und den Agenten trotzdem an die Datei gelassen, bearbeiten Sie nun etwas, das auf main gar nicht mehr existiert. Merge-Konflikt. Und Sie haben den Salat. Ein vermeidbarer Mist, entstanden einzig aus dem Drang des Agents, ein Artefakt umzuschreiben, das die Pipeline längst zementiert hatte. Urrrghhh.
Neuntens: Der Review-Agent. Review-Agenten messen Erfolg an der Anzahl Verbesserungen, die sie zutage fördern, und diese Erfolgs-Metrik korrumpiert den Review im Stillen. Wenn der Diff sauber ist und die Änderung korrekt, gibt es schlicht nichts Umsetzbares zu melden — und der Agent, konfrontiert mit leerer Hand, weitet die Reichweite. Auf einmal liest er Dateien, die der PR gar nicht berührt hat. Auf einmal hat er eine Style-Detail in einer Komponente gefunden, die mit der aktuellen Story nichts zu tun hat. Auf einmal empfiehlt er ein Refactoring drei Verzeichnisse weiter. Ihr Zwei-Dateien-PR wird zum Sieben-Dateien-PR. Ihr Zwei-Reviewer-PR braucht auf einmal einen zusätzlichen Reviewer aus einem Team, mit dem Sie normalerweise gar nicht koordinieren. Der Reviewer ist im Urlaub. Sie gehen zurück zum Agenten, lassen die Out-of-Scope-Änderungen rückgängig machen — und er revertiert dabei übereifrig auch etwas, das Sie behalten wollten. Sie sind nun zwanzig Minuten Mikromanagement tief in einem Review, den Sie — selbst erledigt — in neunzig Sekunden hinter sich gebracht hätten. Irgendwann beginnen Sie sich zu fragen, ob der Agent Ihnen tatsächlich Zeit spart oder ob Sie lediglich — in Minuten und in Tokens — das Privileg bezahlen, von Software beaufsichtigt zu werden.
Zehntens: Visuelle Änderungen, freestyle. "Fügen Sie Abstand zwischen diesen Komponenten ein. Vergrössern Sie die Schriftgrösse des Hauptcontainers. Machen Sie den Hintergrund im Dark Mode eine Nuance heller." Bewusst vage — so reden Menschen über UI. Jetzt die Frage: Wird der Agent korrekt erschliessen, auf welches Element "Hauptcontainer" sich bezieht? Wird er zur size- oder variant-Prop greifen — denjenigen, die Ihre Design-Tokens tragen — oder zur allmächtigen className-Prop, um einen willkürlichen Pixel-Wert einzuschleusen, nur um Ihre Anweisung abzuhaken? Um Ihnen zu gefallen, greift er zu derjenigen Interventionsform, die am schnellsten durchgeht. Das klappt oft — auf den ersten Blick. Die Abstände stimmen, die Schrift ist grösser, das Dark Mode wirkt heller. Sie sind müde, es sieht in Ordnung aus, Sie committen. Was Sie nicht bemerkt haben: Sie haben gerade einen grossen Topf Spaghetti auf den Herd gestellt — und wie in jedem guten italienischen Haushalt werden Sie Ihre Familie (will heissen: Ihr Team) die nächsten sechs Monate damit ernähren, ein bedauernswerter Teller nach dem anderen.
Wer mit Coding Agents an einem echten Codebase gearbeitet hat, hat alle zehn davon gesehen. Wahrscheinlich mehr als einmal. Die Frage ist nicht, ob Übersteuerung passiert. Die Frage ist, was sie kostet und was Sie auf Bibliotheksebene tun können, damit sie aufhört.
Was Übersteuerung tatsächlich kostet
Treten Sie für einen Moment einen Schritt zurück von den zehn Failure Modes und betrachten Sie sie als ein einziges Muster. Jeder einzelne ist Heejins Mechanismus, der sich in einem neuen Korpus abspielt: Die Priors des Modells — wie eine React-Komponente "üblicherweise" aussieht, was ein Changeset "üblicherweise" ist, was eine Toolbar-Suche "üblicherweise" braucht — sind stärker als die Verankerung, die Ihr Codebase bietet. Wenn die Verankerung schwach ist, gewinnen die Priors. Das Modell sieht nicht Ihre Bibliothek; es sieht eine Verteilung und zieht die Ausgabe zur Mitte dieser Verteilung. Das ist die Ursache. Die Frage ist, was diese Ursache kostet — und bezahlt wird sie in zwei Währungen.
Die erste Währung sind Tokens — die Sie messen können. Übersteuerung ist per Konstruktion token-teuer. Jede duplizierte DataTable, jede halluzinierte Tailwind-Klasse, die der Agent anschliessend als falsch identifizieren muss, jeder Out-of-Scope-Review-Befund, der eine dreifache Revert-Runde auslöst — all das läuft gegen denselben Zähler. Schlimmer noch: Es drückt Sie strukturell in Richtung grösserer, teurerer Modelle. Ohne Leitplanken ist der Prompt, den Sie still und leise Opus übergeben, genau der Prompt, den Sonnet — mit Leitplanken — problemlos bewältigen würde, oft sogar besser, für einen Bruchteil der Token-Kosten. Sie kaufen keine zusätzliche Intelligenz ein. Sie kaufen den Spielraum ein, den Ihr Codebase versäumt hat, bereitzustellen. Ein Opus-grosser Agent, losgelassen auf eine nicht verankerte Bibliothek, ist in ökonomischer Sprache: Sie bezahlen den Premium-Tarif, damit das Modell Dinge errät, die Ihr Repository ihm schlicht hätte mitteilen können.
Die zweite Währung ist Zeit — und sie wirkt sich auf eine Art aus, wie es Tokens nicht tun. Je mehr Arbeit Sie abgeben, ohne sie zu prüfen, desto schneller erodiert Ihre eigene Urteilskraft. Agents sollten Ihre Urteilskraft verstärken, nicht ersetzen. Wenn Sie sich dabei ertappen, Code zu committen, den Sie nicht wirklich gelesen haben, und Entscheidungen zu verteidigen, die Sie nicht wirklich getroffen haben — nun ja, was soll ich sagen, dann müssen Sie wohl ein Spaghetti-Liebhaber sein. Der kurzfristige Produktivitätsgewinn ist real. Die langfristigen Kosten am Handwerk sind ebenfalls real. Jeder Entwickler, der sich an dieser Stelle einmal die Finger verbrannt hat, erzählt dieselbe Geschichte: eine Phase selbstbewussten Agent-gesteuerten Shippings, gefolgt von der leisen Erkenntnis, dass er gar nicht mehr weiss, wie das System, das er ausgeliefert hat, eigentlich funktioniert.
Aber es muss nicht soweit kommen. Der Mechanismus ist identifizierbar, und das heisst: Das Gegenmittel ist ebenfalls identifizierbar. Sie machen den Agent nicht klüger — das könnten Sie ohnehin nicht. Sie machen die Verankerung stärker, bis die Priors keinen Spalt mehr finden, durch den sie schlüpfen könnten. In einer React-Komponentenbibliothek übernehmen sieben Leitplanken die Hauptarbeit — eine davon setzt die anderen sechs durch, und mit dieser fängt man an.
Leitplanke 1 — ESLint als Enforcement-Schicht
Jede andere Leitplanke in diesem Beitrag formt die Quelltext-Oberfläche, die der Agent liest. ESLint formt, woran die Ausgabe des Agents gemessen wird, bevor sie irgendwohin landet. Andere Kategorie, andere Schicht — und aus meiner Erfahrung die wichtigste der sieben. Genau deshalb eröffnet sie die Liste. Wenn die anderen sechs scheitern, ist ESLint dasjenige, was die Übersteuerung abfängt, bevor sie auf main ankommt.
Es lohnt sich zu wissen, wo ESLint in meinem Tag sitzt. Bevor der Agent überhaupt eine Zeile schreibt, verbringe ich ernsthaft Zeit im Plan-Mode: Was wirst du tun, wie, hast du das eigentliche Problem wirklich verstanden, klingt der Lösungsansatz, den du vorschlägst, schlüssig? Erst wenn dieses Briefing steht, gebe ich das Go. Und hier die Ehrlichkeit, auf die es mir ankommt — ein wenig Übersteuerung ist völlig in Ordnung. Ich werde den Agenten nicht zwanzigmal bei jeder kleinen Zuwiderhandlung unterbrechen. Die Frage, die mich interessiert, ist, ob das eigentliche Problem gelöst werden kann. Wenn ja, erledigt der Lint-Durchlauf am Ende den Rest. In jedem Plan, den ich schreibe, sind lint ausführen und Changeset anlegen die letzten beiden Punkte auf der Liste. Das ist kein Nachgedanke. Das ist der explizite Checkpoint, an dem die halluzinierten und überkorrigierten Teile rückabgewickelt werden.
Während dieser Lint-Durchlauf läuft — und bei einem grossen Codebase mit radikal definierten Regeln ist er nicht instantan — schaue ich nicht zu. Ich öffne ein zweites Terminal mit einem Problem, das keine starken Abhängigkeiten zum ersten hat, und nehme es parallel auf, sofern ich noch konzentriert bin. War das Plan-Mode-Briefing mental anstrengend, erzwinge ich kein weiteres. Ich gehe ans Klavier, eine Runde Chopin, um den Kopf wieder klarzukriegen. Oder Wäsche, oder kochen, oder kurz hinlegen. Der Linter erledigt die Durchsetzung, die ich sonst von Hand gemacht hätte, ohne dass es mich auch nur einen Tropfen Aufmerksamkeit kostet.
Was diesen Workflow tatsächlich funktionieren lässt, ist nicht ESLint an sich. Es sind die Regeln, die darauf sitzen. Ich bin bei der Definition zugegebenermassen etwas radikal geworden — ein eigenes i18n-Plugin, ein eigenes money-Plugin, ein eigenes strict-Plugin, jedes davon setzt Konventionen durch, die der Agent sonst mit jedem neuen Prompt neu lernen müsste. Das Muster in jeder dieser Regeln ist dasselbe, und es ist der Teil, der die eigentliche Arbeit macht: Die Regel verbietet nicht nur das Anti-Pattern, sie trägt eine "so macht man es stattdessen"-Nachricht mit expliziten Imports und einem ausgearbeiteten Beispiel. Drei Regeln aus dem Stack, damit die Form konkret wird.
Regel eins — i18n/error-message-i18n. Agents lieben es, throw new Error('User not found') zu schreiben. Der Prior aus jedem Tutorial in der Trainingsverteilung ist hartkodiertes Englisch. Die Regel fängt das ab, und die Nachricht lenkt um:
1// Regelverstoss:2throw new Error('User not found')3
4// Regel-Nachricht:5// Error messages must use the t() function for internationalization.6// Use: new Error(t('errorKey')) instead of hardcoded strings.7// Translation keys for Error messages must end with 'Error' suffix.8
9// Korrigiert:10throw new Error(t('userNotFoundError'))Regel zwei — money/no-math-rounding. Agents greifen instinktiv zu Math.round(amount * 0.15), sobald Steuern oder Rabatte ins Spiel kommen — Intl.NumberFormat-Tutorials sind in den Trainingsdaten, Martin Fowlers Money-Pattern nicht. Die Regel verbietet die Floating-Point-Arithmetik, und die Nachricht nennt die Ersatz-API ausdrücklich beim Namen:
1// Regelverstoss:2const tax = Math.round(amount * 0.15)3
4// Regel-Nachricht:5// Math.round() is banned to prevent monetary calculation errors.6// For monetary values, use Money class:7// import { Money, RoundingMode } from '@prasath/money'8// money.multiply(factor, RoundingMode.HALF_UP)9
10// Korrigiert:11import { Money, RoundingMode } from '@prasath/money'12const tax = Money.fromCents(amount, 'EUR').multiply(0.15, RoundingMode.HALF_UP).amountRegel drei — strict/no-empty-string-fallback. Agents kaschieren fehlende Pflichtwerte gerne mit ?? '', weil damit der TypeScript-Fehler verschwindet. Die Regel verweigert das Muster, und die Nachricht erzwingt eine Entscheidung — required oder optional — statt die Mehrdeutigkeit überleben zu lassen:
1// Regelverstoss:2const entityAccessor = params?.entityAccessor ?? ''3
4// Regel-Nachricht:5// Do not use empty string as fallback. If the value is required, throw an error.6// If optional, handle the undefined case explicitly.7
8// Korrigiert (required):9if (!params?.entityAccessor) {10 throw new Error(t('entityAccessorRequiredError'))11}12const entityAccessor = params.entityAccessorLesen Sie diese drei Nachrichten hintereinander, und die Form der Leitplanke wird sichtbar. Jede einzelne tut in einem Zug dreierlei: Sie verbietet das Muster, sie erklärt die Kategorie der Gefahr (i18n, monetäre Präzision, Verdecken von Pflichtwerten), und sie nennt den korrekten Ersatz mit expliziten Imports. Es ist kein "tu das nicht". Es ist "so macht man es stattdessen". Der Agent liest diese Nachrichten beim ersten Verstoss, schreibt den Code um, und die nächste Iteration ist sauber. Nach drei Runden "so macht man es stattdessen" mit einem frischen Agent taucht das verbotene Idiom im Output schlicht nicht mehr auf. Die Instruktions-Oberfläche ist die Regel-Datei selbst — der Agent muss meine CLAUDE.md nicht gelesen haben, um es richtig zu machen, weil ESLint die Anweisung genau in dem Moment zustellt, in dem sie umsetzbar wird.
Ein Zugeständnis, eines das mich mittlerweile wirklich drückt. Auf grösseren Kundenprojekten frisst sich eine radikal definierte ESLint-Konfiguration merklich in die Iterationszeit. Jede zusätzliche Regel — und sobald man sieht, was diese Nachrichten wert sind, fügt man immer weiter welche hinzu — ist eine weitere Millisekunde pro Datei, was sich auf Repository-Niveau zu Minuten pro Lauf summiert. Die Antwort, auf die ich im April migriere, heisst Oxlint — der Rust-basierte Linter von VoidZero, Evan Yous Tooling-Gruppe. Oxlint 1.0 ist 2025 mit über 520 unterstützten ESLint-Regeln erschienen; Anfang 2026 läuft es 50–100-mal schneller als ESLint und 8–12-mal schneller als typescript-eslint auf type-aware-Regeln, und im JS-Plugin-Alpha akzeptiert es die meisten existierenden ESLint-Plugins ohne Modifikation. Airbnb meldet das Linten von 126.000 Dateien in 7 Sekunden auf CI. Shopify setzt es produktiv in der Admin-Konsole ein. Mit so viel Spielraum verschiebt sich der Kalkül für die Frage "wie radikal darf mein Regelwerk sein" — von "was sich mein CI leisten kann" zu "was mein Codebase tatsächlich braucht". Über die Migration schreibe ich in einem eigenen Beitrag, sobald sie steht.
Leitplanke 2 — BEM-Identifier als Round-Trip-Identität
Jede CVA-Datei in der Bibliothek beginnt mit einem BEM-Token. 'Dropzone__wrapper' für den äusseren Wrapper, 'Dropzone__title' für das Titel-Element, 'Dropzone__description' für die Beschreibung darunter. Diese Tokens sind reine, statische Strings — keine dynamische Komposition, keine cx()-Aufrufe, keine Template-Literals, keine Runtime-Interpolation. Sie landen im DOM als stabile Klassennamen, die grep findet, die Storybook sichtbar macht, und die ein Agent beim Inspizieren des gerenderten HTML auf eine einzige Quelldatei zurückführen kann.
So sieht die Form aus, die jede *.cva.ts in der Bibliothek annimmt. Der erste String in jedem base-Array ist der BEM-Identifier; alles dahinter sind Tailwind-Klassen:
1// Dropzone.cva.ts2import { cva } from '@prasath/utils'3
4export const wrapperClassName = cva({5 base: ['Dropzone__wrapper', 'flex flex-col'],6})7
8export const innerWrapperClassName = cva({9 base: [10 'Dropzone__inner-wrapper',11 'relative flex h-full rounded-lg border-2 border-dashed',12 'transition-all duration-200 cursor-pointer',13 ],14 variants: {15 variant: { primary: '...', light: '...', danger: '...' },16 isDragOver: { true: ['scale-[1.02]', 'shadow-lg'] },17 disabled: { true: ['cursor-not-allowed', 'opacity-50'] },18 },19})20
21export const titleClassName = cva({22 base: ['Dropzone__title', 'font-medium px-3 text-lg'],23 variants: {24 variant: { primary: 'text-primary-600', light: 'text-gray-700' },25 },26})27
28export const descriptionClassName = cva({29 base: ['Dropzone__description', 'text-sm max-w-sm px-3 text-balance'],30})Und so sieht das aus, was der Browser tatsächlich rendert. Jede Klasse im Output-Tree ist ein wortwörtlicher String irgendwo im Quellcode. Keine davon wird zur Laufzeit zusammengesetzt. Keine enthält Teilstrings, die nur auftauchen, wenn eine Bedingung erfüllt ist:
1<div class="Dropzone__wrapper flex flex-col">2 <div class="Dropzone__inner-wrapper ... border-gray-300 bg-gray-50/50">3 <div class="Dropzone__content ...">4 <h3 class="Dropzone__title font-medium px-3 text-lg text-gray-700">5 Dateien hier ablegen6 </h3>7 <p class="Dropzone__description text-sm max-w-sm px-3 text-balance">8 Unterstützte Formate: PDF, PNG, JPG9 </p>10 </div>11 </div>12</div>Das ist der Round-Trip. Der Agent sieht Dropzone__title im Inspector. Er führt einen einzigen Befehl aus — grep -r "Dropzone__title" libs/ — und bekommt genau eine Datei zurück: libs/molecules/src/components/Dropzone/Dropzone.cva.ts. Die Datei daneben, Dropzone.tsx, wendet titleClassName() auf genau ein JSX-Element an. Das ist der gesamte Lookup. Kein Reasoning. Keine Chain-of-Thought. Keine Zeit- und Token-Verschwendung für "lass mich mal überlegen, wo diese Klasse herkommen könnte." Nur ein kurzer algorithmischer grep, null Ambiguität, der Agent steht an der Quelldatei und ist bereit zu editieren. Jeden Zyklus, den der Agent sonst mit dem Zusammenbau einer vorläufigen Theorie über den Codebase verbrannt hätte, holt er zurück und lenkt ihn auf die eigentliche Aufgabe um.
Eine weitere Eigenschaft gehört erwähnt, weil sie den ersten Einwand vorwegnimmt, den Leute erheben: Die BEM-Identifier werden im Production-Build weggestrippt. Sie existieren in Development, in Storybook und im gerenderten DOM während der lokalen Iteration — genau dort, wo der Agent-Round-Trip stattfindet. Sie landen nicht beim Endnutzer. Kein Klassennamen-Bloat, kein Auslaufen interner Komponenten-Benennung ins Production-HTML, keine Sorge um die CSS-Bundle-Grösse. Der Round-Trip ist ein Dev-Time-Affordance mit null Runtime-Kosten.
Man stelle dem den Fehlermodus gegenüber. In einer Codebase, in der Klassennamen zur Render-Zeit zusammengesetzt werden — cx('card', isActive && 'card-active', size === 'lg' && 'card-lg') — kann die gerenderte Klasse card-active in einem Tailwind-Plugin definiert sein, von einer CSS-Variable im Parent überschrieben werden, bedingt über drei Komponenten hinweg komponiert sein, und je nach Kontext unterschiedliche Dinge bedeuten. Der Agent, der das DOM inspiziert, kann die Klasse nicht auf eine einzige Quelle zurückführen. Er muss die Komposition durchdenken, und genau an diesem Punkt überschreiben die Priors aus den Trainingsdaten die tatsächliche Struktur des Codebase. Die generischen React-und-Tailwind-Muster aus dem Pre-Training-Korpus gewinnen, und die spezifische Antwort — die in der Klassen-Zusammensetzungs-Funktion dieser Codebase steht — wird überschrieben.
Der ganze Sinn des BEM-Identifiers ist es, diese Einladung auszuschlagen. Sobald die Identität in den Klassen-String selbst codiert ist, kommt der Agent gar nicht erst bis zum Reasoning-Schritt. Er liest die Klasse, greppt nach dem Literal, öffnet die eine Datei, die matcht, und die Grundierung wird in einer einzigen Operation zugestellt, die kein Prior mehr verdrängen kann.
Leitplanke 3 — Scaffold-Generatoren als Agent-Leitplanken
Die dritte Leitplanke holt den Agent aus dem Geschäft des Boilerplate-Schreibens heraus und ins Geschäft des Tool-Aufrufens hinein. Jede neue Komponente, jedes Modal, jedes Formularfeld, jede Tabelle, jede Spalte, jede Seite, jede Action, jede E-Mail — jede wiederkehrende Scaffold-Form im Monorepo — wird über npx nx g erstellt. Der Generator schreibt die Dateien. Er setzt das CVA-Boilerplate auf. Er verdrahtet die Barrel-Exporte. Er scaffoldet die Storybook-Story. Er registriert die neue Komponente im Parent. Der Agent schreibt nichts davon frei. Er ruft den Generator auf, und der Generator liefert einen deterministischen Datei-Baum zurück.
So sieht das in der Praxis aus. Bei einer Anfrage wie "füge eine Edit-Action zur User-Tabelle hinzu, die ein Modal öffnet" beginnt der Agent nicht damit, eine neue Datei zu tippen. Er führt aus:
1npx nx g modal EditUserModal UserManager -p app --triggerName=actions-column --triggerIcon=EditOutlinedEin Befehl. Sechs Dinge passieren in einer einzigen Invocation — deterministisch, in der richtigen Reihenfolge, mit der richtigen Verdrahtung:
- Die Modal-Komponente wird unter dem
components/-Verzeichnis des Parents gescaffoldet, mit dem CVA-Boilerplate bereits an Ort und Stelle. - Der Generator läuft durch die Kinder des Parents, entdeckt die
UserTableund lokalisiert dieactions-Spalte. - Eine
editUser-Action wird in dasmeta.actions-Array dieser Spalte eingefügt — mit demEditOutlined-Icon, dem Translation-Key und einemhandleClick-Callback, der nach oben in den Baum delegiert. - Die
getColumns()-Signatur der Tabelle wird aktualisiert, damit sie das neue Callback-Prop akzeptiert. - Die Parent-Komponente (
UserManager) erhält den nötigen State —isEditUserModalOpen,selectedRow— plus die Verdrahtung, die den Row setzt, bevor das Modal geöffnet wird. - Jede Barrel-Datei entlang des Weges —
components/index.ts,index.tsdes Parents — wird mit den neuen Exporten aktualisiert.
Der resultierende Datei-Baum ist keine Vermutung. Er ist der Vertrag des Generators:
1UserManager/2├── UserManager.tsx (modifiziert — State + Verdrahtung)3├── components/4│ ├── index.ts (aktualisiert)5│ ├── UserTable/6│ │ ├── columns.tsx (modifiziert — Action eingefügt)7│ │ └── UserTable.tsx (modifiziert — Prop hinzugefügt)8│ └── EditUserModal/9│ ├── EditUserModal.tsx10│ └── index.ts11└── index.ts (aktualisiert)Das ist der ganze Punkt. Freies Schreiben ist genau die Oberfläche, auf der Priors aus den Trainingsdaten dominieren. Ein CLI aufzurufen ist genau die Oberfläche, auf der sie es nicht können. Der Agent kann kein Datei-Layout fabrizieren, weil er das Datei-Layout nicht schreibt — er ruft ein Tool auf, dessen Output deterministisch, typisiert und im Besitz der Bibliotheks-Maintainer ist. Die kreative Oberfläche kollabiert auf die Argumente des Generators. Der Agent kann den Barrel-Export nicht vergessen. Er kann das Modal nicht im falschen Verzeichnis platzieren. Er kann keine neue Verzeichnis-Konvention erfinden. Die einzigen Entscheidungen, die er trifft, sind die, welche die Flag-Oberfläche des Generators ihm erlaubt.
Und die Generatoren sind idempotent — eine bewusste Design-Entscheidung, die wichtiger ist als sie klingt. Wenn der Agent denselben Befehl zweimal ausführt, passiert nichts Schlimmes. Existiert EditUserModal bereits und ist mit eigener Business-Logik an die Actions-Spalte angebunden, ersetzt ein erneuter Aufruf des Generators nur die Platzhalter-// TODO-Stubs; jede echte Logik, die der Agent im Modal oder im handleClick der Action geschrieben hat, bleibt erhalten. Das heisst, der Agent kann den Generator in jeder Iteration getrost aufrufen — auch mitten im Gespräch — ohne vorherige Arbeit zu beschädigen. Die CLI ist sicher wiederholbar, und genau das will man haben, wenn die Alternative darin besteht, dass der Agent eine zweite, konfliktierende Version desselben Modals freischreibt.
Das Muster verallgemeinert sich. npx nx g field für ein Formularfeld. npx nx g column für eine Tabellenspalte. npx nx g action für eine Server-Action. npx nx g page für eine Next.js-Route. Jedes wiederkehrende strukturelle Muster im Codebase hat einen Generator, und jeder Generator hat einen CLAUDE.md-Eintrag, der dem Agent sagt, den Generator dem freien Schreiben vorzuziehen. Die Instruktions-Ebene zeigt den Weg; der Generator erstellt; der Agent ruft auf. Das ist eine Arbeitsteilung, bei der Priors keinen Sitz bekommen.
Leitplanke 4 — Storybook als Ground Truth
Jede Komponente der Bibliothek hat ein stories/-Verzeichnis. Eine Story pro Datei, nummeriert, sodass die Default-Variante immer [0] default ist. Jede Story-Datei importiert die Komponente exakt so, wie es eine Anwendung täte. Story-spezifische Demo-Komponenten — die fiktiven Karten, der Beispielinhalt — leben unter stories/components/, niemals inline. Das Ergebnis: Storybook ist keine Dokumentation über die Komponente. Storybook ist die Ground Truth dafür, wie die Komponente verwendet wird.
Eine echte Story-Datei sieht so aus:
1// libs/molecules/src/components/Dropzone/stories/Dropzone.maxFileSize.stories.tsx2import { Banner, Breadcrumbs, Divider } from '@prasath/molecules'3import { FileDownloadFilled } from '@prasath/icons'4import type { Meta, StoryObj } from '@storybook/react'5import { Dropzone } from '@/components'6
7const meta: Meta<typeof Dropzone> = {8 title: 'molecules/Dropzone',9 component: Dropzone,10}11export default meta12
13type Story = StoryObj<typeof Dropzone>14
15const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB16
17export const MaxFileSize: Story = {18 name: '[4] maxFileSize',19 render: () => (20 <section>21 <Divider>22 <Breadcrumbs>23 <Breadcrumbs.Item>Dropzone</Breadcrumbs.Item>24 <Breadcrumbs.Item isActive>maxFileSize</Breadcrumbs.Item>25 </Breadcrumbs>26 </Divider>27 <div>28 <Banner variant="light" title="maxFileSize" className="mb-8">29 Dateien grösser als 1 MB werden abgelehnt. Die Dropzone zeigt die30 Fehlermeldung direkt neben der betroffenen Datei an.31 </Banner>32 <MaxFileSizeDemo />33 </div>34 </section>35 ),36}Drei Fakten, die ein Agent in einem einzigen Durchlauf extrahiert. Erstens: Der Import ist anwendungsseitig — import { Dropzone } from '@/components', exakt die Zeile, die der Agent in eine Produktseite einfügen wird. Zweitens: Der Render-Körper verwendet echtes React — useState, echte Handler, echte Dateien — keine gemockten Render-Props. Drittens: Der Dateiname ist die API — Dropzone.maxFileSize.stories.tsx ist die eine Story-Datei, die die maxFileSize-Prop demonstriert, es gibt nur eine, und sie liegt genau an diesem Pfad. Der Agent muss nicht raten, wo das kanonische Beispiel für maxFileSize lebt. Es ist ein Pfad.
Der Nutzen potenziert sich bei jedem Agent-Lauf. Auf die Anfrage „füge einen File-Upload mit 2-MB-Limit hinzu, der PDFs über dem Cap ablehnt" listet der Agent das stories/-Verzeichnis auf, findet Dropzone.maxFileSize.stories.tsx, liest die Datei und kopiert das funktionierende Beispiel mit angepasstem Cap. Kein Reasoning über die API-Form. Keine halluzinierten Prop-Namen. Keine erfundene Fehlermeldungs-Struktur. Das kanonische Beispiel ist bereits im Codebase, bereits typgeprüft, rendert bereits in Storybook — der Agent muss es nur finden und anpassen.
Die Regel eine Story pro Datei macht den Find-Schritt billig. Wären Stories gruppiert — eine Datei mit zehn export consts, eine pro Prop — müsste der Agent alle zehn scannen, um zu wissen, welche die fragliche Prop abdeckt. Da der Dateiname den Prop-Namen trägt, kollabiert die Agent-Arbeit auf ein Directory-Listing. Der Dateiname verspricht den Inhalt. Grep und Glob erledigen den Rest.
Das Unterverzeichnis stories/components/ ist dieselbe Idee eine Ebene tiefer. Wenn eine Story einen realistischen Payload braucht — eine Dateiliste, eine Tabellenzeile, eine User-Karte — lebt dieser Payload in einer eigenen typisierten Komponente, nicht inline in der Story. stories/components/BasicExample/BasicExample.tsx ist eine echte React-Komponente mit einem echten Props-Interface. Der Agent behandelt sie als zweites Kopier-Ziel: Die äussere Story-Datei zeigt, wie die Komponente mit Props gemountet wird, die innere Demo-Datei zeigt, wie man realistischen State drumherum verdrahtet. Zwei saubere Kopierflächen statt einer verknoteten 200-Zeilen-Render-Funktion.
Warum das als Grounding funktioniert: Prosa-Dokumentation driftet. README-Beispiele veralten in dem Moment, in dem sich ein Prop-Name ändert; MDX-Dokumente synchronisieren sich still mit der Komponente aus; JSDoc-Kommentare erfassen Intention, nicht Verhalten. Stories können nicht driften. Sie werden mit dem Rest der Bibliothek kompiliert, gegen die Komponente typisiert, die sie demonstrieren, und bei jedem Storybook-Build gerendert. Ändert sich das Prop-Surface der Komponente, bricht die Story den Build — nicht die Doku-Seite drei Wochen später. Das ist es, was „Ground Truth" bedeutet: Wenn der Agent Dropzone.maxFileSize.stories.tsx liest, liest er eine Datei, die der Compiler bereits validiert hat. Das Beispiel funktioniert garantiert. Storybook ist kein Dokumentations-Artefakt; es ist ein ausführbares Archiv jeder unterstützten Verwendung, indexiert nach Prop-Name, mit Null-Toleranz für Drift.
Leitplanke 5 — CVA-Wrapper + ExtractVariantProps
Die fünfte Leitplanke schliesst den Kreis auf der Typ-Ebene. CVA-Varianten werden mit einem Wrapper-Helper deklariert — cva.wrapper — der sowohl die className-Funktion als auch ein variantProps-Objekt zurückgibt. ExtractVariantProps<typeof variantProps> projiziert diese Varianten dann in einen TypeScript-Union-Typ. Der Union ist geschlossen. Es gibt keine offenen Strings. size ist 'sm' | 'md' | 'lg', Punkt.
Das Idiom hat drei bewegliche Teile: den Wrapper-Aufruf, die Destrukturierung und die Typ-Projektion. Gekürzt aus Button.cva.ts:
1// libs/atoms/src/components/Button/Button.cva.ts2import type { ExtractVariantProps } from '@prasath/types'3import { cva } from '@prasath/utils'4
5export const [wrapperClassName, variantProps] = cva.wrapper({6 base: ['Button__wrapper', 'flex items-center rounded-theme'],7 variants: {8 variant: {9 primary: 'bg-primary-500 text-on-primary-500',10 secondary: 'bg-secondary-500 text-on-secondary-500',11 light: 'bg-gray-100 text-primary-on-gray-100',12 dark: 'bg-gray-600 text-on-gray-600',13 success: 'bg-success-600 text-on-success-600',14 warning: 'bg-warning-600 text-on-warning-600',15 danger: 'bg-danger-600 text-on-danger-600',16 },17 size: {18 xs: 'h-7 px-3 text-xs',19 sm: 'h-8 px-5 text-sm',20 md: 'h-10 px-5 text-sm',21 lg: 'h-12 px-5 text-sm',22 xl: 'h-14 px-7 text-md',23 '2xl': 'h-16 px-7 text-md',24 '3xl': 'h-20 px-9 text-lg',25 },26 outlined: { true: 'border-2 bg-transparent', false: '' },27 disabled: { true: 'cursor-not-allowed opacity-50', false: '' },28 },29 defaultVariants: { variant: 'light', size: 'md', outlined: false },30})31
32export type VariantProps = ExtractVariantProps<typeof variantProps>33export type Size = NonNullable<VariantProps['size']>34export type Variant = NonNullable<VariantProps['variant']>Und der Consumer — Button.tsx — erweitert Omit<VariantProps, 'as'> direkt auf das Props-Interface, sodass jeder Variant-Key in der CVA-Datei zu einer Button-Prop mit identischem geschlossenem Typ wird:
1// libs/atoms/src/components/Button/Button.tsx2import { type VariantProps } from './Button.cva'3
4export interface Props<T extends React.ElementType = 'button'>5 extends React.ComponentPropsWithoutRef<T>,6 Omit<VariantProps, 'as'> {7 // ...komponentenspezifische Props8}Verfolgen Sie jetzt, was passiert, wenn ein Agent aufgefordert wird, einen extra-grossen Danger-Button hinzuzufügen. Der Agent öffnet Button.tsx, sieht, dass size und variant aus VariantProps kommen, folgt dem Typ zu Button.cva.ts, liest den geschlossenen Union 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' und den geschlossenen Union 'primary' | 'secondary' | 'light' | 'dark' | 'success' | 'warning' | 'danger', und schreibt <Button size="3xl" variant="danger">. Er kann nicht size="extra-large" schreiben. Er kann nicht variant="destructive" schreiben. Beides sind offene Strings, die der Compiler zurückweist, bevor die Datei gespeichert wird.
Dieser letzte Satz ist der ganze Sinn der Leitplanke. In vanilla CVA — ohne die cva.wrapper-Indirektion — leben die Varianten innerhalb eines Aufrufs, nicht eines destrukturierbaren Rückgabewerts, was sie nur für die CVA-eigene Runtime zugänglich macht. Die cva.wrapper-Form hebt die Variant-Map in eine benannte variantProps-Konstante, und ExtractVariantProps reflektiert sie zurück in einen Typ. Der Typ ist jetzt ein Bürger erster Klasse in der öffentlichen Oberfläche der Komponente — kein Nebenprodukt der Styling-Runtime. Jede Variante, die der Designer in die CVA-Datei einfügt, ist nun eine Prop, die der Compiler durchsetzt. Geschlossen auf der Style-Ebene und geschlossen auf der Typ-Ebene, mit einer einzigen Quelle der Wahrheit dazwischen.
Die Leitplanke komponiert mit Storybook. Dropzone.maxFileSize.stories.tsx ist nach Prop-Namen indiziert; Button.cva.ts ist nach Variant-Wert indiziert. Zusammen beschreiben sie die vollständige API-Oberfläche — eine Datei listet jede Prop auf, die andere listet jeden Wert auf, den jede Prop annehmen kann. Ein Agent, der beide lesen kann, hat keinen Ort mehr, an dem er halluzinieren könnte.
Warum das als Grounding funktioniert: Der Agent rät nicht, welche Varianten existieren. Er liest den Typ. Der Typ listet die geschlossene Menge auf, und die geschlossene Menge ist die Ground Truth. Ein Agent, der size: 'huge' schreiben will, bekommt einen Compile-Error, bevor die Datei gespeichert wird; ein Agent, der variant: 'destructive' schreiben will, bekommt denselben. Typ-Ebene-Constraints übersetzen sich direkt in Agent-Ebene-Constraints — und weil die Constraints aus der CVA-Datei selbst abgeleitet sind, driften sie nie aus dem Takt mit dem, was tatsächlich gestylt wird.
Leitplanke 6 — CLAUDE.md als Agent-Verfassung
CLAUDE.md liegt im Wurzelverzeichnis des Repositorys. Claude Code lädt sie beim Start jeder Session automatisch ins Kontextfenster — bevor die erste Zeile meines Prompts gelesen ist. Kein @import. Kein User-Aufruf. Sie ist der System-Prompt des Agents für diese Codebasis — das eine Dokument, das der Agent bereits verinnerlicht hat, bevor ich Hallo sage.
Die Datei ist nicht für den Linter. Sie ist für Intention. Sie kodiert, was ESLint-Regeln kaum erfassen können: architektonische Entscheidungen, Namenskonventionen, die /libs-gegen-/apps-Styling-Trennung, die Regel "Props-Interface lebt im .tsx, niemals in types.ts", die Policy, dass ich — und nicht der Agent — Git-Commits verantworte. Ein Auszug aus dem Übersetzungsabschnitt:
1## Translation Requirements2
3- **NEVER hardcode text strings** — always use t-function4
5**⚠️ ABSOLUTELY FORBIDDEN — eslint-disable for i18n rules:**6
7**NEVER use `eslint-disable` comments to bypass i18n rules.**8
9The following excuses are NOT valid and will be rejected:10- "Internal error, not user-facing" — WRONG11- "Developer-facing error" — WRONG12- "System/configuration error" — WRONG13- "Temporary fix" — WRONG14
15**The ONLY solution is to add the missing translation.16No exceptions. No excuses.**Achten Sie auf den Tonfall. "ABSOLUTELY FORBIDDEN", "NO EXCEPTIONS", "NO EXCUSES". Die Lautstärke ist Absicht. Agents werden auf einem Web voller weicher Empfehlungen trainiert — prefer, consider, you may wish to. Sobald sich eine Lücke bietet, rationalisieren sie eine unbequeme Regel weg. ("Dieser Fehler ist intern, also überspringe ich die Übersetzung.") Kategorische Regeln lassen keine Verhandlungsfläche. Für eine Abkürzung, die das Dokument vorab benannt und abgelehnt hat, gibt es keine Deckung mehr.
Ein zweiter Auszug — der Abschnitt über Währungsarithmetik — zeigt die zweite Hälfte der Technik:
1## Monetary Calculations and Formatting2
3**⚠️ STRICTLY FORBIDDEN — NO EXCEPTIONS:**4- NEVER use `Math.round`, `Math.floor`, `Math.ceil` for monetary values5- NEVER use manual arithmetic like `amount * percentage / 100`6- NEVER use `/ 100` or `* 100` to convert between cents and euros —7 use the Money class8
9**Calculations — Correct approach:**10
11import { Money } from '@prasath/money'12
13const price = Money.fromCents(1100, 'EUR') // €11.0014const tax = price.multiply(0.15) // HALF_EVEN by default15const total = price.add(tax)Ein Verbot allein ist eine halbe Leitplanke. "NEVER use Math.round" sagt dem Agent, was er nicht tun soll. "NEVER use Math.round — stattdessen Money.fromCents(...).multiply(...)" sagt ihm, was er stattdessen tun soll. Jede Regel in CLAUDE.md, die ein vorhandenes Pattern untersagt, benennt auch die Ersatz-API. Schließen Sie jede Ausweichstelle, bevor der Agent sie findet.
CLAUDE.md und der Linter ergänzen sich, sie duplizieren sich nicht. Der Linter ist die Durchsetzungsschicht: Er blockiert den Verstoß beim Commit. CLAUDE.md ist die Intentionsschicht: Sie erklärt, warum die Regel existiert und — entscheidend — wozu stattdessen zu greifen ist. Ein Agent kann npx nx lint laufen lassen und die rote Kringellinie unter Math.round sehen, aber ohne den CLAUDE.md-Eintrag weiß er nicht, dass Money.fromCents(...).multiply(...) die Hausalternative ist. Er erfindet seine eigene — und der nächste PR sieht nicht aus wie der letzte.
Warum es als Verankerung funktioniert: CLAUDE.md ist buchstäblich Teil des System-Prompts. Bis der Agent meine erste Nachricht liest, hat er die Hausregeln, die Ersatz-APIs, die verbotenen Formulierungen, die architektonischen Entscheidungen bereits aufgenommen. Das ist der Schritt mit dem größten Hebel zur Verankerung in einer langen Agent-Session — das Prior, gegen das der Agent nicht übersteuert, weil er angewiesen wurde, es nicht zu tun, in dem einen Dokument, das er garantiert gelesen hat.
Leitplanke 7 — Design-Token-first statt className-Overrides
Sechs Leitplanken weiter ist die Quell-Oberfläche gut verankert — die Primitive tragen Namen, die Varianten sind aufzählbar, die Ground Truth ist indexiert, und der System-Prompt kennt die Hausregeln. Zwei Türen stehen noch einen Spalt offen, und eine davon ist eine Flügeltür. Die erste ist die vertraute className-Prop: ein einzelner String, den jede Komponente der Bibliothek an ihr äusserstes Element weiterreicht, jede Tailwind-Utility einen Tastendruck entfernt. Die zweite — mächtiger, gefährlicher — ist die classNames-Prop (Plural): ein ganzes Objekt, das die Bibliothek ebenfalls durchreicht, indiziert nach BEM-Slot, das dem Aufrufer erlaubt, in jedes interne Element des Primitivs hineinzugreifen und es umzulackieren. className kann den Wrapper neu streichen. classNames kann Titel, Beschreibung, Icon, Badge, Close-Button, Empty State, Skeleton neu streichen — jeden benannten Slot, den die *.cva.ts-Datei der Komponente deklariert. Grosse Macht verlangt grosse Verantwortung, und ein Agent, den man mit classNames allein lässt, wird keine davon aufbringen.
Beide Props sind offene Ausweichluken, die der Agent der geschlossenen Token-Menge vorzieht — jedes Mal, wenn sie verfügbar sind, und jedes Mal, wenn ich ihm nicht ausdrücklich gesagt habe, dass er sie lassen soll. Der Failure Mode wirkt harmlos, bis man sich den Diff ansieht. Gebeten, "den Delete-Button prominenter zu machen", liefert der Agent zuverlässig:
1<Button2 variant="danger"3 className="!px-7 !py-4 !text-[15px] !bg-[#dc2626] !rounded-md"4>5 Konto löschen6</Button>Jedes Zeichen dieses Overrides ist eine Lüge über das System. px-7 und py-4 liegen ausserhalb der Size-Skala, auf die sich die Button-Komponente mit dem Rest der Bibliothek verständigt hat. text-[15px] ist ein arbitrary value — eine Tailwind-Escape-Luke, die eine massgefertigte CSS-Klasse ausserhalb der Typografie-Rampe ausliefert und um ein Pixel vom nächsten Heading abweichen wird, neben dem die Komponente steht. bg-[#dc2626] ist ein Hex-Literal, das den danger-500-Token umgeht — wird die Markenpalette einmal nachgezogen, bleibt der Button auf dem alten Rot stehen, während sich der Rest der Anwendung weiterbewegt. rounded-md widerspricht der Rounded-Theme-Entscheidung, die die CVA-Datei für diese Variante getroffen hat. Und !important auf jeder Utility ist das öffentliche Geständnis des Agents, Zeile für Zeile: dass er weiss, die eigenen Styles der Komponente werden zurückschlagen, und er den Kampf gewinnt, indem er sie überbrüllt.
Das ist die Single-String-Escape-Luke im Einsatz. Die Objekt-förmige Variante ist schlimmer. Fragen Sie denselben Agenten, "diesen Alert so laut zu machen, dass ihn keiner übersieht", und Sie bekommen:
1<Alert2 variant="danger"3 classNames={{4 wrapper: '!bg-[#dc2626] !p-7 !border-4 !border-[#991b1b]',5 title: '!text-[15px] !text-white !font-black',6 description: '!text-white !font-bold',7 icon: '!w-10 !h-10 !text-[#fef2f2]',8 closeButton: '!text-white !bg-[#7f1d1d]',9 }}10>11 Ihr Konto wird in 24 Stunden gelöscht.12</Alert>Jeder Schlüssel in diesem Objekt ist ein BEM-Slot, den die *.cva.ts-Datei der Komponente mit gutem Grund deklariert hat. wrapper, title, description, icon, closeButton — jeder eine Styling-Entscheidung, auf die sich die Bibliothek einmal verständigt, die sie einmal getestet und einmal ausgeliefert hat. Der Objekt-förmige Override greift an all diesen vorbei — in einer einzigen Prop. Wenn className ein Vorschlaghammer ist, dann ist classNames eine komplette Abbruchcrew. Die Single-String-Variante kann nur die Fassade neu streichen. Die Plural-Variante erlaubt dem Agent, in einem einzigen Zug jede tragende Innenwand niederzureissen — kein Typsystem, das es abfängt (die Shape ist bewusst offen gehalten, damit die Komponente erweiterbar bleibt), keine Linter-Regel, die es markiert (die Klassen sind statische Strings; der Compiler ist zufrieden), kein visuelles Review, das es bemerken würde, weil das Styling noch halbwegs nach Brand aussieht, bis Sie es neben einen unangetasteten Alert stellen und merken, dass die beiden nicht mehr zur selben Designsprache gehören. Das ist die Prop, an der Komponentenbibliotheken lautlos auseinanderdriften. Eine ganze Seite, von innen heraus umgebaut, aus der Call-Site, in einem einzigen Objekt-Literal. Grosse Macht, keine Verantwortung.
Diese Leitplanke zu schliessen hat zwei Hälften, und beide müssen in CLAUDE.md explizit sein — und beide gelten gleichermassen für className wie für classNames. Die erste Hälfte ist ein kategorisches Verbot der Formulierungen, zu denen der Agent am ehesten greift. Direkt aus den Hausregeln:
1- **TailwindCSS class names**: NEVER use string interpolation for TailwindCSS2 classes (e.g., avoid `xs:${prefix}-${value.xs}`). Use static class names3 only or pre-defined class arrays to ensure Tailwind's compiler can detect4 them.5- **Avoid CSS hacks**: NEVER use `!important` modifiers in Tailwind classes6 (e.g., `!p-0`, `!justify-start`). Instead, properly structure components7 and use CVA variants or proper class composition to achieve the desired8 styling.Die zweite Hälfte ist die affirmative Richtung — wozu stattdessen zu greifen ist. Visuelle Änderungen laufen über size- und variant-Props, die Design-Tokens über jeden internen Slot gleichzeitig tragen. Wenn die Tokens die angefragte Änderung noch nicht ausdrücken, ist der richtige Schritt nicht, von der Call-Site aus zu overriden — weder die Single-String-className noch erst recht die Multi-Slot-classNames. Sondern: die CVA-Datei öffnen und eine Variante hinzufügen. Ein neues intensity: 'muted' | 'loud' neben der bestehenden variant-Union, ein neues tone: 'brand' | 'neutral' neben size — jede eine geschlossene Ergänzung der aufzählbaren API der Komponente, jede sofort TypeScript, Storybook und dem nächsten Reasoning-Schritt des Agents verfügbar. Jede Variante fächert sich intern auch aus: Ein sauber geschriebenes Alert.cva.ts fädelt intensity: 'loud' durch Wrapper, Titel, Icon, Beschreibung und Close-Button — eine einzige aufzählbare Prop berührt jeden Slot, in den das classNames-Objekt sonst Schlüssel für Schlüssel einzeln hätte hineingreifen müssen. Der Variant-Raum wächst; die Override-Oberfläche nicht. Jede zukünftige Agent-Session liest die erweiterte Union und hat einen legitimen, hausinternen Weg, die visuelle Änderung auszuliefern, ohne eine der beiden Props anzufassen.
Die Token-Palette selbst ist das geschlossene Vokabular, auf dem das aufsetzt. bg-primary-500, text-success-700, border-danger-400 — benannt, in tailwind.config.ts gemappt, semantisch an die Marke geknüpft. Hex-Literale wie #dc2626 und Arbitrary Values wie text-[15px] liegen ausserhalb dieses Vokabulars. Der Linter kann die schlimmsten Ausreisser markieren, aber der strukturelle Fix ist, die Tokens in CLAUDE.md beim Namen zu nennen — die Palette auszubuchstabieren, die Typografie-Rampe auszubuchstabieren, die Spacing-Skala auszubuchstabieren —, damit der Agent sie nie aus der Häufigkeit seiner Trainingsdaten erschliessen muss. In dem Moment, in dem er erschliessen muss, gewinnen die Priors, und Sie sind wieder bei bg-amber-500 in einem Codebase, das Amber nicht führt.
Warum es als Verankerung funktioniert: Design-Tokens übersetzen einen visuellen Wunsch in eine Lookup in einer geschlossenen Menge. "Mach den Delete-Button prominenter" ist eine offene Anweisung auf der className-Schicht — und eine kombinatorische auf der classNames-Schicht, wo der Agent pro Slot unabhängig eine Utility wählen darf. Es gibt praktisch unendlich viele Tailwind-Utilities, zu denen der Agent greifen könnte, und die Multi-Slot-Prop multipliziert diese Oberfläche mit der Anzahl der Slots. Auf der Token-Schicht wird es zu einer endlichen Frage: Gibt es eine passende Variante schon? Wenn ja, verwende sie. Wenn nein, füge eine hinzu. Das Token-Vokabular ist aufzählbar; das Single-String-Override-Vokabular nicht, und das Multi-Slot-Override-Vokabular ist aufzählbar-mal-aufzählbar. Ein Agent, der innerhalb der aufzählbaren Variant-Menge denkt, kann nicht übersteuern — es gibt keinen Ort mehr, an dem er etwas Neues erfinden könnte.
Zusammengeführt: eine Leitplanken-Checkliste
Alle sieben Leitplanken in einer bestehenden Bibliothek einzuführen ist kein Wochenendprojekt. Aber sie komponieren, und jede einzelne zahlt ihre Einführungskosten innerhalb eines oder zweier Sprints Agent-assistierter Arbeit zurück.
| Leitplanke | Failure Mode, der verhindert wird | Einführungsaufwand |
|---|---|---|
| ESLint mit radikalen custom-rules | Alles, was die Quelltext-Oberfläche nicht verhindern konnte | Mittel — eigene Plugins schreiben und pflegen |
| BEM-Identifier in CVA | Duplizierte Komponenten, nicht rückverfolgbare Klassen | Gering — einmal bestehende Klassen umbenennen |
| Scaffold-Generatoren | Erfundene Datei-Layouts, inkonsistenter Boilerplate | Mittel — Generatoren schreiben, Gewohnheiten umstellen |
| Storybook als Ground Truth | Halluzinierte Props, falsch verwendete APIs | Mittel — One-Story-per-File durchsetzen |
| CVA-Wrapper + ExtractVariantProps | Erfundene Varianten, ungültige Sizes | Gering — Idiom-Umstellung, nur im Typ |
| CLAUDE.md als Agent-Verfassung | Workflow-Verstösse (Changesets, Review-Scope) | Gering — ein einziges Dokument, gepflegt über Zeit |
| Design-Token-first-Prompting | className- / classNames-Overrides, halluzinierte Tokens | Mittel — disziplinierte Prompts + Token-Benennung |
Fazit
Eine Vorbemerkung dazu, was dieser Artikel ist — und was er nicht ist. Dies ist ein Bericht aus Praktikerperspektive, keiner aus wissenschaftlicher Sicht. Ich habe diese Leitplanken nicht gegen einen Korpus gebenchmarkt, ich habe nicht auf Modellversionen kontrolliert, ich habe keinen A/B-Test gefahren. Ich habe React-Komponentenbibliotheken gebaut, die an echte Nutzer ausgeliefert wurden, ich habe sie so umgerüstet, dass Coding Agents sie navigieren können, und ich habe Agents genau gegen die oben beschriebenen Patterns gewinnen und verlieren sehen. Nehmen Sie die sieben Leitplanken als Feldnotizen aus dieser Arbeit, nicht als Paper.
Claude 4.7 steht kurz vor dem Release. Ich erwarte eine ordentliche Verbesserung — Anthropic hat in den letzten zwei Jahren keinen Schritt ausgelassen, und jedes Release hat den Loop enger gezogen. Was ich nicht erwarte: dass dadurch eine der sieben Leitplanken optional wird. Die Failure Modes aus Abschnitt 2 sind keine Artefakte einer bestimmten Modellgeneration — sie sind strukturelle Eigenschaften davon, wie ein Agent denkt, wenn seine Priors schwach und seine Ground Truth noch schwächer ist. Bessere Modelle heben die Decke dessen an, was ein Agent tun kann; sie schliessen nicht die Lücke zwischen den Priors und Ihrem Codebase. Diese Lücke schliesst Ihre Quell-Oberfläche, nicht das Modell. Ich wette — heute, schriftlich —, dass jede einzelne der sieben Leitplanken am Release-Tag von 4.7 essenziell sein wird, und am Release-Tag von 5.0 wieder. Sie sind der Unterschied zwischen einer Komponentenbibliothek, die ihre Agent-gestützte Zukunft übersteht, und einer, die von einem Vibe-Coder mit frischem API-Key zu Spaghetti Bolognese verkocht wird.
Nicht LLM allein. LLM + strukturierte Verankerung. Heejins These, angewendet auf eine Codebase statt auf einen Text-Korpus. Das Modell leistet die Arbeit. Die Verankerung leistet die Arbeit, dafür zu sorgen, dass die Arbeit korrekt ist.
Dank und Einladung
Dieser Artikel würde ohne Heejin Dos Vortrag beim Zurich Language AI Meetup nicht existieren. Wer in der Nähe von Textkorrektur, Übersetzungsqualität oder LLM-unterstützter Bearbeitung arbeitet: Ihre Forschung lohnt sich zu verfolgen — sie ist Post-Doctoral Fellow am ETH AI Center und arbeitet an der Schnittstelle von NLP und Bildung.
Mein Dank gilt ebenso Gunther Klobe und Supertext für die Einladung zum Meetup — ohne die dieser Artikel in der vorliegenden Form nicht entstanden wäre.
Wenn Sie eine bestehende Komponentenbibliothek für Coding-Agent-Workflows umrüsten — oder wenn Sie überlegen, von Grund auf in eine zu investieren —, ist es entscheidend, dass Sie jemanden wie mich am Anfang des Prozesses dazuholen, nicht am Ende, nicht als Rettungseinsatz. In sechs Monaten steht ein tragfähiges Fundament. Ich habe ganze Teams über Jahre an einer Komponentenbibliothek arbeiten sehen, ohne dass sie vorankamen, und der Grund war — in jedem Fall, in dem ich nah genug dran war, um es zu diagnostizieren — das Fehlen genau dieser spezifischen, tiefen, tagtäglichen Expertise darin, was eine Komponentenbibliothek an den Nähten zusammenhält.
Ich habe Komponentenbibliotheken bei UBS und bei AXA gebaut. Ich investiere — Tag für Tag — darin, was an der Agent-Frontier im Frontend passiert. Das ist, was ich tue, worin ich gut bin, und womit ich zu jeder Zusammenarbeit erscheine.
Sprechen wir. Melden Sie sich über das Formular unten bei mir.
