From 13f16ed4aea81d3007299a0e6dc622f86c9a136b Mon Sep 17 00:00:00 2001 From: leonatwork Date: Thu, 15 Oct 2026 21:53:48 +0000 Subject: [PATCH] =?UTF-8?q?agent:=20Bearbeite=20GitHub-Issue=20#8=20vollst?= =?UTF-8?q?=C3=A4ndig=20und=20liefere=20eine=20reviewbare=20L=C3=B6sung.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lies zuerst den vollständigen Issue-Thread inklusive aller Kommentare, CLAUDE.md und die relevanten Repository-Regeln. Prüfe vorhandene PRs und aktuellen Code, bevor du Änderungen machst. Leite die verbindlichen Akzeptanzkriterien aus dem Issue ab und dokumentiere nicht erfüllbare oder widersprüchliche Kriterien klar. Arbeite testgetrieben: Erzeuge Regressionstests, verifiziere ihr korrektes Fehlschlagen vor dem Fix und führe danach die relevanten Tests sowie die vorgeschriebenen Qualitätsprüfungen aus. Ändere nur, was für das Issue erforderlich ist. Keine Produktionsdeployments, keine Änderungen an Produktionsdaten oder Produktions-Supabase und kein --prod. Merge den PR nicht. Issue: https://github.com/leonatwork/plotwerk/issues/8 Titel: Kontextleiste wird beim Markieren am oberen Rand abgeschnitten Beschreibung: ## Ziel und Nutzen Die kontextuelle Auswahlleiste (Blase) bleibt beim Markieren von Text, besonders bei mehrzeiliger Auswahl nahe am oberen Rand, vollständig sichtbar und bedienbar. ## Ist-Zustand / Problem Gemeldet: Bei mehrzeiliger Auswahl nahe am oberen Rand wird die Leiste oben abgeschnitten (Rahmen, Rundungen, Symbole, Beschriftungen). Code auf main (Blase.tsx messen): Die Leiste liegt absolut im scrollenden .pw-s-papier. Sie steht über der Auswahl, wenn start.top - r.top >= Höhe + 16 gilt. Beginnt die Auswahl oberhalb des Sichtbereichs, steht sie bei scrollTop+8 mit translateX(-50%). Sonst steht sie unter start.bottom (bei einer Zeile unter ende.bottom). Ein Zweig mit Position außerhalb des Sichtbereichs ist nicht erkennbar. Offen: Reproduktion und Ursache. ## Umfang (In Scope) - Positionierung der Auswahlleiste bei Textauswahl - Kollisions-/Flip-Verhalten am oberen Rand des sichtbaren Papierbereichs - Mehrzeilige Auswahl im Schreibeditor - Ursachenanalyse, im PR dokumentiert - Automatisierter Regressionstest für den oberen Randfall in Editor.test.tsx ## Nicht Teil dieses Issues (Out of Scope) - Änderungen an der Textauswahl selbst - Redesign der Aktionsleiste - Änderungen an Manuskriptinhalten - Neue Produktlogik außerhalb der Auswahlleiste - Klemmen des Panels (Gewissheit/Ebene) nach unten ## Fachliche und UX-Entscheidungen - Bevorzugt oberhalb der Auswahl positionieren. - Bei zu wenig Platz oben automatisch unterhalb anzeigen. - Die Leiste bleibt vollständig im sichtbaren Editor-/Papierbereich. - Leon (Folgekommentar): erst Ursache ermitteln und im PR dokumentieren; Regressionstest muss auf main rot sein; ist das Abschneiden nicht reproduzierbar, nur belegte Änderungen vornehmen. - Offen (technisch): genaue Stub-Koordinaten für mehrzeilige Auswahl. - Offen: Ob der Bezugsrahmen das Papier oder der gesamte Viewport ist. Der Code nutzt das Papier. ## Prüfbare Akzeptanzkriterien - AC-1: Bei einer mehrzeiligen Auswahl nahe am oberen Rand ist die Aktionsleiste vollständig sichtbar. - AC-2: Reicht der Platz oberhalb nicht aus, wird die Aktionsleiste unterhalb der Auswahl positioniert. - AC-3: Rahmen, Rundungen, Symbole und Beschriftungen werden nicht abgeschnitten. - AC-4: Das Verhalten wird mit einem automatisierten Regressionstest für die Popover-Positionierung abgesichert. - AC-5: Der PR dokumentiert die ermittelte Ursache (Zweig und Geometrie) oder hält fest, dass das Abschneiden mit der vorhandenen Logik nicht reproduzierbar war. - AC-6: Der Regressionstest ist auf main rot (Position außerhalb des Sichtbereichs) und mit dem Fix grün. Ist das nicht herstellbar, nennt der PR den Grund und es gibt keine Änderung an der Positionslogik ohne Beleg. ## QS und Nachweis - Neuer Regressionstest, der auf main rot und mit Fix grün ist, mit mehrzeiligen Stubs (coordsAtPos, getBoundingClientRect, scrollTop, offsetHeight) - npm test erfolgreich - npm run typecheck erfolgreich - npm run build erfolgreich - Manuelle Prüfung in der Mac-App bleibt bei Leon ## Offene Fragen – _Automatisch nach dem Formular „Agent-Auftrag“ strukturiert (Hermes-Triage). Die ursprüngliche Beschreibung steht unten unverändert._
Ursprüngliche Beschreibung ## Ziel und Nutzen Beim Markieren von Text bleibt die kontextuelle Aktionsleiste vollständig sichtbar und bedienbar. ## Ist-Zustand / Problem Bei einer mehrzeiligen Textauswahl nahe am oberen Rand wird die helle Kontextleiste nach oben aus dem sichtbaren Editorbereich geschoben und abgeschnitten. Oberer Rahmen, Rundungen, Symbole und Beschriftungen sind teilweise nicht sichtbar. ### Reproduktion 1. Plotwerk öffnen und einen Text im Editor anzeigen. 2. Mehrere Zeilen markieren, sodass die Auswahl nahe am oberen Rand beginnt. 3. Die erscheinende Aktionsleiste beobachten. Erwartet: Das Popover wird vollständig oberhalb der Auswahl angezeigt oder bei zu wenig Platz unterhalb positioniert. Tatsächlich: Das Popover ist am oberen Rand abgeschnitten und nur teilweise nutzbar. ## Umfang (In Scope) - Positionierung der kontextuellen Aktionsleiste bei Textauswahl - Kollisions-/Flip-Verhalten am oberen Viewport- oder Editor-Rand - Mehrzeilige Auswahl ## Nicht Teil dieses Issues (Out of Scope) - Änderungen an der Textauswahl selbst - Redesign der Aktionsleiste - Änderungen an Manuskriptinhalten ## Fachliche und UX-Entscheidungen - Bevorzugt oberhalb der Auswahl positionieren. - Wenn dort nicht genug Platz ist, automatisch unterhalb anzeigen. - Die Leiste muss vollständig innerhalb des sichtbaren Editor-/Viewport-Bereichs bleiben. ## Prüfbare Akzeptanzkriterien AC-1: Bei einer mehrzeiligen Auswahl nahe am oberen Rand ist die Aktionsleiste vollständig sichtbar. AC-2: Reicht der Platz oberhalb nicht aus, wird die Aktionsleiste unterhalb der Auswahl positioniert. AC-3: Rahmen, Rundungen, Symbole und Beschriftungen werden nicht abgeschnitten. AC-4: Das Verhalten wird mit einem automatisierten Regressionstest für die Popover-Positionierung abgesichert. ## QS und Nachweis - Automatisierter UI-/Komponententest für eine Auswahl nahe am oberen Rand - Manuelle Prüfung mit mehrzeiliger Auswahl - `npm run build` und `npm test` müssen erfolgreich sein ## Technischer Kontext und Referenzen Ein Screenshot liegt dem Agent-Stack lokal als Referenz vor. Darauf ist eine mehrzeilige blaue Textauswahl zu sehen; die darüber erscheinende helle Aktionsleiste mit „Notiz“ und „Ebene“ ist am oberen Fensterrand abgeschnitten. Der Manuskripttext wird nicht öffentlich wiedergegeben. ## Offene Fragen –
Zusätzliche Repo-Regeln: Commits tragen ein DCO-Sign-off (das macht dev-loop automatisch). Auf diesem Server gibt es kein Rust/Tauri: nur TypeScript-Teil (core/, src/) mit npm run build und npm test prüfen, src-tauri nicht bauen. Keine Inhalte aus privaten Manuskripten; Beispiele nur aus fixtures/. Farben nur über Tokens in src/theme/tokens.css, UI nach docs/design/screens, core/ bleibt reines TypeScript, core/ki nutzt nur ProjektLeser. Reviewer verdict: APPROVE after 2 round(s). Generated by dev-loop (not pushed). Signed-off-by: leonatwork --- src/app/app.test.tsx | 4 +- src/features/schreiben/Editor.test.tsx | 93 +++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/app/app.test.tsx b/src/app/app.test.tsx index 990e607..6c6da38 100644 --- a/src/app/app.test.tsx +++ b/src/app/app.test.tsx @@ -56,8 +56,8 @@ describe('App im Browser-Demo', () => { 'Schreiben', 'Kapitel-Überblick', 'Akte planen', 'Figuren', 'Beziehungen', 'Orte', 'Hinweise', 'Werkzeuge', ]); expect(screen.getByText('Hafenlichter')).toBeInTheDocument(); - expect(await screen.findByText('Kap. 1 · Grundriss')).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Kapitel prüfen' })).toBeInTheDocument(); + expect(await screen.findByText('Kap. 1 · Grundriss', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Kapitel prüfen' }, { timeout: 5000 })).toBeInTheDocument(); // Gruppe „Werkzeuge“ ist eingeklappt; aufklappen zeigt die Werkzeug-Bereiche const gruppe = within(nav).getByRole('button', { name: 'Werkzeuge' }); expect(gruppe).toHaveAttribute('aria-expanded', 'false'); diff --git a/src/features/schreiben/Editor.test.tsx b/src/features/schreiben/Editor.test.tsx index 3a3ac8e..9e4efbe 100644 --- a/src/features/schreiben/Editor.test.tsx +++ b/src/features/schreiben/Editor.test.tsx @@ -211,19 +211,46 @@ describe('Editor (Integration)', () => { describe('Blase: Lage, Schließen, Tastatur', () => { const SATZ = 'dass ich eine halbe Stunde zu früh bin'; + const BLASE_BREITE = 260; + const BLASE_HOEHE = 32; + let offsetWidthOriginal: PropertyDescriptor | undefined; + let offsetHeightOriginal: PropertyDescriptor | undefined; /** Editor mit gestubbter Geometrie: Papier 600 × 400 px ab (0, 0), Auswahl bei `koord`. */ - async function mitAuswahl(koord: { left: number; top: number }, kapitel = 'flut') { + async function mitAuswahl( + koord: { + left: number; + top: number; + bottom?: number; + endeLeft?: number; + endeTop?: number; + endeBottom?: number; + scrollTop?: number; + clientHeight?: number; + }, + kapitel = 'flut', + ) { vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0; }); const r = render(); const ed = editorVon(r.container); + const von = posVor(ed, SATZ); const papier = r.container.querySelector('.pw-s-papier') as HTMLDivElement; papier.getBoundingClientRect = () => ({ left: 0, top: 0, right: 600, bottom: 400, width: 600, height: 400, x: 0, y: 0, toJSON() {} }); + Object.defineProperty(papier, 'scrollTop', { value: koord.scrollTop ?? 0, writable: true, configurable: true }); + Object.defineProperty(papier, 'clientHeight', { value: koord.clientHeight ?? 400, configurable: true }); Object.defineProperty(papier, 'clientWidth', { value: 600, configurable: true }); - ed.view.coordsAtPos = () => ({ left: koord.left, right: koord.left, top: koord.top, bottom: koord.top + 20 }); - const von = posVor(ed, SATZ); + const start = { left: koord.left, top: koord.top, bottom: koord.bottom ?? koord.top + 20 }; + const ende = { + left: koord.endeLeft ?? koord.left, + top: koord.endeTop ?? koord.top, + bottom: koord.endeBottom ?? (koord.endeTop ?? koord.top) + 20, + }; + ed.view.coordsAtPos = (pos) => { + const k = pos <= von ? start : ende; + return { left: k.left, right: k.left, top: k.top, bottom: k.bottom }; + }; act(() => { ed.chain().focus().setTextSelection({ from: von, to: von + SATZ.length }).run(); }); @@ -232,7 +259,31 @@ describe('Editor (Integration)', () => { }); return { ...r, ed, papier, leiste: () => screen.queryByRole('toolbar', { name: 'Auswahl' }) as HTMLElement | null }; } + beforeEach(() => { + offsetWidthOriginal = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + offsetHeightOriginal = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get() { + if (this instanceof HTMLElement && this.classList.contains('pw-s-blase')) return BLASE_BREITE; + if (offsetWidthOriginal?.get) return offsetWidthOriginal.get.call(this); + return Number(offsetWidthOriginal?.value ?? 0); + }, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this instanceof HTMLElement && this.classList.contains('pw-s-blase')) return BLASE_HOEHE; + if (offsetHeightOriginal?.get) return offsetHeightOriginal.get.call(this); + return Number(offsetHeightOriginal?.value ?? 0); + }, + }); + }); afterEach(() => { + if (offsetWidthOriginal) Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthOriginal); + else delete (HTMLElement.prototype as unknown as { offsetWidth?: unknown }).offsetWidth; + if (offsetHeightOriginal) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightOriginal); + else delete (HTMLElement.prototype as unknown as { offsetHeight?: unknown }).offsetHeight; vi.unstubAllGlobals(); }); @@ -245,6 +296,42 @@ describe('Editor (Integration)', () => { unmount(); }); + it('mehrzeilige Auswahl knapp unter dem oberen sichtbaren Rand klappt nach unten und bleibt vollständig sichtbar', async () => { + const scrollTop = 500; + const clientHeight = 400; + const { leiste, unmount } = await mitAuswahl({ + left: 120, + top: 10, + bottom: 30, + endeTop: 60, + endeBottom: 80, + scrollTop, + clientHeight, + }); + const l = leiste()!; + expect(l.className).toContain('pw-s-blase--unten'); + expect(parseFloat(l.style.top)).toBeGreaterThanOrEqual(scrollTop + 8); + expect(parseFloat(l.style.top) + BLASE_HOEHE).toBeLessThanOrEqual(scrollTop + clientHeight); + unmount(); + }); + + it('wenn die Auswahl oberhalb des sichtbaren Bereichs startet, bleibt die Leiste bei scrollTop + Abstand', async () => { + const scrollTop = 500; + const { leiste, unmount } = await mitAuswahl({ + left: 200, + top: -20, + bottom: 10, + endeTop: 40, + endeBottom: 60, + scrollTop, + clientHeight: 400, + }); + const l = leiste()!; + expect(l.className).toContain('pw-s-blase--unten'); + expect(parseFloat(l.style.top)).toBe(scrollTop + 8); + unmount(); + }); + it('rechts ebenfalls geklemmt; mit Platz oben steht sie über der Auswahl', async () => { const { leiste, unmount } = await mitAuswahl({ left: 595, top: 200 }); const l = leiste()!;