Write the handoff, and rescue the approved templates into the repo
tests / pest (push) Successful in 7m3s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details

The three templates the design was signed off against lived in a
session-scoped scratchpad and would have disappeared with the session — the one
artefact the whole conversion is measured against. They are in docs/design/ now,
with the measurements that were got wrong at least once written down beside
them, and a note that the way to check is to render and measure rather than to
read the source. That distinction already settled one disagreement: the source
said the metric grid used a 20px gap, rendered it is 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/mailboxes tested-20260727-1825-96dac23
nexxo 2026-07-27 20:18:04 +02:00
parent 9b8d5dfd1e
commit 96dac236e8
5 changed files with 2509 additions and 0 deletions

37
docs/design/README.md Normal file
View File

@ -0,0 +1,37 @@
# Freigegebene Vorlagen
Diese drei Dateien sind der **abgenommene** Entwurf für Startseite, Kundenportal
und Konsole. Sie sind der Maßstab, gegen den umgesetzt wird — nicht eine
Inspiration.
Sie lagen ursprünglich in einem sitzungsgebundenen Scratchpad und wären mit der
Sitzung verschwunden.
## Wie man dagegen prüft
Nicht im Quelltext lesen — **rendern und messen**. Das hat schon einmal einen
Streit entschieden: eine Quelltext-Prüfung behauptete 20px Rasterabstand,
gerendert sind es 14px.
```bash
cp docs/design/tpl-portal.html public/__tpl.html # danach wieder löschen
```
Dann im Browser beide Seiten mit `getComputedStyle` abmessen und Spalte gegen
Spalte vergleichen.
## Maße, die schon einmal danebenlagen
| | Vorlage |
|---|---|
| Rasterabstand | 14px |
| Kartenpolsterung | 18px 20px |
| Kartenradius | 16px |
| Beschriftung | 11.5px, `.07em`, Mono |
| Wert | 25px / 600 / `-.02em` / 9px oben |
| Einheit | 13px / 450 |
| Fußzeile | 12px / 6px oben |
| h1 | 30px / 700 / `-.03em` / 1.12 — unter 900px 23px |
| Badge | 12px / 500, 4px 11px, 7px Abstand → 29px hoch |
| Button | 14px / 600, 0 18px, `min-height` 40px, Radius 10px |
| Spalten | 4 → 2 ab 1100px → 1 ab 560px |

File diff suppressed because one or more lines are too long

1270
docs/design/tpl-home.html Normal file

File diff suppressed because one or more lines are too long

