CluPilotCloud/CLAUDE.md

7.8 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