Files
tessera-ctl/.planning/quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/260922-ge2-PLAN.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

54 KiB
Raw Blame History

phase, plan, type, wave, depends_on, autonomous, requirements, files_modified, estimate, must_haves
phase plan type wave depends_on autonomous requirements files_modified estimate must_haves
quick-260922-ge2 01 execute 1
true
QUICK-260922-GE2
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
tokens raw_tokens tasks confidence
130000 130000 2 low
truths artifacts key_links
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 `!`).
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“
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“

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.

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>

@/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

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).
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“ 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 Alle neuen Config-Felder sind optional und werden geklemmt; ohne sie rendert das Widget exakt wie heute. - 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'`; `