13 Commits

Author SHA1 Message Date
schalli d146234bba docs: Version 1.3.0 freigegeben — Unveröffentlicht -> 1.3.0 (2026-09-22), neuer leerer Abschnitt Unveröffentlicht
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m12s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:10:57 +02:00
schalli a8a39a4842 docs: STATE - Basic-Auth vor alpha bleibt (Nutzerentscheidung), Stand nach beiden Widgets bestaetigt
Tessera CI/CD / Lint & Type Check (push) Successful in 54s
Tessera CI/CD / Tests (push) Successful in 1m13s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m52s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:04:50 +02:00
schalli 80a0d23ccf docs(quick-260922-ge2): Akte - XFrame-Ausschnitt, neun Pruefpunkte bestanden, Rahmenhoehen-Befund festgehalten
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 1m10s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:27:55 +02:00
schalli cf70a197cd fix(quick-260922-ge2): Rahmenhoehe in der Kachel = Vorschauhoehe, Vorschau ohne Querbalken
Befund aus dem Browser-Rundgang: example.com setzt `margin: 15vh` — mit
3000 px Vorschauhoehe lag die Ueberschrift bei y 450, in der Kachel mit
720 px Rahmenhoehe bei y 108. Der in der Vorschau gewaehlte Ausschnitt
zeigte in der Kachel also etwas anderes. Die Kachel nutzt jetzt dieselbe
Layouthoehe wie die Vorschau (3000), damit vh-relative Seiten identisch
umbrechen; der Rest wird ohnehin weggeschnitten.

Vorschau: `overflow-x-hidden`, die Eckgriffe am rechten Rand erzeugten
einen 4-px-Querbalken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:22:37 +02:00
schalli 30fdd99724 docs(quick-260922-ge2): Changelog und Anwenderhandbuch - XFrame-Ausschnitt, Zoom, Nur anzeigen
- Changelog: Stichpunkt direkt nach dem XFrame-Stichpunkt unter Unveroeffentlicht -> Neu
- Anwenderhandbuch: je ein Satz in der Widget-Tabellenzeile und im Absatz "Dashboard > Widgets"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:13:42 +02:00
schalli 445b1d3100 feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen
- Resolver: crop (x/y/w/h, geklemmt auf 1280er-Seite, x verschoben statt abgewiesen), zoom (50..150, groesste Stufe <= n), readOnly (nur echtes true)
- xframe-crop.ts: reine Geometrie - computeCropLayout (contain + zentriert, scale 0 bis gemessen), applyCropDrag (Verschieben, vier Ecken, Gegenecke bleibt)
- Kachel: Clip + verschobener, skalierter <iframe> bei fester Layoutbreite 1280, ResizeObserver am Koerper; Zoom-Zweig mit Prozentmassen; 100 % wie bisher; readOnly-Flaeche nur im Ansichtsmodus
- Formular: Checkbox Ausschnitt (ein Aufruf mit crop + readOnly), Vorschau 1280 px breit mit derselben Sandbox und pointer-events none, Rahmen mit vier Griffen (Pointer-Events, Capture-Waechter fuer jsdom), Zahlenfelder, Zoom-Auswahl nur ohne Ausschnitt, Nur anzeigen, dauerhafter Hinweis
- Rahmen als <fieldset> statt div role=group (Biome useSemanticElements, kein biome-ignore)
- Test-Helfer stubResizeObserver, 13 neue Schluessel de/en, Ausschnitt auf der Umlaut-Allowlist; Web-Tests 604 -> 640

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:12:18 +02:00
schalli 5aa577a7fa docs: STATE - Download-Knoepfe der Desktop-App, Nachtrag
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m26s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m3s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:43:40 +02:00
schalli 747a4d432b fix(desktop): Download-Knoepfe in der App reichen an den System-Browser weiter
Befund des Nutzers (22.09.2026): "Herunterladen" unter Einstellungen ->
Desktop-App tut in der App nichts, unter Windows wie Linux. Die Webansicht
hatte keinen Download-Handler; webkit2gtk verwirft Downloads dann still,
WebView2 zeigte ebenfalls nichts.

Das Hauptfenster entsteht jetzt im Code (app.windows in tauri.conf.json
leer), weil nur der Builder `on_download` annimmt. Der Handler bricht den
Download in der App ab und oeffnet die Adresse ueber den Opener im
System-Browser -- mit Fortschritt, Speicherort und Passwortfenster fuer
einen vorgeschalteten Proxy. Masse, Zentrierung, Titel wie bisher; die
Capability "main" gilt unveraendert.

