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();
+ });
+ });
+});