# Handoff — CluPilot (Stand 2026-07-27)

**Für:** frische Claude-Code-Session
**Art:** Zustands-/Fortsetzungs-Handoff
**Zuerst lesen:** dieses Dokument komplett, dann `CLAUDE.md` (R18–R20) und `git log --oneline -15`.
**Vorgänger:** `docs/handoffs/2026-07-25-clupilot-state-handoff.md` (Umgebung, Stack, Befehle, R1–R17 — 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 R18–R20 | `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.
