CluPilotCloud/CLAUDE.md

6.7 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.