31ca115796
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>
218 lines
15 KiB
Markdown
218 lines
15 KiB
Markdown
---
|
||
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.
|