From 897f57808e671f798eebd94cbfbd470b0b5cd5c9 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 21 Jul 2026 14:56:43 +0200 Subject: [PATCH] fix(bias-annotations): backdrop no longer trapped inside node canvas transform The bias annotation editor rendered its dimming backdrop as a plain position:fixed div. Since it sits inside a node card that rete.js positions with a CSS transform for pan/zoom, that transform becomes the containing block for fixed descendants, so the backdrop was confined to the node's own box instead of covering the page. Switch to a native shown via showModal(), which promotes it to the browser's top layer regardless of ancestor transforms, and use ::backdrop for the dimming overlay. showModal() is guarded because jsdom does not implement it, so unit tests keep working. Confirmed both by unit tests and a live browser check: the backdrop's bounding rect now spans the full viewport instead of the node's box. Co-Authored-By: Claude Sonnet 5 --- .../bias-annotations/bias-annotations.css | 18 +++++++++++- .../bias-annotations/bias-annotations.html | 4 +-- .../bias-annotations/bias-annotations.spec.ts | 20 +++++++++++++ .../bias-annotations/bias-annotations.ts | 28 ++++++++++++++++++- 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/app/shared/bias-annotations/bias-annotations.css b/src/app/shared/bias-annotations/bias-annotations.css index 8ae83b5..a6702d8 100644 --- a/src/app/shared/bias-annotations/bias-annotations.css +++ b/src/app/shared/bias-annotations/bias-annotations.css @@ -19,7 +19,23 @@ button:disabled { cursor: not-allowed; opacity: .5; } .bias-actions { justify-content: flex-end; } .bias-actions .danger { color: #b91c1c; } .bias-error, label.invalid em { margin-top: 5px; color: #b91c1c; font-size: 11px; font-style: normal; } -.bias-modal-backdrop { position: fixed; inset: 0; z-index: 10020; display: grid; place-items: center; background: rgb(15 23 42 / 55%); } +.bias-modal-backdrop { + position: fixed; + inset: 0; + z-index: 10020; + display: grid; + place-items: center; + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + margin: 0; + padding: 0; + border: none; + background: transparent; + color: inherit; +} +.bias-modal-backdrop::backdrop { background: rgb(15 23 42 / 55%); } .bias-modal { width: min(620px, 92vw); max-height: 86vh; overflow: auto; border-radius: 14px; background: white; padding: 20px; box-shadow: 0 20px 60px rgb(0 0 0 / 25%); } .bias-modal h3 { margin: 0 0 14px; font-size: 18px; } .bias-form { display: grid; gap: 12px; } diff --git a/src/app/shared/bias-annotations/bias-annotations.html b/src/app/shared/bias-annotations/bias-annotations.html index b4c90c3..06d2f96 100644 --- a/src/app/shared/bias-annotations/bias-annotations.html +++ b/src/app/shared/bias-annotations/bias-annotations.html @@ -42,7 +42,7 @@ @if (editorOpen) { -
+

{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation

@@ -75,6 +75,6 @@
-
+
} } diff --git a/src/app/shared/bias-annotations/bias-annotations.spec.ts b/src/app/shared/bias-annotations/bias-annotations.spec.ts index ba37a2f..49c94bc 100644 --- a/src/app/shared/bias-annotations/bias-annotations.spec.ts +++ b/src/app/shared/bias-annotations/bias-annotations.spec.ts @@ -110,6 +110,26 @@ describe('BiasAnnotationsComponent', () => { expect(component.serverError(1, 'category')).toBeNull(); }); + it('renders the editor as a native so it escapes the node canvas transform, and closing it via the backdrop click works', () => { + (fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click(); + fixture.detectChanges(); + + const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement; + expect(dialog).not.toBeNull(); + + component.onDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent); + expect(component.editorOpen).toBe(false); + }); + + it('does not close when clicking inside the dialog content', () => { + (fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click(); + fixture.detectChanges(); + + const form = fixture.nativeElement.querySelector('.bias-modal') as HTMLElement; + component.onDialogClick({ target: form, stopPropagation: vi.fn() } as unknown as MouseEvent); + expect(component.editorOpen).toBe(true); + }); + it('maps typed mock-output probe errors to nested fields', () => { validationErrors.set([{ code: 'BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH', diff --git a/src/app/shared/bias-annotations/bias-annotations.ts b/src/app/shared/bias-annotations/bias-annotations.ts index 4c72e30..eb05393 100644 --- a/src/app/shared/bias-annotations/bias-annotations.ts +++ b/src/app/shared/bias-annotations/bias-annotations.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, effect, inject, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BiasAnnotation, @@ -55,6 +55,32 @@ export class BiasAnnotationsComponent { draft: BiasAnnotation = {}; clientErrors: Record = {}; + /** + * The editor renders inside a node card, which rete.js positions with a CSS + * `transform` for pan/zoom. A `transform` on any ancestor turns it into the + * containing block for `position: fixed` descendants, so a plain fixed-position + * backdrop would be confined to the node's box instead of covering the page. + * A native `` opened via `showModal()` is promoted to the browser's + * top layer, which sits above the whole document regardless of ancestor + * transforms — no manual DOM reparenting needed. + */ + private readonly modalDialog = viewChild>('biasModalDialog'); + + constructor() { + effect(() => { + const dialog = this.modalDialog()?.nativeElement; + if (dialog && typeof dialog.showModal === 'function' && !dialog.open) { + dialog.showModal(); + } + }); + } + + onDialogClick(event: MouseEvent) { + if (event.target === this.modalDialog()?.nativeElement) { + this.close(event); + } + } + get descriptor(): BiasAnnotationsDescriptor | null { const descriptorSignal = (this.blocks as BlocksService & { biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null