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

---

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