Files
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

20 KiB
Raw Permalink Blame History

phase, plan, subsystem, tags, status, requires, provides, affects, tech-stack, key-files, decisions, metrics, actuals, plan_head_before
phase plan subsystem tags status requires provides affects tech-stack key-files decisions metrics actuals plan_head_before
quick-260922-ge2 01 apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/web/src/test
dashboard
widget
xframe
iframe
crop
zoom
pointer-events
resize-observer
tdd
i18n
complete
quick-260921-qd3 (XFrame-Widget): Resolver, Widget, Formular, Sandbox, Neuladen
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
apps/web/src/messages/de.json + en.json (13 neue Schluessel widgets.xframe.*)
apps/web/src/messages/umlaut-dictionary.ts (Allowlist: Ausschnitt)
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)
created modified
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
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
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
duration completed
ca. 10 min (10:03 bis 10:14 Uhr UTC, 22.09.2026) 2026-09-22
tokens tasks commits
17700 2 2
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)

  • (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
  • (b) Rahmen mit der Maus verschieben -> folgt fluessig, beim Loslassen genau EIN PATCH, Zahlenfelder springen auf die neuen Werte
  • (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
  • (d) Zahlenfeld Breite auf 2000 + Feld verlassen -> Feld zeigt 1280, Links springt auf 0; Hoehe 10 -> 60
  • (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
  • (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)
  • (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

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.