181 lines
7.8 KiB
Markdown
181 lines
7.8 KiB
Markdown
# 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`
|