507
docs/design/tpl-portal.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,163 @@
# Handoff — CluPilot (Stand 2026-07-27)
**Für:** frische Claude-Code-Session
**Art:** Zustands-/Fortsetzungs-Handoff
**Zuerst lesen:** dieses Dokument komplett, dann `CLAUDE.md` (R18R20) und `git log --oneline -15`.
**Vorgänger:** `docs/handoffs/2026-07-25-clupilot-state-handoff.md` (Umgebung, Stack, Befehle, R1R17 — gilt unverändert weiter).
---
## 0. TL;DR
Seit dem letzten Handoff: **34 Commits**, Zweig `feat/portal-design` ist in **`main`** gemerged und gepusht. **637 Pest-Tests grün.**
Inhaltlich waren das drei Blöcke:
1. **Ein Designsystem für alle drei Oberflächen**, gebaut gegen eine vom Nutzer freigegebene Vorlage — und diesmal **gemessen** statt geglaubt.
2. **Erfundene Daten raus, echte Messreihen rein.** Speicher, Datenvolumen und Verfügbarkeit werden jetzt tatsächlich erhoben; ein Demo-Kunde liegt als echte Datenbankzeilen vor.
3. **Drei gemeldete Fehlerklassen abgestellt und als Regeln festgeschrieben**: Icon-Layout (R18), Zeitzone (R19), Bearbeiten im Modal (R20).
**Nächster Block:** die zwölf Konsolen-Ansichten auf das Designsystem umstellen, die Support-Anfragen in der Konsole beantwortbar machen, die Startseite ins Blade übertragen.
---
## 1. Was fertig ist
### 1.1 Designsystem
- **Ein** Token-Satz in `resources/css/portal-tokens.css` (hell: ground `#f6f6f8`, card `#fff`, line `#e9e9ee`, ink `#17171c`, accent `#f97316`, Radien 9/11/16/22). `admin-tokens.css` trägt **nur noch Dichte** (13.5px Basis, engere Radien), keine eigenen Farben.
- Ein Shell für beide Layouts: `resources/views/components/shell/{head,nav,topbar}.blade.php`. Navigation und Brotkrume kommen aus **einer** Quelle, `app/Support/Navigation.php` — auf Route-Namen gematcht, weil sich der *Pfad* der Konsole zwischen host-gebundenem und Fallback-Modus ändert, ihre Route-Namen aber nicht.
- Komponenten: `ui/metric`, `ui/ring`, `ui/spark`, `ui/button`, `ui/badge`, `ui/icon`, `ui/nav-item`.
- Die Kleinkapitälchen-Beschriftung ist **eine** CSS-Klasse `.lbl` (11.5px, `.07em`). Vorher stand die Zahl an drei Stellen einzeln — so ist sie von der Vorlage weggedriftet.
**Wie das Angleichen verifiziert wurde** (wichtig, weil es mehrfach schiefging): Vorlage *und* Umsetzung wurden im Browser gerendert und mit `getComputedStyle` **gemessen**, dann Spalte gegen Spalte verglichen. Das hat sofort einen Streit entschieden — eine Prüfung des Vorlagen-*Quelltexts* behauptete 20px Rasterabstand, gerendert sind es 14px. Codex-Urteil zum Schluss: *„Yes. Side by side, a person would call them the same design."*, einzige Abweichung 1px.
### 1.2 Echte Daten statt Attrappen
- `instance_metrics` (Migrationen `2026_07_27_150000` + `_160000`): eine Zeile je Instanz und Tag, `disk_used_bytes`/`disk_total_bytes` **nullable**, dazu `rx_bytes`, `tx_bytes`, `checks_total`, `checks_ok`.
- `CollectInstanceTraffic` liest den Plattenstand per Proxmox-`guestExec` (`df`), `SyncMonitoringStatus` zählt Prüfungen nur, wenn eine echte Antwort kam.
- **Regel dahinter:** eine Messung, die nicht genommen werden konnte, ist keine Messung von null. Fehlende Tage bleiben fehlend, eine ungeprüfte Instanz liefert `null` statt 100 %.
- `DemoCustomerSeeder`: ein vollständiger Kunde als echte Zeilen (Instanz, Abo, 6 Plätze über vier Rollen, Backup, Periodenverkehr, 30 Tage Messreihe mit Absicht-Zittern und einem Tag auf 286/288). Entfernen ist **eine** Löschung. Das Passwort kommt aus `DEMO_PASSWORD` oder wird zufällig erzeugt und einmalig ausgegeben — auf einem öffentlich erreichbaren Login liegt kein Passwort im Quelltext.
- Entfernt wurden: erfundene Support-Tickets, Dashboard-Fixtures, der Rechenzentrumsname (nur noch das Land), eine erfundene SLA, ein hartkodiertes „B", Laufzeit-Versionen, die keinen Kunden interessieren.
### 1.3 Kundenportal
- Dashboard mit vier Karten (Speicher/Benutzer/Datenvolumen/Verfügbarkeit) auf Vorlagenmaß, Ring 62px, Sparkline aus echter Reihe.
- **Downgrade** über `App\Services\Billing\DowngradeCheck` — geprüft wird gegen **gemessenen** Speicher, nicht gegen das verkaufte Kontingent, und die Blockade wird in den Zahlen des Kunden benannt („Sie haben 31 Benutzer, Start erlaubt 10").
- Benutzer-Tabelle: Bearbeiten (Modal), Sperren/Entsperren, Entfernen. Aktionsspalte **immer** sichtbar.
- Support-Seite auf echte Daten: `support_requests` (Migration `2026_07_27_180000`), Anfrage-Modal, eigene Historie, FAQ mit Sprungzielen ins Panel.
### 1.4 Betrieb
- **VPN** funktioniert (iPhone + Laptop bestätigt). Der Bereitschafts-Test fragte vorher `https://127.0.0.1/`, während das Gateway nur auf `10.66.0.1:443` lauscht — dadurch blieb `VPN_READY` falsch und die App hielt `DNS = 10.66.0.1` zurück. Jetzt eigener Health-Port, 60-s-Fenster.
- **iOS-Zoom** beim Fokussieren abgestellt (`@media (pointer:coarse)` → Felder 16px). Bewusst **nicht** über die Viewport-Sperre, die das Pinch-Zoom mit töten würde.
- Update-Knopf und Update-Beobachter, siehe 2.3.
### 1.5 Regeln (in `CLAUDE.md`, jeweils per Test erzwungen)
| Regel | Inhalt | Test |
|---|---|---|
| **R18** | Icon neben dem Text, nie darüber, nie größer als bestellt | `IconLayoutTest` |
| **R19** | Gespeichert in UTC, angezeigt in `app.display_timezone` via `->local()` | `DisplayTimezoneTest` |
| **R20** | Bearbeiten mit Feldern passiert im Modal, nie in der Zeile | `EditInModalTest` |
---
## 2. Was teuer gelernt wurde
### 2.1 Das Muster hinter den meisten Rückmeldungen
Ich habe eine Regel gelesen, sie geglaubt und **das gerenderte Ergebnis nicht gemessen**. Jede Runde Kritik („verwendest du die vorlage überhaupt", „das habe ich nun 3 mal geschrieben") hatte dieselbe Ursache. Konkrete Ausprägungen:
- **CSS-Kaskade dreimal.** Gleiche Spezifität + Reihenfolge entscheidet; ein undefiniertes `var()` rechnet zu `unset` (bei `background-color` transparent); `.open p` schlägt `.tpl`.
- **Namenskollisionen mit Tailwind-Utilities.** Meine Klasse `.ring` kollidierte mit Tailwinds `ring` (`box-shadow: 0 0 0 3px rgb(59 130 246/.5)`) — daher der blaue Rahmen am Chart, den ich einmal fälschlich als Screenshot-Artefakt abtat. Ebenso `.num` gegen die Tabellenziffern-Utility. Umbenannt in `.metric-ring` / `.count`.
- **Eigene Sammeländerungen.** Ein früherer Massenlauf von mir hatte 23 Seitentiteln `sm:text-3xl` (40px) angehängt; die Vorlage hat 30px.
**Konsequenz für die nächste Session:** bei allem Optischen im Browser messen, nicht im Quelltext lesen. `getComputedStyle` gegen die Vorlage, beide Seiten.
### 2.2 Tests, die den Fehler mitrechnen
Die Zeitzonen-Tests prüften nichts, weil sie den erwarteten Wert **mit demselben falschen Aufruf** bildeten wie die Ansicht. Ein Test, der die Implementierung nachrechnet, ist keiner. Sie prüfen jetzt die Wanduhr und zusätzlich, dass die UTC-Zeit *nicht* erscheint.
### 2.3 Ein Wert ist eine Messung, kein Zustand
Der Update-Knopf war nur freigeschaltet, wenn die letzte Agent-Prüfung `behind > 0` ergab. Die läuft alle fünf Minuten — nach einem Push behauptet die Konsole minutenlang, alles sei aktuell, und **weigert sich zu handeln**. Jetzt immer anbietbar, solange ein Agent lebt und kein Lauf aktiv ist.
Und: **das Update startet die Container neu, auf denen das `wire:poll` läuft.** Mitten im Lauf scheitert jede Anfrage, danach befragt altes JavaScript einen neuen Build — deshalb kam die Fertigmeldung nie an. Ein Alpine-Beobachter (`updateWatcher` in `resources/js/app.js`) fragt jetzt `admin.update.state` (schlichtes JSON, kein Livewire), behandelt eine gescheiterte Anfrage als *den Neustart* und lädt neu, sobald der Build wechselt.
### 2.4 Weitere Fallen
- Blade-Anonymkomponenten müssen unter `resources/views/components/` liegen.
- Tailwind kann keinen Deckkraft-Modifikator auf ein blankes `var()` anwenden → `color-mix(in srgb, …)`.
- `#[Validate]` an einer Livewire-**Eigenschaft** gilt klassenweit: `$this->validate()` in einer anderen Aktion zieht sie mit. Regeln gehören an die Aktion.
- Carbon führt Makros in **einer globalen Tabelle** — zwei Registrierungen mit verschiedenem Rumpf, und die letzte gewinnt für alle Klassen.
- `Illuminate\Support\Carbon` ist **mutabel**: ohne `copy()` schreibt bloßes Anzeigen das Modellattribut um.
- Laravel gibt bei einer `null`-Übersetzung den **Schlüssel** aus (traf die Fehlerseiten).
- `php artisan view:clear` im Container als falscher Benutzer hinterlässt Rechte, die zu `touch(): Utime failed` führen. Danach `chown -R www-data:www-data storage/framework bootstrap/cache`.
---
## 3. Offene Punkte
### 3.1 Zwingend als Nächstes
| # | Punkt | Umfang |
|---|---|---|
| 1 | **Konsolen-Ansichten aufs Designsystem** | 22 Blades unter `resources/views/livewire/admin/`. Sie laufen und sind funktionsfähig, tragen aber noch nicht durchgängig die Vorlagenmaße. |
| 2 | **Support in der Konsole** | Die Anfragen liegen in `support_requests` mit Kunde und Instanz daran, aber es gibt **keine Ansicht, in der man sie beantwortet**. Der Nutzer hat ausdrücklich gesagt, „wer stellt die Anfrage" gehöre in die Konsole und nicht zum Kunden. Fehlt: Warteschlange, Statuswechsel, Antwort + Mail. |
| 3 | **Startseite ins Blade** | Die freigegebene Vorlage liegt in `docs/design/tpl-home.html`; `resources/views/landing.blade.php` ist noch die alte Fassung. |
### 3.2 Vereinbart, noch nicht gebaut
- **Integrationen/Secrets erweitern.** Der Nutzer will *alle* Anbindungen dort: Hetzner DNS, Proxmox, Stripe, Monitoring, Backup-Speicher. `SecretVault::REGISTRY` hat vier Einträge.
- **Mehrere Absender:** no-reply, office, support, info, billing.
- **Automatische E-Mail-Vorlagen:** Bestellbestätigung, Störungsmeldung aus dem Dashboard heraus.
### 3.3 Rollout
- `main` ist gepusht; der Live-Server (`191.218.161.8`) folgt `main`, aktualisiert wird über **Konsole → Einstellungen → Update anfordern** (VPN-only), *nicht* per SSH.
- Der **Demo-Kunde ist auf live noch nicht angelegt.** Nach dem Update:
```
php artisan db:seed --class=DemoCustomerSeeder --force
```
Das Passwort wird einmalig ausgegeben. Entfernen: `User::where('email','demo@clupilot.com')->first()?->delete()`.
- SSH zum Live-Server und `pip install` sind in dieser Umgebung vom Berechtigungsfilter **blockiert**. Nicht umgehen — den Nutzer bitten oder ihn den Deploy auslösen lassen.
### 3.4 Zwei Dinge, die eine Entscheidung brauchen
1. **Badge gegen Button in der Kopfzeile.** Der Nutzer hatte beanstandet, dass der Button größer ist als das Badge. In der freigegebenen Vorlage sind sie *tatsächlich* verschieden hoch (29px gegen 40px, mittig zueinander). Ich bin der Vorlage gefolgt, weil „genauso erstellen" die deutlichere Ansage war. Angleichen wäre eine bewusste Abweichung — seine Entscheidung.
2. **Bestehende Wartungsfenster sind verschoben gespeichert.** Sie wurden über das alte Formular erfasst, das UTC hineinschrieb und UTC zurücklas. Die Anzeige ist ab R19 korrekt und zeigt ehrlich, was gespeichert wurde — wer damals 21:00 *meinte*, hat 23:00 in der Datenbank. Auf dev sichtbar:
```
test | gespeichert 28.07. 21:00 UTC | zeigt jetzt 28.07. 23:00
```
**Bewusst nicht automatisch umgerechnet:** von außen ist nicht unterscheidbar, welches Fenster als Ortszeit gemeint war und welches absichtlich in UTC gesetzt wurde. Muss durchgesehen werden.
---
## 4. Verifikations-Workflow (unverändert gültig)
1. `docker compose exec -T app php artisan test` — muss vollständig grün sein.
2. `docker compose exec -T app npm run build`.
3. **Im Browser ansehen und messen**, bei Optischem gegen die Vorlage (siehe 2.1). Konsolenfehler: 0.
4. Codex-Review (**R15**) — Ablauf steht in der Nutzer-Memory `clupilot-r15-codex-review`. Kurz: `export PATH="$HOME/.local/bin:$PATH"`, dann `codex exec …`; im Hintergrund laufen lassen, der Aufruf überschreitet zwei Minuten.
5. Commit, Push auf `main`.
---
## 5. Wichtige Dateien
| Zweck | Pfad |
|---|---|
| Regeln R18R20 | `CLAUDE.md` |
| Farben/Maße | `resources/css/portal-tokens.css` |
| Dichte Konsole | `resources/css/admin-tokens.css` |
| Navigation (eine Quelle) | `app/Support/Navigation.php` |
| Zeitzone: Anzeige | Makro `->local()` in `app/Providers/AppServiceProvider.php` |
| Zeitzone: Formularfelder | `app/Support/LocalTime.php` |
| Downgrade-Prüfung | `app/Services/Billing/DowngradeCheck.php` |
| Messreihen | `app/Models/InstanceMetric.php`, `app/Provisioning/Jobs/CollectInstanceTraffic.php` |
| Update-Kanal | `app/Services/Deployment/UpdateChannel.php`, `deploy/update-agent.sh` |
| Demo-Kunde | `database/seeders/DemoCustomerSeeder.php` |
| Vorlagen (freigegeben) | `docs/design/tpl-{home,portal,console}.html` + `docs/design/README.md` |
> Die Vorlagen lagen ursprünglich im sitzungsgebundenen Scratchpad und sind jetzt im Repo. `docs/design/README.md` hält fest, **wie** man dagegen prüft (rendern und messen, nicht Quelltext lesen) und listet die Maße, die schon einmal danebenlagen.