Files
tessera-ctl/.planning/quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/260922-ge2-SUMMARY.md
T
schalli 80a0d23ccf
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 1m10s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
docs(quick-260922-ge2): Akte - XFrame-Ausschnitt, neun Pruefpunkte bestanden, Rahmenhoehen-Befund festgehalten
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:27:55 +02:00

268 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.