Compare commits
6 Commits
5aa577a7fa
..
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d146234bba | |||
| a8a39a4842 | |||
| 80a0d23ccf | |||
| cf70a197cd | |||
| 30fdd99724 | |||
| 445b1d3100 |
+9
-8
@@ -4,10 +4,10 @@ milestone: v1.2
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: Tray-Update-Befund des Nutzers geklaert (quick-260922-frg, d73aad1): der Proxy vor alpha verlangt Basic-Auth (401), der Updater kann das nicht; Client zeigt jetzt den Grund und prueft alle 4 h. OFFEN BEIM NUTZER: Passwortschutz vor alpha im Nginx Proxy Manager entfernen (oder /api-proxy/desktop/* freigeben), alpha ziehen, neuen Client einmal per Browser installieren. Davor am 21./22.09.: Bilderrahmen + XFrame gebaut, nachgewiesen, gepusht. Freigabe 1.3.0 auf Zuruf."
|
||||
last_updated: "2026-09-22T10:05:00.000Z"
|
||||
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-22T12:40:00.000Z"
|
||||
last_activity: 2026-09-21
|
||||
last_activity_desc: Quick 260922-frg — Tray-Update-Eintrag nennt den Grund (HTTP 401 am Proxy), Klick prueft erneut, Pruefung alle 4 h; Ursache beim Nutzer: Basic-Auth vor alpha
|
||||
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
|
||||
progress:
|
||||
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)
|
||||
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
|
||||
Last activity: 2026-09-22 - 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
|
||||
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%
|
||||
|
||||
@@ -460,6 +460,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 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
|
||||
|
||||
@@ -501,8 +502,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-22T10:05:00Z
|
||||
Resumed: 2026-09-21 (abends) — Sitzung ueber /gsd-resume-work; danach Bilderrahmen + XFrame, Kosmetik, am 22.09. der Tray-Update-Befund.
|
||||
Stopped at: quick-260922-frg gepusht, CI-Lauf mit Desktop-Neubau laeuft. Beim Nutzer: Passwortschutz vor alpha im Proxy Manager entfernen (sonst kann KEIN Client per Tray aktualisieren), alpha ziehen, neuen Client einmal per Browser installieren. Kein weiterer Auftrag benannt.
|
||||
Last session: 2026-09-22T12:15:00Z
|
||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; danach Bilderrahmen, XFrame, Kosmetik, Tray-Update-Befund, Download-Knoepfe, XFrame-Ausschnitt.
|
||||
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
|
||||
Last activity: 2026-09-22 - 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
|
||||
Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen; auf alpha bestaetigt
|
||||
|
||||
+229
@@ -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>
|
||||
+267
@@ -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.
|
||||
@@ -4,9 +4,12 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.3.0 – 2026-09-22
|
||||
|
||||
### 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“: 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
|
||||
- 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: …“
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
XFRAME_CROP_DEFAULT,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
XFRAME_RELOAD_MAX,
|
||||
XFRAME_RELOAD_MIN,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
XFRAME_ZOOM_OPTIONS,
|
||||
clampXframeCrop,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from './xframe-config';
|
||||
@@ -19,7 +23,14 @@ import {
|
||||
*/
|
||||
describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||
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)', () => {
|
||||
@@ -64,7 +75,101 @@ describe('resolveXframeConfig (quick-260921-qd3)', () => {
|
||||
it('Test 8: alle drei Felder zusammen', () => {
|
||||
expect(
|
||||
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).
|
||||
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 {
|
||||
/** null = keine gueltige https-Adresse -> Leerzustand. */
|
||||
url: string | null;
|
||||
title: string;
|
||||
/** 0 = nie, sonst eine Stufe aus XFRAME_RELOAD_OPTIONS. */
|
||||
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 {
|
||||
@@ -75,10 +113,44 @@ function resolveReload(raw: unknown): number {
|
||||
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 {
|
||||
return {
|
||||
url: resolveUrl(config.url),
|
||||
title: resolveTitle(config.title),
|
||||
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,
|
||||
}));
|
||||
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
import { XFRAME_SANDBOX } from './xframe-config';
|
||||
import { XframeWidget } from './xframe-widget';
|
||||
|
||||
@@ -34,11 +35,14 @@ function frame(): HTMLIFrameElement {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// quick-260922-ge2: Kachelkoerper misst 640x360 (deterministische Geometrie)
|
||||
stubResizeObserver({ width: 640, height: 360 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
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
|
||||
@@ -38,11 +39,36 @@ import { XFRAME_SANDBOX, resolveXframeConfig } from './xframe-config';
|
||||
* versucht. Stattdessen bietet die Kachel immer „In neuem Tab öffnen“
|
||||
* (echter Link, per Tastatur erreichbar; `a` steht im cancel-Selektor des
|
||||
* 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) {
|
||||
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 [tile, setTile] = useState({ w: 0, h: 0 });
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const hasCrop = crop !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (url === null || reloadSeconds === 0 || isEditMode) return;
|
||||
@@ -50,6 +76,18 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
return () => clearInterval(timer);
|
||||
}, [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) {
|
||||
return (
|
||||
<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 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 (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
@@ -75,22 +128,61 @@ export function XframeWidget({ config, isEditMode }: WidgetProps) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<iframe
|
||||
key={`${url}#${reloadNonce}`}
|
||||
src={url}
|
||||
title={title !== '' ? title : url}
|
||||
sandbox={XFRAME_SANDBOX}
|
||||
allow=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
data-testid="xframe-frame"
|
||||
data-reload-nonce={reloadNonce}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
/>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className="relative min-h-0 flex-1 overflow-hidden"
|
||||
data-tile-size={hasCrop ? `${Math.round(tile.w)}x${Math.round(tile.h)}` : undefined}
|
||||
>
|
||||
{crop !== null && layout !== null ? (
|
||||
layout.scale > 0 && (
|
||||
<div
|
||||
data-testid="xframe-crop-clip"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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 && (
|
||||
<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 === '' && (
|
||||
<NewTabLink
|
||||
url={url}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { 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
|
||||
@@ -22,17 +24,31 @@ import { XframeConfigForm } from './xframe-config-form';
|
||||
const texts = (de as { widgets: { xframe: Record<string, string> } }).widgets.xframe;
|
||||
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>) {
|
||||
const onChange = vi.fn();
|
||||
render(<XframeConfigForm config={config} onChange={onChange} />);
|
||||
const url = screen.getByLabelText(texts.urlLabel) as HTMLInputElement;
|
||||
const title = screen.getByLabelText(texts.titleLabel) as HTMLInputElement;
|
||||
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(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
@@ -139,3 +155,159 @@ describe('XframeConfigForm (quick-260921-qd3)', () => {
|
||||
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';
|
||||
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
XFRAME_CROP_DEFAULT,
|
||||
XFRAME_PAGE_WIDTH,
|
||||
XFRAME_RELOAD_OPTIONS,
|
||||
XFRAME_SANDBOX,
|
||||
XFRAME_TITLE_MAX,
|
||||
XFRAME_ZOOM_OPTIONS,
|
||||
type XframeCrop,
|
||||
clampXframeCrop,
|
||||
isHttpsUrl,
|
||||
resolveXframeConfig,
|
||||
} from '@/components/dashboard/widgets/xframe-config';
|
||||
import {
|
||||
XFRAME_PREVIEW_PAGE_HEIGHT,
|
||||
type XframeDragMode,
|
||||
applyCropDrag,
|
||||
} from '@/components/dashboard/widgets/xframe-crop';
|
||||
|
||||
interface XframeConfigFormProps {
|
||||
config: Record<string, unknown>;
|
||||
@@ -17,6 +28,9 @@ interface XframeConfigFormProps {
|
||||
const FIELD_CLASS =
|
||||
'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 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
|
||||
@@ -34,10 +48,22 @@ const LABEL_CLASS = 'mb-1 block text-sm text-foreground';
|
||||
* Der Hinweis, dass manche Webseiten das Einbetten verweigern, steht
|
||||
* dauerhaft: verweigertes Einbetten ist cross-origin nicht zuverlaessig
|
||||
* 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) {
|
||||
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 [urlError, setUrlError] = useState(false);
|
||||
@@ -63,13 +89,6 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
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 => {
|
||||
if (seconds === 0) return t('xframe.reloadOff');
|
||||
if (seconds === 60) return t('xframe.reloadMinute');
|
||||
@@ -77,6 +96,17 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Adresse (nur https) */}
|
||||
@@ -143,6 +173,285 @@ export function XframeConfigForm({ config, onChange }: XframeConfigFormProps) {
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,7 +382,20 @@
|
||||
"reloadMinute": "Jede Minute",
|
||||
"reloadMinutes": "Alle {n} Minuten",
|
||||
"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": {
|
||||
|
||||
@@ -382,7 +382,20 @@
|
||||
"reloadMinute": "Every minute",
|
||||
"reloadMinutes": "Every {n} minutes",
|
||||
"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": {
|
||||
|
||||
@@ -136,6 +136,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Bildausschnitt',
|
||||
// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen
|
||||
'neuem',
|
||||
// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“
|
||||
'Ausschnitt',
|
||||
'Absenderadresse',
|
||||
'muss',
|
||||
'musste',
|
||||
|
||||
@@ -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() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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).
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user