` ohne Handler — die Karte bleibt der Ziehgriff. `PictureFrameConfigForm`
im WidgetSettingsPanel: drei Auswahlfelder (senden nur ihr Feld), Eintragsliste
mit Vorschau/Unterschrift (Entwurf, Uebernahme bei Blur/Enter)/Pfeilen/Entfernen
(Upload wird auch serverseitig geloescht, Fehler verschluckt), Datei hochladen
(deaktiviert ab 30), Webadresse hinzufuegen (http -> `role="alert"`, kein
`onChange`). Listenaenderungen senden IMMER das ganze `images`-Array. Registry
(`minW 4, minH 4, defaultW 8, defaultH 8`), Katalog, Seite, 28 Schluessel je
Sprache unter `widgets.pictureFrame`.
**Doku (Commit c3b4597).** Changelog-Stichpunkt als erster unter
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle und Absatz unter
„Dashboard > Widgets“ im Anwenderhandbuch, zwei Woerter auf der Erlaubnisliste
des Umlaut-Waechters (siehe Deviations).
## Die Tests, und der Beleg dass sie rot waren
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|---|---:|---|
| `dashboard-image-rules.spec.ts` | 10 | `pnpm --filter @tessera/api exec vitest run src/dashboard/dashboard-image-rules.spec.ts` -> `Error: Cannot find module './dashboard-image-rules'`, 1 Test File failed, 10 Faelle nicht ausfuehrbar |
| `dashboard-images.service.spec.ts` | 12 | `... vitest run src/dashboard/dashboard-images.service.spec.ts` -> `Cannot find module './dashboard-images.service'`, 1 failed |
| `dashboard-images.controller.spec.ts` | 5 | `... vitest run src/dashboard/dashboard-images.controller.spec.ts` -> `Cannot find module './dashboard-images.controller'`, 1 failed |
| `picture-frame-config.test.ts` | 11 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/picture-frame-config.test.ts src/lib/dashboard-images-api.test.ts` -> `Failed to resolve import "./picture-frame-config"`, 2 Test Files failed |
| `dashboard-images-api.test.ts` | 5 | derselbe Lauf -> `Failed to resolve import "./dashboard-images-api"` |
| `picture-frame-widget.test.tsx` | 12 | nach der Umsetzung geschrieben (Plan verlangt Rot nur fuer Regel-/Dienst-/Helfer-Tests); erster Lauf 11/12, Test 11 wegen des React-Scheduler-Timers umgestellt (siehe decisions) |
| `picture-frame-config-form.test.tsx` | 9 | nach der Umsetzung geschrieben; erster Lauf 9/9 |
Zusammen 64 neue Faelle; die bestehenden Registry-/Katalog-/Seiten-Tests laufen
mit dem achten Typ (Constraints-Tabelle, `counted` 28 -> 32, Attrappen um
`pictureFrame.*` und das neue Widget-Modul ergaenzt).
## curl-Rundgang gegen die lebende API
Die Container `api`/`web` lagen mit einem alten Image still; die API lief
deshalb aus dem Quelltext (`nest build` + `node dist/main.js`) gegen eine
eigens angelegte, leere Datenbank `tessera_pi9` auf dem lokalen db-Container
(Migrationen angewendet, Admin per Erstanlage), danach wieder geloescht. Kein
Zugriff auf den Testserver.
| Schritt | Ergebnis |
|---|---|
| `POST /dashboard/images` mit 4x4-PNG | 201, `{ id, originalName, mimeType: "image/png", size: 73, createdAt }` |
| `GET /dashboard/images` | 200, Liste mit denselben fuenf Feldern, kein `data` |
| `GET /dashboard/images/
` | 200, `Content-Type: image/png`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, `Content-Security-Policy: default-src 'none'; sandbox`; Bytes per `cmp` identisch mit der Quelle |
| Textdatei als `.png` (`type=image/png`) | 400 `Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.` |
| 6-MiB-Datei | 413 `File too large` (multer/Nest, im Web-Klienten deutsch) |
| erfundene Kennung | 404 |
| ohne Cookie | 401 |
| Kennung mit dem Cookie eines ZWEITEN Benutzers (`GET` und `DELETE`) | 404 / 404 (Punkt (h) der Pruefliste bereits erledigt) |
| eigener `DELETE` | 200 `{ id }`, Liste danach `[]` |
## Messungen (Endstand, HEAD c3b4597)
| Groesse | Ausgang (573d070) | Jetzt |
|---|---:|---:|
| `pnpm type-check` | 4/4 | 4/4 |
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
| API-Tests | 1148 | **1175** (75 Dateien) |
| Web-Tests | 531 | **569** (77 Dateien) |
| `as unknown as` in apps/api/src | 27 | 27 |
| `as unknown as` in apps/web/src | 6 | 6 |
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
| `biome-ignore` in apps/api/src | 1 | 1 |
| `ts-expect-error` | 0 | 0 |
| `dangerouslySetInnerHTML` in den drei neuen Komponenten | – | 0 |
| RLS-Waechter `src/prisma` | 30/30 laut Plan | 78/78 (davon `rls-coverage` + `rls-access-inventory` 35/35) |
| de/en-Schluesselgleichheit `widgets.pictureFrame` | – | 28 = 28 |
Keine neue `any`, kein `!`, kein neues Paket.
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP)
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufuegen“ zeigt „Bilderrahmen“ mit Rahmen-Symbol; die platzierte Kachel (8x8) zeigt „Noch keine Bilder — ueber die Einstellungen hinzufuegen“
- [x] (b) Einstellungen -> Dashboard -> „Bilderrahmen #1“ aufklappen: PNG hochladen -> Vorschau erscheint in der Liste, `GET /dashboard/images` enthaelt den Eintrag ohne `data`
- [x] (c) https-Adresse hinzufuegen -> Eintrag mit Vorschau; http-Adresse -> rote Meldung „Bitte geben Sie eine vollstaendige https-Adresse ein.“, kein Eintrag
- [x] (d) `.txt` als `.png` umbenannt hochladen -> rote Meldung „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“
- [x] (e) Intervall 5 s, zwei Bilder -> Kachel wechselt; Zufall mit drei Bildern -> nie dasselbe zweimal hintereinander; Bildausschnitt umschalten -> `object-cover` / `object-contain` sichtbar anders
- [x] (f) Klick auf das Bild -> Grossansicht mit Unterschrift; Escape schliesst, Hintergrund-Klick schliesst, Schliessen-Knopf schliesst; waehrend geoeffnet kein Wechsel
- [x] (g) Bearbeitungsmodus: Klick auf das Bild oeffnet nichts, die Kachel laesst sich an jeder Stelle ziehen
- [x] (h) `curl -b -o /dev/null -w '%{http_code}' .../dashboard/images/` -> 404 (bereits mit curl gegen die lokale API belegt, siehe Rundgang oben; im Browser optional wiederholen)
- [x] (i) Eintrag entfernen -> Bild verschwindet aus `GET /dashboard/images` und aus der Kachel
- [x] (j) Netzwerk-Tab: das Fremdbild laedt der Browser selbst (Anfrage an den Fremdhost mit `Referrer Policy: no-referrer`), im API-Log kein Aufruf der Fremdadresse
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus HEAD, Playwright-MCP):** alle zehn Punkte bestanden. Belege: (a) Katalog zeigt „Bilderrahmen“ mit Beschreibung, Leerhinweis in der Kachel; (b) `rot.png` hochgeladen, Vorschau 320 px, `GET /dashboard/images` liefert `{id, originalName, mimeType, size, createdAt}` ohne `data`; (c) `http://example.com/bild.png` → Meldung, kein Eintrag; `https://www.gstatic.com/webp/gallery/1.webp` → Eintrag mit Vorschau (eine zuvor eingetragene, serverseitig 400 liefernde Wikimedia-Adresse zeigte korrekt „Bild nicht verfügbar“); (d) Textdatei als `.png` → „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“; (e) Intervall 5 s: blau → gstatic → rot → blau im Sekundentakt gemessen; Zufall mit drei Bildern: 8 Wechsel in 42 s, nie dasselbe zweimal hintereinander; `object-cover` nach Umschalten; (f) Grossansicht nach Portal-Korrektur 8bf3601 ueber den ganzen Viewport (Hintergrund 1905x949), Escape schliesst mit Fokusrueckgabe auf „Bild groß anzeigen“, Hintergrund-Klick schliesst, waehrend geoeffnet 6 s lang kein Wechsel; (g) Bearbeitungsmodus: kein Bild-Knopf im Widget, Ziehen ueber die Bildflaeche verschiebt die Kachel (`translate` 8 → 480 px), kein Dialog; (h) siehe curl; (i) zweiten Eintrag entfernt → `GET /dashboard/images` nur noch `rot.png`, Config nur noch zwei Eintraege; (j) Netzwerk: gstatic-Abruf kommt vom Browser, API-Log ohne Treffer auf `gstatic`.
**Drei Befunde aus dem Rundgang, behoben in 8bf3601:** (1) die Grossansicht war auf die Kachelflaeche (531x216) beschraenkt — die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen; jetzt `createPortal` in `document.body` wie der Kalender-Tooltip; (2) „1 Minuten“ im Wechselintervall → ICU-Plural in de/en, Formular-Test nutzt dafuer `createTranslator` von next-intl auf der echten de.json; (3) Standardgroesse 8x8 (216 px hoch) zu flach → 8x12 wie der Kalender. Web-Tests 569 unveraendert in der Zahl (ein Fall um die Singular-Pruefung ergaenzt).
## Deviations from Plan
1. **[Rule 1 - Bug] `data: file.buffer` kompiliert nicht.** Der Executor-Hinweis
„`data: file.buffer` beim Anlegen geht“ stimmt unter TS 5.9 + Prisma 6.19
nicht: `Bytes` verlangt `Uint8Array`, multers `Buffer` ist
ueber `ArrayBufferLike` getypt und wird abgelehnt (TS2322). Statt einer
Zusicherung kopiert `new Uint8Array(file.buffer)` einmal je Upload
(hoechstens 5 MiB). Aufgabe 1, Commit 737974b.
2. **[Rule 3 - Blocking] Umlaut-Waechter.** Der volle Web-Testlauf meldete die
neuen de.json-Woerter „Bildausschnitt“ und „Webadresse“ als unbekannte
ss-Tokens. Beide sind korrektes Deutsch und stehen jetzt auf
`UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts` (Datei
nicht im Plan). Aufgabe 3, Commit c3b4597.
3. **Verify-Skript Aufgabe 1:** `git show --stat` kuerzt den Migrationspfad
auf `.../20260921120000_dashboard_image/migration.sql`, der `grep` des
Plans auf den vollen Pfad schlaegt deshalb fehl; mit `--stat=200` ist die
Migration im Commit eindeutig nachgewiesen. Kein Code-Befund.
4. **Klassifikationsdokument, mehr als die geplante eine Zeile:** die
Nachmessung mit der Gate-Schleife ergab, dass Bereichs- und Summenzeilen
bereits vor dieser Aufgabe um zwei Rohtreffer (settings 3 statt 4,
bug-reports nie summiert) und die Klassen-Verteilung um ein Paar
(bug-reports) hinterherhingen. Beides ist nachgezogen und im Dokument als
Nachtrag 260921-pi9 begruendet; der Waechter `rls-access-inventory` prueft
nur die Paartabelle und war davon nicht betroffen.
5. **Widget-Test 11** misst die Pause des Wechsels ueber das Verhalten statt
ueber `vi.getTimerCount()` (siehe decisions).
6. **Zusatz des Orchestrators umgesetzt:** Zeile und Absatz in
`docs/anleitung-anwender.md` im Aufgabe-3-Commit.
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
Deploy, kein Zugriff auf den Testserver.
## Known Stubs
Keine. Jede Kette ist verdrahtet: Datei -> Upload -> Config -> Kachel -> Proxy
-> API -> Bytes; https-Adresse -> Config -> Kachel -> Browser.
## Threat Flags
Keine neue Flaeche ausserhalb des `` des Plans: die vier Routen
unter `dashboard/images` und die `
`-Fremdabrufe sind dort als T-PI9-01
bis T-PI9-11 erfasst und mitigiert; `Content-Disposition` ohne Dateinamen und
`Cache-Control: private` sind mit curl belegt.
## Self-Check: PASSED
Alle 16 neu angelegten Dateien liegen auf der Platte, die drei Commits
737974b, c080580 und c3b4597 sind in `git log` auffindbar
(`git rev-list --count 573d070..HEAD` = 3). Die Zahlen der Tabelle stammen
aus tatsaechlich gelaufenen Befehlen.