12 KiB
CluPilot — verbindliche Regeln (Repo-Teil)
Die Vollfassung der Regeln R1–R17 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:
- 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.
- 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-4und.size-5haben dieselbe Spezifität, also entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt.size-4vor.size-5aus. Eine Komponente, diesize-5bedingungslos mitmergt, überstimmt damit jedesclass="size-4"am Aufrufort. Alle so geschriebenen Icons liefen still auf 20px.
Wie es jetzt gebaut ist
resources/views/components/ui/icon.blade.phpsetzt seine Standardgröße nur, wenn der Aufrufort keinesize-/w-/h--Klasse mitgibt, und rendertinline-block shrink-0 align-middlestatt des Preflight-block.resources/views/components/ui/nav-item.blade.phplegt 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:
- Einen gespeicherten Zeitstempel direkt formatieren.
$model->created_at->isoFormat(…)liefert UTC. Richtig ist$model->created_at->local()->isoFormat(…). ->timezone(config('app.timezone')). Das ist die Speicherzone und bleibt UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.- Ein
datetime-local-Feld nur in eine Richtung behandeln. Rein und raus gehören zusammen:LocalTime::toField()undLocalTime::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, VorgabeEurope/Vienna) getrennt vonapp.timezone, das UTC bleibt.->local()als Carbon-Makro inAppServiceProvider. Es kopiert vor dem Umstellen:Illuminate\Support\Carbonist 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\LocalTimehä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:
- 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. - 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\EditSeatalsModalComponent, 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:
- Eine Rolle oder Berechtigung am
User. Alle siebzehn Berechtigungen sind Konsolen-Berechtigungen; sie liegen amoperator-Guard. Einusers-Datensatz mit Konsolenzugang ist die Vermischung in klein. - Eine Auth-Route, die beide Seiten bedient.
RestrictAdminHost::SHAREDführt nur nochlivewire/*undup. - 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:
- 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.
- 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.
- 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.
- Mutationstests nur an der Grenze, an der ein Fehler Daten verliert, Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
- Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review. Eine Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
- 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:
wire:confirm. Das Attribut ruftwindow.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.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
ConfirmRemoveHostund den anderen bestehenden Lösch-Bestätigungen —App\Livewire\Admin\ ConfirmSaveSecret,ConfirmForgetSecret,ConfirmReissueVpnPeer,ConfirmDisableTwoFactorin der Konsole;App\Livewire\ ConfirmDisableTwoFactorundConfirmRevokeSeatim 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 eigenenguard()-/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
_bodystehen; nur der neue_title(und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam hinzu,_confirmheiß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.