# 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:

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 *Speicher*zone 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.
