CluPilotCloud/CLAUDE.md

14 KiB
Raw Blame History

CluPilot — verbindliche Regeln (Repo-Teil)

Die Vollfassung der Regeln R1R17 liegt beim Nutzer, nicht im Repo. Die Kurzfassung steht in docs/handoffs/2026-07-25-clupilot-state-handoff.md §9. Bei Konflikt: STOP & fragen.

Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.


R18 — Icon-Größe und Zeilenumbruch

Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.

Verboten:

  1. Icon zwingt den Text auf eine zweite Zeile. Ein Navigationseintrag, ein Button, ein Tabellen-Action ist einzeilig. Zwei Zeilen sind nur erlaubt, wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) — dann steht das Icon links davon, nicht darüber.
  2. Icon größer als die Zeile, in der es sitzt. Standard ist size-5 (20px) in der Navigation, size-4 (16px) in Buttons, Tabellen und Fließtext. Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand, Statusplakette).

Warum das zweimal schiefging

  • Zeilenumbruch. Tailwinds Preflight setzt svg { display: block }. Ein Icon in einem inline-Elternteil schiebt den folgenden Text damit auf die nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so hoher Kasten mit Schloss oben und Wort darunter.
  • Größe. .size-4 und .size-5 haben dieselbe Spezifität, also entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt .size-4 vor .size-5 aus. Eine Komponente, die size-5 bedingungslos mitmergt, überstimmt damit jedes class="size-4" am Aufrufort. Alle so geschriebenen Icons liefen still auf 20px.

Wie es jetzt gebaut ist

  • resources/views/components/ui/icon.blade.php setzt seine Standardgröße nur, wenn der Aufrufort keine size-/w-/h--Klasse mitgibt, und rendert inline-block shrink-0 align-middle statt des Preflight-block.
  • resources/views/components/ui/nav-item.blade.php legt Label und Icon in eine eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
  • Icons in <x-ui.nav-item> gehören in <x-slot:icon>, nicht in den Default-Slot.

Erzwungen durch

tests/Feature/IconLayoutTest.php — Größe des Aufruforts gewinnt, Icon bleibt inline-block, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.


R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr

Jede Zeit, die ein Mensch liest, geht vorher durch ->local().

Verboten:

  1. Einen gespeicherten Zeitstempel direkt formatieren. $model->created_at->isoFormat(…) liefert UTC. Richtig ist $model->created_at->local()->isoFormat(…).
  2. ->timezone(config('app.timezone')). Das ist die Speicherzone und bleibt UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
  3. Ein datetime-local-Feld nur in eine Richtung behandeln. Rein und raus gehören zusammen: LocalTime::toField() und LocalTime::fromField(). Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen Uhr abliest.

Ausgenommen: diffForHumans() ist relativ und in jeder Zone gleich.

Warum das durchgerutscht ist

Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt aus — sie riefen ->timezone(config('app.timezone')), was sich liest wie „in Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.

Schlimmer als die Anzeigen waren zwei Formulare: Wartungsfenster und Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein eingetragenes „21:00" wurde zu 23:00 Ortszeit.

Und die Tests deckten es nicht auf, weil sie den erwarteten Wert mit demselben falschen Aufruf bildeten. Ein Test, der die Implementierung nachrechnet, prüft nichts.

Wie es jetzt gebaut ist

  • config('app.display_timezone') (APP_DISPLAY_TIMEZONE, Vorgabe Europe/Vienna) getrennt von app.timezone, das UTC bleibt.
  • ->local() als Carbon-Makro in AppServiceProvider. Es kopiert vor dem Umstellen: Illuminate\Support\Carbon ist mutabel, sonst schriebe das bloße Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf — Carbon führt Makros in einer globalen Tabelle, die zweite Registrierung ersetzt die erste für alle.
  • App\Support\LocalTime hält beide Feldrichtungen nebeneinander, damit niemand eine ändert, ohne die andere zu sehen.

Erzwungen durch

tests/Feature/DisplayTimezoneTest.php — kein Blade und kein Livewire-Bauteil darf absolut formatieren ohne ->local(), die UTC-Attrappe ist verboten, Speicherzone bleibt UTC, Sommer- und Winterzeit werden geprüft, ->local() verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe Zeitpunkt zurück.


