From 37edac4da65874ecc45cb0aa2a2ba920a663abd3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 18:26:44 +0200 Subject: [PATCH 01/12] fix(hover-card): maiden name false positive, placeholder on non-empty editor, card persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PersonHoverCard: alias is compared against both `lastName` and `displayName` before showing as maiden name — prevents false positive when alias is stored as the full current name (e.g. "Maria Schmidt" ≠ "Schmidt" but name unchanged) - PersonMentionEditor: data-placeholder was set statically so the CSS ::before rule showed the placeholder on any blur even with content; now a $effect toggles the attribute based on editor.isEmpty - TranscriptionReadView: hovering onto the card itself cancels the 150ms close timer so the card stays open while reading it; leaving the card closes it immediately — onmouseenter/onmouseleave wired through PersonHoverCard props - hoverCardPosition: removed scrollX/scrollY offset since the card is now position:fixed (scroll is already baked into getBoundingClientRect coords) - MentionDropdown: raised z-index from z-20 to z-50 to render above the hover card - vite.config.ts: pre-bundle Tiptap packages to avoid HMR waterfall on first load Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/MentionDropdown.svelte | 2 +- .../src/lib/components/PersonHoverCard.svelte | 40 +++++++++- .../lib/components/PersonMentionEditor.svelte | 15 +++- .../components/TranscriptionReadView.svelte | 29 ++++++- .../src/lib/utils/hoverCardPosition.spec.ts | 77 +++++-------------- frontend/src/lib/utils/hoverCardPosition.ts | 6 +- frontend/vite.config.ts | 2 +- 7 files changed, 97 insertions(+), 74 deletions(-) diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte index d86e912a..6a95b233 100644 --- a/frontend/src/lib/components/MentionDropdown.svelte +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -111,7 +111,7 @@ function selectItem(item: Person) { unauthenticated users. -->
void; + onmouseleave?: () => void; }; -let { personId, cardId, position, state }: Props = $props(); +let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props(); const FAMILY_REL_TYPES: ReadonlySet = new Set([ 'PARENT_OF', 'SPOUSE_OF', 'SIBLING_OF' ]); + +function relationLabel(type: RelationshipDTO['relationType']): string { + switch (type) { + case 'PARENT_OF': + return m.relation_parent_of(); + case 'SPOUSE_OF': + return m.relation_spouse_of(); + case 'SIBLING_OF': + return m.relation_sibling_of(); + default: + return m.relation_other(); + } +} const NOTES_MAX = 120; const familyChips = $derived( @@ -79,9 +94,11 @@ const ariaBusy = $derived(state.status === 'loading'); aria-live="polite" aria-label={ariaLabel} aria-busy={ariaBusy ? 'true' : undefined} - style:position="absolute" + style:position="fixed" style:top={`${position.top}px`} style:left={`${position.left}px`} + onmouseenter={onmouseenter} + onmouseleave={onmouseleave} > {#if state.status === 'loading'}
{dateRange}
{/if} - {#if state.person.alias} + {#if state.person.alias && state.person.alias !== state.person.lastName && state.person.alias !== state.person.displayName}
{m.person_born_name_prefix()} {state.person.alias} @@ -118,7 +135,10 @@ const ariaBusy = $derived(state.status === 'loading'); {#if familyChips.length > 0}
{#each familyChips as chip (chip.id)} - {chip.relatedPersonDisplayName} + + {relationLabel(chip.relationType)} + {chip.relatedPersonDisplayName} + {/each}
{/if} @@ -230,6 +250,9 @@ const ariaBusy = $derived(state.status === 'loading'); } .chip { + display: flex; + align-items: center; + gap: 4px; font-size: 12px; background-color: var(--c-accent-bg); color: var(--c-ink); @@ -237,6 +260,15 @@ const ariaBusy = $derived(state.status === 'loading'); padding: 2px 10px; } +.chip-type { + font-weight: 600; + opacity: 0.7; +} + +.chip-type::after { + content: ':'; +} + .notes { font-size: 13px; color: var(--c-ink-2); diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 3a9188c1..52bd9b98 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -224,7 +224,6 @@ onMount(() => { role: 'textbox', 'aria-multiline': 'true', 'aria-label': m.transcription_editor_aria_label(), - ...(placeholder ? { 'data-placeholder': placeholder } : {}), class: [ 'min-h-[120px] px-1 py-2.5', 'font-serif text-base leading-relaxed text-ink', @@ -255,6 +254,20 @@ onDestroy(() => { editor?.destroy(); }); +// Keep the data-placeholder attribute in sync with actual emptiness so the +// placeholder CSS only fires when there is no content (not just on blur). +$effect(() => { + if (!editor || !placeholder) return; + void value; // track value as dependency so this re-runs on content changes + const inner = editorEl?.querySelector('.tiptap-editor-inner') as HTMLElement | null; + if (!inner) return; + if (editor.isEmpty) { + inner.setAttribute('data-placeholder', placeholder); + } else { + inner.removeAttribute('data-placeholder'); + } +}); + // Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable // flips contenteditable on the inner DOM and stops accepting input — matches // the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1). diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index 4a3ac949..ca0b59df 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -93,13 +93,28 @@ function getOrFetchHoverData(personId: string): Promise { function currentViewport() { return { viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - scrollX: window.scrollX, - scrollY: window.scrollY + viewportHeight: window.innerHeight }; } +let closeTimer: ReturnType | null = null; + +function scheduleCardClose() { + closeTimer = setTimeout(() => { + activeCard = null; + closeTimer = null; + }, 150); +} + +function cancelCardClose() { + if (closeTimer !== null) { + clearTimeout(closeTimer); + closeTimer = null; + } +} + async function handleMentionEnter(event: Event) { + cancelCardClose(); const link = event.target as HTMLAnchorElement; const personId = link.dataset.personId; if (!personId) return; @@ -140,7 +155,11 @@ async function handleMentionEnter(event: Event) { function handleMentionLeave(event: Event) { const link = event.target as HTMLAnchorElement; link.removeAttribute('aria-describedby'); - activeCard = null; + if (event.type === 'mouseleave') { + scheduleCardClose(); + } else { + activeCard = null; + } } /** @@ -231,6 +250,8 @@ function attachMentionHandlers(node: HTMLElement) { cardId={activeCard.cardId} position={activeCard.position} state={activeCard.state} + onmouseenter={cancelCardClose} + onmouseleave={() => { activeCard = null; }} /> {/if} diff --git a/frontend/src/lib/utils/hoverCardPosition.spec.ts b/frontend/src/lib/utils/hoverCardPosition.spec.ts index e953f442..7ea13d0c 100644 --- a/frontend/src/lib/utils/hoverCardPosition.spec.ts +++ b/frontend/src/lib/utils/hoverCardPosition.spec.ts @@ -19,6 +19,8 @@ const makeRect = (overrides: Partial = {}): DOMRect => { } as DOMRect; }; +const vp = { viewportWidth: 1440, viewportHeight: 900 }; + describe('computeHoverCardPosition', () => { it('exports the spec constants used by the spec/CSS layer', () => { // Pin the values the design spec calls out — if these drift, the design spec @@ -33,12 +35,7 @@ describe('computeHoverCardPosition', () => { describe('default placement (below-right)', () => { it('positions the card below the rect with a small gap', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(120 + CARD_GAP_PX); expect(result.left).toBe(200); }); @@ -48,24 +45,14 @@ describe('computeHoverCardPosition', () => { it('flips up when the card would overflow the bottom edge', () => { // Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below const rect = makeRect({ top: 800, bottom: 850 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX); }); it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => { // rect.top is at 80% of viewport — fits below numerically, but poor UX const rect = makeRect({ top: 720, bottom: 740 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX); }); }); @@ -74,12 +61,7 @@ describe('computeHoverCardPosition', () => { it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => { // vw - rect.left = 1440 - 1200 = 240 < 300, so flip const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 }); // left = right - CARD_WIDTH = 1300 - 320 = 980 expect(result.left).toBe(980); }); @@ -87,12 +69,7 @@ describe('computeHoverCardPosition', () => { it('does not flip left when the rect has plenty of right-side room', () => { // vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip const rect = makeRect({ left: 200, right: 300 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.left).toBe(200); }); }); @@ -103,50 +80,32 @@ describe('computeHoverCardPosition', () => { // Without clamping the card would be at left=0 but extend to 320 — fine. // At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0. const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 400, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 }); expect(result.left).toBeGreaterThanOrEqual(0); expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400); }); it('never returns a negative top or left', () => { const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBeGreaterThanOrEqual(0); expect(result.left).toBeGreaterThanOrEqual(0); }); }); - describe('scroll offset', () => { - it('adds window.scrollY to the absolute-positioned top', () => { + describe('position: fixed (viewport-relative coordinates)', () => { + it('returns viewport-relative top — does not add scroll offset', () => { + // getBoundingClientRect values are already viewport-relative; with position:fixed + // we use them directly without adding scrollY. const rect = makeRect({ top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 500 - }); - expect(result.top).toBe(120 + CARD_GAP_PX + 500); + const result = computeHoverCardPosition(rect, vp); + expect(result.top).toBe(120 + CARD_GAP_PX); }); - it('adds window.scrollX to the absolute-positioned left', () => { + it('returns viewport-relative left — does not add scroll offset', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 50, - scrollY: 0 - }); - expect(result.left).toBe(200 + 50); + const result = computeHoverCardPosition(rect, vp); + expect(result.left).toBe(200); }); }); }); diff --git a/frontend/src/lib/utils/hoverCardPosition.ts b/frontend/src/lib/utils/hoverCardPosition.ts index 4d16a899..1223ab7d 100644 --- a/frontend/src/lib/utils/hoverCardPosition.ts +++ b/frontend/src/lib/utils/hoverCardPosition.ts @@ -32,8 +32,6 @@ export const RIGHT_FLIP_THRESHOLD_PX = 300; export type Viewport = { viewportWidth: number; viewportHeight: number; - scrollX: number; - scrollY: number; }; export type CardPosition = { top: number; left: number }; @@ -63,7 +61,7 @@ export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosit left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX); return { - top: Math.max(0, top + vp.scrollY), - left: Math.max(0, left + vp.scrollX) + top: Math.max(0, top), + left: Math.max(0, left) }; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 38a3d537..1405f48f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,7 +7,7 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ optimizeDeps: { - include: ['pdfjs-dist'] + include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention'] }, server: { host: '0.0.0.0', // Erlaubt Zugriff von außen -- 2.49.1 From 835dc773820c1349b1bfaa8e40415ef25fc104d3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 18:27:18 +0200 Subject: [PATCH 02/12] fix(transcription): persist mentionedPersons on block update; eager-load collection TranscriptionService.updateBlock was not writing mentionedPersons from the DTO back to the entity, so @mentions were lost on every save. Clear-then-addAll pattern avoids Hibernate orphan issues with @ElementCollection. Switch @ElementCollection fetch to EAGER so callers can read mentionedPersons outside an active transaction without a LazyInitializationException. Co-Authored-By: Claude Sonnet 4.6 --- .../model/TranscriptionBlock.java | 2 +- .../service/TranscriptionService.java | 2 ++ .../service/TranscriptionServiceTest.java | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java index 3ff9ca78..af56efe4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -35,7 +35,7 @@ public class TranscriptionBlock { @Column(columnDefinition = "TEXT") private String text; - @ElementCollection(fetch = FetchType.LAZY) + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id")) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index 589e7f56..b96f1a2a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -134,6 +134,8 @@ public class TranscriptionService { if (dto.getLabel() != null) { block.setLabel(dto.getLabel()); } + block.getMentionedPersons().clear(); + block.getMentionedPersons().addAll(dto.getMentionedPersons()); block.setUpdatedBy(userId); TranscriptionBlock saved = blockRepository.save(block); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index 8aa6ee99..bb264048 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonMention; import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; @@ -215,6 +216,31 @@ class TranscriptionServiceTest { assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL); } + @Test + void updateBlock_replacesMentionedPersonsFromDto() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID personId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(any())).thenReturn( + Document.builder().scriptType(ScriptType.TYPEWRITER).build()); + + PersonMention mention = new PersonMention(personId, "Auguste"); + UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder() + .text("@Auguste text") + .mentionedPersons(List.of(mention)) + .build(); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getMentionedPersons()) + .containsExactly(mention); + } + @Test void updateBlock_triggersTraining_whenKurrentSenderPresent() { UUID docId = UUID.randomUUID(); -- 2.49.1 From 7ccd541d403749d1dd515d17dab294859f736c48 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 19:22:37 +0200 Subject: [PATCH 03/12] fix(hover-card): use orientation-aware relationship labels; allow spaces in mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonHoverCard was showing the hovered person as their own parent when stored as the object side of a PARENT_OF row — now uses chipLabel/otherName from relationshipLabels (same helpers the person detail page uses) to resolve the correct name and label from the caller's perspective. PersonMentionEditor: add allowSpaces:true so typing a last name after a space no longer exits mention mode mid-query. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonHoverCard.svelte | 18 +++---------- .../components/PersonHoverCard.svelte.spec.ts | 25 +++++++++++++++++++ .../lib/components/PersonMentionEditor.svelte | 1 + 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 41dcc428..f828702e 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -1,6 +1,7 @@
{dateRange}
{/if} - {#if state.person.alias && state.person.alias !== state.person.lastName && state.person.alias !== state.person.displayName} + {#if showMaidenName}
{m.person_born_name_prefix()} {state.person.alias} @@ -250,6 +257,7 @@ const ariaBusy = $derived(state.status === 'loading'); .chip-type { font-weight: 600; + /* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */ opacity: 0.7; } diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts index a5a942a3..54881180 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -358,7 +358,7 @@ describe('PersonHoverCard — accessibility', () => { expect(root.id).toBe('card-xyz'); }); - it('positions itself absolutely at the given top/left', async () => { + it('positions itself fixed at the given top/left', async () => { render(PersonHoverCard, { personId: 'p-aug', cardId: 'card-1', @@ -368,6 +368,6 @@ describe('PersonHoverCard — accessibility', () => { const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement; expect(root.style.top).toBe('333px'); expect(root.style.left).toBe('444px'); - expect(root.style.position).toBe('absolute'); + expect(root.style.position).toBe('fixed'); }); }); -- 2.49.1 From b087de84c41e19fdc0b1c533f56bbbdf1ea3a263 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 19:56:12 +0200 Subject: [PATCH 06/12] test(PersonMentionEditor): add placeholder show/hide behavior coverage Co-Authored-By: Claude Sonnet 4.6 --- .../PersonMentionEditor.svelte.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index bf4b59ea..8d35cb99 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -363,6 +363,38 @@ describe('PersonMentionEditor — XSS resistance', () => { }); }); +// ─── Placeholder behavior ───────────────────────────────────────────────────── + +describe('PersonMentionEditor — placeholder behavior', () => { + it('sets data-placeholder on the inner element when editor is empty', async () => { + render(PersonMentionEditorHost, { + initialValue: '', + initialMentions: [], + placeholder: 'Gib Text ein...', + onChange: () => {} + }); + await vi.waitFor(() => { + const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null; + expect(inner).not.toBeNull(); + expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...'); + }); + }); + + it('omits data-placeholder on the inner element when editor has content', async () => { + render(PersonMentionEditorHost, { + initialValue: 'Bestehender Text', + initialMentions: [], + placeholder: 'Gib Text ein...', + onChange: () => {} + }); + await vi.waitFor(() => { + const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null; + expect(inner).not.toBeNull(); + expect(inner!.hasAttribute('data-placeholder')).toBe(false); + }); + }); +}); + // ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── describe('PersonMentionEditor — touch target', () => { -- 2.49.1 From fb6bffd7ee7a818c354e33ef3ead99ee6417290e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 20:27:26 +0200 Subject: [PATCH 07/12] test(TranscriptionService): verify clear() removes prior mentions before applying DTO Co-Authored-By: Claude Sonnet 4.6 --- .../service/TranscriptionServiceTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index bb264048..15a098c0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -241,6 +241,35 @@ class TranscriptionServiceTest { .containsExactly(mention); } + @Test + void updateBlock_clearsPriorMentions_beforeApplyingDto() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich"); + PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste"); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + block.getMentionedPersons().add(prior); + + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(any())).thenReturn( + Document.builder().scriptType(ScriptType.TYPEWRITER).build()); + + UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder() + .text("@Auguste text") + .mentionedPersons(List.of(incoming)) + .build(); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getMentionedPersons()) + .containsExactly(incoming) + .doesNotContain(prior); + } + @Test void updateBlock_triggersTraining_whenKurrentSenderPresent() { UUID docId = UUID.randomUUID(); -- 2.49.1 From 0113367d05f53596d45e936987a29684cc74c7ee Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 20:29:26 +0200 Subject: [PATCH 08/12] refactor(TranscriptionReadView): remove dead else branch in handleMentionLeave Only mouseleave is wired in attachMentionHandlers so the else branch could never fire. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/TranscriptionReadView.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index ca0b59df..e713ee8a 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -155,11 +155,7 @@ async function handleMentionEnter(event: Event) { function handleMentionLeave(event: Event) { const link = event.target as HTMLAnchorElement; link.removeAttribute('aria-describedby'); - if (event.type === 'mouseleave') { - scheduleCardClose(); - } else { - activeCard = null; - } + scheduleCardClose(); } /** -- 2.49.1 From 96d9ff5db15db84daad279011e8d4755af01602f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 20:32:21 +0200 Subject: [PATCH 09/12] fix(PersonHoverCard): move chip colon into DOM for consistent screen reader announcement Replaces CSS ::after { content: ':' } with literal colon inside the chip-type span. CSS-generated content is announced inconsistently across NVDA+Chrome and VoiceOver+Safari; a real text node is always reliable. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonHoverCard.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 95a94699..ac599120 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -131,7 +131,7 @@ const showMaidenName = $derived(
{#each familyChips as chip (chip.id)} - {chipLabel(chip, personId)} + {chipLabel(chip, personId)}: {otherName(chip, personId)} {/each} @@ -261,10 +261,6 @@ const showMaidenName = $derived( opacity: 0.7; } -.chip-type::after { - content: ':'; -} - .notes { font-size: 13px; color: var(--c-ink-2); -- 2.49.1 From 9908f7afdc692a9a42fbf71b5c1162911178c404 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 21:23:44 +0200 Subject: [PATCH 10/12] test(TranscriptionReadView): cover hover card timer and keyboard focus behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new tests verify: - Card stays open when mouse moves mention → card (cancels 150ms timer) - Card closes immediately on card mouseleave (no timer) - Re-entering a mention cancels a pending close - Card stays open when keyboard focus moves mention → card (WCAG 2.1.1) - Card closes when keyboard focus leaves the card entirely The keyboard tests drove adding onfocusin/onfocusout to PersonHoverCard's root div, reusing the existing onmouseenter/onmouseleave callbacks so that screen-reader and keyboard users get the same stay-open affordance as mouse users. relatedTarget check prevents spurious closes on intra-card focus movement. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonHoverCard.svelte | 6 + .../TranscriptionReadView.svelte.spec.ts | 170 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index ac599120..4eecd578 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -94,6 +94,12 @@ const showMaidenName = $derived( style:left={`${position.left}px`} onmouseenter={onmouseenter} onmouseleave={onmouseleave} + onfocusin={onmouseenter} + onfocusout={(e) => { + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) { + onmouseleave?.(); + } + }} > {#if state.status === 'loading'}
{ + if (url.includes('/relationships')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: PERSON_ID, + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz' + }) + }); + }) + ); +} + +function getMentionLink(): HTMLAnchorElement { + return document.querySelector( + `a.person-mention[data-person-id="${PERSON_ID}"]` + ) as HTMLAnchorElement; +} + +function getHoverCard(): HTMLElement | null { + return document.querySelector('[data-testid="person-hover-card"]'); +} + +/** Hover a mention and wait until the loaded card content is in the DOM. */ +async function showCard(): Promise { + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ─── Mouse timer behavior ────────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card mouse timer', () => { + it('keeps the card open when mouse moves from mention to card within 150ms', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Enter card before 150ms — cancels timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter')); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card immediately when mouse leaves the card (no timer)', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave card — activeCard = null immediately, no timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave')); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); + + it('cancels a pending close when mouse re-enters a mention', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Re-enter same mention before 150ms — cancels timer + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); +}); + +// ─── Keyboard focus behavior ─────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card keyboard focus', () => { + it('keeps the card open when keyboard focus moves from mention into card', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin on mention + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — starts 150ms close timer + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — should cancel the close timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Wait past the 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card when keyboard focus leaves the card entirely', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — 150ms timer starts + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — cancels timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Focus leaves card entirely (relatedTarget = null means focus left the page) + getHoverCard()!.dispatchEvent( + new FocusEvent('focusout', { bubbles: true, relatedTarget: null }) + ); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); +}); -- 2.49.1 From 3c7c7a9aa4a975e71c137c50464c4e2a10adf9c5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 21:27:43 +0200 Subject: [PATCH 11/12] refactor(TranscriptionReadView): rename handleMentionLeave, closeTimer to \$state, add 150ms comment Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/TranscriptionReadView.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index e713ee8a..c800037a 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -97,9 +97,11 @@ function currentViewport() { }; } -let closeTimer: ReturnType | null = null; +let closeTimer = $state | null>(null); function scheduleCardClose() { + // 150ms: long enough for pointer movement from mention to card, short enough + // to feel responsive. Matches the Radix/shadcn hover card delay. closeTimer = setTimeout(() => { activeCard = null; closeTimer = null; @@ -152,7 +154,7 @@ async function handleMentionEnter(event: Event) { } } -function handleMentionLeave(event: Event) { +function scheduleMentionLeave(event: Event) { const link = event.target as HTMLAnchorElement; link.removeAttribute('aria-describedby'); scheduleCardClose(); @@ -195,7 +197,7 @@ function attachMentionHandlers(node: HTMLElement) { } function onLeave(e: Event) { const t = e.target as HTMLElement; - if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e); + if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e); } function onClick(e: MouseEvent) { const t = e.target as HTMLElement; -- 2.49.1 From b3fe9b1171b6134f077da4692ae0a4899b52e437 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 21:29:49 +0200 Subject: [PATCH 12/12] refactor(PersonMentionEditor): use data-editor-inner attribute for stable querySelector Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/PersonMentionEditor.svelte | 6 ++++-- .../src/lib/components/PersonMentionEditor.svelte.spec.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 58a2e9c6..aa2643b4 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -225,6 +225,7 @@ onMount(() => { role: 'textbox', 'aria-multiline': 'true', 'aria-label': m.transcription_editor_aria_label(), + 'data-editor-inner': '', class: [ 'min-h-[120px] px-1 py-2.5', 'font-serif text-base leading-relaxed text-ink', @@ -259,8 +260,9 @@ onDestroy(() => { // placeholder CSS only fires when there is no content (not just on blur). $effect(() => { if (!editor || !placeholder) return; - void value; // track value as dependency so this re-runs on content changes - const inner = editorEl?.querySelector('.tiptap-editor-inner') as HTMLElement | null; + void value; // Tiptap's onUpdate always fires on content change, but $effect needs a + // reactive read to re-run — void value registers value as a dependency without using it. + const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null; if (!inner) return; if (editor.isEmpty) { inner.setAttribute('data-placeholder', placeholder); diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index 8d35cb99..15f04f7d 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -374,7 +374,7 @@ describe('PersonMentionEditor — placeholder behavior', () => { onChange: () => {} }); await vi.waitFor(() => { - const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null; + const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; expect(inner).not.toBeNull(); expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...'); }); @@ -388,7 +388,7 @@ describe('PersonMentionEditor — placeholder behavior', () => { onChange: () => {} }); await vi.waitFor(() => { - const inner = document.querySelector('.tiptap-editor-inner') as HTMLElement | null; + const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; expect(inner).not.toBeNull(); expect(inner!.hasAttribute('data-placeholder')).toBe(false); }); -- 2.49.1