cargo fmt/clippy/test/check gruen (44 Tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:43:27 +02:00
schalli 2c01f9d783 docs(quick-260922-frg): Akte - Tray-Update-Befund: Proxy-401 vor alpha, Client nennt jetzt den Grund
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m11s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m27s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m3s
Plan mit Messungen (API am Proxy vorbei 200, Proxy 401 Basic von zwei
Netzen), Zusammenfassung des Executors, Zeile in der Quick-Tabelle und
Stopp-Punkt: die Behebung des Passwortschutzes liegt beim Nutzer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:28:06 +02:00
schalli d73aad1ef1 fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h
Befund 22.09.2026: alpha bietet 1.2.0-beta.gc001a08 an, der Client auf
a6d1a64 zeigt aber nur den grauen Eintrag "Update installieren". Der
Nginx Proxy Manager vor alpha beantwortet die Update-Anfrage mit 401
(Basic-Auth); die Webansicht kann das Passwortfenster beantworten, der
Updater (eigener reqwest-Client) nicht. tauri-plugin-updater verschluckt
einen Nicht-2xx-Status (updater.rs Z. 529-559: nur Log, last_error
leer, Ergebnis Err(ReleaseNotFound)), und spawn_version_check fing das
mit Err(_) => {} stumm ab. Ein fehlgeschlagener Check war damit vom
Zustand "kein Update" nicht unterscheidbar, und ohne Neustart gab es
keinen Weg, erneut zu pruefen.

- Drei Endzustaende, alle anklickbar: "Auf Version/Beta-Stand ...
  aktualisieren", "Kein Update verfuegbar - erneut pruefen",
  "Update-Pruefung fehlgeschlagen (HTTP n | keine Verbindung) - erneut
  pruefen"; waehrend der Pruefung "Suche nach Updates..." (gesperrt),
  http-Server unveraendert gesperrt
- Bei ReleaseNotFound stellt der Client dieselbe Anfrage einmal selbst
  (Platzhalter ersetzt, Timeout 8 s) und liest nur den Statuscode;
  401/403 erklaeren den Proxy-Passwortschutz, Zugangsdaten werden
  bewusst NICHT in den Client eingebaut
- Benachrichtigung je unterschiedlichem Fehlertext einmal
  (LastCheckNotice), Erfolg leert die Entprellung
- Wiederhol-Thread alle 4 h (std::thread, kein neues Crate), liest die
  Adresse frisch, ueberspringt bei bereits abgelegtem Update
- Klick ohne abgelegtes Update prueft erneut; der Browser-Weg bleibt nur
  Rueckfall einer fehlgeschlagenen Installation
- 7 neue Tests (Labels, Diagnose-URL, Konstanten), zuvor rot (E0425)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:26:09 +02:00
schalli ae36a22a51 docs: STATE - Kosmetik-Nachtrag zum Bilderrahmen, Tabellenzeile pi9 ohne Roh-Pipe
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m9s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:22:05 +02:00
schalli 8b45a281be fix(dashboard): Bilderrahmen - "1 Stunde" statt "60 Minuten", Bildanzahl in der Einstellungs-Kopfzeile
Zwei Kosmetik-Punkte nach dem Browser-Rundgang vom 21.09.2026:

- Das laengste Wechselintervall (3600 s) hiess "60 Minuten", beim XFrame
  heisst dieselbe Stufe "Jede Stunde". Neuer Schluessel
  `pictureFrame.intervalHours` (ICU-Plural, de + en).
- Unter Einstellungen -> Dashboard zeigte "XFrame #1 - Board" seinen Titel,
  "Bilderrahmen #1" nichts. Die Kopfzeile nennt jetzt "- 1 Bild" bzw.
  "- N Bilder" (Schluessel `imageCountOne`/`imageCountMany`, zwei Schluessel
  statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen).

Web-Tests 603 -> 604, type-check und lint unveraendert gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 08:21:23 +02:00
schalli 31ca115796 docs(quick-260921-qd3): Akte - XFrame-Widget gebaut, Browser-Rundgang ohne Befund; Stand nach beiden Widgets
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m9s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m46s
Plan, Zusammenfassung mit abgehakter Pruefliste und die Zeile in der
Quick-Tabelle von STATE.md; Stopp-Punkt: beide bestellten Widgets fertig,
nichts offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:29:07 +02:00
27 changed files with 2734 additions and 83 deletions
+14 -9
View File
@@ -4,10 +4,10 @@ milestone: v1.2
current_phase: 18 current_phase: 18
current_phase_name: desktop-client-fertigstellen current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "Elf Quick-Vorgaenge am 2026-09-21, alle abgeschlossen und verifiziert. Der gesamte Fehler- und Lint-Rueckstand ist abgearbeitet: 2923 → 123 Diagnosen, 0 Fehlerstufe, Tests 447 → 1679. NICHTS OFFEN aus der Fehlerarbeit. NAECHSTER AUFTRAG: zwei neue Dashboard-Widgets. Das erste ist ein Bilderrahmen, die Produktfragen sind bereits geklaert — Bilder werden hochgeladen ODER per https-Webadresse eingebunden (der Browser laedt Fremdbilder direkt, kein Server-Abruf, damit keine SSRF-Flaeche); Einstellungen: Bildausschnitt (ganz sichtbar oder formatfuellend), Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift, Klick zeigt gross; Stil wie die bestehenden Widgets; Bilder gehoeren dem hochladenden Benutzer, Groesse und Anzahl begrenzt. DAS ZWEITE WIDGET HAT DER NUTZER NOCH NICHT BENANNT — danach fragen. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf." stopped_at: "22.09.2026: alle Auftraege erledigt und auf Beta (80a0d23, von alpha gezogen): Bilderrahmen, XFrame inkl. Ausschnitt/Zoom/Nur-anzeigen, Tray-Update nennt den Grund und prueft alle 4 h, Download-Knoepfe im Client. Der Basic-Auth am Proxy vor alpha BLEIBT (Entscheidung des Users) — aus dem Firmennetz greift eine Ausnahme, dort laeuft das Tray-Update; von aussen 401, der Client sagt das jetzt selbst. NICHT als offenen Punkt fuehren. Offen beim Nutzer nur: neuen Client einmal per Browser installieren, Freigabe 1.3.0 auf Zuruf. Kein weiterer Auftrag benannt."
last_updated: "2026-09-21T16:45:00.000Z" last_updated: "2026-09-22T12:40:00.000Z"
last_activity: 2026-09-21 last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System last_activity_desc: Quick 260922-ge2 — XFrame-Ausschnitt (Vorschau mit ziehbarem Rahmen, Einpassen in die Kachel), Zoom, Nur anzeigen; Rahmenhoehe Kachel = Vorschau nach Browser-Befund; web 640
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
total_phases: 18 total_phases: 18
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen (Browser-Befund Rahmenhoehe behoben); davor Desktop: Download-Knoepfe in der App oeffnen jetzt den System-Browser (fast, 747a4d4); davor Quick 260922-frg: Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung (HTTP 401 durch Passwortschutz am Proxy vor alpha), Klick prueft erneut, Pruefung alle 4 h; davor Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -455,7 +455,12 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) | | 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) |
| 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) | | 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) |
| 260921-oxm | **IMAP: STARTTLS erzwingt jetzt wirklich, Outlook-Anhaenge werden erkannt.** Die zwei Befunde aus m34, beide mit Entscheidung des Nutzers behoben. **(B-06, Sicherheit)** `imap.provider.ts` setzte `requireTLS` — eine Option, die es in imapflow 1.4.3 NIRGENDS gibt (Orchestrator: kein Treffer im ganzen Paket). Sie wurde still verworfen, die Bibliothek fiel auf ihr Standardverhalten zurueck und setzte laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — Benutzername und Kennwort gingen dann im Klartext. Ersetzt durch `doSTARTTLS`, nachgeprueft in `imap-flow.d.ts:81` und `imap-flow.js:1183`. Bei implizitem TLS wird ausdruecklich `false` gesetzt, nicht weggelassen: die Bibliothek wirft bei `secure=true` zusammen mit `doSTARTTLS=true`. **Gewollte Verhaltensaenderung:** ein auf STARTTLS eingestelltes Postfach, dessen Server das nicht anbietet, meldet ab jetzt einen Verbindungsfehler statt still im Klartext zu verbinden. **(B-05)** `imap.provider.ts:78` las `.parameters` von einer Zeichenkette — imapflow fuehrt die Parameter in `dispositionParameters` (`imap-flow.d.ts:450`), der Ausdruck war zur Laufzeit immer leer. Anhaenge als `application/octet-stream` (typisch Outlook) wurden ueber die Content-Disposition nicht erkannt; betraf den DKV-Rechnungseinzug. Nachgeprueft: imapflow schreibt die Schluessel klein und setzt RFC-2231-Fortsetzungen selbst zusammen — dafuer war nichts zu tun. **Rot-dann-gruen belegt:** gegen den Stand mit Tests aber ohne Reparatur scheiterten genau 3 von 12 Faellen, danach 12/12. Zwei der fuenf neuen Faelle sind absichtlich von Anfang an gruen — sie sichern ab, dass B-05 nicht zu viel einsammelt. **Die `as any`-Zusicherung konnte ersatzlos entfallen** (sie existierte nur wegen der erfundenen Option); alle sechs uebergebenen Felder sind jetzt deklariert. **Zahlen:** `any` im Backend 15 → 13, `as unknown as` 33 → 27 (Testdoppel-Einhaengung in einen Helfer gezogen statt fuenf neue Umdeutungen), kein Zaehler gestiegen, api-Tests 1143 → 1148, web 73/531, type-check 4/4, lint 5/5. | 2026-09-21 | 7691d1f,d0266bf,6def539 | [260921-oxm-imap-starttls-wirklich-erzwingen-und-anh](./quick/260921-oxm-imap-starttls-wirklich-erzwingen-und-anh/) | | 260921-oxm | **IMAP: STARTTLS erzwingt jetzt wirklich, Outlook-Anhaenge werden erkannt.** Die zwei Befunde aus m34, beide mit Entscheidung des Nutzers behoben. **(B-06, Sicherheit)** `imap.provider.ts` setzte `requireTLS` — eine Option, die es in imapflow 1.4.3 NIRGENDS gibt (Orchestrator: kein Treffer im ganzen Paket). Sie wurde still verworfen, die Bibliothek fiel auf ihr Standardverhalten zurueck und setzte laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — Benutzername und Kennwort gingen dann im Klartext. Ersetzt durch `doSTARTTLS`, nachgeprueft in `imap-flow.d.ts:81` und `imap-flow.js:1183`. Bei implizitem TLS wird ausdruecklich `false` gesetzt, nicht weggelassen: die Bibliothek wirft bei `secure=true` zusammen mit `doSTARTTLS=true`. **Gewollte Verhaltensaenderung:** ein auf STARTTLS eingestelltes Postfach, dessen Server das nicht anbietet, meldet ab jetzt einen Verbindungsfehler statt still im Klartext zu verbinden. **(B-05)** `imap.provider.ts:78` las `.parameters` von einer Zeichenkette — imapflow fuehrt die Parameter in `dispositionParameters` (`imap-flow.d.ts:450`), der Ausdruck war zur Laufzeit immer leer. Anhaenge als `application/octet-stream` (typisch Outlook) wurden ueber die Content-Disposition nicht erkannt; betraf den DKV-Rechnungseinzug. Nachgeprueft: imapflow schreibt die Schluessel klein und setzt RFC-2231-Fortsetzungen selbst zusammen — dafuer war nichts zu tun. **Rot-dann-gruen belegt:** gegen den Stand mit Tests aber ohne Reparatur scheiterten genau 3 von 12 Faellen, danach 12/12. Zwei der fuenf neuen Faelle sind absichtlich von Anfang an gruen — sie sichern ab, dass B-05 nicht zu viel einsammelt. **Die `as any`-Zusicherung konnte ersatzlos entfallen** (sie existierte nur wegen der erfundenen Option); alle sechs uebergebenen Felder sind jetzt deklariert. **Zahlen:** `any` im Backend 15 → 13, `as unknown as` 33 → 27 (Testdoppel-Einhaengung in einen Helfer gezogen statt fuenf neue Umdeutungen), kein Zaehler gestiegen, api-Tests 1143 → 1148, web 73/531, type-check 4/4, lint 5/5. | 2026-09-21 | 7691d1f,d0266bf,6def539 | [260921-oxm-imap-starttls-wirklich-erzwingen-und-anh](./quick/260921-oxm-imap-starttls-wirklich-erzwingen-und-anh/) |
| 260921-pi9 | **Dashboard-Widget „Bilderrahmen“: eigene Bilder oder https-Adressen als Diashow.** Erstes der zwei vom Nutzer bestellten Widgets. **API:** neues Prisma-Modell `DashboardImage` (Bytes in der Datenbank — kein neues Docker-Volume, Sicherung ueber den DB-Dump), handgeschriebene Migration `20260921120000_dashboard_image` mit RLS-Regel inklusive Benutzerdimension; Routen `GET/POST /dashboard/images`, `GET/DELETE /dashboard/images/:id`; Bildtyp ausschliesslich ueber Magic Bytes (PNG/JPEG/GIF/WebP), nicht ueber den behaupteten MIME-Typ; 5 MiB je Datei (multer-Grenze, 413), 30 Bilder je Benutzer; fremde Kennung → 404, nie 403; Binaerantwort mit `Cache-Control: private`, `nosniff`, `Content-Disposition: inline` ohne Dateinamen, CSP `default-src 'none'; sandbox`. **Web:** Widget `picture-frame` mit einer geordneten Liste `images` aus Eintraegen mit `kind`-Unterscheider (`upload` | `url`), Bildausschnitt contain/cover, Intervall 0/5…3600 s, Reihenfolge oder Zufall (nie dasselbe zweimal), Unterschrift-Streifen, Grossansicht per Klick (nicht im Bearbeitungsmodus), kaputte Bilder fallen aus dem Umlauf; Bildverwaltung im `WidgetSettingsPanel` (Upload, https-Adresse, Unterschrift, Pfeile, Entfernen loescht den Upload auch serverseitig). Fremdbilder laedt AUSSCHLIESSLICH der Browser (`<img referrerPolicy="no-referrer">`) — die API ruft nie eine Adresse ab, keine SSRF-Flaeche; https-Pflicht web-seitig zweifach (Formular + Render-Resolver), weil die API Widget-Configs nicht inhaltlich prueft. **Browser-Rundgang (Orchestrator, zehn Punkte) fand drei Dinge, behoben in 8bf3601:** die Grossansicht war auf die Kachelflaeche beschraenkt (ein `react-grid-item` mit CSS-`transform` wird fuer `position: fixed` zum Bezugsrahmen → `createPortal` in `document.body` wie der Kalender-Tooltip), „1 Minuten“ → ICU-Plural, Standardkachel 8x8 zu flach → 8x12. curl-Rundgang gegen die lebende API belegt 201/400/413/404/401 und fremder Benutzer → 404. **Zahlen:** api 1148 → 1175, web 531 → 569, type-check 4/4, lint 5/5, RLS-Waechter 78/78, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Anwenderhandbuch und CHANGELOG ergaenzt. | 2026-09-21 | 737974b,c080580,c3b4597,8bf3601 | [260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc](./quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/) | | 260921-pi9 | **Dashboard-Widget „Bilderrahmen“: eigene Bilder oder https-Adressen als Diashow.** Erstes der zwei vom Nutzer bestellten Widgets. **API:** neues Prisma-Modell `DashboardImage` (Bytes in der Datenbank — kein neues Docker-Volume, Sicherung ueber den DB-Dump), handgeschriebene Migration `20260921120000_dashboard_image` mit RLS-Regel inklusive Benutzerdimension; Routen `GET/POST /dashboard/images`, `GET/DELETE /dashboard/images/:id`; Bildtyp ausschliesslich ueber Magic Bytes (PNG/JPEG/GIF/WebP), nicht ueber den behaupteten MIME-Typ; 5 MiB je Datei (multer-Grenze, 413), 30 Bilder je Benutzer; fremde Kennung → 404, nie 403; Binaerantwort mit `Cache-Control: private`, `nosniff`, `Content-Disposition: inline` ohne Dateinamen, CSP `default-src 'none'; sandbox`. **Web:** Widget `picture-frame` mit einer geordneten Liste `images` aus Eintraegen mit `kind`-Unterscheider (`upload` oder `url`), Bildausschnitt contain/cover, Intervall 0/5…3600 s, Reihenfolge oder Zufall (nie dasselbe zweimal), Unterschrift-Streifen, Grossansicht per Klick (nicht im Bearbeitungsmodus), kaputte Bilder fallen aus dem Umlauf; Bildverwaltung im `WidgetSettingsPanel` (Upload, https-Adresse, Unterschrift, Pfeile, Entfernen loescht den Upload auch serverseitig). Fremdbilder laedt AUSSCHLIESSLICH der Browser (`<img referrerPolicy="no-referrer">`) — die API ruft nie eine Adresse ab, keine SSRF-Flaeche; https-Pflicht web-seitig zweifach (Formular + Render-Resolver), weil die API Widget-Configs nicht inhaltlich prueft. **Browser-Rundgang (Orchestrator, zehn Punkte) fand drei Dinge, behoben in 8bf3601:** die Grossansicht war auf die Kachelflaeche beschraenkt (ein `react-grid-item` mit CSS-`transform` wird fuer `position: fixed` zum Bezugsrahmen → `createPortal` in `document.body` wie der Kalender-Tooltip), „1 Minuten“ → ICU-Plural, Standardkachel 8x8 zu flach → 8x12. curl-Rundgang gegen die lebende API belegt 201/400/413/404/401 und fremder Benutzer → 404. **Zahlen:** api 1148 → 1175, web 531 → 569, type-check 4/4, lint 5/5, RLS-Waechter 78/78, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Anwenderhandbuch und CHANGELOG ergaenzt. | 2026-09-21 | 737974b,c080580,c3b4597,8bf3601 | [260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc](./quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/) |
| 260921-qd3 | **Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel.** Zweites der zwei vom Nutzer bestellten Widgets, vom Nutzer so benannt. Config `url` (https-Pflicht ueber dieselbe `isHttpsUrl`-Regel wie der Bilderrahmen, web-seitig doppelt: Formular + Render-Resolver), `title` (max. 100 Zeichen, Kopfleiste), `reloadSeconds` (0/60/300/600/1800/3600; Timer haengt den Rahmen per `key` neu ein, nicht im Bearbeitungsmodus). `<iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox">` — bewusst OHNE `allow-top-navigation*` (die eingebettete Seite kann den Tessera-Tab nicht umleiten) und OHNE `allow-modals`; `allow=""` (keine Kamera/Mikro/Standort-Delegation), `referrerPolicy="no-referrer"`, `loading="lazy"`. Die API ruft die Adresse nie ab (nur `'xframe'` im `@IsIn` des DTO; kein CSP/Frame-Header in apps/web noetig, per grep belegt). Im Bearbeitungsmodus liegt eine unsichtbare Flaeche ueber dem Rahmen, sonst schluckt der iframe die Zeigerereignisse und die Kachel liesse sich nicht ziehen. Ob eine Seite das Einbetten verweigert, entscheidet die fremde Seite (`X-Frame-Options`/`frame-ancestors`, cross-origin nicht erkennbar) — deshalb dauerhafter Hinweis im Formular und immer ein Link „In neuem Tab öffnen“ (`rel="noopener noreferrer"`, in der Kopfleiste oder als Ecksymbol). Formular als eigenes Modul `xframe-config-form.tsx` wie beim Bilderrahmen; Kachel-Vorgabe 12x12. Browser-Rundgang (Orchestrator, neun Punkte + Tests) ohne Befund: example.com im Rahmen, google.com verweigert mit `X-Frame-Options: sameorigin` und der Link fuehrt trotzdem hin, Neuladen nach 60 s mit genau einem zweiten Dokumentabruf, Ziehen und Groesse aendern ueber dem Rahmen, API-Log ohne Fremdabruf. **Zahlen:** web 569 → 603, api 1175 unveraendert, type-check 4/4, lint 5/5, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Biome `useAnchorContent` wertet `aria-label` nicht als Linkinhalt → `sr-only`-Text statt `biome-ignore`. | 2026-09-21 | d63d9f5,20a9eb2 | [260921-qd3-dashboard-widget-xframe-eine-webseite-pe](./quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/) |
| fast-260922 | **Kosmetik nach dem Browser-Rundgang (fast, ohne Akte).** Bilderrahmen: das laengste Wechselintervall (3600 s) hiess „60 Minuten“, beim XFrame dieselbe Stufe „Jede Stunde“ → neuer Schluessel `pictureFrame.intervalHours` (ICU-Plural, de + en); Kopfzeile „Bilderrahmen #N“ unter Einstellungen → Dashboard nennt jetzt „— 1 Bild“ / „— N Bilder“ (zwei Schluessel statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen). Web-Tests 603 → 604. Hinweis fuer spaeter: `gsd-tools quick-tasks-append` scheitert an dieser Tabelle, weil aeltere Zeilen (260918-gza, 260921-iwr, 260921-m34) unmaskierte `\|` im Text tragen — Zeilen daher von Hand anfuegen. | 2026-09-22 | 8b45a28 | — |
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
## Deferred Items ## Deferred Items
@@ -497,8 +502,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity ## Session Continuity
Last session: 2026-09-21T16:45:00Z Last session: 2026-09-22T12:15:00Z
Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 573d070, CI-Lauf 400 fuer 573d070 erfolgreich (Beta-Images gebaut). WINDOWS #35 und #36 sind im Ledger auf fixed; der Fehler- und Lint-Rueckstand ist abgearbeitet. Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; danach Bilderrahmen, XFrame, Kosmetik, Tray-Update-Befund, Download-Knoepfe, XFrame-Ausschnitt.
Stopped at: Warte auf Nutzerentscheidung. Naechster Auftrag laut Stopp-Punkt: zwei neue Dashboard-Widgets — das erste ein Bilderrahmen (Produktfragen geklaert, siehe stopped_at im Kopf), das zweite hat der Nutzer noch nicht benannt. Offen beim Nutzer: alpha ziehen, Windows-Client pruefen, Freigabe 1.3.0 auf Zuruf. Stopped at: Alles gebaut, nachgewiesen, gepusht; alpha gezogen und vom Nutzer bestaetigt. Basic-Auth vor alpha bleibt (Nutzerentscheidung, intern Ausnahme) — kein offener Punkt. Offen beim Nutzer: neuen Client einmal per Browser installieren; Freigabe 1.3.0 auf Zuruf.
Resume file: None Resume file: None
Last activity: 2026-09-21 - Quick 260921-pi9: Dashboard-Widget „Bilderrahmen“ (Upload in der Datenbank oder https-Adresse, Diashow mit Grossansicht), im Browser zehnpunktig nachgewiesen; Quick 260921-qd3 (XFrame) laeuft Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen; auf alpha bestaetigt
@@ -0,0 +1,240 @@
---
phase: quick-260921-qd3
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260921-QD3]
files_modified:
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/widget-settings-panel.test.tsx
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
estimate:
tokens: 110000
raw_tokens: 110000
tasks: 2
confidence: low
must_haves:
truths:
- "Im Widget-Katalog gibt es „XFrame“ (Beschreibung „Webseite einbetten“ / „Embed a web page“) mit Fenster-Symbol; eine frisch platzierte Kachel (12×8) zeigt zentriert grau „Keine Adresse eingestellt — über die Einstellungen festlegen“ und keinen Rahmen."
- "Unter Einstellungen → Dashboard → XFrame trägt der Benutzer eine https-Adresse (Übernahme bei Blur/Enter), optional einen Titel (höchstens 100 Zeichen) und ein Neuladen-Intervall (Nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) ein; eine http-, data- oder javascript-Adresse wird mit deutscher `role=\"alert\"`-Meldung abgewiesen und NICHT gespeichert; dauerhaft steht der Hinweis, dass manche Webseiten das Einbetten verweigern."
- "Die Kachel rendert genau ein `<iframe>` mit `src` = Adresse, `sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"` (keine Freigabe der Navigation des obersten Fensters), `referrerPolicy=\"no-referrer\"`, `loading=\"lazy\"`, leerem `allow`; mit Titel als schmale Kopfleiste samt Knopf „In neuem Tab öffnen“, ohne Titel derselbe Knopf als kleines Symbol in der Ecke; der Knopf ist ein `<a target=\"_blank\" rel=\"noopener noreferrer\">` mit `aria-label` und per Tastatur erreichbar."
- "Bei Neuladen-Intervall > 0 wird der Rahmen im Takt neu eingehängt (`key` aus Adresse + Zähler, Zähler sichtbar als `data-reload-nonce`); der Timer wird beim Aushängen geräumt; im Bearbeitungsmodus läuft kein Timer."
- "Im Bearbeitungsmodus liegt eine transparente Fläche über dem Rahmen, damit die ganze Kachel Ziehgriff bleibt (der Rahmen schluckt sonst die Mausereignisse); im Ansichtsmodus gibt es diese Fläche nicht."
- "Der Server ruft die Adresse nie ab (kein Proxy, kein Fetch); die API lässt `xframe` als Widget-Typ zu und prüft die Konfiguration wie bisher nicht inhaltlich — die https-Prüfung läuft web-seitig zweifach (Formular UND `resolveXframeConfig` beim Rendern)."
- "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1175 (Stand nach pi9), Web-Tests mindestens 590 (heute 569), Umlaut-Wächter grün; `as unknown as` api 27 / web 6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, kein `!`, keine neue `any`."
artifacts:
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeConfig`, `resolveXframeConfig`, `XFRAME_SANDBOX`, `XFRAME_RELOAD_OPTIONS`, `XFRAME_RELOAD_MIN/MAX`, `XFRAME_TITLE_MAX`, Re-Export `isHttpsUrl` aus picture-frame-config.ts; ohne React-Import"
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Kachel mit Kopfleiste/Ecksymbol, `<iframe>`, Neulade-Timer, Bearbeitungs-Overlay, Leerzustand"
- "apps/web/src/components/settings/widget-settings-panel.tsx — Zweig `widget.widgetType === 'xframe'` mit `XframeConfig`-Formular (Adresse, Titel, Intervall, Hinweis) und Titel in der Instanz-Kopfzeile"
- "apps/web/src/components/dashboard/widget-registry.tsx — `WidgetType` + `'xframe'`, `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, `XframeIcon`, Registry-Eintrag, `wireXframeWidget`"
- "apps/api/src/dashboard/dto/create-widget.dto.ts — `'xframe'` in `@IsIn([...])`"
- "apps/web/src/messages/de.json + en.json — Namensraum `widgets.xframe`, identischer Schlüsselsatz"
- "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“; docs/anleitung-anwender.md — Zeile in der Widget-Tabelle + Satz im Abschnitt Dashboard > Widgets"
key_links:
- "Katalog `WIDGET_TYPES` -> `addWidget('xframe')` -> `POST /dashboard/widgets` mit `widgetType: 'xframe'` -> `CreateWidgetDto @IsIn` (ohne den Eintrag 400) -> Kachel über `WIDGET_REGISTRY.xframe.component` (verdrahtet in `(portal)/page.tsx`)"
- "Formular `commitUrl` -> `isHttpsUrl` -> `onChange({ url })` -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig(config)` -> `url` nur wenn https, sonst `null` -> Leerzustand"
- "`reloadSeconds > 0 && !isEditMode` -> `setInterval` -> `reloadNonce + 1` -> `key` wechselt -> `<iframe>` wird neu eingehängt; Aufräumfunktion `clearInterval`"
- "`isEditMode` -> `<div className=\"absolute inset-0\" aria-hidden>` NACH dem `<iframe>` im DOM -> Mausereignisse treffen die Fläche, nicht den Rahmen -> `mousedown` steigt zur Karte `.widget-drag-handle` auf (dashboard-grid.tsx: `handle` ganze Karte, `cancel` nur input/textarea/select/button/a/[data-no-drag]/.widgetNoDrag)"
---
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“
<objective>
Ein neues Dashboard-Widget „XFrame“ (Widget-Typ `xframe`, Übersetzungs-Namensraum `widgets.xframe`): eine Webseite wird per https-Adresse als eingebetteter Rahmen (`<iframe>`) in der Kachel angezeigt. Einstellungen: Adresse, Titel, optionales Neuladen-Intervall. Stil und Bedienmuster wie die bestehenden Widgets; die eingebettete Seite darf die Tessera-Seite nicht verlassen (Sandbox ohne Freigabe der Navigation des obersten Fensters); der Server ruft die Adresse nie ab; das Formular weist dauerhaft darauf hin, dass manche Seiten das Einbetten verweigern, und die Kachel bietet immer „In neuem Tab öffnen“.
Purpose: zweites der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“, das erste — Bilderrahmen, quick-260921-pi9 — ist in HEAD c3b4597); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen.
Output: reiner Konfigurations-Resolver mit Tests (zuerst rot), Web-Widget + Formular im Einstellungs-Panel + Katalog/Registry/Seiten-Verdrahtung + Übersetzungen mit Tests, ein Wort im API-DTO, Changelog- und Handbuch-Eintrag; alle Tore grün.
</objective>
## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)
1. **Typ und Name.** Widget-Typ `xframe`, Anzeigename „XFrame“ in beiden Sprachen (Wahl des Nutzers), Beschreibung de „Webseite einbetten“ / en „Embed a web page“. Neunter Widget-Typ — `picture-frame` ist bereits da (verifiziert in HEAD: Registry, Katalog, Seite, DTO, Panel tragen es); `xframe` wird überall **nach** `picture-frame` ergänzt.
2. **Konfiguration** im bestehenden Config-JSON: `url: string` (nur https), `title?: string` (höchstens 100 Zeichen), `reloadSeconds: number` (0 = nie; Auswahl 0/60/300/600/1800/3600; Voreinstellung 0). Resolver `resolveXframeConfig(raw)` mit Voreinstellungen und Klemmung, reines Modul ohne React-Import, zuerst rot getestet. **https-Prüfung:** `isHttpsUrl` ist in `picture-frame-config.ts` exportiert (HEAD, Zeile 45: echter `URL`-Parser, `protocol === 'https:'`) — wird importiert und re-exportiert, nicht dupliziert. **Befund am Code (wie pi9):** die API prüft Widget-Konfigurationen nicht inhaltlich (`UpdateWidgetConfigDto` nur `@IsObject()`, `updateWidgetConfig` führt flach zusammen) — deshalb läuft die https-Prüfung web-seitig zweifach: Formular (abweisen) UND Resolver (beim Rendern fällt jede Nicht-https-Adresse auf `null` → Leerzustand). Ein manipulierter Config-Wert schadet nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
3. **Rendering.** `<iframe src={url} title={title || url} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="h-full w-full border-0 bg-background">` mit `XFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'`. Keine Freigabe der Navigation des obersten Fensters (kein `allow-top-navigation`, kein `allow-top-navigation-by-user-activation`) — die eingebettete Seite kann den Tessera-Tab nicht umlenken. Dateikopf-Kommentar erklärt, warum `allow-same-origin` trotzdem nötig ist: ohne dieses Token läuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten sind dann unbenutzbar; der Origin ist der der Fremdseite, nicht Tesseras — die Sandbox hat hier allein die Aufgabe, Navigation des obersten Fensters und Modaldialoge (`alert`/`confirm`/`prompt` sind ohne `allow-modals` gesperrt) zu unterbinden. `allow` bleibt leer (keine Delegation von Kamera/Mikrofon/Standort). **Kein CSP-Umbau nötig:** in apps/web ist nirgends eine Content-Security-Policy, `frame-src` oder `X-Frame-Options` gesetzt (verifiziert per grep über next.config, middleware, api main.ts) — Einbetten fremder https-Seiten braucht keine Header-Änderung. Neuladen: bei `reloadSeconds > 0` bumpt ein `setInterval` einen `reloadNonce`-Zustand, der Teil des `key` des `<iframe>` ist → Neueinhängen; Aufräumfunktion räumt den Timer; im Bearbeitungsmodus kein Timer.
4. **Bearbeitungsmodus.** Ein `<iframe>` schluckt Mausereignisse und bricht das Ziehen. Bei `isEditMode` liegt eine transparente Fläche `<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />` **nach** dem `<iframe>` im DOM über dem Rahmen (Standard-`pointer-events`), damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte, `cancel`-Selektor `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — eine `div` startet das Ziehen). Im Ansichtsmodus keine Fläche. Test: im Bearbeitungsmodus existiert die Fläche, im Ansichtsmodus nicht.
5. **„In neuem Tab öffnen“.** `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={…}>` mit Inline-SVG (externer Link), per Tastatur erreichbar (echter Link). Mit Titel sitzt er rechts in der Kopfleiste; ohne Titel als kleines Symbol in der rechten oberen Ecke (`absolute right-1 z-10`, `top-1` im Ansichtsmodus, `top-6` im Bearbeitungsmodus — die Griff-Kopfleiste der Karte ist 20 px hoch, `top-6` = 24 px liegt darunter). Der Link ist ein `a` und damit im `cancel`-Selektor: im Bearbeitungsmodus klickbar, startet kein Ziehen. Leerzustand (keine gültige Adresse): zentrierter grauer Text `xframe.empty`, kein Rahmen, kein Link. Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der Fremdseite) ist cross-origin nicht zuverlässig erkennbar — **nicht** versuchen; stattdessen zeigt das Formular dauerhaft den Hinweis `xframe.embedHint`, und die Kachel bietet den Link immer, sobald eine Adresse gesetzt ist.
6. **Einstellungsformular** `XframeConfig({ config, onChange })` als weitere Formularfunktion **in** `widget-settings-panel.tsx` (Muster `ClockConfig`/`FavoritesConfig`; das Formular ist klein — drei Felder plus Hinweis —, deshalb kein eigenes Modul wie beim Bilderrahmen): Adresse als Entwurf mit Übernahme bei Blur/Enter (`http://` → `role="alert"` `xframe.urlInvalid`, kein `onChange`); Titel als Entwurf mit Übernahme bei Blur/Enter; Intervall als `<select>`; gleiche Klassenketten wie die Nachbarformulare (`mb-1 block text-sm text-foreground`, `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`). Zusätzlich zeigt die Instanz-Kopfzeile des Panels „— Titel“ auch für `xframe` (Bedingung, die heute `note`/`favorites` abdeckt, erweitern).
7. **Constraints** `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }` (eine Webseite braucht Breite — halbe Rasterbreite). **Symbol** `XframeIcon`: Inline-SVG Browserfenster (`rect x=3 y=4 width=18 height=16 rx=2`, `line 3 9 → 21 9`, zwei kleine Kreise `cx=6.5`/`cx=9.5` bei `cy=6.5`, `r=0.5`), gleiche Attribute wie die Nachbarn (`aria-hidden`, `stroke="currentColor"`, `strokeWidth="2"`).
8. **Texte** Deutsch mit „Sie“ plus Englisch; CHANGELOG-Stichpunkt unter „Unveröffentlicht → Neu“ (kein Fließtext), Zeile in der Widget-Tabelle von `docs/anleitung-anwender.md` (nach „Bilderrahmen“) und ein Satz im Abschnitt „Dashboard > Widgets“ der persönlichen Einstellungen. **Umlaut-Wächter** (`apps/web/src/messages/umlaut-guard.spec.ts`): jedes Token mit `ae/oe/ue/ss` in de.json muss in `UMLAUT_ALLOWLIST` stehen; vorab gegen die Allowlist geprüft — einziger neuer Verdachts-Token ist **`neuem`** („In neuem Tab öffnen“, korrektes Deutsch wie das bereits gelistete `neuen`) → in `umlaut-dictionary.ts` in die Allowlist aufnehmen (Muster pi9: `Webadresse`, `Bildausschnitt`). `Adresse` und `lassen` sind bereits gelistet.
9. **API:** nur `'xframe'` in `CreateWidgetDto @IsIn` (sonst 400 beim Anlegen). Kein Prisma-Schema, keine Migration (Schema-Tor feuert nicht), kein neuer Endpunkt, **niemals** ein serverseitiger Abruf der Adresse (keine SSRF-Fläche). Keine neuen Pakete.
## Ausgangsmessung (21.09.2026, HEAD c3b4597 nach pi9)
| Größe | Wert |
|---|---:|
| API-Tests | 1175 |
| Web-Tests | 569 |
| `as unknown as` in apps/api/src | 27 |
| `as unknown as` in apps/web/src | 6 |
| `lint/style/noNonNullAssertion` in apps/api/src | 56 |
| `lint/suspicious/noExplicitAny` in apps/api/src | 13 (jede begründet) |
| `biome-ignore` in apps/api/src | 1 |
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-registry.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-config.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md
</context>
## Hinweise für den Executor
- **Ausgangspunkt ist HEAD (c3b4597).** pi9 ist vollständig committet; `git status` zeigt nur `.planning/`. Alle Stellen, an denen pi9 `picture-frame` eingetragen hat (`git diff 573d070 HEAD --stat`), bekommen `xframe` **direkt dahinter** in derselben Form: `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, `wire…Widget`, `WIDGET_TYPES` im Katalog, Import + `wire…` in `(portal)/page.tsx`, `vi.mock` in `page.test.tsx`, Übersetzungsattrappe im Katalog-Test, Typliste + Erwartungstabelle + Zähler im Registry-Test (32 → 36, „neun Typen“), `@IsIn` im DTO, Zweig im Panel.
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
- **Rot-Nachweis:** `xframe-config.test.ts` und die Widget-Tests werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `sandbox`, `allow`, `referrerPolicy`, `loading` sind reguläre React-Props des `<iframe>` — keine Zusicherung nötig.
- **jsdom lädt keine Unterressourcen** — ein `<iframe src="https://…">` im Test erzeugt keinen Netzabruf; Attribute per `getAttribute` prüfen, Neueinhängen über Objektidentität (`before !== after`) und `data-reload-nonce`.
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260921-qd3`, deutsche Betreffzeile. Akte/STATE-Commit macht der Orchestrator.
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1: Resolver (rot → grün), Widget, Formular im Panel, Verdrahtung, Übersetzungen, API-DTO — Ende-zu-Ende „Adresse eintragen → Seite erscheint in der Kachel“</name>
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, apps/api/src/dashboard/dto/create-widget.dto.ts</files>
<behavior>
- `resolveXframeConfig({})` → `{ url: null, title: '', reloadSeconds: 0 }`; `url: 'https://intern.example/board'` → unverändert (getrimmt); `url: ' https://a.de/x '` → `'https://a.de/x'`; `url: 'http://a.de'`, `'javascript:alert(1)'`, `'data:text/html,x'`, `'ftp://a.de'`, `'kein-url'`, `42`, `''` → `url: null`; `title: ' Board '` → `'Board'`; Titel mit 150 Zeichen → auf 100 gekürzt; `title: 7` → `''`; `reloadSeconds`: fehlt/`'abc'`/`-5`/`30` → `0` (unter dem kleinsten Intervall heißt „nie“ — schützt die Fremdseite vor Sekundentakt), `60` → `60`, `90` → `60` (größte Auswahlstufe ≤ n), `599` → `300`, `600` → `600`, `99999` → `3600`.
- `XFRAME_SANDBOX` enthält `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups`, `allow-popups-to-escape-sandbox` und kein Token, das `top-navigation` oder `modals` enthält (Regex-Prüfung auf den Konstantenwert). `XFRAME_RELOAD_OPTIONS` gleich `[0, 60, 300, 600, 1800, 3600]`. `isHttpsUrl` ist aus `xframe-config` importierbar und liefert für `'HTTPS://A.DE'` true, für `'http://a.de'` false.
- Widget, `{}` → Text `xframe.empty`, kein `<iframe>`, kein Link. `{ url: 'http://a.de' }` → ebenfalls Leerzustand (Resolver weist ab).
- Widget, `{ url: U }` → genau ein `<iframe>` (`data-testid="xframe-frame"`) mit `src === U`, `title === U`, `sandbox === XFRAME_SANDBOX`, `sandbox` enthält kein `top-navigation`, `referrerpolicy === 'no-referrer'`, `loading === 'lazy'`, `allow === ''`, `data-reload-nonce === '0'`; kein `<h2>`; ein Link `role="link"` mit Name `xframe.openInNewTab`, `href === U`, `target === '_blank'`, `rel` enthält `noopener` und `noreferrer`, Klasse enthält `absolute` (Ecksymbol).
- Widget, `{ url: U, title: 'Board' }` → `<h2>` mit „Board“, `<iframe title="Board">`, der Link steht in der Kopfleiste (Vorfahre mit Klasse `border-b`), Klasse ohne `absolute`.
- Widget, `{ url: U, title: '<b>x</b>' }` → der Text `<b>x</b>` erscheint wörtlich (`getByText`), `container.querySelector('b')` ist `null` (React-Escaping).
- Widget, `{ url: U, reloadSeconds: 60 }` mit Fake-Timern → `vi.getTimerCount()` 1; nach `advanceTimersByTime(60_000)` ist das `<iframe>`-Element ein **anderes** Objekt als vorher und `data-reload-nonce === '1'`, nach weiteren 60 000 ms `'2'`; `unmount()` → `vi.getTimerCount()` 0. `reloadSeconds: 0` → `getTimerCount()` 0. `isEditMode: true` + `reloadSeconds: 60` → `getTimerCount()` 0 (kein Neuladen beim Bearbeiten).
- Widget, `isEditMode: true` → `data-testid="xframe-edit-overlay"` existiert, hat `aria-hidden="true"`, Klassen `absolute` und `inset-0`, steht im DOM **nach** dem `<iframe>` (`compareDocumentPosition`); `isEditMode: false` → kein Overlay.
- Panel (`WidgetSettingsPanel` mit `{ id: 'x1', widgetType: 'xframe', config: { url: U, title: 'Board', reloadSeconds: 300 } }`, aufgeklappt): Feld `xframe-url` hat Wert U, Feld `xframe-title` Wert „Board“, Auswahl `xframe-reload` Wert `'300'`, der Hinweis `widgets.xframe.embedHint` (Text aus de.json) ist sichtbar, Instanz-Kopfzeile enthält „— Board“. Adresse auf `https://b.de/` ändern + Blur → `updateWidgetConfig('x1', { url: 'https://b.de/' })` genau einmal und `onWidgetUpdate` gleich. Adresse auf `http://b.de` + Enter → `role="alert"` mit `widgets.xframe.urlInvalid`, `aria-invalid` am Feld, **kein** Aufruf. Feld leeren + Blur → `{ url: '' }` (Adresse entfernen ist erlaubt → Leerzustand). Titel ändern + Blur → `{ title: 'Neu' }`; Auswahl `'600'` → `{ reloadSeconds: 600 }`. Gleiche Adresse erneut übernehmen (Blur ohne Änderung) → kein Aufruf.
- Registry: `WIDGET_CONSTRAINTS.xframe` gleich `{ minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, Gesamtzähler 36; Katalog zeigt einen Knopf mit Namen /XFrame/; `CreateWidgetDto` lässt `'xframe'` zu (Auszug aus der `@IsIn`-Liste im DTO-Kommentar: „nine supported types“).
</behavior>
<action>
**Reihenfolge: Resolver rot → grün, dann Widget (Tests zuerst), dann Formular im Panel, zuletzt Verdrahtung, Übersetzungen, DTO.**
1. **`xframe-config.ts`** (Muster `picture-frame-config.ts`, ohne React-Import). Dateikopf-Kommentar (Deutsch, wie die Nachbarn): Zweck; warum die https-Prüfung ALLEIN hier und im Formular liegt (API prüft Config nicht inhaltlich, Entscheidung 2, T-QD3-03); warum `allow-same-origin` in der Sandbox bleibt und welche Tokens bewusst fehlen (Entscheidung 3, T-QD3-01). Exporte: `XFRAME_RELOAD_OPTIONS = [0, 60, 300, 600, 1800, 3600] as const`-artig als `number[]`, `XFRAME_RELOAD_MIN = 60`, `XFRAME_RELOAD_MAX = 3600`, `XFRAME_TITLE_MAX = 100`, `XFRAME_SANDBOX` (Entscheidung 3, exakter String), `interface XframeConfig { url: string | null; title: string; reloadSeconds: number }`, `resolveXframeConfig(config: Record<string, unknown>): XframeConfig`, und `export { isHttpsUrl } from './picture-frame-config'` (Re-Export, damit Formular und Tests eine Quelle haben; Kommentar: bewusst geteilt mit dem Bilderrahmen, eine Regel für „https-Adresse“ im ganzen Dashboard). Regeln: `url` nur wenn String, getrimmt, `isHttpsUrl` true — sonst `null`; `title` nur wenn String, getrimmt, `slice(0, XFRAME_TITLE_MAX)` — sonst `''`; `reloadSeconds`: nicht endliche Zahl oder `< XFRAME_RELOAD_MIN` → 0, `≥ XFRAME_RELOAD_MAX` → 3600, sonst größter Wert aus `XFRAME_RELOAD_OPTIONS`, der `≤ n` ist (damit das `<select>` im Formular immer eine passende Option zeigt). **`xframe-config.test.ts`** mit allen Fällen aus `<behavior>` (mindestens 12 `it`), vor der Umsetzung rot.
2. **`xframe-widget.tsx`** (`'use client'`, `export function XframeWidget({ config, isEditMode }: WidgetProps)`; `instanceId` wird nicht gebraucht — Props-Muster wie `PictureFrameWidget`). Dateikopf-Kommentar: Sandbox-Begründung (verweist auf `XFRAME_SANDBOX`), warum der Server nie abruft (T-QD3-04), warum im Bearbeitungsmodus eine Fläche über dem Rahmen liegt (Entscheidung 4, T-QD3-07). Aufbau: `const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config])`; `const [reloadNonce, setReloadNonce] = useState(0)`; `useEffect` mit Abhängigkeiten `[url, reloadSeconds, isEditMode]`: wenn `url === null || reloadSeconds === 0 || isEditMode` → nichts; sonst `const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000)` und Aufräumfunktion `clearInterval(timer)`. Leerzustand (`url === null`): `<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">{t('xframe.empty')}</div>` und sonst nichts. Andernfalls Wurzel `<div className="relative flex h-full w-full flex-col overflow-hidden">`: (a) bei Titel eine Kopfleiste `<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">` mit `<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>` (Muster Favoriten-Kopfzeile) und dem Link (Klasse `shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground`); (b) Rumpf `<div className="relative min-h-0 flex-1">` mit dem `<iframe>` (Entscheidung 3; `key={`${url}#${reloadNonce}`}`, `data-testid="xframe-frame"`, `data-reload-nonce={reloadNonce}`), danach bei `isEditMode` die Fläche aus Entscheidung 4, danach — nur ohne Titel — der Link als Ecksymbol (`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground` plus `top-1`/`top-6` je Modus). Der Link (eine kleine Funktion `NewTabLink({ url, className })` in derselben Datei): `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={t('xframe.openInNewTab')}>` mit Inline-SVG 16×16 (`path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"`, `polyline points="15 3 21 3 21 9"`, `line x1="10" y1="14" x2="21" y2="3"`, `aria-hidden`). Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im Bearbeitungsmodus überdeckt die 20-px-Griffleiste der Karte den oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen. **`xframe-widget.test.tsx`** (Muster `picture-frame-widget.test.tsx`: `vi.mock('next-intl')` mit Durchreiche `t(key) => key`, Fake-Timer in `beforeEach`, `cleanup` + `useRealTimers` in `afterEach`), mindestens 10 `it` gemäß `<behavior>`, vor der Umsetzung rot.
3. **Formular im Panel** (`widget-settings-panel.tsx`): Import `XFRAME_RELOAD_OPTIONS, XFRAME_TITLE_MAX, isHttpsUrl, resolveXframeConfig` aus `@/components/dashboard/widgets/xframe-config`; Zweig `{widget.widgetType === 'xframe' && (<XframeConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />)}` nach dem Bilderrahmen-Zweig; die Kopfzeilen-Bedingung `(widget.widgetType === 'note' || widget.widgetType === 'favorites')` um `|| widget.widgetType === 'xframe'` erweitern. Funktion `XframeConfig` am Dateiende (Kommentar `// XFrame (quick-260921-qd3, Muster ClockConfig/FavoritesConfig)`): `const { url, title, reloadSeconds } = resolveXframeConfig(config)`; `urlDraft` (`useState(url ?? '')`), `urlError` (boolean), `titleDraft` (`useState(title)`). `commitUrl`: `const raw = urlDraft.trim()`; leer → `setUrlError(false)`, wenn `url !== null` → `onChange({ url: '' })`; nicht `isHttpsUrl(raw)` → `setUrlError(true)`, kein `onChange`; sonst `setUrlError(false)`, wenn `raw !== url` → `onChange({ url: raw })`. `commitTitle`: `const next = titleDraft.trim().slice(0, XFRAME_TITLE_MAX)`; wenn `next !== title` → `onChange({ title: next })`. Felder: Adresse `<input id="xframe-url" type="url" inputMode="url" placeholder={t('xframe.urlPlaceholder')} aria-invalid={urlError || undefined} aria-describedby="xframe-url-hint">` mit `onBlur={commitUrl}` und Enter-Handling wie `commitFontSize` in `ClockConfig`; darunter `<p id="xframe-url-hint" className="mt-1 text-xs text-muted-foreground">{t('xframe.embedHint')}</p>` (dauerhaft, Entscheidung 5) und bei Fehler `<p role="alert" className="mt-1 text-xs text-destructive">{t('xframe.urlInvalid')}</p>`; Titel `<input id="xframe-title" type="text" maxLength={XFRAME_TITLE_MAX} placeholder={t('xframe.titlePlaceholder')}>` mit Blur/Enter-Übernahme; Intervall `<select id="xframe-reload" value={String(reloadSeconds)} onChange={(e) => onChange({ reloadSeconds: Number(e.target.value) })}>` über `XFRAME_RELOAD_OPTIONS` mit Beschriftung `0 → t('xframe.reloadOff')`, `60 → t('xframe.reloadMinute')`, `3600 → t('xframe.reloadHour')`, sonst `t('xframe.reloadMinutes', { n: s / 60 })`. Labels `mb-1 block text-sm text-foreground`, Felder die Klassenkette aus Entscheidung 6, Abstände `space-y-4`. **`widget-settings-panel.test.tsx`**: neuer `describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)')` nach dem vorhandenen Muster (Texte aus der echten de.json, `updateWidgetConfig`-Attrappe, Instanz aufklappen per Klick auf die Kopfzeile), mindestens 6 `it` gemäß `<behavior>`.
4. **Verdrahtung** (alles „nach `picture-frame`“): `widget-registry.tsx` — Kopfkommentar um `xframe: Webseite als Rahmen (quick-260921-qd3)` ergänzen, `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe` mit Kommentar (`// quick-260921-qd3: eine Webseite braucht Breite — 12x8 = halbe Rasterbreite; 4x4 kleinste Kachel, in der ein Rahmen noch Sinn hat`), `XframeIcon` (Entscheidung 7), Registry-Eintrag `xframe: { type: 'xframe', nameKey: 'xframe.name', descriptionKey: 'xframe.description', icon: XframeIcon, ...WIDGET_CONSTRAINTS.xframe, component: PlaceholderWidget }`, `wireXframeWidget` nach dem Muster `wirePictureFrameWidget`. `widget-registry.test.tsx` — `'xframe'` in `ALL_WIDGET_TYPES`, Zeile in der `toEqual`-Tabelle, `counted` 32 → 36, Testtitel „…; quick-260921-qd3: XFrame dazu, neun Typen“. `widget-catalog-modal.tsx` — `'xframe'` als letzter Eintrag in `WIDGET_TYPES`; `widget-catalog-modal.test.tsx` — Attrappe um `'xframe.name': 'XFrame'`, `'xframe.description': 'Webseite einbetten'`. `(portal)/page.tsx` — `wireXframeWidget` in die Import-Liste, `import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'`, `wireXframeWidget(XframeWidget)` nach `wirePictureFrameWidget`; `page.test.tsx` — `vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }))`.
5. **Übersetzungen** `de.json`/`en.json`, Namensraum `widgets.xframe` direkt nach `pictureFrame`, exakt diese Schlüssel in beiden Dateien: `name` („XFrame“/„XFrame“), `description` („Webseite einbetten“/„Embed a web page“), `empty` („Keine Adresse eingestellt — über die Einstellungen festlegen“/„No address set — configure it in the settings“), `openInNewTab` („In neuem Tab öffnen“/„Open in a new tab“), `urlLabel` („Adresse (https)“/„Address (https)“), `urlPlaceholder` („https://…“ beide), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“/„Please enter a complete https address.“), `titleLabel` („Titel“/„Title“), `titlePlaceholder` („Titel (optional)“/„Title (optional)“), `reloadLabel` („Automatisch neu laden“/„Reload automatically“), `reloadOff` („Nie“/„Never“), `reloadMinute` („Jede Minute“/„Every minute“), `reloadMinutes` („Alle {n} Minuten“/„Every {n} minutes“), `reloadHour` („Jede Stunde“/„Every hour“), `embedHint` („Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.“/„Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.“). **`umlaut-dictionary.ts`**: `'neuem'` in `UMLAUT_ALLOWLIST` aufnehmen (Kommentar `// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen`). Danach `pnpm --filter @tessera/web exec vitest run src/messages` — der Wächter meldet jedes weitere vergessene Token mit Pfad; dann ebenfalls in die Allowlist (nur korrekte deutsche Wörter, keine Umschreibungen).
6. **API-DTO** `create-widget.dto.ts`: `'xframe'` als letzter Eintrag in `@IsIn([...])`, Kommentar „one of the nine supported types ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3)“. Sonst nichts an der API.
Commit: `feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall` (Wortlaut frei, Stil beachten).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/messages "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && grep -q "'xframe'" apps/api/src/dashboard/dto/create-widget.dto.ts && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;if(!d||!e){console.error('xframe-Namensraum fehlt');process.exit(1)}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
</verify>
<done>`xframe-config.test.ts` ≥ 12, `xframe-widget.test.tsx` ≥ 10, neuer Panel-`describe` ≥ 6 Fälle — alle grün, Resolver- und Widget-Tests nachweislich zuerst rot (Rot-Lauf im SUMMARY). Registry-/Katalog-/Seiten-Tests grün mit dem neunten Typ (Zähler 36). Umlaut-Wächter grün (`neuem` gelistet). Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.xframe`. Kette nachgewiesen (Tests): https-Adresse im Formular → `updateWidgetConfig(id, { url })`; http → Meldung, kein Aufruf; Kachel rendert `<iframe>` mit exakter Sandbox, `no-referrer`, `lazy`, leerem `allow`; Neuladen hängt neu ein und räumt den Timer; im Bearbeitungsmodus Overlay und kein Timer; Titel wird escaped. `as unknown as` web 6 / api 27, keine `any`, kein `!`. `pnpm --filter @tessera/web exec tsc --noEmit` und beide Linter ohne Befund.</done>
</task>
<task type="auto">
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
<action>
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (vor dem Bilderrahmen; kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard“.
2. **`docs/anleitung-anwender.md`**: (a) in der Widget-Tabelle (Kopf „| Widget | Zweck |“, Zeile ~73) nach der Zeile „Bilderrahmen“ eine Zeile „| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem |“; (b) im Satz „Für Uhr, Suchleiste, Kalender, Notizen, Favoriten und Bilderrahmen gibt es zusätzliche Einstellungen (…)“ (Zeile ~84) „und XFrame“ sowie in der Klammer „Adresse, Titel und Neuladen des XFrame“ ergänzen; (c) im Absatz „**Dashboard > Widgets:**“ (Zeile ~154) am Ende einen Satz anfügen: „Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“, „Einstellungen > Dashboard“).
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in pi9-SUMMARY (Tabelle „Endmessung“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep) und ins SUMMARY schreiben.
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; platzierte Kachel ist 12×8 und zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen → Kachel zeigt die Seite; (c) `http://…` eintragen → rote Meldung, Netzwerk-Tab zeigt keinen PATCH; (d) Titel „Board“ → Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren → Symbol wandert in die rechte obere Ecke; (e) Klick auf das Symbol öffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol; (f) Intervall „Jede Minute“ → nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1); (g) Bearbeitungsmodus: Kachel lässt sich an einer Stelle **über dem Rahmen** anfassen und ziehen, Größe ändern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen); (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) → Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert; (i) API-Log während (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser lädt sie; (j) `pnpm --filter @tessera/api test` und `-web test` grün, Zähler wie Ausgangsmessung.
Commit: `docs(quick-260921-qd3): Changelog und Anwenderhandbuch - XFrame-Widget` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'XFrame' CHANGELOG.md && test "$(grep -c '| XFrame |' docs/anleitung-anwender.md)" -eq 1 && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
</verify>
<done>Changelog-Stichpunkt steht als erster unter „Unveröffentlicht → Neu“; Handbuch trägt die Tabellenzeile, den erweiterten Satz und den Absatz-Zusatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1175 Tests, Web ≥ 590 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die zehnpunktige Prüfliste steht im SUMMARY; genau zwei Code/Doku-Commits mit Scope `quick-260921-qd3` (`git log --oneline -2`).</done>
</task>
</tasks>
<assumption_delta_decision>
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → Abfrage liefert `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt über die Aufgabenbeschreibung: **feuert nicht** — genau EINE Adresse, EIN Titel, EIN Intervall; keine zweite Variante, kein Pflichtfeld wird optional, kein abgeleiteter Wert wird gewählt. `url` bleibt einfacher String im Config-JSON, kein Vereinigungstyp nötig. Entscheidung: `no-change`.
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; der Browser rendert eine Fremdseite in einem Rahmen, die API kennt nur das Wort `xframe` in einer Zulassungsliste. Keine COVERAGE.md nötig.
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang → feuert nicht.
</assumption_delta_decision>
<threat_model>
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
## Vertrauensgrenzen
| Grenze | Beschreibung |
|---|---|
| Config-JSON → Browser | `url`/`title`/`reloadSeconds` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in `<iframe src>`, `title`, Text und Timer |
| Tessera-Seite ↔ eingebettete Fremdseite | Die Fremdseite läuft im eigenen Origin innerhalb der Tessera-Kachel; sie sieht die Anfrage (und ohne Gegenmaßnahme den Referrer) und könnte versuchen, das oberste Fenster zu navigieren oder Berechtigungen zu nutzen |
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab; der Server nie |
| Widget → Grid (Ziehen) | Ein `<iframe>` schluckt Mausereignisse; die Bedienbarkeit des Bearbeitungsmodus hängt am Overlay |
## STRIDE-Register
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|---|---|---|---|---|---|
| T-QD3-01 | Spoofing / Elevation (Navigation des obersten Fensters, Phishing) | `<iframe sandbox>` in `xframe-widget.tsx` | high | mitigate | `sandbox={XFRAME_SANDBOX}` OHNE `allow-top-navigation` und OHNE `allow-top-navigation-by-user-activation` — die Fremdseite kann `window.top` nicht umlenken, der Tessera-Tab bleibt Tessera. Ohne `allow-modals`: keine `alert`/`confirm`/`prompt`-Dialoge, die als Tessera-Dialoge missdeutet werden könnten. `allow-popups-to-escape-sandbox` betrifft nur NEUE Fenster (Popups verlassen die Sandbox, damit z. B. Anmelde-Popups der Fremdseite funktionieren), nie den Tessera-Tab. Getestet: Konstante und gerendertes Attribut enthalten kein `top-navigation`/`modals`. |
| T-QD3-02 | Tampering (Clickjacking, Richtung) | Tessera als Einbettender | low | accept | Hier bettet Tessera FREMDE Seiten ein — die klassische Clickjacking-Richtung (jemand bettet Tessera ein) ist unverändert und außerhalb des Umfangs: Tesseras eigene `frame-ancestors`/`X-Frame-Options`-Lage wird durch dieses Widget nicht berührt (in apps/web ist heute kein solcher Header gesetzt; das war vor dem Widget so und bleibt so). Umgekehrt kann die eingebettete Seite Tessera-Elemente nicht überlagern: sie lebt in ihrer eigenen Kachel, Tessera legt nichts Interaktives über sie außer dem Bearbeitungs-Overlay. |
| T-QD3-03 | Tampering (gefährliche Schemata: `javascript:`, `data:`, `http:` Mixed Content) | `url` im Config-JSON | high | mitigate | `isHttpsUrl` (echter `URL`-Parser, `protocol === 'https:'`) im Formular (Abweisung mit Meldung, kein Speichern) UND in `resolveXframeConfig` beim Rendern (`url` wird `null` → Leerzustand, `<iframe>` wird nicht einmal gerendert). Serverseitig nicht prüfbar (API kennt keine Config-Inhalte) — Risiko bleibt auf das eigene Dashboard beschränkt. Getestet: `http:`, `javascript:`, `data:`, `ftp:`, Unparsbares → `null`. |
| T-QD3-04 | Server-Side Request Forgery | API | high | mitigate | Die API ruft NIE die Adresse ab: kein Proxy-Endpunkt, kein Fetch, `url` ist für die API ein undurchsichtiger JSON-Wert; einzige API-Änderung ist das Wort `'xframe'` in `@IsIn`. Der Browser des Benutzers lädt die Seite — interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (i). |
| T-QD3-05 | Information Disclosure (Referrer) | `<iframe>` auf Fremdhost, Link „In neuem Tab öffnen“ | low | mitigate | `referrerPolicy="no-referrer"` am `<iframe>`, `rel="noopener noreferrer"` am Link — der Fremdhost erfährt die Tessera-Adresse nicht, das neue Fenster hat keinen `window.opener`. |
| T-QD3-06 | Elevation of Privilege (Berechtigungs-Delegation) | `allow`-Attribut | medium | mitigate | `allow=""` — keine Delegation von Kamera, Mikrofon, Standort, Zahlung o. ä. an die Fremdseite; getestet (`getAttribute('allow') === ''`). |
| T-QD3-07 | Denial of Service (Bedienbarkeit: Ziehen/Größe im Bearbeitungsmodus) | Overlay in `xframe-widget.tsx` | medium | mitigate | Transparente Fläche über dem Rahmen nur bei `isEditMode` (getestet: vorhanden/nicht vorhanden, Position im DOM nach dem `<iframe>`), damit `mousedown` die Karte erreicht; im Ansichtsmodus bleibt die Seite bedienbar. Rundgang (g). |
| T-QD3-08 | Denial of Service (Neulade-Takt gegen Fremdhost / eigenen Browser) | Timer | low | mitigate | Resolver klemmt: alles unter 60 s wird „nie“, Obergrenze 3600 s; kein Timer im Bearbeitungsmodus; Aufräumfunktion beim Aushängen (getestet über `vi.getTimerCount()`). |
| T-QD3-09 | Tampering (XSS über Titel) | `title` in Kopfleiste und `<iframe title>` | medium | mitigate | Nur React-Textknoten bzw. Attributwert (React escaped), keine HTML-Einfügung; Titel auf 100 Zeichen gekürzt. Getestet: `<b>x</b>` erscheint wörtlich, kein `<b>`-Element. |
| T-QD3-10 | Repudiation | Änderungen an Adresse/Titel | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; für ASVS 1 ausreichend. |
| T-QD3-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `<iframe>` ist HTML, Sandbox ein Attribut. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
</threat_model>
<verification>
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit), `tsc --noEmit` beider Apps, Biome beider Apps, Zähler `as unknown as`, `'xframe'` im DTO, Schlüsselparität `widgets.xframe`.
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung, Handbuch-Zeile genau einmal.
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Katalog, Leerzustand, https → Seite erscheint, http → Meldung, Kopfleiste/Ecksymbol, neuer Tab ohne Verlassen des Tessera-Tabs, Neuladen nach 60 s, Ziehen über dem Rahmen im Bearbeitungsmodus, verweigertes Einbetten bleibt leer + Link funktioniert, kein Server-Abruf.
</verification>
<success_criteria>
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
- [ ] Sandbox exakt `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, `allow=""`, `referrerPolicy="no-referrer"`, `loading="lazy"` — per Test am gerenderten Element
- [ ] http/javascript/data-Adressen: Formular weist ab (Meldung, kein Speichern), Resolver rendert nichts
- [ ] Neuladen: Neueinhängen im Takt, Timer geräumt, kein Timer im Bearbeitungsmodus
- [ ] Bearbeitungsmodus: Overlay vorhanden und nach dem Rahmen im DOM; Ansichtsmodus ohne Overlay
- [ ] Katalog/Registry/Seite/DTO tragen `xframe` als neunten Typ, Registry-Zähler 36
- [ ] Beide Sprachdateien vollständig, Texte siezen, Umlaut-Wächter grün (`neuem` gelistet)
- [ ] Changelog-Stichpunkt und Handbuch-Zeile/-Sätze vorhanden
- [ ] Tore grün, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260921-qd3`
</success_criteria>
<output>
Nach Abschluss `.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md` anlegen (Muster `260921-pi9-SUMMARY.md`): Rot-Nachweis der Resolver-/Widget-Tests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
</output>
@@ -0,0 +1,217 @@
---
phase: quick-260921-qd3
plan: 01
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/api/src/dashboard/dto
tags: [dashboard, widget, xframe, iframe, sandbox, tdd, i18n]
status: complete
requires:
- "STATE.md „NAECHSTER AUFTRAG“: zweites der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
- "quick-260921-pi9 (Bilderrahmen): isHttpsUrl in picture-frame-config.ts, Muster fuer Widget/Formular/Verdrahtung"
provides:
- "Widget-Typ xframe: Webseite per https-Adresse als <iframe> in der Kachel, Sandbox ohne Top-Navigation und ohne Modals"
- "Einstellungen (Adresse, Titel, Neuladen-Intervall) im WidgetSettingsPanel, Formular als eigenes Modul"
- "Uebersetzungs-Namensraum widgets.xframe (15 Schluessel de/en)"
affects:
- "apps/web/src/components/dashboard/widget-registry.tsx (neunter Typ, Zaehler 36)"
- "apps/api/src/dashboard/dto/create-widget.dto.ts (@IsIn)"
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: neuem)"
tech-stack:
added: []
patterns:
- "Fremdseite laedt nur der Browser (<iframe referrerPolicy=no-referrer sandbox=…>), die API kennt keinen Proxy und ruft die Adresse nie ab"
- "https-Pruefung zweifach web-seitig (Formular weist ab, Resolver rendert nichts), weil die API Config-Inhalte nicht prueft"
- "Neuladen ueber key-Wechsel des <iframe> (Adresse + Zaehler), Zaehler als data-reload-nonce sichtbar"
- "Transparente Flaeche NACH dem <iframe> im DOM nur im Bearbeitungsmodus, damit die Karte Ziehgriff bleibt"
key-files:
created:
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/xframe-config-form.tsx
- apps/web/src/components/settings/xframe-config-form.test.tsx
modified:
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/widget-settings-panel.test.tsx
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
decisions:
- "Formular als eigenes Modul xframe-config-form.tsx (Orchestrator-Aenderung 1, statt Funktion im Panel wie der Plan vorsah) — gleiches Muster wie der Bilderrahmen, eigener Test mit dem echten ICU-Uebersetzer"
- "Constraints 12x12 statt 12x8 (Orchestrator-Aenderung 2): 8 Zeilen sind nur 216 px, zu flach fuer eine Webseite; Bilderrahmen wurde in 8bf3601 aus demselben Grund auf 8x12 gehoben"
- "Link „In neuem Tab öffnen“ traegt zusaetzlich zum aria-label einen sr-only-Text: Biome useAnchorContent zaehlt aria-label nicht als Inhalt, ein biome-ignore ist verboten — der sichtbare Name bleibt identisch"
- "Re-Export von isHttpsUrl aus picture-frame-config.ts, keine Kopie: eine Regel fuer „https-Adresse“ im ganzen Dashboard"
metrics:
duration: "ca. 8 min (19:12 bis 19:20 Uhr, 21.09.2026)"
completed: 2026-09-21
actuals:
tokens: 14400
tasks: 2
commits: 2
plan_head_before: 8bf3601
---
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“ Summary
Ein neues Dashboard-Widget zeigt eine Webseite per https-Adresse als
eingebetteten Rahmen in der Kachel. Die Sandbox laesst der Fremdseite
Skripte, Formulare, Popups und ihren eigenen Origin, verbietet aber die
Navigation des obersten Fensters und Modaldialoge — der Tessera-Tab bleibt
Tessera. Adresse, optionaler Titel (Kopfleiste) und Neuladen-Intervall
(nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) stehen unter Einstellungen ->
Dashboard; „In neuem Tab öffnen“ ist immer da, sobald eine Adresse gesetzt
ist. Der Server ruft die Adresse nie ab. Alle Tore sind gruen.
## Was gebaut wurde
**Resolver (`xframe-config.ts`).** Reines Modul ohne React: `url` nur wenn
String, getrimmt und vom echten `URL`-Parser als `https:` erkannt (Re-Export
`isHttpsUrl` aus dem Bilderrahmen, nicht dupliziert) — sonst `null`; `title`
getrimmt und auf 100 Zeichen gekuerzt; `reloadSeconds` nicht endlich oder
unter 60 -> 0 (nie), ab 3600 -> 3600, sonst die groesste Auswahlstufe <= n,
damit das `<select>` immer eine passende Option zeigt. `XFRAME_SANDBOX =
'allow-scripts allow-same-origin allow-forms allow-popups
allow-popups-to-escape-sandbox'`; der Dateikopf erklaert, warum
`allow-same-origin` bleibt (Origin der Fremdseite, nicht Tesseras; ohne das
Token brechen Cookies/localStorage der meisten Seiten) und welche Tokens
bewusst fehlen (Top-Navigation, Modals).
**Widget (`xframe-widget.tsx`).** Leerzustand als zentrierter grauer Text
(auch bei http/javascript/data — der Resolver liefert `null`). Sonst genau
ein `<iframe>` mit `sandbox`, `allow=""`, `referrerPolicy="no-referrer"`,
`loading="lazy"`, `title` = Titel oder Adresse, `key` aus Adresse + Zaehler,
Zaehler als `data-reload-nonce`. Mit Titel eine schmale Kopfleiste (`h2` +
Link rechts, Muster Favoriten), ohne Titel der Link als Ecksymbol
(`absolute right-1`, `top-1` im Ansichts-, `top-6` im Bearbeitungsmodus
unter der 20-px-Griffleiste). Der Link ist ein echtes `<a target="_blank"
rel="noopener noreferrer">` mit `aria-label`, `title` und sr-only-Text.
Timer nur bei `url !== null && reloadSeconds > 0 && !isEditMode`,
Aufraeumfunktion `clearInterval`. Im Bearbeitungsmodus liegt eine
transparente `div` (`absolute inset-0`, `aria-hidden`) NACH dem Rahmen im
DOM, damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt.
**Formular (`xframe-config-form.tsx`, eigenes Modul).** Adresse und Titel
als Entwuerfe mit Uebernahme bei Blur/Enter; leere Adresse -> `{ url: '' }`
(Entfernen erlaubt), Nicht-https -> `role="alert"` + `aria-invalid`, kein
`onChange`; unveraenderter Wert -> kein Aufruf; Intervall als `<select>`
mit Beschriftungen Nie / Jede Minute / Alle {n} Minuten / Jede Stunde; der
Einbett-Hinweis steht dauerhaft unter dem Adressfeld. Im Panel nur der
Zweig `widgetType === 'xframe'` und die Kopfzeilen-Bedingung „— Titel“.
**Verdrahtung.** `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe =
{ minW 4, minH 4, defaultW 12, defaultH 12 }`, `XframeIcon`
(Browserfenster), Registry-Eintrag, `wireXframeWidget`, Katalog-Liste,
Import + Verdrahtung in `(portal)/page.tsx`, `'xframe'` in
`CreateWidgetDto @IsIn` (Kommentar: neun Typen). de/en: 15 Schluessel unter
`widgets.xframe`, identischer Satz; `neuem` auf der Umlaut-Allowlist.
**Doku (Commit 20a9eb2).** Changelog-Stichpunkt als erster unter
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle, erweiterter Satz
und Absatz-Zusatz unter „Dashboard > Widgets“ im Anwenderhandbuch.
## Die Tests, und der Beleg dass sie rot waren
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|---|---:|---|
| `xframe-config.test.ts` | 12 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-config.test.ts"`, Test Files 2 failed (2), Tests no tests |
| `xframe-widget.test.tsx` | 12 | derselbe Lauf -> `Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-widget.test.tsx"`; nach der Umsetzung erster Lauf 12/12 |
| `xframe-config-form.test.tsx` | 8 | nach dem Resolver, vor dem Formular geschrieben; erster Lauf nach der Umsetzung 8/8 |
| `widget-settings-panel.test.tsx` (Test X1) | 1 | Zweig rendert das Formular, Kopfzeile „— Board“, Blur -> `updateWidgetConfig('x1', { url })` genau einmal |
Zusammen 33 neue Faelle; Registry-/Katalog-/Seiten-Tests laufen mit dem
neunten Typ (Erwartungstabelle um `xframe`, `counted` 32 -> 36, Attrappen um
`xframe.*` und das neue Widget-Modul ergaenzt). Web-Tests 569 -> 603
(+34: 33 neue Faelle + 1 durch `it.each` in der Registry).
## Messungen (Endstand, HEAD 20a9eb2)
| Groesse | Ausgang (8bf3601) | Jetzt |
|---|---:|---:|
| `pnpm type-check` | 4/4 | 4/4 |
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
| API-Tests | 1175 | **1175** (75 Dateien) |
| Web-Tests | 569 | **603** (80 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` / `!` / `any` in den neuen Dateien | – | 0 / 0 / 0 |
| de/en-Schluesselgleichheit `widgets.xframe` | – | 15 = 15 |
Keine neue `any`, kein `!`, kein neues Paket, kein Prisma-/Schema-Pfad.
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; die platzierte Kachel ist 12x12 und zeigt „Keine Adresse eingestellt — über die Einstellungen festlegen“
- [x] (b) Einstellungen -> Dashboard -> „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen -> Kachel zeigt die Seite
- [x] (c) `http://…` eintragen -> rote Meldung „Bitte geben Sie eine vollständige https-Adresse ein.“, Netzwerk-Tab zeigt keinen PATCH
- [x] (d) Titel „Board“ -> Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren -> Symbol wandert in die rechte obere Ecke
- [x] (e) Klick auf das Symbol oeffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol
- [x] (f) Intervall „Jede Minute“ -> nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1)
- [x] (g) Bearbeitungsmodus: Kachel laesst sich an einer Stelle ueber dem Rahmen anfassen und ziehen, Groesse aendern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen)
- [x] (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) -> Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert
- [x] (i) API-Log waehrend (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser laedt sie
- [x] (j) `pnpm --filter @tessera/api test` (1175) und `pnpm --filter @tessera/web test` (603) gruen, Zaehler wie Ausgangsmessung (siehe Tabelle oben)
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus 20a9eb2, Playwright-MCP):** alle Punkte bestanden. Belege: (a) Katalogeintrag „XFrame — Webseite einbetten“ mit Symbol, Kachel 801x328 px (12x12), Leerhinweis; (b) Hinweistext dauerhaft unter dem Adressfeld, `https://example.com/` → Kachel zeigt „Example Domain“ im Rahmen (`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"`, `referrerpolicy=no-referrer`, `allow=""`, `loading=lazy`); (c) `http://example.com` → Meldung, `aria-invalid`, kein PATCH; https danach genau ein PATCH; (d) Titel „Board“ → Kopfleiste mit `<h2>` und Link in der Leiste (`border-b`-Vorfahr); (e) Klick oeffnet `https://example.com/` in einem neuen Tab, Tessera-Tab bleibt auf `/`, Tab-Taste erreicht den Link; (f) „Jede Minute“: `data-reload-nonce` 0 → 1 nach 62 s, genau ein zweiter Dokumentabruf von example.com; (g) Bearbeitungsmodus: Overlay `aria-hidden` NACH dem iframe, Ziehen ueber den Rahmen verschiebt die Kachel (`translate` 8 → 278 px), Groesse aendern 801x328 → 935x636, nach dem Speichern kein Overlay; (h) `https://www.google.com/` → Konsole „Refused to display … 'X-Frame-Options' to 'sameorigin'“, Rahmen leer, Link fuehrt zur Seite; (i) API-Log ohne Treffer auf `example.com`/`google.com`. Keine Befunde, keine Korrektur noetig.
## Deviations from Plan
1. **Orchestrator-Aenderung 1 umgesetzt:** das Einstellungsformular liegt
als `XframeConfigForm` in `apps/web/src/components/settings/xframe-config-form.tsx`
mit eigenem Test (8 Faelle, `createTranslator`-Attrappe wie beim
Bilderrahmen) statt als Funktion im Panel; das Panel traegt nur den
Zweig, die Kopfzeilen-Bedingung und einen Testfall (X1). Aufgabe 1,
Commit d63d9f5.
2. **Orchestrator-Aenderung 2 umgesetzt:** Constraints `12x12` statt `12x8`
(Registry, Registry-Test, Pruefpunkt (a)). Aufgabe 1, Commit d63d9f5.
3. **[Rule 3 - Blocking] Biome `useAnchorContent`** meldete den Link
„In neuem Tab öffnen“ (nur `aria-hidden`-SVG als Kind, `aria-label`
zaehlt fuer die Regel nicht als Inhalt) als neue Warnung (web 53 -> 54).
Kein `biome-ignore` erlaubt — der Link traegt zusaetzlich einen
`sr-only`-Text mit demselben Namen; `aria-label` und `title` bleiben wie
im Plan. Web-Lint wieder 53. Aufgabe 1, Commit d63d9f5.
4. **Widget-Test 7** prueft `vi.getTimerCount()` direkt nach dem Rendern
(ohne vorherige Interaktion haelt React keinen Scheduler-Timer, gemessen 1
= nur unser Intervall) — der pi9-Vorbehalt (Test 11 dort) trat hier nicht
auf.
5. **Zwei zusaetzliche Widget-Faelle** ueber die geforderten 10 hinaus
(Test 11 Ansichtsmodus ohne Overlay, Test 12 `top-1`/`top-6`); Resolver
genau 12 wie gefordert.
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
Deploy, kein Zugriff auf den Testserver.
## Known Stubs
Keine. Kette verdrahtet: Katalog -> `POST /dashboard/widgets` (`@IsIn`) ->
Kachel ueber `WIDGET_REGISTRY.xframe.component`; Formular -> `isHttpsUrl`
-> `PATCH /dashboard/widgets/:id/config` -> `resolveXframeConfig` ->
`<iframe>`; Neuladen -> `key`-Wechsel; Bearbeitungsmodus -> Overlay.
## Threat Flags
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-QD3-01
(Sandbox ohne Top-Navigation/Modals), -03 (https zweifach), -04 (kein
Server-Abruf), -05 (no-referrer, noopener noreferrer), -06 (`allow=""`),
-07 (Overlay), -08 (Klemmung 60..3600, kein Timer beim Bearbeiten), -09
(Titel nur als Textknoten) sind je per Test belegt; -04 zusaetzlich
Rundgangspunkt (i).
## Self-Check: PASSED
Alle 6 neu angelegten Dateien liegen auf der Platte, die zwei Commits
d63d9f5 und 20a9eb2 sind in `git log` auffindbar
(`git rev-list --count 8bf3601..HEAD` = 2). Die Zahlen der Tabelle stammen
aus tatsaechlich gelaufenen Befehlen.
@@ -0,0 +1,122 @@
---
phase: quick-260922-frg
plan: 01
type: tdd
autonomous: true
subsystem: apps/desktop/src-tauri
requirements: []
---
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut
## Befund (Orchestrator, 22.09.2026)
Der Nutzer sieht im Tray-Menü nur den grauen Eintrag „Update installieren“,
obwohl alpha das Paket `1.2.0-beta.gc001a08` anbietet und der Client auf
Stand `a6d1a64` steht (Neustart der App ändert nichts). Nachgemessen:
- Die Update-Anfrage genau in der Form des Clients
(`/api-proxy/desktop/update?target=windows&arch=x86_64&current=1.2.0&base=https://alpha.tessera.ctl.de`)
liefert **auf dem alpha-Server selbst** (Port 3000 am Proxy vorbei) `200` mit
gültigem Manifest. Tessera-seitig ist alles in Ordnung.
- **Vor** Tessera steht der Nginx Proxy Manager und antwortet auf jede Anfrage
an `alpha.tessera.ctl.de` mit `401 Authorization Required`
(`WWW-Authenticate: Basic`), gemessen vom Dev-Host (192.168.13.11) und vom
Testserver selbst (192.168.200.240 über die öffentliche Adresse 217.7.63.32).
Die Webansicht der App kann so ein Passwortfenster beantworten und sich das
merken; der Updater (`tauri-plugin-updater`, eigener `reqwest`-Client) nicht.
- Im Plugin führt ein Nicht-2xx-Status NICHT zu einem Fehler mit Statuscode:
`updater.rs` Z. 529-559 loggt nur „did not respond with a successful status
code“, lässt `last_error` leer und endet in `Err(Error::ReleaseNotFound)`.
Unser `spawn_version_check` fängt das mit `Err(_) => {}` — **stumm**. Der
Eintrag bleibt für immer „Update installieren“ (gesperrt), die App prüft
außerdem nur beim Start (und beim Serverwechsel).
Das ist der eigentliche Produktfehler dieser Aufgabe: ein fehlgeschlagener
Update-Check ist vom Zustand „kein Update“ nicht unterscheidbar, und es gibt
keinen Weg, die Prüfung ohne Neustart zu wiederholen. Den Passwortschutz am
Proxy selbst kann der Client nicht lösen (und soll es nicht: Zugangsdaten
gehören nicht in ausgelieferte Clients) — er wird dem Nutzer als Grund
angezeigt.
## Gebundene Entscheidungen (Orchestrator)
1. **Der Update-Eintrag hat drei Endzustände, alle anklickbar außer während
Prüfung/Installation und bei http-Server:**
- Update gefunden → `Auf Beta-Stand <sha7> aktualisieren` / `Auf Version X.Y.Z aktualisieren` (wie bisher), Klick installiert.
- Kein Update → `Kein Update verfügbar – erneut prüfen`, Klick startet die Prüfung erneut.
- Prüfung fehlgeschlagen → `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` bzw. ohne Status `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`, Klick startet die Prüfung erneut.
- Während der Prüfung: `Suche nach Updates…` (gesperrt). Während Download/Installation wie bisher (gesperrt, Fortschritt im Text).
- http-Server: `Update nur über https möglich` (gesperrt, unverändert).
- Beim Bau des Menüs steht `Suche nach Updates…` (gesperrt), weil `setup` die Prüfung sofort startet; ohne gespeicherte Server-Adresse `Kein Update verfügbar – erneut prüfen` (Klick ohne Adresse: nichts tun).
2. **Statuscode nachliefern.** Bei `Err(ReleaseNotFound)` (= Server hat geantwortet, aber nicht 2xx/204) stellt der Client dieselbe Anfrage einmal mit seinem eigenen `reqwest`-Client (Timeout 8 s, Muster `check_server`) an die konkret gebaute Adresse (Platzhalter ersetzt: `target` = `std::env::consts::OS`, `arch` = `std::env::consts::ARCH`, `current` = `CARGO_PKG_VERSION`, `base` wie bisher) und liest NUR den Statuscode. Rumpf wird nicht ausgewertet. Bei `Err(Reqwest(..))`/`Err(Network(..))`/`Err(Io(..))` des Plugins (keine Verbindung, TLS, Timeout) keine zweite Anfrage: Status `None`.
3. **Benachrichtigung mit Erklärung**, einmal je unterschiedlichem Fehlertext (Mutex<String> mit dem zuletzt gemeldeten Text; gleicher Text wird bei der periodischen Prüfung nicht erneut gemeldet). Texte über eine reine Funktion `check_failure_labels(status: Option<u16>) -> (String, String)`:
- `Some(401)` / `Some(403)`: Menü `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen`; Body `Der Server hat die Update-Anfrage mit HTTP 401 abgewiesen. Meist steht ein Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind davon nicht betroffen.`
- `Some(n)` sonst: Menü `Update-Prüfung fehlgeschlagen (HTTP n) – erneut prüfen`; Body `Der Server hat auf die Update-Anfrage mit HTTP n geantwortet statt mit Paketdaten.`
- `None`: Menü `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`; Body `Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier Stunden erneut – oder über den Menüeintrag.`
4. **Periodische Prüfung alle 4 Stunden** (`std::thread::spawn` mit `std::thread::sleep(Duration::from_secs(4 * 3600))` in Schleife; kein neues Crate). Je Durchlauf: gespeicherte Adresse frisch über `stored_server_url(app)` lesen (Serverwechsel berücksichtigt); wenn `PendingUpdate` bereits ein Update hält → überspringen (keine wiederholte Benachrichtigung „Neuer Beta-Stand“); sonst `spawn_version_check`. Der Thread wird in `setup` einmal gestartet.
5. **Klick auf „update“:** `PendingUpdate.take()` → vorhanden: `spawn_update_install` (wie bisher); sonst: wenn eine Server-Adresse gespeichert ist → `spawn_version_check` (manuelles „erneut prüfen“); ohne Adresse → nichts. Der bisherige Browser-Rückfall (`open_download_page`) bleibt NUR im Fehlerpfad der Installation.
6. Ein Mutex-Zustand für die Benachrichtigungs-Entprellung als eigener `app.manage`-Typ (`LastCheckNotice(Mutex<String>)`), damit `spawn_version_check` keine Signatur-Änderung nach außen braucht.
7. Alle Menütexte Deutsch (wie bisher, „Sie“ in Benachrichtigungen), Kommentare Deutsch im Stil der Datei, Verweise auf `updater.rs`-Zeilen wie im Bestand.
## Aufgabe (eine Datei Code, plus Changelog)
<tasks>
<task type="auto" tdd="true">
<name>Aufgabe 1: lib.rs — Zustände des Update-Eintrags, Statuscode-Diagnose, Entprellung, 4-Stunden-Prüfung, Klick = erneut prüfen; Tests; Changelog</name>
<files>apps/desktop/src-tauri/src/lib.rs, CHANGELOG.md</files>
<behavior>
- `check_failure_labels(Some(401))` → Menü enthält `HTTP 401` und endet auf `– erneut prüfen`; Body enthält `Passwortschutz` und `Zugriffsliste`. `Some(403)` gleiche Body-Erklärung mit `HTTP 403`. `Some(502)` → Menü `HTTP 502`, Body ohne `Passwortschutz`, enthält `statt mit Paketdaten`. `None` → Menü enthält `keine Verbindung`, Body enthält `vier Stunden`.
- `diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0")` → `https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64&current=1.2.0&base=https%3A%2F%2Falpha.example` (Schlussstrich getrimmt; `base` kodiert wie in `update_endpoint`).
- Konstanten: `UPDATE_ITEM_CHECKING == "Suche nach Updates…"`, `UPDATE_ITEM_NONE == "Kein Update verfügbar – erneut prüfen"`, `UPDATE_ITEM_INSECURE` unverändert.
- `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint` unverändert (bestehende Tests bleiben grün).
</behavior>
<action>
1. Konstanten: `UPDATE_ITEM_DEFAULT` entfernen (durch `UPDATE_ITEM_CHECKING` und `UPDATE_ITEM_NONE` ersetzt), Kommentare anpassen. Neue Konstante `UPDATE_CHECK_INTERVAL: Duration = 4 h` mit Begründung (Tray-App läuft tagelang; nur Start-Prüfung → Update nie gesehen, Befund 22.09.2026).
2. Reine Funktionen `check_failure_labels(status: Option<u16>) -> (String, String)` und `diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String` (nutzt `api_url` + `url::Url::query_pairs_mut` wie `update_endpoint`, damit die Kodierung identisch ist). Tests zuerst (rot: Funktionen fehlen), mindestens 6 `#[test]` gemäß `<behavior>`.
3. `async fn probe_update_status(url: String) -> Option<u16>`: `reqwest::Client::builder().timeout(8 s)`, `GET`, `Some(resp.status().as_u16())`, bei Fehler `None`. Keine Auswertung des Rumpfs, kein Folgen von Redirects nötig (Standard).
4. `spawn_version_check`: zu Beginn `UPDATE_ITEM_CHECKING` + gesperrt (statt DEFAULT). Ergebnisse:
- `Ok(Some(update))` wie bisher.
- `Ok(None)` → Text `UPDATE_ITEM_NONE`, `set_enabled(true)`.
- `Err(InsecureTransportProtocol)` → wie bisher (gesperrt).
- `Err(ReleaseNotFound)` → `probe_update_status(diagnostic_update_url(&server_url, std::env::consts::OS, std::env::consts::ARCH, env!("CARGO_PKG_VERSION"))).await` → `check_failure_labels(status)` → Menütext setzen, `set_enabled(true)`, Benachrichtigung nur, wenn der Body vom zuletzt gemeldeten (`LastCheckNotice`) abweicht; danach dort ablegen.
- `Err(_)` sonst → `check_failure_labels(None)`, gleiche Behandlung.
Bei `Ok(Some)` und `Ok(None)` `LastCheckNotice` leeren, damit ein späterer Fehler wieder gemeldet wird.
5. `setup`: Menüeintrag mit `UPDATE_ITEM_CHECKING` bauen, wenn eine Adresse gespeichert ist, sonst `UPDATE_ITEM_NONE`; `.enabled(server_url.is_none())` entsprechend. `app.manage(LastCheckNotice(Mutex::new(String::new())))`. Nach dem Start der Erstprüfung den Wiederhol-Thread starten: `let handle = app.handle().clone(); std::thread::spawn(move || loop { std::thread::sleep(UPDATE_CHECK_INTERVAL); let pending = handle.state::<PendingUpdate>().0.lock().map(|g| g.is_some()).unwrap_or(false); if pending { continue; } if let Some(url) = stored_server_url(&handle) { spawn_version_check(handle.clone(), url); } })`. Kommentar: warum kein `tokio::time` (kein neues Feature/Crate) und warum `PendingUpdate` den Durchlauf überspringt.
6. Klick „update“: `take()` wie bisher; `None` → `if let Some(url) = stored_server_url(app) { spawn_version_check(app.clone(), url) }`. Kommentar aktualisieren (der Browser-Weg ist nicht mehr der Rückfall des Klicks).
7. `cargo fmt`, `cargo clippy` (0 Warnungen, wie CI), `cargo test` in `apps/desktop/src-tauri` — alle bestehenden Tests plus die neuen grün. `cargo build` (Debug reicht lokal; Release/Windows baut die CI).
8. `CHANGELOG.md` unter „Unveröffentlicht → Behoben“ als erster Stichpunkt: „Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start“ (kein Fließtext, Tonlage der Nachbarzeilen).
Commit: `fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h` (Wortlaut frei, Stil `git log --oneline -15`).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo clippy 2>&1 | tail -3 && cargo test 2>&1 | tail -5 && grep -q 'HTTP 401' /home/vicolab/projects/tessera-ctl/CHANGELOG.md</automated>
</verify>
<done>Tests in `lib.rs` ≥ 6 neue, alle grün, die Label-/URL-Tests nachweislich zuerst rot (Kompilierfehler „cannot find function“ zählt als rot — im SUMMARY nennen). `cargo clippy` ohne Warnung, `cargo fmt --check` sauber, `cargo build` erfolgreich. Menüzustände wie in Entscheidung 1; Klick ohne abgelegtes Update startet die Prüfung; Wiederhol-Thread alle 4 h; Benachrichtigung entprellt. CHANGELOG-Zeile steht. Genau ein Commit mit Scope `desktop`.</done>
</task>
</tasks>
## Hinweise für den Executor
- Nur `apps/desktop/src-tauri/src/lib.rs` und `CHANGELOG.md` anfassen. Keine neuen Crates, keine Cargo.toml-Änderung (reqwest ist da, `url` kommt über `tauri::Url`).
- `MenuItem::set_text`/`set_enabled` liefern `Result`, wie im Bestand mit `let _ =` ignorieren.
- `stored_server_url(app)` existiert (siehe `open_download_page`). `AppHandle` ist Clone + Send; `std::thread::spawn` mit dem Klon ist zulässig (die Setup-Funktion nutzt bereits `app.handle().clone()` für den Async-Task).
- Die Prüfung im CI: `cargo check` + `cargo clippy`; lokal zusätzlich `cargo test`. Der Cross-Bau für Windows läuft in der CI (Stempel ändert sich, weil `apps/desktop` berührt wird).
- Nicht anfassen: `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint`, `spawn_update_install`.
## Verifikation durch den Orchestrator (nach CI)
- CI-Lauf grün, Job `desktop` hat neu gebaut (kein Cache-Treffer), Manifest im API-Abbild trägt den neuen Stempel.
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` und eine Benachrichtigung mit der Proxy-Erklärung.
<threat_model>
ASVS 1, block on high.
| ID | Bedrohung | Schwere | Disposition |
|---|---|---|---|
| T-FRG-01 | Diagnose-Anfrage folgt Redirects zu fremden Hosts | low | Nur Statuscode wird gelesen, kein Rumpf; Ziel ist die vom Nutzer gespeicherte Server-Adresse; kein Geheimnis in der Anfrage. Akzeptiert. |
| T-FRG-02 | Benachrichtigungs-Spam durch periodische Prüfung gegen kaputten Proxy | low | Entprellung über `LastCheckNotice` (gleicher Text wird nicht erneut gemeldet). Mitigiert. |
| T-FRG-03 | Proxy-Zugangsdaten in den Client einbauen, um 401 zu umgehen | high | Ausdrücklich NICHT umgesetzt (Entscheidung Befund); der Grund wird angezeigt, die Behebung liegt am Proxy. Mitigiert durch Nicht-Bau. |
| T-FRG-04 | Wiederhol-Thread startet Prüfung während Installation | low | Während der Installation ist `PendingUpdate` durch `take()` leer, ein Durchlauf würde nur eine Prüfung anstoßen; `spawn_version_check` setzt den Menütext — Restrisiko: Fortschrittstext wird überschrieben, wenn genau im Download-Fenster die 4-h-Marke fällt. Akzeptiert (Download dauert Minuten, Intervall Stunden). |
</threat_model>
@@ -0,0 +1,152 @@
---
phase: quick-260922-frg
plan: 01
subsystem: apps/desktop/src-tauri
tags: [desktop, tauri, updater, tray, proxy, 401, tdd]
status: complete
requires:
- "quick-260917-kgc (In-App-Updater, PendingUpdate, spawn_version_check)"
provides:
- "Update-Eintrag im Tray mit drei sichtbaren Endzustaenden, nie mehr stumm gesperrt"
- "Statuscode-Diagnose nach Err(ReleaseNotFound) ueber eigene Anfrage"
- "Wiederholte Update-Pruefung alle vier Stunden"
- "Klick auf den Eintrag ohne abgelegtes Update prueft erneut"
affects:
- "apps/desktop/src-tauri/src/lib.rs"
- "CHANGELOG.md"
tech-stack:
added: []
patterns:
- "Fehlerzustand eines Hintergrund-Checks im Menuetext ausschreiben statt Eintrag stumm sperren"
- "Zweite Diagnose-Anfrage nur fuer den Statuscode, wenn eine Bibliothek ihn verschluckt"
- "Benachrichtigung ueber Mutex<String> mit dem zuletzt gemeldeten Text entprellen"
key-files:
created: []
modified:
- apps/desktop/src-tauri/src/lib.rs
- CHANGELOG.md
decisions:
- "Proxy-Zugangsdaten werden NICHT in den Client eingebaut (T-FRG-03); der Grund wird angezeigt, die Behebung liegt am Proxy"
- "Wiederhol-Thread als std::thread mit sleep statt tokio::time, damit kein neues Feature/Crate noetig ist"
- "diagnostic_update_url liefert String statt Option: bei unparsbarer Adresse faellt sie auf api_url zurueck, gespeicherte Adressen sind ohnehin immer parsebar"
- "report_check_failure und clear_check_notice als eigene Helfer, damit die drei Fehlerzweige in spawn_version_check kurz bleiben"
metrics:
duration: "ca. 20 min (11:05 bis 11:26 Uhr, 22.09.2026)"
completed: 2026-09-22
actuals:
tokens: 5600
tasks: 1
commits: 1
plan_head_before: ae36a22
---
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut Summary
Der Update-Eintrag im Menue des Infobereich-Symbols zeigt jetzt in jedem Fall,
was die Pruefung ergeben hat: ein Update, kein Update, oder den Grund des
Fehlschlags (z. B. „HTTP 401“, wenn der Passwortschutz am Proxy die Anfrage
abweist). Ein Klick auf den Eintrag prueft erneut, und die App prueft von
selbst alle vier Stunden statt nur beim Start. Nur der http-Fall bleibt
weiterhin dauerhaft gesperrt.
## Was sich fuer den Betrieb aendert
Bisher konnte der Nutzer nicht unterscheiden, ob es kein Update gibt oder ob
die Pruefung gescheitert ist — in beiden Faellen stand da grau „Update
installieren“, und ohne Neustart der App wurde nie wieder geprueft. Genau das
war auf dem Client gegen alpha passiert: der Nginx Proxy Manager antwortet auf
die Update-Anfrage mit 401, das Updater-Plugin macht daraus stumm
`ReleaseNotFound`, und der Eintrag blieb grau, obwohl alpha das Paket
`1.2.0-beta.gc001a08` bereithielt.
Jetzt steht in diesem Fall „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut
prüfen“ im Menue, und einmalig erscheint eine Benachrichtigung, die den
Passwortschutz am vorgeschalteten Proxy als wahrscheinlichen Grund nennt und
klarstellt, dass Anmeldung und Arbeiten in der App nicht betroffen sind. Den
Passwortschutz selbst kann und soll der Client nicht umgehen; die Behebung
liegt am Proxy (Ausnahme fuer `/api-proxy/desktop/*` oder Aufhebung des
Schutzes fuer alpha).
## Umsetzung
- **Konstanten:** `UPDATE_ITEM_DEFAULT` („Update installieren“) ist weg,
ersetzt durch `UPDATE_ITEM_CHECKING` („Suche nach Updates…“, gesperrt
waehrend der Pruefung) und `UPDATE_ITEM_NONE` („Kein Update verfügbar –
erneut prüfen“, anklickbar). Neu `UPDATE_CHECK_INTERVAL = 4 h`.
- **Reine Funktionen:** `check_failure_labels(Option<u16>)` liefert Menue- und
Benachrichtigungstext (401/403 mit Proxy-Erklaerung, andere Codes neutral,
`None` = keine Verbindung). `diagnostic_update_url` baut die Update-Adresse
mit ersetzten Platzhaltern in genau der Kodierung von `update_endpoint`.
- **Statuscode-Diagnose:** Bei `Err(ReleaseNotFound)` stellt
`probe_update_status` dieselbe Anfrage einmal mit eigenem `reqwest`-Client
(8 s Timeout, Muster `check_server`) und liest nur den Statuscode. Bei
Verbindungsfehlern des Plugins (`Err(_)` sonst) keine zweite Anfrage.
- **Entprellung:** `LastCheckNotice(Mutex<String>)` als eigener
`app.manage`-Typ; `report_check_failure` meldet nur einen abweichenden Text,
`clear_check_notice` leert ihn nach `Ok(Some)`/`Ok(None)`.
- **Wiederhol-Thread:** in `setup` einmal gestartet, `std::thread::spawn` mit
`sleep(UPDATE_CHECK_INTERVAL)` in Schleife, liest die Adresse je Durchlauf
frisch und ueberspringt, wenn `PendingUpdate` bereits ein Update haelt.
- **Klick „update“:** `take()` wie bisher; ohne abgelegtes Update startet
`spawn_version_check` mit der gespeicherten Adresse; ohne Adresse nichts.
`open_download_page` bleibt nur im Fehlerpfad der Installation.
- **Menuebau:** „Suche nach Updates…“ (gesperrt) mit gespeicherter Adresse,
sonst „Kein Update verfügbar – erneut prüfen“ (aktiv).
- **Changelog:** neuer erster Stichpunkt unter „Unveröffentlicht → Behoben“.
## TDD Gate Compliance
**RED (nachgewiesen):** Die sieben neuen Tests wurden zuerst eingefuegt.
`cargo test` brach mit neun Fehlern `E0425` ab — `cannot find function
check_failure_labels`, `cannot find function diagnostic_update_url`,
`cannot find value UPDATE_ITEM_CHECKING / UPDATE_ITEM_NONE /
UPDATE_CHECK_INTERVAL`. Der Kompilierfehler zaehlt laut Plan als rot.
**GREEN:** Nach der Umsetzung `cargo test`: 44 bestanden, 0 fehlgeschlagen
(37 Bestandstests plus 7 neue). `is_update_newer`, `update_labels`,
`release_labels`, `update_endpoint`, `spawn_update_install` unveraendert.
**REFACTOR:** Zwei Helfer (`report_check_failure`, `clear_check_notice`)
herausgezogen, damit die Fehlerzweige in `spawn_version_check` lesbar bleiben;
der Kommentar bei `with_desktop_marker` nennt nicht mehr den alten Text
„Update installieren“.
## Cargo-Ergebnisse (apps/desktop/src-tauri, lokal)
| Schritt | Ergebnis |
|---|---|
| `cargo fmt --check` | sauber |
| `cargo clippy` | 0 Warnungen, 0 Fehler |
| `cargo test` | 44 passed, 0 failed |
| `cargo build` (Debug) | erfolgreich, Systembibliotheken vorhanden |
| `grep 'HTTP 401' CHANGELOG.md` | Treffer |
## Commit
- `d73aad1` fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h — `apps/desktop/src-tauri/src/lib.rs`, `CHANGELOG.md`
## Deviations from Plan
None - plan executed exactly as written. Einzige Ergaenzung ausserhalb der
Aufzaehlung: der Doc-Kommentar bei `with_desktop_marker` verwies noch auf den
entfernten Menuetext „Update installieren“ und wurde mitgezogen (reine
Kommentar-Korrektur, kein Verhalten).
## Known Stubs
Keine.
## Offen (fuer den Orchestrator, nach CI)
- CI-Lauf: Job `desktop` muss neu bauen (apps/desktop beruehrt), Manifest im
API-Abbild traegt den neuen Stempel.
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt
„Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ plus die
Benachrichtigung mit der Proxy-Erklaerung. Damit der Client danach das
Update tatsaechlich bekommt, muss der Proxy die Update-Anfrage durchlassen.
## Self-Check: PASSED
- `apps/desktop/src-tauri/src/lib.rs` vorhanden und geaendert
- `CHANGELOG.md` enthaelt die neue Zeile
- Commit `d73aad1` in `git log` vorhanden
@@ -0,0 +1,229 @@
---
phase: quick-260922-ge2
plan: 01
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260922-GE2]
files_modified:
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/xframe-config-form.tsx
- apps/web/src/components/settings/xframe-config-form.test.tsx
- apps/web/src/test/fake-resize-observer.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
estimate:
tokens: 130000
raw_tokens: 130000
tasks: 2
confidence: low
must_haves:
truths:
- "Unter Einstellungen → Dashboard → XFrame schaltet „Nur einen Ausschnitt der Seite anzeigen“ eine Vorschau der Seite bei 1280 Pixel Breite frei (sichtbare Höhe 420 px, eigener Bildlauf); darüber liegt ein Rahmen, den der Benutzer mit der Maus verschiebt und an den vier Ecken in der Größe zieht; beim Loslassen wird der Ausschnitt genau einmal gespeichert; Links/Oben/Breite/Höhe stehen zusätzlich als Zahlenfelder (Übernahme bei Blur/Enter) mit demselben Klemmen."
- "Die Kachel zeigt bei gesetztem Ausschnitt nur diesen Bereich der Seite, eingepasst (contain) und zentriert; ändert sich die Kachelgröße, skaliert der Ausschnitt mit und bleibt immer ganz sichtbar (gemessen per ResizeObserver am Kachelkörper)."
- "Ohne Ausschnitt gibt es eine Vergrößerung (50/60/75/90/100/125/150 %) für die ganze Seite; 100 % rendert exakt wie heute (keine Transformation); die Auswahl ist nur ohne Ausschnitt sichtbar."
- "„Nur anzeigen“ legt im Ansichtsmodus eine transparente Fläche über den Rahmen (Klicken und Scrollen in der Seite sind gesperrt); beim Einschalten des Ausschnitts wird es automatisch mit eingeschaltet; „In neuem Tab öffnen“ bleibt klickbar; im Bearbeitungsmodus liegt weiterhin nur die Bearbeitungsfläche (nie beide)."
- "Unsinnige Werte kommen nie ins CSS: Ausschnitt-Zahlen werden auf x ≥ 0, y ≥ 0, w ∈ [100, 1280], h ∈ [60, 4000], x + w ≤ 1280 (x wird verschoben, nicht abgewiesen) geklemmt, ungültige Formen fallen auf null; Zoom auf die nächstniedrigere erlaubte Stufe (mindestens 50); readOnly nur bei echtem true."
- "Das Formular sagt dauerhaft, dass der Ausschnitt eine Position auf der Seite ist und verrutschen kann, wenn die Seite ihren Aufbau ändert; Seiten, die das Einbetten verweigern, bleiben in Vorschau und Kachel leer (Hinweis von qd3 bleibt)."
- "Sandbox, referrerPolicy, allow, loading, Neuladen-Nonce/Key, Kopfleiste, Leerzustand und Bearbeitungsfläche sind unverändert; die Vorschau nutzt dieselbe Sandbox und pointer-events: none; der Server ruft die Adresse weiterhin nie ab; alle Tore grün (type-check 4/4, lint 5/5 mit web weiterhin 53 Warnungen, Web-Tests ≥ 634 bei heute 604, API-Tests 1175, `as unknown as` web 6, kein `any`, kein `!`)."
artifacts:
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeCrop`, `XframeConfig` um `crop | null`, `zoom`, `readOnly` erweitert; `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W/MIN_H/MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS`, `XFRAME_ZOOM_DEFAULT`, `clampXframeCrop`; Resolver klemmt"
- "apps/web/src/components/dashboard/widgets/xframe-crop.ts — reine Geometrie ohne React: `computeCropLayout(crop, tileW, tileH)`, `applyCropDrag(mode, start, dxPage, dyPage)`, `XFRAME_PREVIEW_PAGE_HEIGHT`"
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Ausschnitt-Modus (Clip + verschobener, skalierter <iframe>), Zoom-Modus, readOnly-Fläche, ResizeObserver am Kachelkörper, `data-tile-size`"
- "apps/web/src/components/settings/xframe-config-form.tsx — Checkbox Ausschnitt, Vorschau mit Rahmen und vier Griffen (Pointer-Events), Zahlenfelder, Zoom-Auswahl (nur ohne Ausschnitt), Checkbox „Nur anzeigen“, dauerhafter Hinweis"
- "apps/web/src/test/fake-resize-observer.ts — `stubResizeObserver({ width, height })` für Widget- und Formular-Test (ein einziger Cast `as ResizeObserverEntry`)"
- "apps/web/src/messages/de.json + en.json — 13 neue Schlüssel unter `widgets.xframe`, identischer Satz; umlaut-dictionary.ts: `Ausschnitt` gelistet"
- "CHANGELOG.md — Stichpunkt direkt nach dem XFrame-Stichpunkt; docs/anleitung-anwender.md — je ein Satz in Tabellenzeile und Absatz „Dashboard > Widgets“"
key_links:
- "Checkbox `xframe-crop-enable` -> `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf) -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig` -> `crop !== null` -> ResizeObserver misst den Körper -> `computeCropLayout` -> Clip-`div` + <iframe style={left/top/transform}>"
- "Rahmen-`onPointerDown` (Capture, jsdom: ohne) -> `onPointerMove` -> `applyCropDrag(mode, startCrop, dx/p, dy/p)` -> Entwurf im lokalen State (Rahmen folgt der Maus) -> `onPointerUp` -> `clampXframeCrop` -> genau ein `onChange({ crop })` nur bei Änderung"
- "`zoom !== 100 && crop === null` -> <iframe absolute style={width/height %, transform scale}>; `zoom === 100` -> heutige Klassen `h-full w-full`, kein style"
- "`readOnly && !isEditMode` -> `xframe-readonly-overlay` NACH dem Rahmen/Clip im DOM; `isEditMode` -> nur `xframe-edit-overlay`; Ecksymbol-Link `z-10` bleibt über beiden"
---
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“
<objective>
Das XFrame-Widget (quick-260921-qd3, HEAD 5aa577a) bekommt drei Erweiterungen: (1) einen **Ausschnitt** der eingebetteten Seite — in den Einstellungen wählt der Benutzer ihn in einer Vorschau der Seite bei fester Seitenbreite 1280 px, indem er ein Rechteck verschiebt und an den Ecken zieht (alternativ vier Zahlenfelder); die Kachel zeigt nur diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn mit der Kachelgröße; (2) eine **Vergrößerung** (50–150 %) für die Ganzseiten-Ansicht ohne Ausschnitt; (3) **„Nur anzeigen“** — eine Fläche über dem Rahmen sperrt Klicks und Bildlauf in der Seite (beim Einschalten des Ausschnitts automatisch an). Ehrliche Grenzen stehen im Formular: der Ausschnitt ist eine Pixelposition auf der Seite, keine Inhaltserkennung; verweigernde Seiten bleiben leer.
Purpose: Produktwunsch des Nutzers (abgestimmt); die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan verfeinert sie, ohne sie neu zu öffnen.
Output: erweiterter Resolver + neues reines Geometrie-Modul (zuerst rot), erweitertes Widget und Formular mit Tests, Test-Helfer für den ResizeObserver, Übersetzungen, Changelog- und Handbuch-Sätze; alle Tore grün.
</objective>
## Gebundene Entscheidungen (Orchestrator, verfeinert — nicht neu verhandeln)
1. **Konfiguration** (alle Felder optional, Resolver klemmt; Speicherung im bestehenden Config-JSON per flacher Zusammenführung der API). `crop: { x, y, w, h } | null` in Seitenpixeln bei virtueller Seitenbreite `XFRAME_PAGE_WIDTH = 1280` (Konstante, keine UI). Klemmen in `clampXframeCrop` (exportiert, EINE Funktion für Resolver, Zahlenfelder und Ziehen): alle vier Werte auf ganze Zahlen runden; `w` auf [`XFRAME_CROP_MIN_W` 100, 1280], `h` auf [`XFRAME_CROP_MIN_H` 60, `XFRAME_CROP_MAX_H` 4000], `x = max(0, x)`, `y = max(0, y)`, danach `x + w > 1280 → x = 1280 − w` (x verschieben, nie abweisen). Resolver: `crop` nur, wenn ein Nicht-null-Objekt mit vier endlichen Zahlen `x, y, w, h` vorliegt → geklemmt; alles andere → `null`. `zoom: number` in Prozent, `XFRAME_ZOOM_OPTIONS = [50, 60, 75, 90, 100, 125, 150]`, `XFRAME_ZOOM_DEFAULT = 100`; nicht endliche Zahl/fehlt → 100; sonst die größte Stufe ≤ n, unter 50 → 50. `readOnly: boolean`, nur `=== true` → true. `XFRAME_CROP_DEFAULT = { x: 0, y: 0, w: 1280, h: 720 }`.
2. **Reine Geometrie** in NEUEM Modul `xframe-crop.ts` (ohne React, eigener Test zuerst rot): `computeCropLayout(crop, tileW, tileH) → { scale, left, top, frameHeight }` mit `scale = min(tileW / crop.w, tileH / crop.h)` (contain, darf > 1 sein), `left = (tileW − crop.w·scale) / 2`, `top = (tileH − crop.h·scale) / 2`, `frameHeight = max(crop.y + crop.h, 720)`; `tileW ≤ 0 || tileH ≤ 0 → { scale: 0, left: 0, top: 0, frameHeight }` (nichts rendern, bis gemessen). `applyCropDrag(mode, start, dxPage, dyPage) → XframeCrop` mit `mode ∈ 'move' | 'nw' | 'ne' | 'sw' | 'se'`: Kanten `left/top/right/bottom` aus `start` bilden; `move` verschiebt alle vier um `(dx, dy)` und klemmt die Position bei fester Größe auf `left ∈ [0, 1280 − w]`, `top ∈ [0, max(0, XFRAME_PREVIEW_PAGE_HEIGHT − h)]`; die Eckmodi bewegen nur die beiden Kanten der Ecke (`nw`: left+dx, top+dy; `ne`: right+dx, top+dy; `sw`: left+dx, bottom+dy; `se`: right+dx, bottom+dy), klemmen `left ≥ 0`, `top ≥ 0`, `right ≤ 1280`, `bottom ≤ XFRAME_PREVIEW_PAGE_HEIGHT` und halten die Mindestgröße an der bewegten Kante (`left = min(left, right − 100)`, `right = max(right, left + 100)`, `top = min(top, bottom − 60)`, `bottom = max(bottom, top + 60)`) — die gegenüberliegende Ecke bleibt stehen; Ergebnis durch `clampXframeCrop`. `XFRAME_PREVIEW_PAGE_HEIGHT = 3000` lebt hier (Vorschau UND Ziehmathematik brauchen es; 4000 bleibt die Speichergrenze für getippte Werte — ein getippter Wert jenseits der Vorschau ist gültig, aber in der Vorschau nicht sichtbar; im Hinweis nicht erwähnen, Randfall).
3. **Rendering (Widget).** Der Kachelkörper (`<div className="relative min-h-0 flex-1 overflow-hidden">`, bereits vorhanden) bekommt `ref={bodyRef}`; ein `useEffect` mit Abhängigkeit `hasCrop = crop !== null` hängt NUR bei Ausschnitt einen `ResizeObserver` an (Muster dashboard-grid.tsx Zeile 134: `entries[0].contentRect`), hält `{ w, h }` im State, Aufräumfunktion `disconnect()`. Körper trägt im Ausschnitt-Modus `data-tile-size={`${Math.round(w)}x${Math.round(h)}`}`. **Ausschnitt-Modus** (`crop !== null`, nur wenn `layout.scale > 0`): Clip `<div data-testid="xframe-crop-clip" style={{ position: 'absolute', left, top, width: crop.w·scale, height: crop.h·scale, overflow: 'hidden' }}>` mit dem `<iframe>` darin: `style={{ position: 'absolute', left: −crop.x·scale, top: −crop.y·scale, width: XFRAME_PAGE_WIDTH, height: frameHeight, transform: `scale(${scale})`, transformOrigin: '0 0' }}`, `className="border-0 bg-background"` (KEIN `h-full w-full`), alle heutigen Attribute (`key`, `src`, `title`, `sandbox`, `allow=""`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) unverändert. Dateikopf erklärt: cross-origin lässt sich die Seite nicht von außen scrollen — deshalb wird der Rahmen SELBST verschoben und skaliert und ein Clip schneidet ihn; die feste Layoutbreite 1280 hält den Seitenaufbau über alle Kachelgrößen stabil, sonst würde die Seite bei jeder Kachelgröße anders umbrechen und der Ausschnitt verrutschen. **Zoom-Modus** (`crop === null`, `z = zoom / 100 ≠ 1`): `className="absolute left-0 top-0 border-0 bg-background"`, `style={{ width: pct, height: pct, transform: `scale(${z})`, transformOrigin: '0 0' }}` mit `pct = `${Math.round(10000 / z) / 100}%`` (60 % → `166.67%`, 150 % → `66.67%`; absolut positioniert, damit die Prozente sicher am Körper hängen). `z === 1` → exakt heute: `className="h-full w-full border-0 bg-background"`, kein `style`. **readOnly**: `!isEditMode && readOnly` → `<div data-testid="xframe-readonly-overlay" aria-hidden="true" className="absolute inset-0" />` NACH dem Rahmen bzw. Clip im DOM; `isEditMode` → nur die bestehende `xframe-edit-overlay` (nie beide). Das Ecksymbol (`z-10`) und die Kopfleiste liegen über beiden Flächen — unverändert.
4. **Formular** (`xframe-config-form.tsx`, unter den bestehenden Feldern, in dieser Reihenfolge): (a) Checkbox `xframe-crop-enable` (Klassen wie `show-date-toggle` im Panel: `h-4 w-4 rounded border-border text-primary`, Label `text-sm text-foreground`) — Einschalten → `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf), Ausschalten → `onChange({ crop: null })` (readOnly unberührt); (b) bei `crop !== null` der Vorschaublock; (c) Zahlenfelder; (d) Zoom-Auswahl `<select id="xframe-zoom">` NUR bei `crop === null` (Optionen aus `XFRAME_ZOOM_OPTIONS`, Text `t('xframe.zoomOption', { n })`, `value={String(zoom)}`, `onChange({ zoom: Number(value) })`); (e) Checkbox `xframe-readonly`; (f) darunter dauerhaft `<p id="xframe-crop-hint" className="text-xs text-muted-foreground">{t('xframe.cropHint')}</p>`. **Vorschau**: äußerer `<div ref={previewRef} data-testid="xframe-crop-preview" className="relative overflow-y-auto rounded border border-border bg-background" style={{ height: 420 }}>`; Breite per `ResizeObserver` auf `previewRef` (gleiches Muster wie im Widget), `p = width > 0 ? width / 1280 : 0.5`; darin die Bühne `<div data-testid="xframe-crop-stage" className="relative" style={{ width: 1280·p, height: XFRAME_PREVIEW_PAGE_HEIGHT·p }}>` mit (i) `<iframe data-testid="xframe-crop-preview-frame" src={url} title={t('xframe.cropRectangle')} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="border-0" style={{ position: 'absolute', left: 0, top: 0, width: 1280, height: XFRAME_PREVIEW_PAGE_HEIGHT, transform: `scale(${p})`, transformOrigin: '0 0', pointerEvents: 'none' }}>`, (ii) dem Rahmen `<div role="group" aria-label={t('xframe.cropRectangle')} data-testid="xframe-crop-rect" className="absolute cursor-move touch-none border-2 border-primary" style={{ left: c.x·p, top: c.y·p, width: c.w·p, height: c.h·p }}>` (`c = draft ?? crop`) mit vier Griffen `<div aria-hidden="true" data-testid="xframe-crop-handle-{nw|ne|sw|se}" className="absolute h-3 w-3 touch-none bg-primary …">` an den Ecken (`-left-1.5 -top-1.5 cursor-nwse-resize`, `-right-1.5 -top-1.5 cursor-nesw-resize`, `-left-1.5 -bottom-1.5 cursor-nesw-resize`, `-right-1.5 -bottom-1.5 cursor-nwse-resize`). Keine Maskierung außerhalb des Rahmens (Rand genügt). Ohne `url`: statt Bühne `<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">{t('xframe.cropPreviewEmpty')}</p>`, kein `<iframe>`. Über der Vorschau ein Hinweis `t('xframe.cropPreviewHint')` (Bedienung + „1280 Pixel Breite“). **Pointer-Logik** (eine Fabrik `dragProps(mode)` liefert `onPointerDown/onPointerMove/onPointerUp/onPointerCancel` für Rahmen und Griffe; Griffe rufen in `onPointerDown` `e.stopPropagation()`, sonst startet der Rahmen zusätzlich ein Verschieben): `onPointerDown` — nur Haupttaste (`e.button === 0`), `dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop }`, Capture per Helfer `capturePointer(e.currentTarget, e.pointerId)`; `onPointerMove` — bei aktivem Zug `setDraft(applyCropDrag(mode, start, (e.clientX − startX) / p, (e.clientY − startY) / p))`; `onPointerUp` — Capture lösen, `dragRef.current = null`, Ergebnis `next = draft ?? start`, `setDraft(null)`, `onChange({ crop: next })` NUR wenn sich eines der vier Felder gegenüber `crop` geändert hat (ein bloßer Klick erzeugt keinen PATCH); `onPointerCancel` — verwerfen ohne `onChange`. Deltas statt `getBoundingClientRect` — Bildlauf im Vorschaubehälter stört so nicht. **Capture-Wächter (Befund, jsdom 29.1.1):** jsdom kennt `PointerEvent` (clientX/pointerId funktionieren), aber NICHT `Element.setPointerCapture`/`releasePointerCapture` — Helfer `capturePointer(el: HTMLElement, id: number)` / `releasePointer(el, id)` rufen nur, wenn `typeof el.setPointerCapture === 'function'` (kompiliert, Biome still — geprüft); im Browser wird gefangen, in jsdom nicht (die Tests feuern Move/Up auf demselben Element). **Zahlenfelder** als eigene kleine Funktion `CropNumberFields({ crop, onCommit })` mit `key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`}` (State-Reset per Key, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen): vier String-Entwürfe, `<input id="xframe-crop-{x|y|w|h}" type="number" inputMode="numeric" min={0} aria-describedby="xframe-crop-unit-hint">` in einem `grid grid-cols-4 gap-2`, Labels `t('xframe.cropX')` … (`mb-1 block text-sm text-foreground`), Felder `h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground`; Übernahme bei Blur/Enter: `Number(draft)` endlich → `next = clampXframeCrop({ ...crop, [k]: n })`, `onCommit(next)` nur bei Änderung; nicht endlich → nichts. Darunter `<p id="xframe-crop-unit-hint" className="text-xs text-muted-foreground">{t('xframe.cropUnitHint')}</p>`.
5. **Kachelgrößen-Testhaken — Entscheidung: KEIN `measuredSize`-Prop.** Befund: `apps/web/src/test/setup.ts` polyfillt `ResizeObserver` bereits global für jsdom (feuert in `observe()` sofort mit `contentRect` 1200×800). Der Produktionscode bleibt frei von Test-Oberfläche; Widget- und Formular-Test überschreiben den Observer dateiweise mit expliziter Größe über den neuen Helfer `apps/web/src/test/fake-resize-observer.ts`: `export function stubResizeObserver(size: { width: number; height: number }): void` ruft `vi.stubGlobal('ResizeObserver', class { constructor(cb: ResizeObserverCallback) {…} observe() { this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this); } unobserve() {} disconnect() {} })` — EIN Cast `as ResizeObserverEntry` (geprüft: kompiliert unter strict/dom, kein `as unknown as`; jsdom hat `DOMRectReadOnly`); Tests rufen `vi.unstubAllGlobals()` in `afterEach`. Widget-Test misst 640×360, Formular-Test 640 breit (`p = 0.5`: 50 Bildschirm-px = 100 Seiten-px, glatte Zahlen). Die Mathematik selbst wird in `xframe-crop.test.ts` direkt geprüft.
6. **Übersetzungen** `widgets.xframe.*`, 13 neue Schlüssel in de UND en (gleicher Satz, Deutsch mit „Sie“): `cropEnable` („Nur einen Ausschnitt der Seite anzeigen“ / „Show only a section of the page“), `cropHint` („Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.“ / „The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.“), `cropPreviewHint` („Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.“ / „Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.“), `cropPreviewEmpty` („Die Vorschau erscheint, sobald eine Adresse eingetragen ist.“ / „The preview appears once an address is set.“), `cropRectangle` („Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe“ / „Section – drag to move, corners to resize“), `cropX` („Links“/„Left“), `cropY` („Oben“/„Top“), `cropW` („Breite“/„Width“), `cropH` („Höhe“/„Height“), `cropUnitHint` („Werte in Pixeln der Seite bei 1280 Pixel Breite“ / „Values in page pixels at 1280 pixels wide“), `zoomLabel` („Vergrößerung der ganzen Seite“ / „Zoom of the full page“), `zoomOption` („{n} %“ beide), `readOnly` („Nur anzeigen – Klicks und Scrollen im Rahmen sperren“ / „View only – block clicks and scrolling inside the frame“). **Umlaut-Wächter** (Tokenizer `/[A-Za-zÄÖÜäöüß]+/g`, ganze Wörter): einziger neuer Verdachts-Token ist `Ausschnitt` (korrektes Deutsch; `Bildausschnitt` ist ein anderer Token) → in `UMLAUT_ALLOWLIST` nach `neuem` mit Kommentar `// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“`; `muss`, `Adresse` sind gelistet. Danach `pnpm --filter @tessera/web exec vitest run src/messages` — meldet der Wächter weitere Tokens, ebenfalls listen (nur korrekte deutsche Wörter).
7. **Tore unverändert**: kein `any`, `as unknown as` web bleibt 6, kein `!`, keine HTML-Einfügung, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome-Warnungen web bleiben **53** (Ausgang gemessen) — Pointer-Handler stehen nicht auf der a11y-Handlerliste von Biome (`onClick/onMouseDown/onKey*`); sollte dennoch eine a11y-Warnung auf Rahmen oder Griffen erscheinen, ohne `biome-ignore` beheben und im SUMMARY vermerken. Keine neuen Pakete. Keine Änderung an `widget-settings-panel.tsx` (der Zweig reicht `config`/`onChange` durch), keiner an Registry, Katalog, API — die API führt die neuen Felder flach zusammen, `crop: null` wird als JSON-null gespeichert.
## Ausgangsmessung (22.09.2026, HEAD 5aa577a, Baum sauber)
| Größe | Wert |
|---|---:|
| Web-Tests | 604 |
| API-Tests | 1175 |
| `as unknown as` in apps/web/src | 6 (davon 1 in test/setup.ts) |
| `as unknown as` in apps/api/src | 27 |
| Biome-Warnungen web (`pnpm --filter @tessera/web lint`) | 53 |
| `noNonNullAssertion` / `noExplicitAny` / `biome-ignore` in apps/api/src | 56 / 13 / 1 |
| jsdom | 29.1.1 — `PointerEvent` ja, `setPointerCapture` nein, `DOMRectReadOnly` ja |
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.test.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.test.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
@/home/vicolab/projects/tessera-ctl/apps/web/src/test/setup.ts
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md
</context>
## Hinweise für den Executor
- **Ausgangspunkt HEAD 5aa577a**, Baum sauber. Nur die in `files_modified` genannten Dateien anfassen; **nie** `.planning/**` (Akte/STATE macht der Orchestrator).
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
- **Rot-Nachweis:** `xframe-config.test.ts` (neue Fälle), `xframe-crop.test.ts` und die neuen Widget-Fälle werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
- **Bestehende Erwartungen anpassen:** `xframe-config.test.ts` Test 1 und Test 8 prüfen mit `toEqual` das ganze Objekt — um `crop: null, zoom: 100, readOnly: false` erweitern, sonst rot aus dem falschen Grund.
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `style`-Werte sind Zahlen (React hängt `px` an) oder Template-Strings aus eigenen Zahlen; `transform`/`transformOrigin`/`pointerEvents` sind reguläre CSS-Props.
- **jsdom lädt keine Unterressourcen** — auch der Vorschau-`<iframe>` erzeugt keinen Netzabruf. Inline-Styles per `element.style.left` usw. prüfen (`'20px'`, `'-160px'`, `'scale(0.8)'`, `'166.67%'`).
- **Pointer-Tests:** `fireEvent.pointerDown(el, { clientX, clientY, pointerId: 1, button: 0 })`, dann `pointerMove` und `pointerUp` auf DEMSELBEN Element (jsdom fängt nicht); Griffe stoppen die Propagation, sonst zählt der Rahmen mit.
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260922-ge2`, deutsche Betreffzeile, Abschlusszeile `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1: Resolver + Geometrie (rot → grün), Widget-Ausschnitt/Zoom/readOnly, Formular mit Vorschau und Ziehen, Übersetzungen — Ende-zu-Ende „Ausschnitt einschalten → Rahmen ziehen → Kachel zeigt den Ausschnitt eingepasst“</name>
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-crop.ts, apps/web/src/components/dashboard/widgets/xframe-crop.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/xframe-config-form.tsx, apps/web/src/components/settings/xframe-config-form.test.tsx, apps/web/src/test/fake-resize-observer.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<reversibility rating="reversible">Alle neuen Config-Felder sind optional und werden geklemmt; ohne sie rendert das Widget exakt wie heute.</reversibility>
<behavior>
- Resolver (≥ 8 neue `it` in `xframe-config.test.ts`): `crop` fehlt / `null` / `'x'` / `{ x: 1 }` / `{ x: 'a', y: 0, w: 500, h: 300 }` → `null`; `{ x: 0, y: 0, w: 1280, h: 720 }` → unverändert; `{ x: 500, y: 0, w: 1000, h: 300 }` → `x: 280` (x verschoben, w bleibt); `w: 50 → 100`, `w: 5000 → 1280` (und x → 0), `h: 10 → 60`, `h: 9999 → 4000`; `x: -5 → 0`, `y: -1 → 0`; `x: 10.6 → 11` (gerundet); `zoom` fehlt/`'abc'`/`NaN` → 100, `70 → 60`, `200 → 150`, `0 → 50`, `10 → 50`, `125 → 125`; `readOnly: true → true`, `'true'`/`1`/fehlt → `false`; `XFRAME_ZOOM_OPTIONS` gleich `[50, 60, 75, 90, 100, 125, 150]`, `XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_DEFAULT` gleich `{ x: 0, y: 0, w: 1280, h: 720 }`; alle sechs Felder zusammen (`url`, `title`, `reloadSeconds`, `crop`, `zoom`, `readOnly`) → `toEqual` mit Erwartung; `clampXframeCrop` ist exportiert und liefert für `{ x: 1200, y: 0, w: 200, h: 100 }` → `{ x: 1080, y: 0, w: 200, h: 100 }`.
- Geometrie (≥ 9 `it` in `xframe-crop.test.ts`): `computeCropLayout({0,0,1280,720}, 640, 720)` → `scale 0.5, left 0, top 180` (breitenbegrenzt); `(…, 1280, 180)` → `scale 0.25, left 480, top 0` (höhenbegrenzt); `({0,0,200,100}, 800, 400)` → `scale 4, left 0, top 0` (Vergrößerung erlaubt); `frameHeight`: `{0,0,1280,100}` → 720, `{0,3000,1280,400}` → 3400; `(crop, 0, 0)` und `(crop, 640, 0)` → `scale 0` (Rest 0); `applyCropDrag('move', {100,100,400,300}, 100, 50)` → `{200,150,400,300}`; `move` um `+2000, 0` → `x: 880` (1280 − 400), `move` um `−500, −500` → `{0,0,400,300}`; `'se'` um `+100, +100` → `{100,100,500,400}`; `'nw'` um `+350, +10` → `{400,110,100,290}` (Mindestbreite an der bewegten Kante, rechte Kante 500 bleibt); `'ne'` um `+2000, 0` → `w: 1180` (rechte Kante 1280); `'sw'` um `0, +5000` → `h: 2900` (untere Kante 3000).
- Widget (≥ 6 neue `it`, `stubResizeObserver({ width: 640, height: 360 })` in `beforeEach`, `vi.unstubAllGlobals()` in `afterEach`): (1) `{ url: U, crop: {0,0,1280,720} }` → Körper `data-tile-size="640x360"`; Clip `style.left '0px'`, `top '0px'`, `width '640px'`, `height '360px'`, `overflow 'hidden'`; `<iframe>` im Clip mit `style.left '0px'`, `top '0px'`, `width '1280px'`, `height '720px'`, `transform 'scale(0.5)'`, `transformOrigin '0 0'`, Klasse OHNE `h-full`, und weiterhin `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `loading 'lazy'`, `data-reload-nonce '0'`; (2) `{ url: U, crop: {200,100,800,400} }` → Clip `left '0px'`, `top '20px'`, `width '640px'`, `height '320px'`; `<iframe>` `left '-160px'`, `top '-80px'`, `height '720px'`, `transform 'scale(0.8)'`; (3) `{ url: U, zoom: 60 }` → kein Clip, `<iframe>` `style.width '166.67%'`, `height '166.67%'`, `transform 'scale(0.6)'`, Klasse enthält `absolute`; (4) `{ url: U, zoom: 100 }` und `{ url: U }` → `style.transform ''`, Klasse enthält `h-full` und `w-full`, kein Clip; `{ url: U, crop: {…}, zoom: 60 }` → Ausschnitt-Modus, Zoom ignoriert (kein `scale(0.6)`); (5) `{ url: U, readOnly: true }` Ansichtsmodus → `xframe-readonly-overlay` mit `aria-hidden="true"`, Klassen `absolute inset-0`, im DOM NACH dem `<iframe>`; kein `xframe-edit-overlay`; Link `xframe.openInNewTab` vorhanden mit Klasse `z-10`; (6) `{ url: U, readOnly: true }` Bearbeitungsmodus → `xframe-edit-overlay` vorhanden, `xframe-readonly-overlay` NICHT; `{ url: U }` Ansichtsmodus → kein readonly-Overlay; (7) `{ url: U, crop: {…} }` Bearbeitungsmodus → Clip vorhanden UND `xframe-edit-overlay` vorhanden; `{ url: U, crop: {…}, title: 'Board' }` → `<h2>` Board bleibt. Bestehende Tests 1–12 bleiben grün.
- Formular (≥ 7 neue `it`, `stubResizeObserver({ width: 640, height: 420 })`, Übersetzer wie bisher aus der echten de.json): (1) `{ url: U }` → Checkbox `texts.cropEnable` nicht angehakt, keine Vorschau, `<select>` `texts.zoomLabel` vorhanden mit Optionen `['50','60','75','90','100','125','150']` und Texten `'50 %' … '150 %'`, Wert `'100'`; Klick auf die Checkbox → `onChange` genau einmal mit `{ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true }`; (2) `{ url: U, crop: {100,100,400,300} }` → Checkbox angehakt, `xframe-crop-preview` vorhanden, Vorschau-`<iframe>` mit `src U`, `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `style.pointerEvents 'none'`, `width '1280px'`, `height '3000px'`, `transform 'scale(0.5)'`; Rahmen `xframe-crop-rect` (`role="group"`, Name `texts.cropRectangle`) mit `style.left '50px'`, `top '50px'`, `width '200px'`, `height '150px'`; vier Griffe vorhanden; KEIN `<select>` `texts.zoomLabel`; Klick auf die Checkbox → `onChange({ crop: null })` genau einmal; (3) Zahlenfelder: `xframe-crop-w` auf `'2000'` + Blur → `onChange({ crop: { x: 0, y: 100, w: 1280, h: 300 } })`; `xframe-crop-h` auf `'10'` + Enter → `onChange({ crop: { x: 100, y: 100, w: 400, h: 60 } })`; `xframe-crop-x` auf `'abc'` + Blur → kein weiterer Aufruf; `xframe-crop-x` auf `'100'` (unverändert) + Blur → kein Aufruf; (4) Ziehen am Rahmen: `pointerDown(rect, { clientX: 100, clientY: 100, pointerId: 1, button: 0 })`, `pointerMove(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` noch NICHT gerufen, Rahmen `style.left '100px'`, `top '75px'` (Entwurf folgt); `pointerUp(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` genau einmal mit `{ crop: { x: 200, y: 150, w: 400, h: 300 } }`; (5) Ecke `xframe-crop-handle-se`: Down/Move `+50/+50`/Up → genau ein Aufruf `{ crop: { x: 100, y: 100, w: 500, h: 400 } }` (der Rahmen hat NICHT zusätzlich verschoben); `xframe-crop-handle-nw` um `−50/−50` → `{ crop: { x: 0, y: 0, w: 500, h: 400 } }`; (6) Down + Up ohne Bewegung → kein Aufruf; `pointerCancel` nach Move → kein Aufruf und Rahmen wieder bei `50px`; (7) `{ url: U, readOnly: true }` → Checkbox `texts.readOnly` angehakt, Klick → `onChange({ readOnly: false })`; `{ url: U }` → nicht angehakt, Klick → `{ readOnly: true }`; Hinweis `texts.cropHint` immer sichtbar; (8) `{ crop: {…} }` ohne `url` → `xframe-crop-preview-empty` mit `texts.cropPreviewEmpty`, kein Vorschau-`<iframe>`; (9) Zoom-Auswahl `'60'` → `onChange({ zoom: 60 })` sofort. Bestehende Tests 1–8 bleiben grün.
</behavior>
<action>
**Reihenfolge: Resolver + Geometrie rot → grün, dann Widget (Tests zuerst), dann Formular (Tests zuerst), zuletzt Übersetzungen + Allowlist.**
1. **`xframe-config.ts`** (Entscheidung 1): Dateikopf um einen Absatz ergänzen — warum Ausschnitt-Zahlen geklemmt und nie abgewiesen werden (T-GE2-05: kein negativer, kein riesiger, kein NaN-Wert erreicht das CSS; ein manipulierter Config-Wert ergibt höchstens einen anderen Ausschnitt) und dass `zoom` nur ohne Ausschnitt wirkt. Exporte: `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W`, `XFRAME_CROP_MIN_H`, `XFRAME_CROP_MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS: number[]`, `XFRAME_ZOOM_DEFAULT`, `interface XframeCrop { x: number; y: number; w: number; h: number }`, `clampXframeCrop(raw: XframeCrop): XframeCrop`, `XframeConfig` um `crop: XframeCrop | null; zoom: number; readOnly: boolean` erweitert; private `resolveCrop(raw: unknown)` (Objekt-Prüfung ohne Cast: `typeof raw === 'object' && raw !== null`, dann die vier Felder über `Record<string, unknown>`-Zugriff mit `typeof === 'number' && Number.isFinite`), `resolveZoom(raw: unknown)` nach dem Muster `resolveReload` (größte Stufe ≤ n, unter 50 → 50, nicht endlich → 100), `resolveReadOnly(raw: unknown)` (`raw === true`). **`xframe-config.test.ts`**: neuer `describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit allen Fällen aus `<behavior>`; Tests 1 und 8 um die drei neuen Felder ergänzen. Vor der Umsetzung rot.
2. **`xframe-crop.ts`** NEU (Entscheidung 2, ohne React-Import). Dateikopf: Zweck (Geometrie für Kachel und Vorschau), warum contain + Zentrierung (der Ausschnitt muss immer GANZ sichtbar sein, Kachelseitenverhältnis ≠ Ausschnittverhältnis), warum `frameHeight` mindestens 720 (die Seite braucht Layouthöhe, sonst rendern viele Seiten ihren Inhalt nicht), warum die Ziehmathematik die gegenüberliegende Ecke festhält. Exporte: `XFRAME_PREVIEW_PAGE_HEIGHT = 3000`, `type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se'`, `interface XframeCropLayout { scale: number; left: number; top: number; frameHeight: number }`, `computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout`, `applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop` — beide exakt wie in Entscheidung 2 beschrieben, Ergebnis von `applyCropDrag` durch `clampXframeCrop`. **`xframe-crop.test.ts`** mit den Fällen aus `<behavior>` (≥ 9 `it`), vor der Umsetzung rot (Import schlägt fehl).
3. **`xframe-widget.tsx`** (Entscheidung 3). Dateikopf um zwei Absätze ergänzen: (a) Ausschnitt — cross-origin kann Tessera die Seite nicht von außen scrollen, deshalb wird der `<iframe>` selbst mit `left/top` verschoben und mit `transform: scale` skaliert, ein Clip-`div` schneidet ihn; die feste Layoutbreite `XFRAME_PAGE_WIDTH` hält den Seitenaufbau über alle Kachelgrößen stabil (sonst bräche die Seite bei jeder Kachelgröße anders um und der Ausschnitt verrutschte); der ResizeObserver am Körper liefert die Kachelgröße; (b) readOnly — eine transparente Fläche über dem Rahmen sperrt Zeiger-Eingaben (Bedienkomfort, KEINE Sicherheitsmaßnahme: die Seite läuft weiter, T-GE2-02); nie zusammen mit der Bearbeitungsfläche. Imports: `useRef` dazu, `computeCropLayout` aus `./xframe-crop`, `XFRAME_PAGE_WIDTH` aus `./xframe-config`. State `const [tile, setTile] = useState({ w: 0, h: 0 })`, `const bodyRef = useRef<HTMLDivElement>(null)`, `const hasCrop = crop !== null`; `useEffect(() => { const el = bodyRef.current; if (!el || !hasCrop) return; const observer = new ResizeObserver((entries) => { const rect = entries[0].contentRect; setTile({ w: rect.width, h: rect.height }); }); observer.observe(el); return () => observer.disconnect(); }, [hasCrop])`. **Hook-Reihenfolge:** alle Hooks VOR dem Leerzustand-`return` (wie heute `useEffect` für den Timer). Ein kleines `frameAttrs`-Objekt (`src`, `title`, `sandbox`, `allow`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) und der `key` werden in allen drei Zweigen identisch gesetzt (Zweige: Ausschnitt / Zoom ≠ 100 / heute). Körper: `ref={bodyRef}`, `data-tile-size` nur bei `hasCrop`; Reihenfolge der Kinder: Rahmen bzw. Clip → `isEditMode && xframe-edit-overlay` → `!isEditMode && readOnly && xframe-readonly-overlay` → Ecksymbol-Link (unverändert `z-10`). **`xframe-widget.test.tsx`**: Import `stubResizeObserver` aus `@/test/fake-resize-observer`; `beforeEach` zusätzlich `stubResizeObserver({ width: 640, height: 360 })`, `afterEach` zusätzlich `vi.unstubAllGlobals()`; neuer `describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 6 `it`; Inline-Styles über `(el as HTMLElement).style.left` usw. — `getByTestId` liefert `HTMLElement`, kein Cast nötig; der `<iframe>` über `frame()` wie bisher). Vor der Umsetzung rot.
4. **`apps/web/src/test/fake-resize-observer.ts`** NEU (Entscheidung 5): Kopfkommentar (Englisch wie setup.ts): warum ein dateiweiser Stub statt des globalen Polyfills (explizite Größe, deterministische Geometrie), dass der eine Cast `as ResizeObserverEntry` genügt (die Klasse ist strukturell ein `ResizeObserver`), Aufräumen per `vi.unstubAllGlobals()`. Export `stubResizeObserver(size: { width: number; height: number }): void`. Import von `vi` aus `vitest`. Kein `as unknown as`.
5. **`xframe-config-form.tsx`** (Entscheidung 4). Dateikopf um einen Absatz ergänzen: Vorschau = derselbe `<iframe>` mit derselben Sandbox (T-GE2-01), `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen — Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite), Ziehen über Pointer-Events mit Capture (Wächter für jsdom), Entwurf lokal, ein `onChange` beim Loslassen; Ausschnitt = Position, kein Inhalt (Hinweis dauerhaft). Imports: `useEffect, useRef` dazu; `XFRAME_CROP_DEFAULT, XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, XFRAME_ZOOM_OPTIONS, clampXframeCrop` und `type XframeCrop` aus `xframe-config`; `XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag` und `type XframeDragMode` aus `@/components/dashboard/widgets/xframe-crop`. Destrukturierung um `crop, zoom, readOnly` erweitern. Konstante `PREVIEW_HEIGHT_PX = 420`. Komponenten in derselben Datei: `CropPreview({ url, crop, onCommit })` (Vorschau + Rahmen + Griffe + Pointer-Logik + Breitenmessung) und `CropNumberFields({ crop, onCommit })` (mit `key` vom Aufrufer); Helfer `capturePointer`/`releasePointer` (typeof-Wächter) als Modulfunktionen; `sameCrop(a, b)` für den Änderungsvergleich. Reihenfolge im JSX nach dem Intervall-Feld: Checkbox Ausschnitt → (bei crop) Hinweis `cropPreviewHint`, `CropPreview`, `CropNumberFields key=…` → (ohne crop) Zoom-Auswahl → Checkbox readOnly → `cropHint`. Klassen der Checkboxen wie `show-date-toggle` im Panel (`flex items-center gap-3`, Input `h-4 w-4 rounded border-border text-primary`). **`xframe-config-form.test.tsx`**: Import `stubResizeObserver`; `beforeEach` `stubResizeObserver({ width: 640, height: 420 })`, `afterEach` `cleanup()` + `vi.unstubAllGlobals()`; `renderForm` liefert zusätzlich `cropEnable`, `readOnly` (per `getByLabelText`) und Helfer `rect()`, `handle(corner)`, `numberField(k)`; neuer `describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 7 `it`). Vor der Umsetzung rot.
6. **Übersetzungen + Allowlist** (Entscheidung 6): 13 Schlüssel in `de.json` und `en.json` unter `widgets.xframe` nach `embedHint`; `Ausschnitt` in `UMLAUT_ALLOWLIST`; `pnpm --filter @tessera/web exec vitest run src/messages` grün.
Commit: `feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen` (Wortlaut frei, Stil beachten, Co-Authored-By-Zeile).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx src/components/settings/xframe-config-form.test.tsx src/components/settings/widget-settings-panel.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;for(const k of ['cropEnable','cropHint','cropPreviewHint','cropPreviewEmpty','cropRectangle','cropX','cropY','cropW','cropH','cropUnitHint','zoomLabel','zoomOption','readOnly']){if(!(k in d)||!(k in e)){console.error('fehlt:',k);process.exit(1)}}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
</verify>
<done>`xframe-config.test.ts` ≥ 20 Fälle (12 alte + ≥ 8 neue), `xframe-crop.test.ts` ≥ 9, `xframe-widget.test.tsx` ≥ 18 (12 + ≥ 6), `xframe-config-form.test.tsx` ≥ 15 (8 + ≥ 7) — alle grün, die neuen Resolver-, Geometrie- und Widget-Fälle nachweislich zuerst rot (Rot-Lauf im SUMMARY). Umlaut-Wächter grün (`Ausschnitt` gelistet), beide Sprachdateien tragen dieselben 13 neuen Schlüssel. Kette nachgewiesen (Tests): Checkbox → EIN `onChange` mit Ausschnitt + readOnly; Ziehen → Entwurf folgt, EIN `onChange` beim Loslassen mit geklemmtem Ausschnitt; Ecke → Größe, Gegenecke bleibt; Zahlenfelder klemmen; Kachel → Clip und verschobener, skalierter Rahmen für gemessene 640×360; Zoom 60 → Prozentmaße + `scale(0.6)`, Zoom 100 → wie heute; readOnly-Fläche nur im Ansichtsmodus, Link darüber; Sandbox/no-referrer/allow/lazy in Kachel UND Vorschau. `tsc --noEmit` ohne Befund, Biome web weiterhin 53 Warnungen, `as unknown as` web 6, keine `any`, kein `!`.</done>
</task>
<task type="auto">
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
<action>
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als Stichpunkt DIREKT NACH dem bestehenden XFrame-Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert“.
2. **`docs/anleitung-anwender.md`**: (a) in der Zeile „| XFrame | …“ der Widget-Tabelle (Zeile 83) vor dem schließenden „ |“ genau einen Satz anfügen: „Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen.“; (b) im Absatz „**Dashboard > Widgets:**“ (Zeile 155) nach dem letzten Satz („… dass manche Webseiten das Einbetten verweigern.“) genau einen Satz anfügen: „Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“).
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in der qd3-SUMMARY (Tabelle „Messungen“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep, Biome-Warnungen web) und ins SUMMARY schreiben.
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Einstellungen → Dashboard → XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken → genau ein PATCH mit `crop` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen über der ganzen Breite (0/0/1280/720), Zahlenfelder zeigen 0/0/1280/720, Zoom-Auswahl ist verschwunden; (b) Rahmen mit der Maus verschieben → folgt flüssig, beim Loslassen genau ein PATCH, Zahlenfelder springen auf die neuen Werte; (c) Ecke unten rechts ziehen → Größe ändert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links ziehen bis unter die Mindestbreite → Rahmen bleibt 100 px breit, rechte Kante steht; (d) Zahlenfeld Breite auf 2000 → Feld zeigt 1280, Links springt auf 0; Höhe 10 → 60; (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip` mit `data-tile-size` am Körper, `<iframe>` mit `transform: scale(…)`), Sandbox/no-referrer/allow unverändert; (f) Kachel im Bearbeitungsmodus vergrößern/verkleinern → nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen über dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`); (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar; (h) Ausschnitt abhaken → PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % → Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % → kein `style`; (i) verweigernde Seite (`https://www.google.com`) → Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse.
Commit: `docs(quick-260922-ge2): Changelog und Anwenderhandbuch - XFrame-Ausschnitt, Zoom, Nur anzeigen` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator; Co-Authored-By-Zeile).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -A1 'Dashboard-Widget „XFrame“: eine Webseite per https-Adresse' CHANGELOG.md | grep -q 'nur einen Ausschnitt der Webseite zeigen' && grep -q '| XFrame | .*Wahlweise zeigen Sie nur einen Ausschnitt der Seite' docs/anleitung-anwender.md && grep -q 'Nur einen Ausschnitt der Seite anzeigen' docs/anleitung-anwender.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
</verify>
<done>Changelog-Stichpunkt steht direkt nach dem XFrame-Stichpunkt; Handbuch trägt den Tabellensatz und den Absatzsatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web 53 Warnungen; API 1175 Tests, Web ≥ 634 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die neunpunktige Prüfliste steht im SUMMARY; genau zwei Commits mit Scope `quick-260922-ge2` (`git log --oneline -2`).</done>
</task>
</tasks>
<assumption_delta_decision>
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung, real ausgeführt): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; alles bleibt im Browser (Rahmen, Transformationen, Pointer-Events), die API sieht nur drei weitere Schlüssel im undurchsichtigen Config-JSON. Keine COVERAGE.md nötig. **Feuert nicht.**
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt: kein Pflichtfeld wird optional (die drei Felder sind neu und optional), keine zweite Variante einer Identität; `zoom` macht aus dem festen 100 % einen gewählten Wert — eine Anzeigeeinstellung je Kachel, kein Identitätsmodell. Entscheidung: `no-change`. **Feuert nicht.**
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang (Config-JSON bleibt `Json`). **Feuert nicht.**
</assumption_delta_decision>
<threat_model>
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
## Vertrauensgrenzen
| Grenze | Beschreibung |
|---|---|
| Config-JSON → Browser | `crop`/`zoom`/`readOnly` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in Inline-Styles (`left/top/width/height/transform`) |
| Tessera-Seite ↔ eingebettete Fremdseite (Kachel UND Vorschau) | Zwei `<iframe>` derselben Adresse; die Fremdseite läuft in ihrem Origin, sieht keine Tessera-Eingaben, kann das oberste Fenster nicht navigieren |
| Zeiger → Rahmen/Griffe (Vorschau) | Pointer-Capture bindet Bewegungen an Tesseras eigene Elemente; die Vorschau-Seite ist für Zeiger unerreichbar |
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab (jetzt bis zu zweimal: Kachel, Vorschau); der Server nie |
## STRIDE-Register
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|---|---|---|---|---|---|
| T-GE2-01 | Spoofing / Elevation (Navigation des obersten Fensters, Modaldialoge) | `<iframe sandbox>` in Kachel (alle drei Render-Zweige) UND Vorschau | high | mitigate | Beide Rahmen tragen exakt `XFRAME_SANDBOX` (ohne `top-navigation`, ohne `modals`), `allow=""`, `referrerPolicy="no-referrer"`; Clip, Verschiebung und `transform: scale` ändern nichts an der Sandbox — getestet am gerenderten Attribut im Ausschnitt-Modus (Widget-Test 1) und an der Vorschau (Formular-Test 2). |
| T-GE2-02 | Information Disclosure / Tampering (Fehldeutung: readOnly als Schutz) | `xframe-readonly-overlay` | low | accept | Die Fläche ist Bedienkomfort, keine Sicherheitsmaßnahme: die Fremdseite lädt und läuft weiter (Skripte, Cookies, Neuladen), nur Zeigerereignisse erreichen sie nicht; Tastaturfokus in den Rahmen ist weiterhin möglich. Dateikopf des Widgets sagt das ausdrücklich; die Sicherheitsgrenze bleibt die Sandbox (T-GE2-01). |
| T-GE2-03 | Tampering (Vorschau-Seite fängt Zeigerereignisse / Clickjacking in der Vorschau) | Vorschau-`<iframe>` mit `pointer-events: none`, Rahmen und Griffe darüber | medium | mitigate | `style.pointerEvents 'none'` (getestet), Rahmen/Griffe absolut über dem Rahmen, Capture auf Tesseras eigenen Elementen; die Fremdseite kann keine Ziehbewegung abfangen und keinen Klick unter dem Rahmen entgegennehmen. |
| T-GE2-04 | Server-Side Request Forgery | API | high | mitigate | Unverändert: die API ruft NIE die Adresse ab, kein Proxy, kein Fetch; die Vorschau lädt die Seite ein zweites Mal im Browser des Benutzers, nie am Server; keine API-Änderung in dieser Aufgabe. Nachweis im Rundgang (i). |
| T-GE2-05 | Denial of Service (riesige/negative/NaN-Maße im CSS: Browser friert bei 10⁹-px-Rahmen ein, negative Breiten, `transform: scale(NaN)`) | `crop`/`zoom` → Inline-Styles | medium | mitigate | `clampXframeCrop` (Rundung, w ∈ [100, 1280], h ∈ [60, 4000], x/y ≥ 0, x + w ≤ 1280) im Resolver, in den Zahlenfeldern und beim Ziehen; `zoom` nur aus der erlaubten Stufenliste (mindestens 50); `computeCropLayout` liefert bei ungemessener Kachel `scale 0` (nichts rendern statt Division durch 0); Vorschau-Rahmen fest 1280×3000. Getestet: Resolver-Fälle (5000 → 1280, 9999 → 4000, −5 → 0, NaN → null/100), Geometrie (Nullkachel). |
| T-GE2-06 | Tampering (XSS über Konfigurationswerte) | Inline-Styles, `aria-label`, Hinweistexte | low | mitigate | Keine HTML-Einfügung; alle Style-Werte sind Zahlen oder Template-Strings aus eigenen geklemmten Zahlen (React setzt sie als CSS-Eigenschaften, nicht als Markup); Texte nur aus den Sprachdateien. Sichtbarer Nachweis im Rundgang (e), (h). |
| T-GE2-07 | Elevation of Privilege (Pointer-Capture) | `capturePointer` auf Rahmen/Griffen | low | accept | Capture bindet nur die Ereignisse eines Zeigers an ein Tessera-eigenes Element bis zum Loslassen; `onPointerCancel` verwirft; keine Rechte, kein Zugriff auf die Fremdseite; in jsdom fehlt Capture, im Browser wird sie ordnungsgemäß gelöst. |
| T-GE2-08 | Denial of Service (Vorschau lädt die Fremdseite zusätzlich) | Vorschau-`<iframe>` | low | accept | Nur während die Einstellungen offen sind und nur bei gesetztem Ausschnitt; kein Neuladen-Timer in der Vorschau; höchstens ein zweiter Dokumentabruf — vertretbar. |
| T-GE2-09 | Repudiation | Änderungen an Ausschnitt/Zoom/readOnly | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; ASVS 1 genügt. |
| T-GE2-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — Pointer-Events, `ResizeObserver`, `transform` sind Browser-APIs. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
</threat_model>
<verification>
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit mit den 13 neuen Schlüsseln), `tsc --noEmit`, Biome mit exakt 53 Warnungen, Zähler `as unknown as` web 6.
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps (API 1175, Web ≥ 634), Disziplin-Zähler wie in der Ausgangsmessung, Changelog-Stichpunkt und Handbuch-Sätze genau einmal.
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Ausschnitt einschalten → Vorschau mit Rahmen; Ziehen verschiebt; Ecke ändert die Größe; Zahlenfelder klemmen; Kachel zeigt den Ausschnitt eingepasst und zentriert; Kachelgröße ändern → Ausschnitt skaliert mit; „Nur anzeigen“ sperrt Klicks, Link bleibt klickbar; Zoom 60 % in der Ganzseiten-Ansicht; verweigernde Seite bleibt leer; kein Server-Abruf.
</verification>
<success_criteria>
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
- [ ] Resolver klemmt Ausschnitt (x-Verschiebung statt Abweisung), Zoom-Stufen, readOnly — per Test
- [ ] `computeCropLayout`/`applyCropDrag` rein, ohne React, mit den Rand- und Mindestgrößenfällen getestet
- [ ] Kachel: Clip + verschobener, skalierter Rahmen für gemessene Größe; Zoom-Zweig; 100 % wie heute; readOnly-Fläche nur im Ansichtsmodus; Sandbox unverändert
- [ ] Formular: Checkbox → EIN Aufruf mit Ausschnitt + readOnly; Vorschau mit `pointer-events: none` und derselben Sandbox; Ziehen/Ecken → EIN Aufruf beim Loslassen; Zahlenfelder; Zoom nur ohne Ausschnitt; dauerhafter Hinweis
- [ ] Kein `measuredSize`-Prop; Kachelgröße im Test über `stubResizeObserver` (ein Cast `as ResizeObserverEntry`); Pointer-Capture mit typeof-Wächter
- [ ] Beide Sprachdateien vollständig (13 neue Schlüssel), Texte siezen, Umlaut-Wächter grün (`Ausschnitt` gelistet)
- [ ] Changelog-Stichpunkt und Handbuch-Sätze vorhanden
- [ ] Tore grün, Biome web 53 Warnungen, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260922-ge2`
</success_criteria>
<output>
Nach Abschluss `.planning/quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/260922-ge2-SUMMARY.md` anlegen (Muster `260921-qd3-SUMMARY.md`): Rot-Nachweis der Resolver-/Geometrie-/Widget-Tests, Zahlen der Endmessung, die neunpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
</output>
@@ -0,0 +1,267 @@
---
phase: quick-260922-ge2
plan: 01
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/web/src/test
tags: [dashboard, widget, xframe, iframe, crop, zoom, pointer-events, resize-observer, tdd, i18n]
status: complete
requires:
- "quick-260921-qd3 (XFrame-Widget): Resolver, Widget, Formular, Sandbox, Neuladen"
provides:
- "XFrame-Ausschnitt: crop {x,y,w,h} in Seitenpixeln bei fester Layoutbreite 1280, in der Kachel eingepasst (contain) und zentriert, skaliert mit der Kachelgroesse"
- "Vorschau der Seite in den Einstellungen mit verschieb- und ziehbarem Rahmen (vier Ecken) plus Zahlenfelder Links/Oben/Breite/Hoehe"
- "Vergroesserung 50..150 % fuer die Ganzseiten-Ansicht ohne Ausschnitt"
- "„Nur anzeigen“: transparente Flaeche ueber dem Rahmen im Ansichtsmodus"
- "Test-Helfer stubResizeObserver({ width, height }) fuer deterministische Geometrie in jsdom"
affects:
- "apps/web/src/messages/de.json + en.json (13 neue Schluessel widgets.xframe.*)"
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: Ausschnitt)"
tech-stack:
added: []
patterns:
- "Cross-origin laesst sich die Seite nicht von aussen scrollen: der <iframe> selbst wird mit left/top verschoben, mit transform: scale skaliert und von einem Clip-div geschnitten; feste Layoutbreite 1280 haelt den Seitenaufbau stabil"
- "EINE Klemmregel clampXframeCrop fuer Resolver, Zahlenfelder und Ziehen — Werte werden verschoben/gekappt, nie abgewiesen (T-GE2-05)"
- "Reine Geometrie (computeCropLayout, applyCropDrag) ohne React in eigenem Modul, zuerst rot getestet"
- "Pointer-Events mit Capture-Waechter (typeof setPointerCapture) — Browser faengt, jsdom nicht; Deltas statt getBoundingClientRect"
- "Kein Test-Prop im Produktionscode: Kachelgroesse im Test ueber dateiweisen ResizeObserver-Stub (ein Cast as ResizeObserverEntry)"
key-files:
created:
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
- apps/web/src/test/fake-resize-observer.ts
modified:
- apps/web/src/components/dashboard/widgets/xframe-config.ts
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
- apps/web/src/components/settings/xframe-config-form.tsx
- apps/web/src/components/settings/xframe-config-form.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
decisions:
- "Rahmen der Vorschau als <fieldset aria-label> statt <div role=\"group\">: Biome useSemanticElements meldete role=group (web 53 -> 54), biome-ignore ist verboten; das <fieldset> traegt die Gruppen-Rolle implizit, getByRole('group', { name }) findet es, Preflight nimmt Rand/Innenabstand (m-0 p-0 min-w-0 zusaetzlich)"
- "Objekt-Pruefung im Resolver ueber Typwaechter isRecord(raw): raw is Record<string, unknown> statt Zuweisung — `const r: Record<string, unknown> = raw` kompiliert unter strict nicht (object ohne Index-Signatur), ein Cast waere gegen die Regel"
- "Kopfkommentar des Test-Helfers ohne die woertliche Phrase des Zaehlers — der grep zaehlt Kommentare mit"
metrics:
duration: "ca. 10 min (10:03 bis 10:14 Uhr UTC, 22.09.2026)"
completed: 2026-09-22
actuals:
tokens: 17700
tasks: 2
commits: 2
plan_head_before: 5aa577a
---
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“ Summary
Das XFrame-Widget zeigt auf Wunsch nur einen Ausschnitt der eingebetteten
Seite: in den Einstellungen erscheint eine Vorschau der Seite bei fester
Breite 1280 px, darueber ein Rahmen, den der Benutzer verschiebt und an
den vier Ecken zieht (oder Links/Oben/Breite/Hoehe eintippt); die Kachel
zeigt genau diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn
mit der Kachelgroesse. Ohne Ausschnitt gibt es eine Vergroesserung
50–150 % fuer die ganze Seite; „Nur anzeigen“ sperrt Klicken und Scrollen
im Rahmen und wird beim Einschalten des Ausschnitts automatisch mit
gesetzt. Sandbox, no-referrer, `allow=""`, Neuladen, Kopfleiste und
Bearbeitungsflaeche sind unveraendert; der Server ruft die Adresse
weiterhin nie ab. Alle Tore gruen.
## Was gebaut wurde
**Resolver (`xframe-config.ts`, Commit 445b1d3).** Neue Exporte
`XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_MIN_W` 100, `XFRAME_CROP_MIN_H` 60,
`XFRAME_CROP_MAX_H` 4000, `XFRAME_CROP_DEFAULT` {0,0,1280,720},
`XFRAME_ZOOM_OPTIONS` [50,60,75,90,100,125,150], `XFRAME_ZOOM_DEFAULT` 100,
`interface XframeCrop`, `clampXframeCrop` (runden; w auf [100,1280], h auf
[60,4000], x/y >= 0, dann `x + w > 1280 -> x = 1280 - w` — verschieben, nie
abweisen). `XframeConfig` um `crop: XframeCrop | null`, `zoom`, `readOnly`
erweitert; `resolveCrop` nimmt nur ein Nicht-null-Objekt mit vier endlichen
Zahlen (Typwaechter `isRecord`, kein Cast), `resolveZoom` liefert die
groesste Stufe <= n (unter 50 -> 50, nicht endlich -> 100), `resolveReadOnly`
nur bei `=== true`. Der Dateikopf erklaert, warum geklemmt und nie
abgewiesen wird (T-GE2-05) und dass `zoom` nur ohne Ausschnitt wirkt.
**Geometrie (`xframe-crop.ts`, NEU, ohne React).** `XFRAME_PREVIEW_PAGE_HEIGHT`
3000; `computeCropLayout(crop, tileW, tileH)` -> `{ scale, left, top,
frameHeight }` mit `scale = min(tileW/w, tileH/h)` (contain, darf > 1 sein),
Zentrierung im Rest, `frameHeight = max(y + h, 720)`; Nullkachel ->
`scale 0` (nichts rendern, keine Division durch 0). `applyCropDrag(mode,
start, dxPage, dyPage)`: `move` verschiebt bei fester Groesse und klemmt an
den Seitenraendern; `nw/ne/sw/se` bewegen nur die zwei Kanten ihrer Ecke,
die Gegenecke bleibt, Mindestgroesse an der bewegten Kante; Ergebnis durch
`clampXframeCrop`.
**Widget (`xframe-widget.tsx`).** Kachelkoerper mit `ref`, `overflow-hidden`
und — nur bei Ausschnitt — `data-tile-size="WxH"`; ein `useEffect` haengt
nur bei `crop !== null` einen `ResizeObserver` an (Muster dashboard-grid),
Aufraeumfunktion `disconnect()`. Drei Render-Zweige mit identischen
`frameAttrs` (src, title, sandbox, allow, referrerPolicy, loading,
data-testid, data-reload-nonce) und `key`: (1) Ausschnitt — Clip-`div`
(`xframe-crop-clip`, absolut, `overflow: hidden`, Groesse `w·scale ×
h·scale`, Position `left/top` aus dem Layout) mit dem `<iframe>` darin
(`left = -x·scale`, `top = -y·scale`, `width 1280`, `height frameHeight`,
`transform: scale(s)`, `transformOrigin 0 0`, Klasse ohne `h-full w-full`),
gerendert erst ab `scale > 0`; (2) Zoom ≠ 100 ohne Ausschnitt — absolut
positionierter Rahmen mit `width/height = 10000/z %` (60 % -> 166.67 %) und
`transform: scale(z)`; (3) 100 % — exakt wie bisher, kein `style`. Nach dem
Rahmen: `isEditMode` -> `xframe-edit-overlay`; `!isEditMode && readOnly` ->
`xframe-readonly-overlay` (`absolute inset-0`, `aria-hidden`); nie beide.
Ecksymbol-Link mit `z-10` bleibt ueber beiden. Dateikopf: warum der Rahmen
selbst verschoben und skaliert wird (cross-origin kein Scrollen von
aussen), warum die Layoutbreite fest 1280 ist, und dass readOnly
Bedienkomfort und keine Sicherheitsmassnahme ist (T-GE2-02).
**Formular (`xframe-config-form.tsx`).** Unter dem Intervall-Feld in dieser
Reihenfolge: Checkbox `xframe-crop-enable` (Einschalten -> EIN `onChange({
crop: XFRAME_CROP_DEFAULT, readOnly: true })`, Ausschalten -> `{ crop: null
}`); bei Ausschnitt Hinweis `cropPreviewHint`, `CropPreview` und
`CropNumberFields` (mit `key` aus den vier Werten -> State-Reset nach
Ziehen/Speichern); ohne Ausschnitt die Zoom-Auswahl `xframe-zoom`; Checkbox
`xframe-readonly`; dauerhaft `cropHint`. `CropPreview`: aeusserer Behaelter
420 px hoch mit eigenem Bildlauf, Breite per `ResizeObserver`, `p = width /
1280` (vor der Messung 0.5); Buehne `1280·p × 3000·p` mit dem Vorschau-
`<iframe>` (dieselbe `XFRAME_SANDBOX`, `allow=""`, `no-referrer`, `lazy`,
`width 1280`, `height 3000`, `transform: scale(p)`, `pointer-events: none`,
T-GE2-01/-03) und dem Rahmen (`<fieldset aria-label>`, `xframe-crop-rect`,
`cursor-move touch-none border-2 border-primary`) mit vier Griffen
(`xframe-crop-handle-{nw,ne,sw,se}`, `h-3 w-3 bg-primary`, Ecken-Cursor).
Pointer-Logik als Fabrik `dragProps(mode)`: `onPointerDown` nur Haupttaste,
Griffe `stopPropagation`, Zugzustand in `dragRef`, Capture per
`capturePointer` (typeof-Waechter); `onPointerMove` -> Entwurf aus
`applyCropDrag` mit Deltas `/ p`; `onPointerUp` -> Capture loesen, `onCommit`
nur bei Aenderung gegenueber `crop` (`sameCrop`); `onPointerCancel` ->
verwerfen ohne Aufruf; alle drei pruefen `drag.mode === mode`, damit das
Bubbling vom Griff zum Rahmen (jsdom ohne Capture) nichts doppelt
ausloest. Ohne Adresse statt der Buehne `xframe-crop-preview-empty`.
`CropNumberFields`: vier `type="number"`-Felder `xframe-crop-{x,y,w,h}` im
4er-Raster, Uebernahme bei Blur/Enter, `Number(draft)` endlich ->
`clampXframeCrop({ ...crop, [k]: n })`, leer/`abc`/unveraendert -> nichts;
darunter `cropUnitHint`.
**Test-Helfer (`apps/web/src/test/fake-resize-observer.ts`, NEU).**
`stubResizeObserver({ width, height })` stubbt `ResizeObserver` dateiweise
per `vi.stubGlobal` mit `DOMRectReadOnly`-`contentRect` — ein einziger Cast
`as ResizeObserverEntry`; Aufraeumen per `vi.unstubAllGlobals()`. Kein
`measuredSize`-Prop im Produktionscode.
**Uebersetzungen.** 13 neue Schluessel unter `widgets.xframe` in de UND en
(28 = 28 Schluessel gesamt, Gleichheit per Skript geprueft), Deutsch mit
„Sie“; `Ausschnitt` auf der Umlaut-Allowlist nach `neuem`.
**Doku (Commit 30fdd99).** Changelog-Stichpunkt direkt nach dem
XFrame-Stichpunkt unter „Unveroeffentlicht -> Neu“; Anwenderhandbuch: ein
Satz in der Tabellenzeile „XFrame“ und ein Satz am Ende des Absatzes
„Dashboard > Widgets“.
## Die Tests, und der Beleg dass sie rot waren
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|---|---:|---|
| `xframe-crop.test.ts` | 11 (neu) | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-crop" from "src/components/dashboard/widgets/xframe-crop.test.ts". Does the file exist?` (0 test) |
| `xframe-config.test.ts` | 21 (12 + 9) | derselbe Lauf -> `21 tests \| 11 failed` (Tests 1 und 8 mit erweitertem `toEqual`, Tests 13–21 neu; Fehlerstellen Zeilen 26/78/97/106/111/118/128/139/146/161/172) |
| `xframe-widget.test.tsx` | 19 (12 + 7) | derselbe Lauf -> `19 tests \| 6 failed` (Tests 13, 14, 15, 16, 17, 19: `Unable to find an element by: [data-testid="xframe-crop-clip"]` bzw. `xframe-readonly-overlay`; Test 18 war schon gruen, weil er nur Abwesenheit prueft) — gesamt `Test Files 3 failed (3), Tests 17 failed \| 23 passed (40)` |
| `xframe-config-form.test.tsx` | 17 (8 + 9) | nach den Uebersetzungs-Schluesseln, vor dem Formular geschrieben: `pnpm --filter @tessera/web exec vitest run src/components/settings/xframe-config-form.test.tsx` -> `Test Files 1 failed (1), Tests 17 failed (17)` (der erweiterte `renderForm`-Helfer sucht die Labels `cropEnable`/`readOnly`, die das alte Formular nicht rendert — dadurch auch Tests 1–8 rot); erster Lauf nach der Umsetzung 17/17 |
Nach der Umsetzung: Resolver 21/21, Geometrie 11/11, Widget 19/19, Formular
17/17, `src/messages` 6/6, `widget-settings-panel.test.tsx` unveraendert
gruen (7 Dateien, 86 Faelle im `<verify>`-Lauf). Zusammen 36 neue Faelle;
Web-Tests 604 -> **640** (Plan: >= 634).
## Messungen (Endstand, HEAD 30fdd99)
| Groesse | Ausgang (5aa577a) | Jetzt |
|---|---:|---:|
| `pnpm type-check` | 4/4 | 4/4 |
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web **53**, keine Stufe `error`) |
| API-Tests | 1175 | **1175** (75 Dateien) |
| Web-Tests | 604 | **640** (81 Dateien) |
| `as unknown as` in apps/api/src | 27 | 27 |
| `as unknown as` in apps/web/src | 6 | 6 (davon 1 in test/setup.ts) |
| `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` / `!` / `any` in den angefassten Web-Dateien | – | 0 / 0 / 0 |
| de/en-Schluesselgleichheit `widgets.xframe` | 15 = 15 | 28 = 28 |
Keine neue `any`, kein `!`, kein neues Paket (T-GE2-SC nicht ausgeloest),
kein Prisma-/Schema-Pfad, keine Aenderung an Panel, Registry, Katalog oder
API.
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
- [x] (a) Einstellungen -> Dashboard -> XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken -> genau EIN PATCH mit `crop: {x:0,y:0,w:1280,h:720}` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen ueber der ganzen Breite; Zahlenfelder zeigen 0/0/1280/720; die Zoom-Auswahl ist verschwunden
- [x] (b) Rahmen mit der Maus verschieben -> folgt fluessig, beim Loslassen genau EIN PATCH, Zahlenfelder springen auf die neuen Werte
- [x] (c) Ecke unten rechts ziehen -> Groesse aendert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links bis unter die Mindestbreite ziehen -> Rahmen bleibt 100 px breit, rechte Kante steht
- [x] (d) Zahlenfeld Breite auf 2000 + Feld verlassen -> Feld zeigt 1280, Links springt auf 0; Hoehe 10 -> 60
- [x] (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip`, `data-tile-size` am Koerper, `<iframe>` mit `transform: scale(…)`); `sandbox`, `referrerpolicy=no-referrer`, `allow=""` unveraendert
- [x] (f) Kachel im Bearbeitungsmodus vergroessern/verkleinern -> nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen ueber dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`)
- [x] (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar
- [x] (h) Ausschnitt abhaken -> PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % -> Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % -> kein `style`
- [x] (i) verweigernde Seite (`https://www.google.com`) -> Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse
**Rundgang durch den Orchestrator am 22.09.2026 (lokaler Stack, Abbilder aus cf70a19, Playwright-MCP):** alle neun Punkte bestanden. Belege: (a) Anhaken → genau ein PATCH `{crop:{0,0,1280,720}, readOnly:true}`, Vorschau 403 px hoch mit Bildlaufleiste, Rahmen ueber die ganze Breite (Akzentfarbe gelb, nicht blau — die Kachelfarbe des Nutzers), Felder 0/0/1280/720, Zoom-Auswahl weg; (b) Verschieben folgt der Maus (200 px waehrend des Ziehens), beim Loslassen ein PATCH `{x:194,y:97,…}` (= 200/1,03), Felder springen mit; (c) Ecke unten rechts: obere linke Ecke bleibt (714/413), Groesse 620x310 → 739x389; Ecke oben links weit nach rechts: Breite bleibt 100 px (103 px Bildschirm), rechte Kante steht; (d) Breite 2000 → 1280 und Links → 0; Hoehe 10 → 60; (e) Kachel 933x601 zeigt genau den Ausschnitt 256/430/768/200 (`xframe-crop-clip` 933x243 bei top 179, `<iframe>` `left -311px; top -522px; scale(1.21)`), Sandbox/`no-referrer`/`allow=""` unveraendert; (f) Kachel auf 663x321 verkleinert → Ausschnitt skaliert auf 0,86 und bleibt ganz sichtbar, `xframe-edit-overlay` beim Ziehen da; (g) „Nur anzeigen“: Klick auf „Learn more“ im Rahmen bewirkt nichts (Frame bleibt example.com), `xframe-readonly-overlay` vorhanden, Link „In neuem Tab öffnen“ da; aus → Klick an der auf die Skalierung umgerechneten Stelle navigiert den Rahmen (Playwright selbst kann in einem per `transform` skalierten iframe nicht klicken — Werkzeuggrenze, per `elementFromPoint` + `mouse.click` umgangen); (h) Abhaken → PATCH `{crop:null}`, Zoom-Auswahl erscheint, 60 % → `width: 166.67%; height: 166.67%; transform: scale(0.6)`; (i) google.com → Vorschau-Rahmen und Kachel leer, beide Hinweise stehen, API-Log ohne Treffer.
**Ein Befund aus dem Rundgang, behoben in cf70a19:** die Kachel nutzte als Layouthoehe des Rahmens `max(crop.y + crop.h, 720)`, die Vorschau 3000 px. example.com setzt `margin: 15vh` — die Ueberschrift lag in der Vorschau bei y 450, in der Kachel mit 720 px Rahmen bei y 108; der in der Vorschau gewaehlte Ausschnitt haette in der Kachel etwas anderes gezeigt. Jetzt nutzt die Kachel dieselbe Layouthoehe wie die Vorschau (`XFRAME_PREVIEW_PAGE_HEIGHT`), damit vh-relative Seiten identisch umbrechen (Tests 4/13 angepasst). Zweitens ein 4-px-Querbalken in der Vorschau durch die Eckgriffe am rechten Rand → `overflow-x-hidden`. Web-Tests 640 unveraendert gruen.
## Deviations from Plan
1. **[Rule 3 - Blocking] Biome `useSemanticElements`** meldete den Rahmen
`<div role="group">` als neue Warnung (web 53 -> 54); `biome-ignore` ist
verboten. Der Rahmen ist jetzt ein `<fieldset aria-label=…>` (implizite
Gruppen-Rolle; `getByRole('group', { name })` findet ihn; Klassen
zusaetzlich `m-0 p-0 min-w-0` gegen Browser-Vorgaben). Formular-Test 10
prueft `tagName === 'FIELDSET'` statt `getAttribute('role')`. Die
Pointer-Handler nehmen `PointerEvent<HTMLElement>`. Web-Lint wieder 53.
Aufgabe 1, Commit 445b1d3.
2. **[Rule 3 - Blocking] `resolveCrop`:** die im Plan skizzierte Zuweisung
`const record: Record<string, unknown> = raw` kompiliert unter `strict`
nicht (`object` ohne Index-Signatur, TS2322). Statt eines Casts ein
Typwaechter `isRecord(raw): raw is Record<string, unknown>`. Aufgabe 1,
Commit 445b1d3.
3. **Zaehler-Falle:** der Kopfkommentar des Test-Helfers enthielt die
Phrase „No `as unknown as`“ und liess den grep-Zaehler auf 7 springen;
umformuliert („no double cast through `unknown`“). Kein Code betroffen.
4. **`onPointerCancel` prueft wie Move/Up den Zugmodus** (`drag.mode ===
mode`), damit ein Abbruch am Griff nicht zusaetzlich den Rahmen-Handler
durchlaeuft (Bubbling ohne Capture in jsdom). Verhalten im Test 14
unveraendert.
5. **Commit-Nachricht von Aufgabe 1 einmal per `--amend` korrigiert**
(vor jedem weiteren Commit, nichts baute darauf): die Testzahl stand
zunaechst als 638, gemessen sind 640. Der endgueltige Hash ist 445b1d3.
6. **Zwei Widget-Faelle mehr** als die geforderten sechs (Test 18 und 19
getrennt), ein Resolver-Fall mehr (Test 21 `clampXframeCrop`).
Nicht geaendert: `STATE.md`, `ROADMAP.md`, `.planning/**` (ausser dieser
Akte), keine neue Abhaengigkeit, kein Deploy, kein Zugriff auf den
Testserver. Ein lokaler Docker-Stack wurde nicht angefasst.
## Known Stubs
Keine. Kette verdrahtet: Checkbox -> `onChange({ crop, readOnly })` ->
`PATCH /dashboard/widgets/:id/config` (flache Zusammenfuehrung, bestehend)
-> `resolveXframeConfig` -> `crop !== null` -> `ResizeObserver` ->
`computeCropLayout` -> Clip + `<iframe style>`; Rahmen `pointerdown/move/up`
-> `applyCropDrag` -> Entwurf -> `clampXframeCrop` -> EIN `onChange`;
Zahlenfelder -> `clampXframeCrop` -> `onChange`; `zoom` -> Prozentmasse +
`scale`; `readOnly` -> Flaeche nur im Ansichtsmodus.
## Threat Flags
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-GE2-01
(beide Rahmen mit exakt `XFRAME_SANDBOX`, `allow=""`, no-referrer —
Widget-Test 13/15, Formular-Test 10), T-GE2-03 (`pointer-events: none` der
Vorschau — Formular-Test 10), T-GE2-04 (keine API-Aenderung — Rundgang i),
T-GE2-05 (Klemmung — Resolver-Tests 13–17, Geometrie-Test 5), T-GE2-06
(nur Zahlen/Template-Strings in Styles, Texte aus Sprachdateien), T-GE2-02
und -07 im Dateikopf des Widgets bzw. Formulars ausdruecklich als
Bedienkomfort/Capture ohne Rechte benannt.
## Self-Check: PASSED
Alle 3 neu angelegten Dateien liegen auf der Platte, die zwei Commits
445b1d3 und 30fdd99 sind in `git log` auffindbar
(`git rev-list --count 5aa577a..HEAD` = 2). Die Zahlen der Tabelle stammen
aus tatsaechlich gelaufenen Befehlen.
+5
View File
@@ -4,9 +4,12 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.3.0 – 2026-09-22
### Neu ### Neu
- Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard - Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard
- Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert
- Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard - Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard
- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen - Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen
- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“ - Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“
@@ -22,6 +25,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Behoben ### Behoben
- Desktop-App: die Knöpfe „Herunterladen“ unter Einstellungen → Desktop-App waren in der App ohne Funktion (Windows und Linux) – der Download öffnet sich jetzt im Browser des Systems
- Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start
- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt - Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt
## 1.2.0 – 2026-09-17 ## 1.2.0 – 2026-09-17
+324 -29
View File
@@ -12,13 +12,24 @@ use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tauri_plugin_updater::{Update, UpdaterExt}; use tauri_plugin_updater::{Update, UpdaterExt};
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version /// Menuetext des Update-Eintrags, solange eine Pruefung laeuft (gesperrt).
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc /// `spawn_version_check` setzt den Eintrag zu Beginn hierauf, auch nach einem
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart); /// Serverwechsel, damit kein Hinweis des alten Servers stehen bleibt. Der
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check` /// Eintrag installiert ein gefundenes Update seit quick-260917-kgc direkt in
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein /// der App (Download, Signaturpruefung, Installation, Neustart).
/// Hinweis des alten Servers stehen bleibt. const UPDATE_ITEM_CHECKING: &str = "Suche nach Updates…";
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
/// Menuetext, wenn die Pruefung kein Update ergab. Der Eintrag bleibt
/// anklickbar und startet die Pruefung erneut (quick-260922-frg): der Nutzer
/// soll nie vor einem stumm ausgegrauten Eintrag stehen. Auch der Starttext
/// ohne gespeicherte Server-Adresse (Klick ohne Adresse tut nichts).
const UPDATE_ITEM_NONE: &str = "Kein Update verfügbar – erneut prüfen";
/// Abstand der wiederholten Update-Pruefung. Die Tray-App laeuft tagelang
/// durch; mit der Pruefung nur beim Start hat ein Client ein neues Paket auf
/// dem Server nie gesehen (Befund 22.09.2026, quick-260922-frg). Vier
/// Stunden reichen fuer den Beta-Kanal und belasten den Server nicht.
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 3600);
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt: /// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte /// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
@@ -37,6 +48,15 @@ struct VersionResponse {
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das. /// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>); struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
/// dieselbe Meldung erscheinen (T-FRG-02). Nur ein ABWEICHENDER Text wird
/// erneut gemeldet; ein Erfolg (`Ok(..)`) leert den Wert, damit ein spaeterer
/// Fehler wieder sichtbar wird. Eigener `app.manage`-Typ, damit
/// `spawn_version_check` seine Signatur behaelt.
struct LastCheckNotice(Mutex<String>);
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse. /// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar /// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem /// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
@@ -76,7 +96,7 @@ fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -
/// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte /// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte
/// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert /// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall /// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall
/// des Tray-Eintrags "Update installieren" (`open_download_page`) bekommt /// einer fehlgeschlagenen Update-Installation (`open_download_page`) bekommt
/// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen /// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen
/// die Download-Links sichtbar bleiben. /// die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url { fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
@@ -179,6 +199,78 @@ fn update_endpoint(server: &str) -> Option<tauri::Url> {
Some(url) Some(url)
} }
/// Baut die Update-Adresse mit ERSETZTEN Platzhaltern -- dieselbe Anfrage,
/// die das Plugin stellt (`update_endpoint`, gleiche Kodierung ueber
/// `query_pairs_mut`), nur mit konkreten Werten. `spawn_version_check` stellt
/// sie nach `Err(ReleaseNotFound)` einmal selbst, um den Statuscode zu
/// erfahren: das Plugin verschluckt einen Nicht-2xx-Status (updater.rs
/// Z. 529-559 loggt nur, `last_error` bleibt leer, Ergebnis ist
/// `ReleaseNotFound`). Reine Funktion, darum in den Tests mit festen Werten
/// geprueft.
fn diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String {
let base = server.trim_end_matches('/');
match tauri::Url::parse(&api_url(server, "/desktop/update")) {
Ok(mut url) => {
url.query_pairs_mut()
.append_pair("target", target)
.append_pair("arch", arch)
.append_pair("current", current)
.append_pair("base", base);
url.into()
}
// Gespeicherte Adressen sind immer parsebar (parse_server_url);
// reine Absicherung, damit die Funktion kein Option liefern muss.
Err(_) => api_url(server, "/desktop/update"),
}
}
/// Liefert (Menuetext, Benachrichtigungstext) fuer eine fehlgeschlagene
/// Update-Pruefung. `status` ist der Antwortcode des Servers, wenn er
/// geantwortet hat (`Some`), sonst `None` (keine Verbindung, TLS, Timeout).
/// 401/403 bekommen die Proxy-Erklaerung: Befund 22.09.2026 -- der Nginx
/// Proxy Manager vor alpha verlangt Basic-Auth, die Webansicht beantwortet
/// das Passwortfenster, der Updater (eigener `reqwest`-Client) kann es nicht.
/// Zugangsdaten gehoeren nicht in ausgelieferte Clients (T-FRG-03), darum
/// wird der Grund angezeigt, nicht umgangen. Alle Texte enden auf
/// "– erneut prüfen", weil der Klick die Pruefung wiederholt.
fn check_failure_labels(status: Option<u16>) -> (String, String) {
match status {
Some(code @ (401 | 403)) => (
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
format!(
"Der Server hat die Update-Anfrage mit HTTP {code} abgewiesen. Meist steht ein \
Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die \
App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind \
davon nicht betroffen."
),
),
Some(code) => (
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
format!("Der Server hat auf die Update-Anfrage mit HTTP {code} geantwortet statt mit Paketdaten."),
),
None => (
"Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen".to_string(),
"Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier \
Stunden erneut – oder über den Menüeintrag."
.to_string(),
),
}
}
/// Stellt die Update-Anfrage ein zweites Mal mit eigenem `reqwest`-Client
/// (Timeout 8 s, Muster `check_server`) und liest NUR den Statuscode -- der
/// Rumpf wird nicht ausgewertet, Redirects folgen dem Standard (T-FRG-01:
/// Ziel ist die vom Nutzer gespeicherte Adresse, kein Geheimnis in der
/// Anfrage). `None`, wenn keine Antwort kam.
async fn probe_update_status(url: String) -> Option<u16> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.ok()?;
let resp = client.get(url).send().await.ok()?;
Some(resp.status().as_u16())
}
/// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version /// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version
/// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei /// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei
/// gleicher Basis der Beta-Stempel. /// gleicher Basis der Beta-Stempel.
@@ -321,14 +413,57 @@ async fn check_for_update(
.await .await
} }
/// Meldet eine fehlgeschlagene Update-Pruefung: Menuetext mit Grund, Eintrag
/// wieder anklickbar (Klick = erneut pruefen), Benachrichtigung nur, wenn
/// der Text vom zuletzt gemeldeten abweicht (`LastCheckNotice`, T-FRG-02).
fn report_check_failure(app: &AppHandle, update_item: &MenuItem<Wry>, status: Option<u16>) {
let (menu_text, body) = check_failure_labels(status);
let _ = update_item.set_text(&menu_text);
let _ = update_item.set_enabled(true);
let changed = app
.state::<LastCheckNotice>()
.0
.lock()
.map(|mut last| {
if *last == body {
false
} else {
*last = body.clone();
true
}
})
.unwrap_or(true);
if changed {
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
}
}
/// Leert den zuletzt gemeldeten Fehlertext -- nach einer gelungenen Pruefung
/// soll ein spaeterer Fehler wieder gemeldet werden.
fn clear_check_notice(app: &AppHandle) {
if let Ok(mut last) = app.state::<LastCheckNotice>().0.lock() {
last.clear();
}
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem /// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt /// Serverwechsel, beim Klick auf den Eintrag und alle
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den /// `UPDATE_CHECK_INTERVAL` erneut laufen kann (async, non-blocking wie
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten /// bisher). Setzt den Update-Eintrag zuerst auf "Suche nach Updates…"
/// Servers stehen bleiben. /// (gesperrt) und leert den abgelegten Update-Stand -- nach einem Wechsel
/// darf kein Hinweis des alten Servers stehen bleiben. Jeder Ausgang der
/// Pruefung endet in einem sichtbaren, anklickbaren Zustand (Update gefunden
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
/// nicht unterscheidbar (quick-260922-frg).
fn spawn_version_check(app: AppHandle, server_url: String) { fn spawn_version_check(app: AppHandle, server_url: String) {
let update_item = app.state::<TrayItems>().update.clone(); let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT); let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
let _ = update_item.set_enabled(false); let _ = update_item.set_enabled(false);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = None; *pending = None;
@@ -365,8 +500,13 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() { if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text)); *pending = Some((update, menu_text));
} }
clear_check_notice(&app);
}
Ok(None) => {
let _ = update_item.set_text(UPDATE_ITEM_NONE);
let _ = update_item.set_enabled(true);
clear_check_notice(&app);
} }
Ok(None) => {}
// Der Release-Bau verweigert http-Endpunkte (config.rs // Der Release-Bau verweigert http-Endpunkte (config.rs
// `validate_endpoints`), der Debug-Bau warnt nur. Keine // `validate_endpoints`), der Debug-Bau warnt nur. Keine
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der // `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
@@ -374,15 +514,33 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => { Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
let _ = update_item.set_text(UPDATE_ITEM_INSECURE); let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
} }
Err(_) => {} // Der Server hat geantwortet, aber nicht mit 2xx/204: das Plugin
// verschluckt den Statuscode (updater.rs Z. 529-559). Dieselbe
// Anfrage einmal selbst stellen und nur den Status lesen -- so
// steht im Eintrag "HTTP 401", wenn ein Passwortschutz am Proxy
// die Anfrage abweist (Befund 22.09.2026).
Err(tauri_plugin_updater::Error::ReleaseNotFound) => {
let status = probe_update_status(diagnostic_update_url(
&server_url,
std::env::consts::OS,
std::env::consts::ARCH,
env!("CARGO_PKG_VERSION"),
))
.await;
report_check_failure(&app, &update_item, status);
}
// Keine Verbindung, TLS, Timeout (Reqwest/Network/Io) oder
// unlesbares Manifest: keine zweite Anfrage, Status unbekannt.
Err(_) => report_check_failure(&app, &update_item, None),
} }
}); });
} }
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der /// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update /// bisherige Weg des Tray-Eintrags, seit quick-260922-frg NUR noch Rueckfall,
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne /// wenn die Installation in der App fehlschlaegt (ein Klick ohne abgelegtes
/// `desktop=1` (siehe `with_desktop_marker`). /// Update prueft stattdessen erneut). Bewusst ohne `desktop=1` (siehe
/// `with_desktop_marker`).
fn open_download_page(app: &AppHandle) { fn open_download_page(app: &AppHandle) {
if let Some(server) = stored_server_url(app) { if let Some(server) = stored_server_url(app) {
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/')); let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
@@ -555,6 +713,39 @@ pub fn run() {
get_client_info get_client_info
]) ])
.setup(|app| { .setup(|app| {
// Hauptfenster im Code statt in tauri.conf.json (`app.windows` ist
// leer), weil nur der Builder einen Download-Handler annimmt. Ohne
// Handler verpufft ein Klick auf "Herunterladen" (Einstellungen ->
// Desktop-App) in der Webansicht: webkit2gtk verwirft den Download
// still, WebView2 zeigte ebenfalls nichts (Nutzer, 22.09.2026,
// Windows und Linux). Der Handler bricht den Download in der App ab
// und reicht die Adresse an den System-Browser weiter -- dort gibt
// es Fortschritt, Speicherort und ein Passwortfenster fuer einen
// vorgeschalteten Proxy. Masse/Zentrierung wie bisher in der
// Konfiguration; Fensterzustand stellt tauri_plugin_window_state
// weiterhin her.
tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::App("setup.html".into()),
)
.title("Tessera")
.inner_size(1280.0, 800.0)
.center()
.decorations(true)
.resizable(true)
.on_download(|webview, event| {
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
let _ = webview
.app_handle()
.opener()
.open_url(url.to_string(), None::<&str>);
return false;
}
true
})
.build()?;
let server_url = stored_server_url(app.handle()); let server_url = stored_server_url(app.handle());
if let Some(url) = &server_url { if let Some(url) = &server_url {
@@ -566,13 +757,16 @@ pub fn run() {
} }
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen · // Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
// Server-Adresse ändern... · Update installieren · — · // Server-Adresse ändern... · Suche nach Updates… · — ·
// Autostart-Haken · — · Beenden. // Autostart-Haken · — · Beenden.
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis // "connected" ist nicht anklickbar; "update" startet gesperrt als
// die Versionspruefung eine neuere Version findet, und heisst dann // "Suche nach Updates…", weil die Erstpruefung unten sofort
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ... // anlaeuft, und heisst danach "Auf Version X.Y.Z aktualisieren"
// aktualisieren"; "autostart" spiegelt den tatsaechlichen // bzw. "Auf Beta-Stand ... aktualisieren", "Kein Update verfügbar
// Systemzustand beim Start. // – erneut prüfen" oder "Update-Prüfung fehlgeschlagen (...) –
// erneut prüfen" (alle anklickbar). Ohne gespeicherte Adresse
// "Kein Update verfügbar – erneut prüfen" (Klick tut nichts);
// "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
let connected = let connected =
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1) MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
.enabled(false) .enabled(false)
@@ -580,8 +774,13 @@ pub fn run() {
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
let change_server = let change_server =
MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?; MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?;
let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT) let update_text = if server_url.is_some() {
.enabled(false) UPDATE_ITEM_CHECKING
} else {
UPDATE_ITEM_NONE
};
let update = MenuItemBuilder::with_id("update", update_text)
.enabled(server_url.is_none())
.build(app)?; .build(app)?;
let autostart_label = if cfg!(target_os = "windows") { let autostart_label = if cfg!(target_os = "windows") {
"Mit Windows starten" "Mit Windows starten"
@@ -610,6 +809,7 @@ pub fn run() {
update: update.clone(), update: update.clone(),
}); });
app.manage(PendingUpdate(Mutex::new(None))); app.manage(PendingUpdate(Mutex::new(None)));
app.manage(LastCheckNotice(Mutex::new(String::new())));
let autostart_for_menu = autostart.clone(); let autostart_for_menu = autostart.clone();
@@ -637,8 +837,12 @@ pub fn run() {
} }
"update" => { "update" => {
// `take()` verhindert Doppelklick-Downloads, der // `take()` verhindert Doppelklick-Downloads, der
// gesperrte Eintrag ebenso; ohne abgelegten Stand // gesperrte Eintrag ebenso. Ohne abgelegten Stand
// bleibt der Browser-Weg (Einstellungen → Desktop-App). // ("Kein Update verfügbar" / "Update-Prüfung
// fehlgeschlagen") prueft der Klick erneut -- der
// Browser-Weg (`open_download_page`) ist nur noch
// Rueckfall einer fehlgeschlagenen Installation.
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
let pending = app let pending = app
.state::<PendingUpdate>() .state::<PendingUpdate>()
.0 .0
@@ -649,7 +853,11 @@ pub fn run() {
Some((update, menu_text)) => { Some((update, menu_text)) => {
spawn_update_install(app.clone(), update, menu_text) spawn_update_install(app.clone(), update, menu_text)
} }
None => open_download_page(app), None => {
if let Some(url) = stored_server_url(app) {
spawn_version_check(app.clone(), url);
}
}
} }
} }
"autostart" => { "autostart" => {
@@ -696,6 +904,34 @@ pub fn run() {
spawn_version_check(app.handle().clone(), url); spawn_version_check(app.handle().clone(), url);
} }
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
// schlichter Betriebssystem-Thread mit `sleep` statt
// `tokio::time`: das Tauri-Async-Runtime braeuchte dafuer das
// `time`-Feature bzw. ein eigenes Crate, und ein schlafender
// Thread kostet nichts. Je Durchlauf wird die Adresse frisch
// gelesen (Serverwechsel zaehlt). Haelt `PendingUpdate` bereits
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
// Waehrend einer Installation ist der Stand per `take()` leer;
// faellt die 4-h-Marke genau in den Download, wird nur der
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
let handle = app.handle().clone();
std::thread::spawn(move || loop {
std::thread::sleep(UPDATE_CHECK_INTERVAL);
let pending = handle
.state::<PendingUpdate>()
.0
.lock()
.map(|guard| guard.is_some())
.unwrap_or(false);
if pending {
continue;
}
if let Some(url) = stored_server_url(&handle) {
spawn_version_check(handle.clone(), url);
}
});
Ok(()) Ok(())
}) })
.on_window_event(|window, event| { .on_window_event(|window, event| {
@@ -1008,4 +1244,63 @@ mod tests {
Err("Diese Adresse ist ungültig.".to_string()) Err("Diese Adresse ist ungültig.".to_string())
); );
} }
#[test]
fn check_failure_labels_401_nennt_proxy_passwortschutz() {
let (menu_text, body) = check_failure_labels(Some(401));
assert!(menu_text.contains("HTTP 401"));
assert!(menu_text.ends_with("– erneut prüfen"));
assert!(body.contains("HTTP 401"));
assert!(body.contains("Passwortschutz"));
assert!(body.contains("Zugriffsliste"));
}
#[test]
fn check_failure_labels_403_gleiche_erklaerung() {
let (menu_text, body) = check_failure_labels(Some(403));
assert!(menu_text.contains("HTTP 403"));
assert!(body.contains("HTTP 403"));
assert!(body.contains("Passwortschutz"));
}
#[test]
fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() {
let (menu_text, body) = check_failure_labels(Some(502));
assert!(menu_text.contains("HTTP 502"));
assert!(menu_text.ends_with("– erneut prüfen"));
assert!(!body.contains("Passwortschutz"));
assert!(body.contains("statt mit Paketdaten"));
}
#[test]
fn check_failure_labels_ohne_status_keine_verbindung() {
let (menu_text, body) = check_failure_labels(None);
assert!(menu_text.contains("keine Verbindung"));
assert!(menu_text.ends_with("– erneut prüfen"));
assert!(body.contains("vier Stunden"));
}
#[test]
fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() {
assert_eq!(
diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"),
"https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64&current=1.2.0&base=https%3A%2F%2Falpha.example"
);
}
#[test]
fn diagnostic_update_url_trimmt_schlussstrich() {
assert_eq!(
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64&current=1.2.0&base=https%3A%2F%2Falpha.example"
);
}
#[test]
fn update_item_konstanten() {
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
}
} }
+1 -12
View File
@@ -9,18 +9,7 @@
}, },
"app": { "app": {
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [ "windows": [],
{
"label": "main",
"title": "Tessera",
"url": "setup.html",
"width": 1280,
"height": 800,
"center": true,
"decorations": true,
"resizable": true
}
],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
} }
@@ -1,10 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
XFRAME_CROP_DEFAULT,
XFRAME_PAGE_WIDTH,
XFRAME_RELOAD_MAX, XFRAME_RELOAD_MAX,
XFRAME_RELOAD_MIN, XFRAME_RELOAD_MIN,
XFRAME_RELOAD_OPTIONS, XFRAME_RELOAD_OPTIONS,
XFRAME_SANDBOX, XFRAME_SANDBOX,
XFRAME_TITLE_MAX, XFRAME_TITLE_MAX,
XFRAME_ZOOM_OPTIONS,
clampXframeCrop,
isHttpsUrl, isHttpsUrl,
resolveXframeConfig, resolveXframeConfig,
} from './xframe-config'; } from './xframe-config';
@@ -19,7 +23,14 @@ import {
*/ */
describe('resolveXframeConfig (quick-260921-qd3)', () => { describe('resolveXframeConfig (quick-260921-qd3)', () => {
it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => { it('Test 1: leere Konfiguration -> keine Adresse, leerer Titel, nie neu laden', () => {
expect(resolveXframeConfig({})).toEqual({ url: null, title: '', reloadSeconds: 0 }); expect(resolveXframeConfig({})).toEqual({
url: null,
title: '',
reloadSeconds: 0,
crop: null,
zoom: 100,
readOnly: false,
});
}); });
it('Test 2: https-Adresse bleibt (getrimmt)', () => { it('Test 2: https-Adresse bleibt (getrimmt)', () => {
@@ -64,7 +75,101 @@ describe('resolveXframeConfig (quick-260921-qd3)', () => {
it('Test 8: alle drei Felder zusammen', () => { it('Test 8: alle drei Felder zusammen', () => {
expect( expect(
resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }), resolveXframeConfig({ url: 'https://a.de/', title: ' Board ', reloadSeconds: 1800 }),
).toEqual({ url: 'https://a.de/', title: 'Board', reloadSeconds: 1800 }); ).toEqual({
url: 'https://a.de/',
title: 'Board',
reloadSeconds: 1800,
crop: null,
zoom: 100,
readOnly: false,
});
});
});
/**
* quick-260922-ge2: Ausschnitt, Zoom, readOnly. Die Werte landen in
* Inline-Styles (T-GE2-05) — der Resolver klemmt und weist nie ab: ein
* manipulierter Wert ergibt hoechstens einen anderen Ausschnitt, nie
* `scale(NaN)` oder einen 10⁹-px-Rahmen.
*/
describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
it('Test 13: crop fehlt / null / String / unvollstaendig / Nicht-Zahl -> null', () => {
expect(resolveXframeConfig({}).crop).toBeNull();
expect(resolveXframeConfig({ crop: null }).crop).toBeNull();
expect(resolveXframeConfig({ crop: 'x' }).crop).toBeNull();
expect(resolveXframeConfig({ crop: { x: 1 } }).crop).toBeNull();
expect(resolveXframeConfig({ crop: { x: 'a', y: 0, w: 500, h: 300 } }).crop).toBeNull();
expect(resolveXframeConfig({ crop: { x: Number.NaN, y: 0, w: 500, h: 300 } }).crop).toBeNull();
});
it('Test 14: gueltiger Ausschnitt bleibt unveraendert; x wird verschoben, wenn x + w > 1280 (nie abgewiesen)', () => {
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 1280, h: 720 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
expect(resolveXframeConfig({ crop: { x: 500, y: 0, w: 1000, h: 300 } }).crop).toEqual({ x: 280, y: 0, w: 1000, h: 300 });
});
it('Test 15: Breite und Hoehe geklemmt — w 50 -> 100, w 5000 -> 1280 (x -> 0), h 10 -> 60, h 9999 -> 4000', () => {
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 50, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 100, h: 300 });
expect(resolveXframeConfig({ crop: { x: 300, y: 0, w: 5000, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 1280, h: 300 });
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 10 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 60 });
expect(resolveXframeConfig({ crop: { x: 0, y: 0, w: 500, h: 9999 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 4000 });
});
it('Test 16: negative Position -> 0; Nachkommastellen gerundet', () => {
expect(resolveXframeConfig({ crop: { x: -5, y: -1, w: 500, h: 300 } }).crop).toEqual({ x: 0, y: 0, w: 500, h: 300 });
expect(resolveXframeConfig({ crop: { x: 10.6, y: 2.4, w: 500.4, h: 300.5 } }).crop).toEqual({
x: 11,
y: 2,
w: 500,
h: 301,
});
});
it('Test 17: zoom fehlt / "abc" / NaN -> 100; 70 -> 60, 200 -> 150, 0 -> 50, 10 -> 50, 125 -> 125', () => {
expect(resolveXframeConfig({}).zoom).toBe(100);
expect(resolveXframeConfig({ zoom: 'abc' }).zoom).toBe(100);
expect(resolveXframeConfig({ zoom: Number.NaN }).zoom).toBe(100);
expect(resolveXframeConfig({ zoom: 70 }).zoom).toBe(60);
expect(resolveXframeConfig({ zoom: 200 }).zoom).toBe(150);
expect(resolveXframeConfig({ zoom: 0 }).zoom).toBe(50);
expect(resolveXframeConfig({ zoom: 10 }).zoom).toBe(50);
expect(resolveXframeConfig({ zoom: 125 }).zoom).toBe(125);
});
it('Test 18: readOnly nur bei echtem true; "true", 1, fehlt -> false', () => {
expect(resolveXframeConfig({ readOnly: true }).readOnly).toBe(true);
expect(resolveXframeConfig({ readOnly: 'true' }).readOnly).toBe(false);
expect(resolveXframeConfig({ readOnly: 1 }).readOnly).toBe(false);
expect(resolveXframeConfig({}).readOnly).toBe(false);
});
it('Test 19: Konstanten — Zoom-Stufen, Seitenbreite 1280, Vorgabe-Ausschnitt 0/0/1280/720', () => {
expect(XFRAME_ZOOM_OPTIONS).toEqual([50, 60, 75, 90, 100, 125, 150]);
expect(XFRAME_PAGE_WIDTH).toBe(1280);
expect(XFRAME_CROP_DEFAULT).toEqual({ x: 0, y: 0, w: 1280, h: 720 });
});
it('Test 20: alle sechs Felder zusammen', () => {
expect(
resolveXframeConfig({
url: 'https://a.de/',
title: 'Board',
reloadSeconds: 60,
crop: { x: 100, y: 200, w: 800, h: 400 },
zoom: 75,
readOnly: true,
}),
).toEqual({
url: 'https://a.de/',
title: 'Board',
reloadSeconds: 60,
crop: { x: 100, y: 200, w: 800, h: 400 },
zoom: 75,
readOnly: true,
});
});
it('Test 21: clampXframeCrop ist exportiert — { 1200, 0, 200, 100 } -> x 1080', () => {
expect(clampXframeCrop({ x: 1200, y: 0, w: 200, h: 100 })).toEqual({ x: 1080, y: 0, w: 200, h: 100 });
}); });
}); });
@@ -41,12 +41,50 @@ export const XFRAME_SANDBOX =
// ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen). // ganzen Dashboard (echter URL-Parser, Protokoll kleingeschrieben verglichen).
export { isHttpsUrl }; export { isHttpsUrl };
/** Virtuelle Layoutbreite der eingebetteten Seite in Pixeln — Konstante, keine UI. */
export const XFRAME_PAGE_WIDTH = 1280;
export const XFRAME_CROP_MIN_W = 100;
export const XFRAME_CROP_MIN_H = 60;
/** Speichergrenze fuer getippte Werte; die Vorschau zeigt 3000 (xframe-crop.ts). */
export const XFRAME_CROP_MAX_H = 4000;
/** Ausschnitt in Seitenpixeln bei XFRAME_PAGE_WIDTH. */
export interface XframeCrop {
x: number;
y: number;
w: number;
h: number;
}
export const XFRAME_CROP_DEFAULT: XframeCrop = { x: 0, y: 0, w: XFRAME_PAGE_WIDTH, h: 720 };
/** Vergroesserung der ganzen Seite in Prozent (nur ohne Ausschnitt). */
export const XFRAME_ZOOM_OPTIONS: number[] = [50, 60, 75, 90, 100, 125, 150];
export const XFRAME_ZOOM_DEFAULT = 100;
export interface XframeConfig { export interface XframeConfig {
/** null = keine gueltige https-Adresse -> Leerzustand. */ /** null = keine gueltige https-Adresse -> Leerzustand. */
url: string | null; url: string | null;
title: string; title: string;
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */ /** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
reloadSeconds: number; reloadSeconds: number;
/** null = ganze Seite; sonst geklemmter Ausschnitt (quick-260922-ge2). */
crop: XframeCrop | null;
/** Stufe aus XFRAME_ZOOM_OPTIONS, wirkt nur bei crop === null. */
zoom: number;
/** Transparente Flaeche ueber dem Rahmen im Ansichtsmodus. */
readOnly: boolean;
}
/**
* EINE Klemmregel fuer Resolver, Zahlenfelder und Ziehen: runden, Breite
* [100, 1280], Hoehe [60, 4000], Position >= 0, dann x so verschieben, dass
* der Ausschnitt in der Seite bleibt (nie abweisen, T-GE2-05).
*/
export function clampXframeCrop(raw: XframeCrop): XframeCrop {
const w = Math.min(XFRAME_PAGE_WIDTH, Math.max(XFRAME_CROP_MIN_W, Math.round(raw.w)));
const h = Math.min(XFRAME_CROP_MAX_H, Math.max(XFRAME_CROP_MIN_H, Math.round(raw.h)));
let x = Math.max(0, Math.round(raw.x));
const y = Math.max(0, Math.round(raw.y));
if (x + w > XFRAME_PAGE_WIDTH) x = XFRAME_PAGE_WIDTH - w;
return { x, y, w, h };
} }
function resolveUrl(raw: unknown): string | null { function resolveUrl(raw: unknown): string | null {
@@ -75,10 +113,44 @@ function resolveReload(raw: unknown): number {
return best; return best;
} }
function finiteNumber(raw: unknown): raw is number {
return typeof raw === 'number' && Number.isFinite(raw);
}
/** Typwaechter statt Cast: ein Nicht-null-Objekt darf als Schluessel-Wert-Menge gelesen werden. */
function isRecord(raw: unknown): raw is Record<string, unknown> {
return typeof raw === 'object' && raw !== null;
}
/** Nur ein Nicht-null-Objekt mit vier endlichen Zahlen x, y, w, h -> geklemmt; alles andere -> null. */
function resolveCrop(raw: unknown): XframeCrop | null {
if (!isRecord(raw)) return null;
const { x, y, w, h } = raw;
if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(w) || !finiteNumber(h)) return null;
return clampXframeCrop({ x, y, w, h });
}
/** Groesste Zoom-Stufe <= n (Muster resolveReload); unter 50 -> 50; nicht endlich -> 100. */
function resolveZoom(raw: unknown): number {
if (!finiteNumber(raw)) return XFRAME_ZOOM_DEFAULT;
let best = XFRAME_ZOOM_OPTIONS[0];
for (const option of XFRAME_ZOOM_OPTIONS) {
if (option <= raw && option > best) best = option;
}
return best;
}
function resolveReadOnly(raw: unknown): boolean {
return raw === true;
}
export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig { export function resolveXframeConfig(config: Record<string, unknown>): XframeConfig {
return { return {
url: resolveUrl(config.url), url: resolveUrl(config.url),
title: resolveTitle(config.title), title: resolveTitle(config.title),
reloadSeconds: resolveReload(config.reloadSeconds), reloadSeconds: resolveReload(config.reloadSeconds),
crop: resolveCrop(config.crop),
zoom: resolveZoom(config.zoom),
readOnly: resolveReadOnly(config.readOnly),
}; };
} }
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag, computeCropLayout } from './xframe-crop';
/**
* xframe-crop.test — NEU (quick-260922-ge2).
*
* Reine Geometrie ohne React: Einpassen (contain) und Zentrieren des
* Ausschnitts in der Kachel, Layouthoehe des Rahmens, Nullkachel ohne
* Division durch 0 (T-GE2-05); Ziehmathematik der Vorschau — Verschieben
* bei fester Groesse, Ecken bewegen nur ihre zwei Kanten, die Gegenecke
* bleibt stehen, Mindestgroesse an der bewegten Kante.
*/
const FULL = { x: 0, y: 0, w: 1280, h: 720 };
const START = { x: 100, y: 100, w: 400, h: 300 };
describe('computeCropLayout (quick-260922-ge2)', () => {
it('Test 1: breitenbegrenzt — 1280x720 in 640x720 -> scale 0.5, left 0, top 180', () => {
expect(computeCropLayout(FULL, 640, 720)).toEqual({ scale: 0.5, left: 0, top: 180, frameHeight: 3000 });
});
it('Test 2: hoehenbegrenzt — 1280x720 in 1280x180 -> scale 0.25, left 480, top 0', () => {
expect(computeCropLayout(FULL, 1280, 180)).toEqual({ scale: 0.25, left: 480, top: 0, frameHeight: 3000 });
});
it('Test 3: Vergroesserung erlaubt — 200x100 in 800x400 -> scale 4', () => {
expect(computeCropLayout({ x: 0, y: 0, w: 200, h: 100 }, 800, 400)).toEqual({
scale: 4,
left: 0,
top: 0,
frameHeight: 3000,
});
});
it('Test 4: frameHeight ist immer die Vorschauhoehe 3000 — gleiches Layout wie in der Vorschau (vh-Masse)', () => {
expect(computeCropLayout({ x: 0, y: 0, w: 1280, h: 100 }, 640, 360).frameHeight).toBe(3000);
expect(computeCropLayout({ x: 0, y: 3000, w: 1280, h: 400 }, 640, 360).frameHeight).toBe(3000);
});
it('Test 5: ungemessene Kachel (0x0 oder 640x0) -> scale 0, left 0, top 0 (nichts rendern)', () => {
expect(computeCropLayout(FULL, 0, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 3000 });
expect(computeCropLayout(FULL, 640, 0)).toEqual({ scale: 0, left: 0, top: 0, frameHeight: 3000 });
});
});
describe('applyCropDrag (quick-260922-ge2)', () => {
it('Test 6: move um +100/+50 verschiebt bei fester Groesse', () => {
expect(applyCropDrag('move', START, 100, 50)).toEqual({ x: 200, y: 150, w: 400, h: 300 });
});
it('Test 7: move ueber den Rand -> an der Kante geklemmt (x 880 = 1280 - 400; -500/-500 -> 0/0)', () => {
expect(applyCropDrag('move', START, 2000, 0)).toEqual({ x: 880, y: 100, w: 400, h: 300 });
expect(applyCropDrag('move', START, -500, -500)).toEqual({ x: 0, y: 0, w: 400, h: 300 });
});
it('Test 8: se um +100/+100 -> groesser, obere linke Ecke bleibt', () => {
expect(applyCropDrag('se', START, 100, 100)).toEqual({ x: 100, y: 100, w: 500, h: 400 });
});
it('Test 9: nw um +350/+10 -> Mindestbreite 100 an der bewegten Kante, rechte Kante 500 bleibt', () => {
expect(applyCropDrag('nw', START, 350, 10)).toEqual({ x: 400, y: 110, w: 100, h: 290 });
});
it('Test 10: ne um +2000/0 -> rechte Kante 1280 (w 1180), linke Kante bleibt', () => {
expect(applyCropDrag('ne', START, 2000, 0)).toEqual({ x: 100, y: 100, w: 1180, h: 300 });
});
it('Test 11: sw um 0/+5000 -> untere Kante an der Vorschauhoehe 3000 (h 2900)', () => {
expect(XFRAME_PREVIEW_PAGE_HEIGHT).toBe(3000);
expect(applyCropDrag('sw', START, 0, 5000)).toEqual({ x: 100, y: 100, w: 400, h: 2900 });
});
});
@@ -0,0 +1,88 @@
/**
* xframe-crop — reine Geometrie des XFrame-Ausschnitts (quick-260922-ge2),
* ohne React, fuer Kachel UND Vorschau.
*
* Einpassen (contain) und Zentrieren: der Ausschnitt muss in der Kachel
* immer GANZ sichtbar sein, und das Seitenverhaeltnis der Kachel stimmt
* praktisch nie mit dem des Ausschnitts ueberein — deshalb der kleinere
* der beiden Massstaebe (darf > 1 sein: ein kleiner Ausschnitt in einer
* grossen Kachel wird vergroessert) und die Zentrierung im Rest.
*
* `frameHeight` ist IMMER die Vorschauhoehe (3000 Seitenpixel), nicht
* "Unterkante des Ausschnitts": viele Seiten setzen Masse relativ zur
* Fensterhoehe (`vh`, `100vh`-Zentrierung, `margin: 15vh` bei example.com).
* Haetten Vorschau und Kachel verschiedene Rahmenhoehen, laege derselbe
* Inhalt an verschiedenen Stellen — der in der Vorschau gewaehlte Ausschnitt
* zeigte in der Kachel etwas anderes (Browser-Rundgang 22.09.2026: Ueberschrift
* in der Vorschau bei y 450, in der Kachel mit 720 px Rahmen bei y 108).
* Gleiche Hoehe = gleiches Layout; der Rest wird ohnehin weggeschnitten.
*
* Ziehmathematik der Vorschau: `move` verschiebt bei fester Groesse und
* klemmt an den Seitenraendern; die Eckmodi bewegen nur die zwei Kanten
* ihrer Ecke — die gegenueberliegende Ecke bleibt stehen, damit sich der
* Rahmen so verhaelt, wie man es von jedem Bildeditor kennt; die
* Mindestgroesse wird an der BEWEGTEN Kante gehalten. Das Ergebnis geht
* durch `clampXframeCrop` (eine Klemmregel fuer alles, T-GE2-05).
*/
import {
XFRAME_CROP_MIN_H,
XFRAME_CROP_MIN_W,
XFRAME_PAGE_WIDTH,
type XframeCrop,
clampXframeCrop,
} from './xframe-config';
/**
* Hoehe der Seite in Seitenpixeln — in der Vorschau (Stage, Ziehen) UND als
* Layouthoehe des Rahmens in der Kachel (siehe Kopfkommentar); 4000 bleibt
* die Speichergrenze fuer `crop.y + crop.h`.
*/
export const XFRAME_PREVIEW_PAGE_HEIGHT = 3000;
export type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se';
export interface XframeCropLayout {
/** 0 = Kachel noch nicht gemessen -> nichts rendern. */
scale: number;
left: number;
top: number;
frameHeight: number;
}
export function computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout {
const frameHeight = XFRAME_PREVIEW_PAGE_HEIGHT;
if (tileW <= 0 || tileH <= 0) return { scale: 0, left: 0, top: 0, frameHeight };
const scale = Math.min(tileW / crop.w, tileH / crop.h);
return {
scale,
left: (tileW - crop.w * scale) / 2,
top: (tileH - crop.h * scale) / 2,
frameHeight,
};
}
export function applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop {
let left = start.x;
let top = start.y;
let right = start.x + start.w;
let bottom = start.y + start.h;
if (mode === 'move') {
left = Math.min(Math.max(0, left + dxPage), XFRAME_PAGE_WIDTH - start.w);
top = Math.min(Math.max(0, top + dyPage), Math.max(0, XFRAME_PREVIEW_PAGE_HEIGHT - start.h));
return clampXframeCrop({ x: left, y: top, w: start.w, h: start.h });
}
if (mode === 'nw' || mode === 'sw') {
left = Math.min(Math.max(0, left + dxPage), right - XFRAME_CROP_MIN_W);
} else {
right = Math.max(Math.min(XFRAME_PAGE_WIDTH, right + dxPage), left + XFRAME_CROP_MIN_W);
}
if (mode === 'nw' || mode === 'ne') {
top = Math.min(Math.max(0, top + dyPage), bottom - XFRAME_CROP_MIN_H);
} else {
bottom = Math.max(Math.min(XFRAME_PREVIEW_PAGE_HEIGHT, bottom + dyPage), top + XFRAME_CROP_MIN_H);
}
return clampXframeCrop({ x: left, y: top, w: right - left, h: bottom - top });
}
@@ -6,6 +6,7 @@ vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key, useTranslations: () => (key: string) => key,
})); }));
import { stubResizeObserver } from '@/test/fake-resize-observer';
import { XFRAME_SANDBOX } from './xframe-config'; import { XFRAME_SANDBOX } from './xframe-config';
import { XframeWidget } from './xframe-widget'; import { XframeWidget } from './xframe-widget';
@@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
// quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie)
stubResizeObserver({ width: 640, height: 360 });
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals();
}); });
describe('XframeWidget (quick-260921-qd3)', () => { describe('XframeWidget (quick-260921-qd3)', () => {
@@ -151,3 +155,114 @@ describe('XframeWidget (quick-260921-qd3)', () => {
expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6'); expect(screen.getByRole('link', { name: 'xframe.openInNewTab' }).className).toContain('top-6');
}); });
}); });
/**
* quick-260922-ge2: Ausschnitt (Clip + verschobener, skalierter Rahmen bei
* gemessener Kachel 640x360), Zoom-Zweig (Prozentmasse + scale), 100 % wie
* heute, readOnly-Flaeche nur im Ansichtsmodus (T-GE2-02), Sandbox in jedem
* Zweig unveraendert (T-GE2-01).
*/
describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
it('Test 13: Ausschnitt 0/0/1280/720 in 640x360 -> data-tile-size, Clip 640x360 bei 0/0, Rahmen 1280x720 scale(0.5), Sandbox unveraendert', () => {
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } });
const body = container.querySelector('[data-tile-size]');
expect(body?.getAttribute('data-tile-size')).toBe('640x360');
const clip = screen.getByTestId('xframe-crop-clip');
expect(clip.style.position).toBe('absolute');
expect(clip.style.left).toBe('0px');
expect(clip.style.top).toBe('0px');
expect(clip.style.width).toBe('640px');
expect(clip.style.height).toBe('360px');
expect(clip.style.overflow).toBe('hidden');
const f = frame();
expect(clip.contains(f)).toBe(true);
expect(f.style.left).toBe('0px');
expect(f.style.top).toBe('0px');
expect(f.style.width).toBe('1280px');
expect(f.style.height).toBe('3000px');
expect(f.style.transform).toBe('scale(0.5)');
expect(f.style.transformOrigin).toBe('0 0');
expect(f.className).not.toContain('h-full');
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(f.getAttribute('allow')).toBe('');
expect(f.getAttribute('loading')).toBe('lazy');
expect(f.getAttribute('data-reload-nonce')).toBe('0');
expect(f.getAttribute('src')).toBe(URL_A);
});
it('Test 14: Ausschnitt 200/100/800/400 in 640x360 -> Clip 640x320 bei 0/20, Rahmen um -160/-80 verschoben, scale(0.8)', () => {
renderWidget({ url: URL_A, crop: { x: 200, y: 100, w: 800, h: 400 } });
const clip = screen.getByTestId('xframe-crop-clip');
expect(clip.style.left).toBe('0px');
expect(clip.style.top).toBe('20px');
expect(clip.style.width).toBe('640px');
expect(clip.style.height).toBe('320px');
const f = frame();
expect(f.style.left).toBe('-160px');
expect(f.style.top).toBe('-80px');
expect(f.style.height).toBe('3000px');
expect(f.style.transform).toBe('scale(0.8)');
});
it('Test 15: zoom 60 ohne Ausschnitt -> kein Clip, Rahmen 166.67 % mit scale(0.6), absolut positioniert', () => {
renderWidget({ url: URL_A, zoom: 60 });
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
const f = frame();
expect(f.style.width).toBe('166.67%');
expect(f.style.height).toBe('166.67%');
expect(f.style.transform).toBe('scale(0.6)');
expect(f.style.transformOrigin).toBe('0 0');
expect(f.className).toContain('absolute');
expect(f.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
});
it('Test 16: zoom 100 und ohne zoom -> wie heute (kein style, h-full w-full, kein Clip); Ausschnitt + zoom 60 -> Zoom ignoriert', () => {
renderWidget({ url: URL_A, zoom: 100 });
expect(frame().style.transform).toBe('');
expect(frame().className).toContain('h-full');
expect(frame().className).toContain('w-full');
expect(screen.queryByTestId('xframe-crop-clip')).not.toBeInTheDocument();
cleanup();
renderWidget({ url: URL_A });
expect(frame().style.transform).toBe('');
expect(frame().getAttribute('style')).toBeNull();
expect(frame().className).toContain('h-full');
cleanup();
renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, zoom: 60 });
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
expect(frame().style.transform).toBe('scale(0.5)');
expect(frame().style.transform).not.toBe('scale(0.6)');
});
it('Test 17: readOnly im Ansichtsmodus -> Flaeche aria-hidden, absolute inset-0, NACH dem Rahmen; kein Edit-Overlay; Link z-10 bleibt', () => {
renderWidget({ url: URL_A, readOnly: true }, false);
const overlay = screen.getByTestId('xframe-readonly-overlay');
expect(overlay.getAttribute('aria-hidden')).toBe('true');
expect(overlay.className).toContain('absolute');
expect(overlay.className).toContain('inset-0');
expect(frame().compareDocumentPosition(overlay) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByTestId('xframe-edit-overlay')).not.toBeInTheDocument();
const link = screen.getByRole('link', { name: 'xframe.openInNewTab' });
expect(link.className).toContain('z-10');
});
it('Test 18: readOnly im Bearbeitungsmodus -> nur Edit-Overlay; ohne readOnly im Ansichtsmodus -> keine Flaeche', () => {
renderWidget({ url: URL_A, readOnly: true }, true);
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
cleanup();
renderWidget({ url: URL_A }, false);
expect(screen.queryByTestId('xframe-readonly-overlay')).not.toBeInTheDocument();
});
it('Test 19: Ausschnitt im Bearbeitungsmodus -> Clip UND Edit-Overlay; mit Titel bleibt die Kopfleiste', () => {
const { container } = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 } }, true);
expect(screen.getByTestId('xframe-crop-clip')).toBeInTheDocument();
expect(screen.getByTestId('xframe-edit-overlay')).toBeInTheDocument();
cleanup();
const second = renderWidget({ url: URL_A, crop: { x: 0, y: 0, w: 1280, h: 720 }, title: 'Board' });
expect(second.container.querySelector('h2')).toHaveTextContent('Board');
expect(container.querySelector('h2')).toBeNull();
});
});
@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import type { WidgetProps } from '@/components/dashboard/widget-registry'; import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config'; import { XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
import { computeCropLayout } from './xframe-crop';
/** /**
* XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen * XframeWidget — eine Webseite per https-Adresse als eingebetteter Rahmen
@@ -38,11 +39,36 @@ import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“ * versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des * (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
* Rasters und startet im Bearbeitungsmodus kein Ziehen). * Rasters und startet im Bearbeitungsmodus kein Ziehen).
*
* Ausschnitt (quick-260922-ge2): cross-origin kann Tessera die Seite nicht
* von aussen scrollen oder zuschneiden. Deshalb wird der <iframe> SELBST
* mit `left/top` verschoben und mit `transform: scale` skaliert, und ein
* Clip-`div` (overflow hidden) schneidet ihn auf den Ausschnitt. Der Rahmen
* behaelt die feste Layoutbreite `XFRAME_PAGE_WIDTH` (1280): sie haelt den
* Seitenaufbau ueber alle Kachelgroessen stabil — sonst braeche die Seite
* bei jeder Kachelgroesse anders um und der in den Einstellungen gewaehlte
* Ausschnitt verrutschte. Die Kachelgroesse liefert ein ResizeObserver am
* Koerper (nur im Ausschnitt-Modus angehaengt); `computeCropLayout` passt
* den Ausschnitt ein (contain) und zentriert ihn. Ohne Ausschnitt gilt die
* Vergroesserung `zoom` (Prozentmasse + scale); 100 % rendert wie bisher.
*
* „Nur anzeigen“ (`readOnly`, T-GE2-02): eine transparente Flaeche NACH dem
* Rahmen sperrt Zeigerereignisse — Klicken und Scrollen in der Seite. Das
* ist Bedienkomfort, KEINE Sicherheitsmassnahme: die Seite laedt und laeuft
* weiter (Skripte, Cookies, Neuladen), die Sicherheitsgrenze bleibt die
* Sandbox. Im Bearbeitungsmodus liegt nur die Bearbeitungsflaeche, nie
* beide.
*/ */
export function XframeWidget({ config, isEditMode }: WidgetProps) { export function XframeWidget({ config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config]); const { url, title, reloadSeconds, crop, zoom, readOnly } = useMemo(
() => resolveXframeConfig(config),
[config],
);
const [reloadNonce, setReloadNonce] = useState(0); const [reloadNonce, setReloadNonce] = useState(0);
const [tile, setTile] = useState({ w: 0, h: 0 });
const bodyRef = useRef<HTMLDivElement>(null);
const hasCrop = crop !== null;
useEffect(() => { useEffect(() => {
if (url === null || reloadSeconds === 0 || isEditMode) return; if (url === null || reloadSeconds === 0 || isEditMode) return;
@@ -50,6 +76,18 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [url, reloadSeconds, isEditMode]); }, [url, reloadSeconds, isEditMode]);
// Kachelgroesse nur im Ausschnitt-Modus messen (Muster dashboard-grid.tsx).
useEffect(() => {
const el = bodyRef.current;
if (!el || !hasCrop) return;
const observer = new ResizeObserver((entries) => {
const rect = entries[0].contentRect;
setTile({ w: rect.width, h: rect.height });
});
observer.observe(el);
return () => observer.disconnect();
}, [hasCrop]);
if (url === null) { if (url === null) {
return ( return (
<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">
@@ -59,6 +97,21 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
} }
const openLabel = t('xframe.openInNewTab'); const openLabel = t('xframe.openInNewTab');
const frameKey = `${url}#${reloadNonce}`;
const frameAttrs = {
src: url,
title: title !== '' ? title : url,
sandbox: XFRAME_SANDBOX,
allow: '',
referrerPolicy: 'no-referrer' as const,
loading: 'lazy' as const,
'data-testid': 'xframe-frame',
'data-reload-nonce': reloadNonce,
};
const layout = crop === null ? null : computeCropLayout(crop, tile.w, tile.h);
const zoomFactor = zoom / 100;
// 60 % -> 166.67 %, 150 % -> 66.67 %: der Rahmen wird groesser gelegt und dann verkleinert.
const zoomPct = `${Math.round(10000 / zoomFactor) / 100}%`;
return ( return (
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
@@ -75,22 +128,61 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
/> />
</div> </div>
)} )}
<div className="relative min-h-0 flex-1"> <div
<iframe ref={bodyRef}
key={`${url}#${reloadNonce}`} className="relative min-h-0 flex-1 overflow-hidden"
src={url} data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
title={title !== '' ? title : url} >
sandbox={XFRAME_SANDBOX} {crop !== null && layout !== null ? (
allow="" layout.scale > 0 && (
referrerPolicy="no-referrer" <div
loading="lazy" data-testid="xframe-crop-clip"
data-testid="xframe-frame" style={{
data-reload-nonce={reloadNonce} position: 'absolute',
className="h-full w-full border-0 bg-background" left: layout.left,
/> top: layout.top,
width: crop.w * layout.scale,
height: crop.h * layout.scale,
overflow: 'hidden',
}}
>
<iframe
key={frameKey}
{...frameAttrs}
className="border-0 bg-background"
style={{
position: 'absolute',
left: -crop.x * layout.scale,
top: -crop.y * layout.scale,
width: XFRAME_PAGE_WIDTH,
height: layout.frameHeight,
transform: `scale(${layout.scale})`,
transformOrigin: '0 0',
}}
/>
</div>
)
) : zoomFactor !== 1 ? (
<iframe
key={frameKey}
{...frameAttrs}
className="absolute left-0 top-0 border-0 bg-background"
style={{
width: zoomPct,
height: zoomPct,
transform: `scale(${zoomFactor})`,
transformOrigin: '0 0',
}}
/>
) : (
<iframe key={frameKey} {...frameAttrs} className="h-full w-full border-0 bg-background" />
)}
{isEditMode && ( {isEditMode && (
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" /> <div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />
)} )}
{!isEditMode && readOnly && (
<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-readonly-overlay" />
)}
{title === '' && ( {title === '' && (
<NewTabLink <NewTabLink
url={url} url={url}
@@ -66,6 +66,8 @@ describe('PictureFrameConfigForm (quick-260921-pi9)', () => {
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('5 Sekunden');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('30 Minuten');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute'); expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Minute');
expect(Array.from(interval.options).map((o) => o.textContent)).toContain('1 Stunde');
expect(Array.from(interval.options).map((o) => o.textContent)).not.toContain('60 Minuten');
fireEvent.change(fit, { target: { value: 'cover' } }); fireEvent.change(fit, { target: { value: 'cover' } });
expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' }); expect(onChange).toHaveBeenLastCalledWith({ fit: 'cover' });
@@ -128,6 +128,8 @@ export function PictureFrameConfigForm({ config, onChange }: PictureFrameConfigF
function intervalLabel(seconds: number): string { function intervalLabel(seconds: number): string {
if (seconds === 0) return t('pictureFrame.intervalOff'); if (seconds === 0) return t('pictureFrame.intervalOff');
if (seconds < 60) return t('pictureFrame.intervalSeconds', { n: seconds }); if (seconds < 60) return t('pictureFrame.intervalSeconds', { n: seconds });
// 3600 s heisst wie beim XFrame „1 Stunde“, nicht „60 Minuten“.
if (seconds >= 3600) return t('pictureFrame.intervalHours', { n: seconds / 3600 });
return t('pictureFrame.intervalMinutes', { n: seconds / 60 }); return t('pictureFrame.intervalMinutes', { n: seconds / 60 });
} }
@@ -263,3 +263,25 @@ describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)', () => {
expect(updateWidgetConfig).toHaveBeenCalledWith('x1', { url: 'https://b.de/' }); expect(updateWidgetConfig).toHaveBeenCalledWith('x1', { url: 'https://b.de/' });
}); });
}); });
describe('WidgetSettingsPanel — Bilderrahmen-Kopfzeile (Kosmetik nach quick-260921-pi9)', () => {
it('Test P1: Kopfzeile zeigt "— 1 Bild" bzw. "— 3 Bilder", ohne Bilder keinen Zusatz', () => {
const one = { id: 'p1', widgetType: 'picture-frame', config: { images: [{ kind: 'url', url: 'https://a.de/x.png' }] } };
const three = {
id: 'p2',
widgetType: 'picture-frame',
config: {
images: [
{ kind: 'url', url: 'https://a.de/1.png' },
{ kind: 'upload', imageId: 'b' },
{ kind: 'url', url: 'https://a.de/3.png' },
],
},
};
const none = { id: 'p3', widgetType: 'picture-frame', config: {} };
render(<WidgetSettingsPanel widgets={[one, three, none]} onWidgetUpdate={vi.fn()} />);
expect(screen.getByRole('button', { name: /Bilderrahmen #1/ })).toHaveTextContent('— 1 Bild');
expect(screen.getByRole('button', { name: /Bilderrahmen #2/ })).toHaveTextContent('— 3 Bilder');
expect(screen.getByRole('button', { name: /Bilderrahmen #3/ })).not.toHaveTextContent('—');
});
});
@@ -18,6 +18,7 @@ import {
CALENDAR_MAX_EVENTS_LIMIT, CALENDAR_MAX_EVENTS_LIMIT,
resolveCalendarConfig, resolveCalendarConfig,
} from '@/components/dashboard/widgets/calendar-month'; } from '@/components/dashboard/widgets/calendar-month';
import { resolvePictureFrameConfig } from '@/components/dashboard/widgets/picture-frame-config';
/** /**
* IANA timezones offered for clock configuration (D-12). * IANA timezones offered for clock configuration (D-12).
@@ -128,6 +129,18 @@ export function WidgetSettingsPanel({
— {widget.config.title.trim()} — {widget.config.title.trim()}
</span> </span>
)} )}
{/* Bilderrahmen hat keinen Titel — die Kopfzeile nennt die Bildanzahl */}
{widget.widgetType === 'picture-frame' &&
resolvePictureFrameConfig(widget.config).images.length > 0 && (
<span className="ml-2 text-muted-foreground">
—{' '}
{resolvePictureFrameConfig(widget.config).images.length === 1
? t('pictureFrame.imageCountOne')
: t('pictureFrame.imageCountMany', {
n: resolvePictureFrameConfig(widget.config).images.length,
})}
</span>
)}
</span> </span>
<svg <svg
aria-hidden="true" aria-hidden="true"
@@ -1,6 +1,8 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json'; import de from '@/messages/de.json';
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
import { stubResizeObserver } from '@/test/fake-resize-observer';
/** /**
* xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten * xframe-config-form.test — NEU (quick-260921-qd3). Texte aus der echten
@@ -22,17 +24,31 @@ import { XframeConfigForm } from './xframe-config-form';
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe; const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
const URL_A = 'https://intern.example/board'; const URL_A = 'https://intern.example/board';
type Corner = 'nw' | 'ne' | 'sw' | 'se';
type CropKey = 'x' | 'y' | 'w' | 'h';
function renderForm(config: Record<string, unknown>) { function renderForm(config: Record<string, unknown>) {
const onChange = vi.fn(); const onChange = vi.fn();
render(<XframeConfigForm config={config} onChange={onChange} />); render(<XframeConfigForm config={config} onChange={onChange} />);
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement; const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement; const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement; const reload = screen.getByLabelText(texts.reloadLabel) as HTMLSelectElement;
return { onChange, url, title, reload }; const cropEnable = screen.getByLabelText(texts.cropEnable) as HTMLInputElement;
const readOnly = screen.getByLabelText(texts.readOnly) as HTMLInputElement;
const rect = () => screen.getByTestId('xframe-crop-rect');
const handle = (corner: Corner) => screen.getByTestId(`xframe-crop-handle-${corner}`);
const numberField = (k: CropKey) => screen.getByLabelText(texts[`crop${k.toUpperCase()}`]) as HTMLInputElement;
return { onChange, url, title, reload, cropEnable, readOnly, rect, handle, numberField };
} }
beforeEach(() => {
// quick-260922-ge2: Vorschau 640 px breit -> p = 0.5 (50 Bildschirm-px = 100 Seiten-px)
stubResizeObserver({ width: 640, height: 420 });
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
}); });
describe('XframeConfigForm (quick-260921-qd3)', () => { describe('XframeConfigForm (quick-260921-qd3)', () => {
@@ -139,3 +155,159 @@ describe('XframeConfigForm (quick-260921-qd3)', () => {
expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 }); expect(onChange).toHaveBeenCalledWith({ reloadSeconds: 600 });
}); });
}); });
/**
* quick-260922-ge2: Ausschnitt (Checkbox -> EIN Aufruf mit Ausschnitt +
* readOnly), Vorschau mit derselben Sandbox und pointer-events none
* (T-GE2-01, T-GE2-03), Ziehen des Rahmens und der Ecken -> Entwurf folgt,
* EIN Aufruf beim Loslassen; Zahlenfelder klemmen; Zoom nur ohne
* Ausschnitt; „Nur anzeigen“; dauerhafter Hinweis.
*
* jsdom kennt kein setPointerCapture — Move/Up feuern auf DEMSELBEN
* Element; Griffe stoppen die Propagation, sonst zaehlt der Rahmen mit.
*/
const CROP = { x: 100, y: 100, w: 400, h: 300 };
describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)', () => {
it('Test 9: ohne Ausschnitt -> Checkbox aus, keine Vorschau, Zoom-Auswahl mit sieben Stufen; Einschalten -> EIN Aufruf mit Ausschnitt + readOnly', () => {
const { onChange, cropEnable } = renderForm({ url: URL_A });
expect(cropEnable.checked).toBe(false);
expect(screen.queryByTestId('xframe-crop-preview')).not.toBeInTheDocument();
const zoom = screen.getByLabelText(texts.zoomLabel) as HTMLSelectElement;
expect(Array.from(zoom.options).map((o) => o.value)).toEqual(['50', '60', '75', '90', '100', '125', '150']);
expect(Array.from(zoom.options).map((o) => o.textContent)).toEqual([
'50 %',
'60 %',
'75 %',
'90 %',
'100 %',
'125 %',
'150 %',
]);
expect(zoom.value).toBe('100');
fireEvent.click(cropEnable);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true });
});
it('Test 10: mit Ausschnitt -> Vorschau-Rahmen mit Sandbox, no-referrer, pointer-events none, 1280x3000 scale(0.5); Rahmen 50/50/200x150, vier Griffe, keine Zoom-Auswahl; Ausschalten -> { crop: null }', () => {
const { onChange, cropEnable, rect } = renderForm({ url: URL_A, crop: CROP });
expect(cropEnable.checked).toBe(true);
expect(screen.getByTestId('xframe-crop-preview')).toBeInTheDocument();
const preview = screen.getByTestId('xframe-crop-preview-frame') as HTMLIFrameElement;
expect(preview.getAttribute('src')).toBe(URL_A);
expect(preview.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
expect(preview.getAttribute('referrerpolicy')).toBe('no-referrer');
expect(preview.getAttribute('allow')).toBe('');
expect(preview.style.pointerEvents).toBe('none');
expect(preview.style.width).toBe('1280px');
expect(preview.style.height).toBe('3000px');
expect(preview.style.transform).toBe('scale(0.5)');
const r = rect();
// <fieldset> traegt die Gruppen-Rolle implizit (Biome useSemanticElements statt div role="group")
expect(r.tagName).toBe('FIELDSET');
expect(screen.getByRole('group', { name: texts.cropRectangle })).toBe(r);
expect(r.style.left).toBe('50px');
expect(r.style.top).toBe('50px');
expect(r.style.width).toBe('200px');
expect(r.style.height).toBe('150px');
for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
expect(screen.getByTestId(`xframe-crop-handle-${corner}`)).toBeInTheDocument();
}
expect(screen.queryByLabelText(texts.zoomLabel)).not.toBeInTheDocument();
fireEvent.click(cropEnable);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ crop: null });
});
it('Test 11: Zahlenfelder — Breite 2000 + Blur klemmt auf 1280 (x -> 0); Hoehe 10 + Enter -> 60; "abc" und unveraendert -> kein Aufruf', () => {
const { onChange, numberField } = renderForm({ url: URL_A, crop: CROP });
expect(numberField('x').value).toBe('100');
expect(numberField('w').value).toBe('400');
fireEvent.change(numberField('w'), { target: { value: '2000' } });
fireEvent.blur(numberField('w'));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 0, y: 100, w: 1280, h: 300 } });
fireEvent.change(numberField('h'), { target: { value: '10' } });
fireEvent.keyDown(numberField('h'), { key: 'Enter' });
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith({ crop: { x: 100, y: 100, w: 400, h: 60 } });
fireEvent.change(numberField('x'), { target: { value: 'abc' } });
fireEvent.blur(numberField('x'));
expect(onChange).toHaveBeenCalledTimes(2);
fireEvent.change(numberField('x'), { target: { value: '100' } });
fireEvent.blur(numberField('x'));
expect(onChange).toHaveBeenCalledTimes(2);
});
it('Test 12: Rahmen ziehen -> Entwurf folgt der Maus ohne Aufruf, Loslassen -> EIN Aufruf mit verschobenem Ausschnitt', () => {
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
expect(rect().style.left).toBe('100px');
expect(rect().style.top).toBe('75px');
fireEvent.pointerUp(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ crop: { x: 200, y: 150, w: 400, h: 300 } });
});
it('Test 13: Ecke unten rechts +50/+50 -> groesser, Gegenecke bleibt, genau ein Aufruf; Ecke oben links -50/-50 -> 0/0/500x400', () => {
const { onChange, handle } = renderForm({ url: URL_A, crop: CROP });
fireEvent.pointerDown(handle('se'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
fireEvent.pointerMove(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
fireEvent.pointerUp(handle('se'), { clientX: 150, clientY: 150, pointerId: 1 });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ crop: { x: 100, y: 100, w: 500, h: 400 } });
cleanup();
const second = renderForm({ url: URL_A, crop: CROP });
fireEvent.pointerDown(second.handle('nw'), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
fireEvent.pointerMove(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
fireEvent.pointerUp(second.handle('nw'), { clientX: 50, clientY: 50, pointerId: 1 });
expect(second.onChange).toHaveBeenCalledTimes(1);
expect(second.onChange).toHaveBeenCalledWith({ crop: { x: 0, y: 0, w: 500, h: 400 } });
});
it('Test 14: Klick ohne Bewegung -> kein Aufruf; pointerCancel nach Bewegung -> kein Aufruf, Rahmen wieder bei 50px', () => {
const { onChange, rect } = renderForm({ url: URL_A, crop: CROP });
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
fireEvent.pointerUp(rect(), { clientX: 100, clientY: 100, pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
fireEvent.pointerDown(rect(), { clientX: 100, clientY: 100, pointerId: 1, button: 0 });
fireEvent.pointerMove(rect(), { clientX: 150, clientY: 125, pointerId: 1 });
expect(rect().style.left).toBe('100px');
fireEvent.pointerCancel(rect(), { pointerId: 1 });
expect(onChange).not.toHaveBeenCalled();
expect(rect().style.left).toBe('50px');
expect(rect().style.top).toBe('50px');
});
it('Test 15: „Nur anzeigen“ — angehakt bei readOnly true, Klick -> { readOnly: false }; sonst aus, Klick -> { readOnly: true }; Hinweis immer sichtbar', () => {
const { onChange, readOnly } = renderForm({ url: URL_A, readOnly: true });
expect(readOnly.checked).toBe(true);
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
fireEvent.click(readOnly);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ readOnly: false });
cleanup();
const second = renderForm({ url: URL_A });
expect(second.readOnly.checked).toBe(false);
expect(screen.getByText(texts.cropHint)).toBeInTheDocument();
fireEvent.click(second.readOnly);
expect(second.onChange).toHaveBeenCalledWith({ readOnly: true });
});
it('Test 16: Ausschnitt ohne Adresse -> Leerhinweis der Vorschau, kein Vorschau-Rahmen', () => {
renderForm({ crop: CROP });
expect(screen.getByTestId('xframe-crop-preview-empty')).toHaveTextContent(texts.cropPreviewEmpty);
expect(screen.queryByTestId('xframe-crop-preview-frame')).not.toBeInTheDocument();
expect(screen.getByText(texts.cropPreviewHint)).toBeInTheDocument();
});
it('Test 17: Zoom-Auswahl 60 -> onChange({ zoom: 60 }) sofort', () => {
const { onChange } = renderForm({ url: URL_A });
fireEvent.change(screen.getByLabelText(texts.zoomLabel), { target: { value: '60' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ zoom: 60 });
});
});
@@ -1,13 +1,24 @@
'use client'; 'use client';
import { type KeyboardEvent, useState } from 'react'; import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import {
XFRAME_CROP_DEFAULT,
XFRAME_PAGE_WIDTH,
XFRAME_RELOAD_OPTIONS, XFRAME_RELOAD_OPTIONS,
XFRAME_SANDBOX,
XFRAME_TITLE_MAX, XFRAME_TITLE_MAX,
XFRAME_ZOOM_OPTIONS,
type XframeCrop,
clampXframeCrop,
isHttpsUrl, isHttpsUrl,
resolveXframeConfig, resolveXframeConfig,
} from '@/components/dashboard/widgets/xframe-config'; } from '@/components/dashboard/widgets/xframe-config';
import {
XFRAME_PREVIEW_PAGE_HEIGHT,
type XframeDragMode,
applyCropDrag,
} from '@/components/dashboard/widgets/xframe-crop';
interface XframeConfigFormProps { interface XframeConfigFormProps {
config: Record<string, unknown>; config: Record<string, unknown>;
@@ -17,6 +28,9 @@ interface XframeConfigFormProps {
const FIELD_CLASS = const FIELD_CLASS =
'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground'; 'h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground';
const LABEL_CLASS = 'mb-1 block text-sm text-foreground'; const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
const CHECKBOX_CLASS = 'h-4 w-4 rounded border-border text-primary';
/** Sichtbare Hoehe der Vorschau in Bildschirmpixeln (eigener Bildlauf darin). */
const PREVIEW_HEIGHT_PX = 420;
/** /**
* Einstellungen des XFrame-Widgets im WidgetSettingsPanel * Einstellungen des XFrame-Widgets im WidgetSettingsPanel
@@ -34,10 +48,22 @@ const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht * Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig * dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
* erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“. * erkennbar, die Kachel bietet dafuer immer „In neuem Tab öffnen“.
*
* Ausschnitt (quick-260922-ge2): die Vorschau ist DERSELBE <iframe> wie in
* der Kachel mit derselben Sandbox, `allow=""` und no-referrer (T-GE2-01),
* bei fester Seitenbreite 1280 auf die Vorschaubreite skaliert und mit
* `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen —
* Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite).
* Ziehen laeuft ueber Pointer-Events mit Capture (Wächter, weil jsdom kein
* `setPointerCapture` kennt), aus Deltas statt `getBoundingClientRect`
* (Bildlauf im Vorschaubehaelter stoert so nicht); der Entwurf bleibt lokal,
* beim Loslassen gibt es genau EIN `onChange` — und nur bei Aenderung.
* Der Ausschnitt ist eine Position auf der Seite, kein Inhalt: der Hinweis
* dazu steht dauerhaft unter den Feldern.
*/ */
export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) { export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const { url, title, reloadSeconds } = resolveXframeConfig(config); const { url, title, reloadSeconds, crop, zoom, readOnly } = resolveXframeConfig(config);
const [urlDraft, setUrlDraft] = useState(url ?? ''); const [urlDraft, setUrlDraft] = useState(url ?? '');
const [urlError, setUrlError] = useState(false); const [urlError, setUrlError] = useState(false);
@@ -63,13 +89,6 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
if (next !== title) onChange({ title: next }); if (next !== title) onChange({ title: next });
}; };
const onEnter = (commit: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
const reloadLabel = (seconds: number): string => { const reloadLabel = (seconds: number): string => {
if (seconds === 0) return t('xframe.reloadOff'); if (seconds === 0) return t('xframe.reloadOff');
if (seconds === 60) return t('xframe.reloadMinute'); if (seconds === 60) return t('xframe.reloadMinute');
@@ -77,6 +96,17 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
return t('xframe.reloadMinutes', { n: seconds / 60 }); return t('xframe.reloadMinutes', { n: seconds / 60 });
}; };
// Einschalten setzt Ausschnitt UND „Nur anzeigen“ in EINEM Aufruf (ein
// PATCH); Ausschalten laesst readOnly unberuehrt.
const toggleCrop = (checked: boolean) => {
if (checked) onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true });
else onChange({ crop: null });
};
const commitCrop = (next: XframeCrop) => {
if (crop === null || !sameCrop(next, crop)) onChange({ crop: next });
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Adresse (nur https) */} {/* Adresse (nur https) */}
@@ -143,6 +173,285 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
))} ))}
</select> </select>
</div> </div>
{/* Ausschnitt ein/aus (quick-260922-ge2) */}
<div className="flex items-center gap-3">
<input
id="xframe-crop-enable"
type="checkbox"
className={CHECKBOX_CLASS}
checked={crop !== null}
onChange={(e) => toggleCrop(e.target.checked)}
/>
<label htmlFor="xframe-crop-enable" className="text-sm text-foreground">
{t('xframe.cropEnable')}
</label>
</div>
{crop !== null ? (
<>
<p className="text-xs text-muted-foreground">{t('xframe.cropPreviewHint')}</p>
<CropPreview
url={url}
crop={crop}
onCommit={commitCrop}
rectangleLabel={t('xframe.cropRectangle')}
emptyText={t('xframe.cropPreviewEmpty')}
/>
{/* key: State-Reset, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen */}
<CropNumberFields key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`} crop={crop} onCommit={commitCrop} />
</>
) : (
/* Vergroesserung nur fuer die ganze Seite */
<div>
<label htmlFor="xframe-zoom" className={LABEL_CLASS}>
{t('xframe.zoomLabel')}
</label>
<select
id="xframe-zoom"
value={String(zoom)}
onChange={(e) => onChange({ zoom: Number(e.target.value) })}
className={FIELD_CLASS}
>
{XFRAME_ZOOM_OPTIONS.map((n) => (
<option key={n} value={String(n)}>
{t('xframe.zoomOption', { n })}
</option>
))}
</select>
</div>
)}
{/* „Nur anzeigen“ — Bedienkomfort, keine Sicherheitsmassnahme (T-GE2-02) */}
<div className="flex items-center gap-3">
<input
id="xframe-readonly"
type="checkbox"
className={CHECKBOX_CLASS}
checked={readOnly}
onChange={(e) => onChange({ readOnly: e.target.checked })}
/>
<label htmlFor="xframe-readonly" className="text-sm text-foreground">
{t('xframe.readOnly')}
</label>
</div>
<p id="xframe-crop-hint" className="text-xs text-muted-foreground">
{t('xframe.cropHint')}
</p>
</div>
);
}
function onEnter(commit: () => void) {
return (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
}
function sameCrop(a: XframeCrop, b: XframeCrop): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
// jsdom (29.x) kennt PointerEvent, aber kein Element.setPointerCapture —
// im Browser wird gefangen, im Test feuern Move/Up auf demselben Element.
function capturePointer(el: HTMLElement, id: number) {
if (typeof el.setPointerCapture === 'function') el.setPointerCapture(id);
}
function releasePointer(el: HTMLElement, id: number) {
if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(id);
}
interface DragState {
mode: XframeDragMode;
startX: number;
startY: number;
start: XframeCrop;
}
const HANDLE_CLASS: Record<Exclude<XframeDragMode, 'move'>, string> = {
nw: '-left-1.5 -top-1.5 cursor-nwse-resize',
ne: '-right-1.5 -top-1.5 cursor-nesw-resize',
sw: '-left-1.5 -bottom-1.5 cursor-nesw-resize',
se: '-right-1.5 -bottom-1.5 cursor-nwse-resize',
};
interface CropPreviewProps {
url: string | null;
crop: XframeCrop;
onCommit: (next: XframeCrop) => void;
rectangleLabel: string;
emptyText: string;
}
/** Vorschau der Seite bei 1280 px Breite mit verschieb- und ziehbarem Rahmen. */
function CropPreview({ url, crop, onCommit, rectangleLabel, emptyText }: CropPreviewProps) {
const previewRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const [width, setWidth] = useState(0);
const [draft, setDraft] = useState<XframeCrop | null>(null);
useEffect(() => {
const el = previewRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
setWidth(entries[0].contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Massstab Bildschirm/Seite; vor der Messung 0.5 (640 px Vorschau).
const p = width > 0 ? width / XFRAME_PAGE_WIDTH : 0.5;
const c = draft ?? crop;
const dragProps = (mode: XframeDragMode) => ({
onPointerDown: (e: PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
// Griffe: sonst startet der Rahmen zusaetzlich ein Verschieben.
if (mode !== 'move') e.stopPropagation();
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop };
capturePointer(e.currentTarget, e.pointerId);
},
onPointerMove: (e: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.mode !== mode) return;
setDraft(applyCropDrag(drag.mode, drag.start, (e.clientX - drag.startX) / p, (e.clientY - drag.startY) / p));
},
onPointerUp: (e: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.mode !== mode) return;
releasePointer(e.currentTarget, e.pointerId);
dragRef.current = null;
const next = draft ?? drag.start;
setDraft(null);
onCommit(clampXframeCrop(next));
},
onPointerCancel: (e: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.mode !== mode) return;
releasePointer(e.currentTarget, e.pointerId);
dragRef.current = null;
setDraft(null);
},
});
return (
<div
ref={previewRef}
data-testid="xframe-crop-preview"
className="relative overflow-x-hidden overflow-y-auto rounded border border-border bg-background"
style={{ height: PREVIEW_HEIGHT_PX }}
>
{url === null ? (
<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">
{emptyText}
</p>
) : (
<div
data-testid="xframe-crop-stage"
className="relative"
style={{ width: XFRAME_PAGE_WIDTH * p, height: XFRAME_PREVIEW_PAGE_HEIGHT * p }}
>
<iframe
data-testid="xframe-crop-preview-frame"
src={url}
title={rectangleLabel}
sandbox={XFRAME_SANDBOX}
allow=""
referrerPolicy="no-referrer"
loading="lazy"
className="border-0"
style={{
position: 'absolute',
left: 0,
top: 0,
width: XFRAME_PAGE_WIDTH,
height: XFRAME_PREVIEW_PAGE_HEIGHT,
transform: `scale(${p})`,
transformOrigin: '0 0',
pointerEvents: 'none',
}}
/>
{/* <fieldset> statt div role="group" (Biome useSemanticElements): implizite
Gruppen-Rolle, Name per aria-label; Preflight nimmt Rand und Innenabstand. */}
<fieldset
aria-label={rectangleLabel}
data-testid="xframe-crop-rect"
className="absolute m-0 min-w-0 cursor-move touch-none border-2 border-primary p-0"
style={{ left: c.x * p, top: c.y * p, width: c.w * p, height: c.h * p }}
{...dragProps('move')}
>
{(Object.keys(HANDLE_CLASS) as Array<keyof typeof HANDLE_CLASS>).map((corner) => (
<div
key={corner}
aria-hidden="true"
data-testid={`xframe-crop-handle-${corner}`}
className={`absolute h-3 w-3 touch-none bg-primary ${HANDLE_CLASS[corner]}`}
{...dragProps(corner)}
/>
))}
</fieldset>
</div>
)}
</div>
);
}
const CROP_KEYS: Array<keyof XframeCrop> = ['x', 'y', 'w', 'h'];
const CROP_LABEL_KEYS: Record<keyof XframeCrop, string> = {
x: 'xframe.cropX',
y: 'xframe.cropY',
w: 'xframe.cropW',
h: 'xframe.cropH',
};
/** Links/Oben/Breite/Hoehe als Zahlenfelder; Uebernahme bei Blur/Enter, geklemmt. */
function CropNumberFields({ crop, onCommit }: { crop: XframeCrop; onCommit: (next: XframeCrop) => void }) {
const t = useTranslations('widgets');
const [drafts, setDrafts] = useState<Record<keyof XframeCrop, string>>({
x: String(crop.x),
y: String(crop.y),
w: String(crop.w),
h: String(crop.h),
});
const commit = (k: keyof XframeCrop) => {
const n = Number(drafts[k]);
if (drafts[k].trim() === '' || !Number.isFinite(n)) return;
onCommit(clampXframeCrop({ ...crop, [k]: n }));
};
return (
<div>
<div className="grid grid-cols-4 gap-2">
{CROP_KEYS.map((k) => (
<div key={k}>
<label htmlFor={`xframe-crop-${k}`} className={LABEL_CLASS}>
{t(CROP_LABEL_KEYS[k])}
</label>
<input
id={`xframe-crop-${k}`}
type="number"
inputMode="numeric"
min={0}
value={drafts[k]}
onChange={(e) => setDrafts((d) => ({ ...d, [k]: e.target.value }))}
onBlur={() => commit(k)}
onKeyDown={onEnter(() => commit(k))}
aria-describedby="xframe-crop-unit-hint"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
/>
</div>
))}
</div>
<p id="xframe-crop-unit-hint" className="mt-1 text-xs text-muted-foreground">
{t('xframe.cropUnitHint')}
</p>
</div> </div>
); );
} }
+17 -1
View File
@@ -348,10 +348,13 @@
"intervalOff": "Kein Wechsel", "intervalOff": "Kein Wechsel",
"intervalSeconds": "{n} Sekunden", "intervalSeconds": "{n} Sekunden",
"intervalMinutes": "{n, plural, one {# Minute} other {# Minuten}}", "intervalMinutes": "{n, plural, one {# Minute} other {# Minuten}}",
"intervalHours": "{n, plural, one {# Stunde} other {# Stunden}}",
"orderLabel": "Reihenfolge der Bilder", "orderLabel": "Reihenfolge der Bilder",
"orderSequence": "Reihenfolge", "orderSequence": "Reihenfolge",
"orderRandom": "Zufall", "orderRandom": "Zufall",
"imagesLabel": "Bilder", "imagesLabel": "Bilder",
"imageCountOne": "1 Bild",
"imageCountMany": "{n} Bilder",
"captionPlaceholder": "Bildunterschrift (optional)", "captionPlaceholder": "Bildunterschrift (optional)",
"uploadButton": "Bild hochladen", "uploadButton": "Bild hochladen",
"uploadHint": "PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder", "uploadHint": "PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder",
@@ -379,7 +382,20 @@
"reloadMinute": "Jede Minute", "reloadMinute": "Jede Minute",
"reloadMinutes": "Alle {n} Minuten", "reloadMinutes": "Alle {n} Minuten",
"reloadHour": "Jede Stunde", "reloadHour": "Jede Stunde",
"embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem." "embedHint": "Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.",
"cropEnable": "Nur einen Ausschnitt der Seite anzeigen",
"cropHint": "Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.",
"cropPreviewHint": "Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.",
"cropPreviewEmpty": "Die Vorschau erscheint, sobald eine Adresse eingetragen ist.",
"cropRectangle": "Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe",
"cropX": "Links",
"cropY": "Oben",
"cropW": "Breite",
"cropH": "Höhe",
"cropUnitHint": "Werte in Pixeln der Seite bei 1280 Pixel Breite",
"zoomLabel": "Vergrößerung der ganzen Seite",
"zoomOption": "{n} %",
"readOnly": "Nur anzeigen – Klicks und Scrollen im Rahmen sperren"
} }
}, },
"admin": { "admin": {
+17 -1
View File
@@ -348,10 +348,13 @@
"intervalOff": "No change", "intervalOff": "No change",
"intervalSeconds": "{n} seconds", "intervalSeconds": "{n} seconds",
"intervalMinutes": "{n, plural, one {# minute} other {# minutes}}", "intervalMinutes": "{n, plural, one {# minute} other {# minutes}}",
"intervalHours": "{n, plural, one {# hour} other {# hours}}",
"orderLabel": "Image order", "orderLabel": "Image order",
"orderSequence": "In order", "orderSequence": "In order",
"orderRandom": "Random", "orderRandom": "Random",
"imagesLabel": "Images", "imagesLabel": "Images",
"imageCountOne": "1 image",
"imageCountMany": "{n} images",
"captionPlaceholder": "Caption (optional)", "captionPlaceholder": "Caption (optional)",
"uploadButton": "Upload image", "uploadButton": "Upload image",
"uploadHint": "PNG, JPEG, GIF or WebP, at most 5 MB, up to 30 images", "uploadHint": "PNG, JPEG, GIF or WebP, at most 5 MB, up to 30 images",
@@ -379,7 +382,20 @@
"reloadMinute": "Every minute", "reloadMinute": "Every minute",
"reloadMinutes": "Every {n} minutes", "reloadMinutes": "Every {n} minutes",
"reloadHour": "Every hour", "reloadHour": "Every hour",
"embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page." "embedHint": "Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.",
"cropEnable": "Show only a section of the page",
"cropHint": "The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.",
"cropPreviewHint": "Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.",
"cropPreviewEmpty": "The preview appears once an address is set.",
"cropRectangle": "Section – drag to move, corners to resize",
"cropX": "Left",
"cropY": "Top",
"cropW": "Width",
"cropH": "Height",
"cropUnitHint": "Values in page pixels at 1280 pixels wide",
"zoomLabel": "Zoom of the full page",
"zoomOption": "{n} %",
"readOnly": "View only – block clicks and scrolling inside the frame"
} }
}, },
"admin": { "admin": {
@@ -136,6 +136,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Bildausschnitt', 'Bildausschnitt',
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen // XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
'neuem', 'neuem',
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
'Ausschnitt',
'Absenderadresse', 'Absenderadresse',
'muss', 'muss',
'musste', 'musste',
+33
View File
@@ -0,0 +1,33 @@
import { vi } from 'vitest';
/**
* fake-resize-observer — file-scoped ResizeObserver stub with an explicit
* size (quick-260922-ge2).
*
* setup.ts already polyfills ResizeObserver globally for jsdom, but it
* always reports 1200x800. Components whose geometry depends on the
* measured size (XFrame crop clip, crop preview) need deterministic,
* test-chosen numbers — so a test file overrides the global with this
* stub and restores it via `vi.unstubAllGlobals()` in `afterEach`.
*
* The stub class is structurally a ResizeObserver (observe / unobserve /
* disconnect); the single `as ResizeObserverEntry` cast is enough because
* jsdom provides `DOMRectReadOnly` and the callback only reads
* `contentRect`; no double cast through `unknown` is needed.
*/
export function stubResizeObserver(size: { width: number; height: number }): void {
vi.stubGlobal(
'ResizeObserver',
class {
private readonly cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
}
observe() {
this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this);
}
unobserve() {}
disconnect() {}
},
);
}
+2 -2
View File
@@ -80,7 +80,7 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt | | Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
| Stoppuhr | Zeitmessung mit Rundenzeiten | | Stoppuhr | Zeitmessung mit Rundenzeiten |
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder | | Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem | | XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen). Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen und XFrame gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
@@ -152,7 +152,7 @@ Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. G
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück. - **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera. - **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. **Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.
**Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen. **Dashboard > Kalender:** Hier verwalten Sie die Kalenderquellen, aus denen das Kalender-Widget seine Termine bezieht — Quellen hinzufügen, die Verbindung testen und nicht mehr benötigte Quellen wieder entfernen.