R20 — Bearbeiten passiert im Modal, nie in der Zeile

Sobald etwas Eingabefelder hat, geht ein Modal auf.

Verboten:

  1. Inline-Bearbeitung in einer Tabellenzeile. Kein <input>, kein <textarea> in einem <td>. Die Zeile wächst, die Spalten daneben springen, und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein Darstellungsfehler, nicht wie ein Formular.
  2. Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft. Er schickt openModal — alles andere ist der Inline-Editor unter neuem Namen.

Nicht betroffen: Formulare, die die Seite sind — Anlegen-Formulare, die Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen bestehenden Datensatz an Ort und Stelle.

Ausnahmen, die kein Modal brauchen: ein einzelnes <select> oder eine Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert, keine Höhenänderung.

Warum das aufgeschrieben wurde

Das Projekt hatte das Modal längst — EditDatacenter, mit genau dieser Begründung im Kopfkommentar („avoids the row-height jump of inline editing"). Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die Bearbeitung inline gebaut, obwohl das Muster danebenlag.

Wie es jetzt gebaut ist

  • App\Livewire\EditSeat als ModalComponent, geöffnet über $dispatch('openModal', { component: 'edit-seat', arguments: { uuid } }).
  • Ein Modal ist ohne die Route-Middleware der Seite erreichbar. Es löst deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom Browser hydrierten Eigenschaft zu glauben.

Erzwungen durch

tests/Feature/EditInModalTest.php — kein Seiten-Blade darf ein Eingabefeld in einem <td> wachsen lassen, und die Benutzertabelle muss edit-seat per openModal öffnen.


R21 — Konsole und Portal teilen keine Identität

Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.

Verboten:

  1. Eine Rolle oder Berechtigung am User. Alle siebzehn Berechtigungen sind Konsolen-Berechtigungen; sie liegen am operator-Guard. Ein users-Datensatz mit Konsolenzugang ist die Vermischung in klein.
  2. Eine Auth-Route, die beide Seiten bedient. RestrictAdminHost::SHARED führt nur noch livewire/* und up.
  3. Eine Anmeldeansicht für beide. Das Portal hat Fortify, die Konsole hat App\Livewire\Auth\OperatorLogin.

Warum das aufgeschrieben wurde

Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto? Registrieren" darauf. register stand nicht in SHARED und konnte dort auch nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität zweier Personengruppen in einer Tabelle.

Erzwungen durch

tests/Feature/IdentitySeparationTest.php


R22 — Der Aufwand richtet sich nach der Aufgabe, nicht nach dem Verfahren

Prüfen ist ein Mittel, kein Selbstzweck. Wer für eine Tabellenumstellung zwölf Stunden und zehn Prüfrunden braucht, hat die Aufgabe nicht verstanden.

Verbindliche Obergrenzen:

  1. Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach wird ein offener Befund geparkt — mit schriftlicher Begründung — und nicht weitergeschliffen. Ein zweiter Re-Review desselben Befundes ist verboten.
  2. Codex/R15: höchstens zwei Runden ohne P1. Danach gehen die Restbefunde als Folgepunkte in den Merge Request. Bis zur Nullmeldung zu schleifen ist kein Qualitätsmerkmal, sondern fehlende Priorisierung.
  3. Ein Review sieht nur den Diff. Dateien, die seit dem letzten grünen Testlauf nicht angefasst wurden, werden nicht erneut geprüft — weder von einem Prüfer noch beim Aufräumen. Was unverändert ist, war beim letzten grünen Lauf schon in Ordnung.
  4. Mutationstests nur an der Grenze, an der ein Fehler Daten verliert, Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
  5. Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review. Eine Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
  6. Unabhängige Arbeit läuft gleichzeitig. Analysen (Review, Codex, Testlauf) blockieren einander nicht und werden parallel gestartet.

Warum das aufgeschrieben wurde

Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.


R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog

Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in einem Fenster, das der Browser zeichnet.

Verboten:

  1. wire:confirm. Das Attribut ruft window.confirm() auf, bevor die Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin, kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
  2. confirm( aus eigenem JavaScript. Derselbe native Dialog, nur ohne die Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe Problem unter anderem Namen.

Warum das aufgeschrieben wurde

Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte Identitäten nach R21), ein Benutzerzugang entzogen.

Wie es jetzt gebaut ist

  • Sechs eigene Bestätigungs-Modals nach dem Muster von ConfirmRemoveHost und den anderen bestehenden Lösch-Bestätigungen — App\Livewire\Admin\ ConfirmSaveSecret, ConfirmForgetSecret, ConfirmReissueVpnPeer, ConfirmDisableTwoFactor in der Konsole; App\Livewire\ ConfirmDisableTwoFactor und ConfirmRevokeSeat im Portal. Geöffnet über $dispatch('openModal', { component: '…', arguments: { … } }), derselbe Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen benannt statt „OK").
  • Das Modal mutiert nichts selbst. Der eigentliche Vorgang (save(), forget(), reissue(), disableTwoFactor(), revoke()) bleibt unverändert auf der Seiten-Komponente stehen, mit ihren eigenen guard()-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein Event aus (z. B. secret-save-confirmed), das die Seiten-Komponente per #[On(...)] auffängt und an die bestehende Methode weiterreicht — das hält die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt sie im Modal zu verdoppeln.
  • Der bisherige Bestätigungssatz bleibt wortgleich als _body stehen; nur der neue _title (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam hinzu, _confirm heißt jetzt wie überall sonst im Repo die kurze Knopfbeschriftung statt eines ganzen Satzes.

Erzwungen durch

tests/Feature/ConfirmInModalTest.php — kein Blade-File im Repo darf wire:confirm enthalten, und kein JavaScript darf confirm( aufrufen.


R24 — Ein Modal wird nie höher als der Bildschirm

Kopf und Fuß stehen fest, gescrollt wird nur die Mitte.

Verboten:

  1. Ein Modal, das mit seinem Inhalt wächst. Es läuft unten aus dem Fenster heraus und nimmt seinen eigenen Speichern-Knopf mit — erreichbar nur, indem man die Seite hinter dem Overlay scrollt, auf dem Telefon also gar nicht.
  2. Titel oder Knopfzeile im Scrollbereich. Was man lesen muss, um zu wissen, wo man ist, und was man erreichen muss, um fertig zu werden, gehören beide außerhalb.
  3. Eine Blade-Direktive in der Attributliste eines Komponenten-Tags (<x-ui.modal @if(…) wire:poll @endif>). Sie landet im Attribut-Beutel und zerlegt die View — dieselbe Falle wie @disabled an einer Komponente. Solche Attribute gehören auf ein eigenes Element im Inhalt.

Wie es gebaut ist

  • resources/views/vendor/wire-elements-modal/modal.blade.php begrenzt das Panel einmal für alle Modals: max-h-[calc(100dvh-3rem)], Spalte, overflow-hidden. dvh statt vh, weil am Telefon die Browserleiste zu vh zählt — genau der Fall, in dem der letzte Zentimeter zählt.
  • resources/views/components/ui/modal.blade.php ist die andere Hälfte: header-Slot (oder title/subtitle), scrollender Rumpf, footer-Slot. min-h-0 am Rumpf ist die Zeile, die es wirken lässt — ein Flex-Kind wird ohne sie nicht kleiner als sein Inhalt, und overflow greift nie.
  • Ein Knopf im Fuß, der ein Formular im Rumpf abschickt, benutzt type="submit" form="…". Das ist der Preis für einen Fuß, der nicht wegscrollt — und ein HTML-Attribut, kein Trick.

Gilt für

Jedes Modal, in dem ein Eingabefeld steckt (<form, <input, <textarea, <select, x-ui.input). Eine zweizeilige Rückfrage mit einem Knopf hat nichts, was festbleiben müsste, und wird nicht umgebaut.

Erzwungen durch

tests/Feature/ModalHeightTest.php — das Panel bleibt begrenzt, jedes Modal mit einem Feld benutzt <x-ui.modal>, und keine Blade-Direktive steht in der Attributliste eines Komponenten-Tags.