feat(person-mention): PR-B2 — read-mode rendering + hover card (issue #362) #371
@@ -11,6 +11,7 @@ bun.lockb
|
||||
# Build artifacts
|
||||
/.svelte-kit/
|
||||
/.svelte-kit-backup/
|
||||
/.svelte-kit.old/
|
||||
|
||||
# Generated files
|
||||
/.svelte-kit-backup/
|
||||
|
||||
163
frontend/e2e/person-mention-read.spec.ts
Normal file
163
frontend/e2e/person-mention-read.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||
const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json');
|
||||
|
||||
/**
|
||||
* E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21).
|
||||
*
|
||||
* Strategy:
|
||||
* - Create a document, a Person, and a transcription block whose text contains
|
||||
* `@DisplayName` and whose mentionedPersons sidecar links to that person.
|
||||
* - Open the document in read mode.
|
||||
* - B20: page.hover() on the .person-mention link → hover card mounts.
|
||||
* - B21: with context.setHasTouch(true), page.tap() on the link → navigates
|
||||
* to /persons/{id} without ever showing the hover card.
|
||||
*/
|
||||
|
||||
let docId: string;
|
||||
let personId: string;
|
||||
let docHref: string;
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Person mention — read mode', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
// 1. Person we will mention.
|
||||
const personRes = await request.post('/api/persons', {
|
||||
data: {
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
personType: 'PERSON',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
}
|
||||
});
|
||||
if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`);
|
||||
const person = await personRes.json();
|
||||
personId = person.id;
|
||||
|
||||
// 2. Document with a PDF so the transcription panel is mountable.
|
||||
// Sara #3: timestamp the title so a previous run that crashed in beforeAll
|
||||
// (and therefore skipped afterAll cleanup) cannot collide with this one.
|
||||
const uniqueSuffix = Date.now();
|
||||
const docRes = await request.post('/api/documents', {
|
||||
multipart: {
|
||||
title: `E2E Person Mention Read ${uniqueSuffix}`,
|
||||
documentDate: '1945-05-08'
|
||||
}
|
||||
});
|
||||
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
|
||||
const doc = await docRes.json();
|
||||
docId = doc.id;
|
||||
docHref = `${baseURL}/documents/${docId}`;
|
||||
|
||||
await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title as string,
|
||||
documentDate: '1945-05-08',
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Annotation to anchor the block on the page.
|
||||
const annRes = await request.post(`/api/documents/${docId}/annotations`, {
|
||||
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' }
|
||||
});
|
||||
if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`);
|
||||
|
||||
// 4. Block text contains @Auguste Raddatz; sidecar links it to personId.
|
||||
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.1,
|
||||
text: 'Brief an @Auguste Raddatz vom Mai 1944',
|
||||
label: null,
|
||||
mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }]
|
||||
}
|
||||
});
|
||||
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
if (docId) await request.delete(`/api/documents/${docId}`);
|
||||
if (personId) await request.delete(`/api/persons/${personId}`);
|
||||
});
|
||||
|
||||
test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
await expect(link).toHaveAttribute('href', `/persons/${personId}`);
|
||||
// The @ trigger is stripped from the rendered text per spec
|
||||
await expect(link).toHaveText('Auguste Raddatz');
|
||||
});
|
||||
|
||||
test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
|
||||
const card = page.getByTestId('person-hover-card');
|
||||
await expect(card).toBeVisible({ timeout: 5000 });
|
||||
// Loaded state: person displayName is rendered inside the card
|
||||
await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz');
|
||||
// Footer link points to /persons/{id}
|
||||
await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('B20: hover card unmounts on mouseleave', async ({ page }) => {
|
||||
await page.goto(docHref);
|
||||
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await link.hover();
|
||||
await expect(page.getByTestId('person-hover-card')).toBeVisible();
|
||||
|
||||
// Move pointer away
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 });
|
||||
});
|
||||
|
||||
test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
...devices['Pixel 7'],
|
||||
storageState: STORAGE_STATE
|
||||
});
|
||||
const touchPage = await context.newPage();
|
||||
try {
|
||||
await touchPage.goto(docHref);
|
||||
await touchPage.getByRole('button', { name: 'Transkription' }).click();
|
||||
|
||||
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
|
||||
await expect(link).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Sara #2: assert no card *before* the tap so the test actually proves
|
||||
// the touch device suppression worked, not just that we navigated away.
|
||||
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
|
||||
|
||||
await link.tap();
|
||||
// The card never mounted — the tap navigated directly per spec.
|
||||
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
{ ignores: ['src/paraglide/**'] },
|
||||
{ ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"person_mention_open_link": "Zur Person",
|
||||
"person_mention_hover_hint": "Klick öffnet Seite",
|
||||
"person_mention_load_error": "Person konnte nicht geladen werden.",
|
||||
"person_mention_loading": "Lade Person…",
|
||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||
"person_mention_btn_label": "Person verlinken",
|
||||
"person_mention_create_new": "Neue Person anlegen",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"person_mention_open_link": "Open person",
|
||||
"person_mention_hover_hint": "Click opens the page",
|
||||
"person_mention_load_error": "Could not load person.",
|
||||
"person_mention_loading": "Loading person…",
|
||||
"person_mention_popup_empty": "No persons found",
|
||||
"person_mention_btn_label": "Link person",
|
||||
"person_mention_create_new": "Create new person",
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
"person_mention_open_link": "Ir a la persona",
|
||||
"person_mention_hover_hint": "Clic abre la página",
|
||||
"person_mention_load_error": "No se pudo cargar la persona.",
|
||||
"person_mention_loading": "Cargando persona…",
|
||||
"person_mention_popup_empty": "No se encontraron personas",
|
||||
"person_mention_btn_label": "Vincular persona",
|
||||
"person_mention_create_new": "Crear nueva persona",
|
||||
|
||||
270
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
270
frontend/src/lib/components/PersonHoverCard.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { LoadState } from '$lib/types/personHoverCard';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
type Props = {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
position: { top: number; left: number };
|
||||
state: LoadState;
|
||||
};
|
||||
|
||||
let { personId, cardId, position, state }: Props = $props();
|
||||
|
||||
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||
'PARENT_OF',
|
||||
'SPOUSE_OF',
|
||||
'SIBLING_OF'
|
||||
]);
|
||||
const NOTES_MAX = 120;
|
||||
|
||||
const familyChips = $derived(
|
||||
state.status === 'loaded'
|
||||
? state.relationships.filter((r) => FAMILY_REL_TYPES.has(r.relationType))
|
||||
: []
|
||||
);
|
||||
|
||||
const dateRange = $derived(
|
||||
state.status === 'loaded'
|
||||
? formatLifeDateRange(state.person.birthYear, state.person.deathYear)
|
||||
: ''
|
||||
);
|
||||
|
||||
/**
|
||||
* Cut the notes excerpt at the last word boundary inside the NOTES_MAX
|
||||
* window. Mid-word truncation is especially ugly in German compound nouns
|
||||
* ("…Familienzu…"), so prefer the previous space if there is one within
|
||||
* a reasonable distance. Fall back to a hard cut for strings with no
|
||||
* spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5.
|
||||
*/
|
||||
function truncateAtWordBoundary(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
const window = text.slice(0, max);
|
||||
const lastSpace = window.lastIndexOf(' ');
|
||||
// If the last space is too close to the start (< 70% of the window) we'd
|
||||
// produce a near-empty excerpt — fall back to the hard cut instead.
|
||||
const minBoundary = Math.floor(max * 0.7);
|
||||
const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window;
|
||||
return cut + '…';
|
||||
}
|
||||
|
||||
const notesExcerpt = $derived.by(() => {
|
||||
if (state.status !== 'loaded') return null;
|
||||
const notes = state.person.notes;
|
||||
if (!notes) return null;
|
||||
return truncateAtWordBoundary(notes, NOTES_MAX);
|
||||
});
|
||||
|
||||
// Accessible name for the region landmark — required by WCAG 1.3.1.
|
||||
// Falls back to a localised loading label so axe-core never sees an unnamed
|
||||
// region (Leonie FINDING-02 / Elicit NFR concern).
|
||||
const ariaLabel = $derived(
|
||||
state.status === 'loaded' ? state.person.displayName : m.person_mention_loading()
|
||||
);
|
||||
|
||||
// aria-busy="true" while loading so SR clients know the region's contents
|
||||
// will change. Cleared on loaded/error so the new content is announced.
|
||||
const ariaBusy = $derived(state.status === 'loading');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="person-hover-card"
|
||||
data-testid="person-hover-card"
|
||||
id={cardId}
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={ariaBusy ? 'true' : undefined}
|
||||
style:position="absolute"
|
||||
style:top={`${position.top}px`}
|
||||
style:left={`${position.left}px`}
|
||||
>
|
||||
{#if state.status === 'loading'}
|
||||
<div
|
||||
data-testid="person-hover-card-skeleton"
|
||||
class="skeleton"
|
||||
role="status"
|
||||
aria-label={m.person_mention_loading()}
|
||||
>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
{:else if state.status === 'error'}
|
||||
<div data-testid="person-hover-card-error" class="error-message">
|
||||
{m.person_mention_load_error()}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="person-hover-card-content" class="content">
|
||||
<div class="header">
|
||||
<div class="name" data-testid="person-hover-card-name">{state.person.displayName}</div>
|
||||
{#if dateRange}
|
||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||
{/if}
|
||||
{#if state.person.alias}
|
||||
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if familyChips.length > 0}
|
||||
<div class="chips" data-testid="person-hover-card-chips">
|
||||
{#each familyChips as chip (chip.id)}
|
||||
<span class="chip">{chip.relatedPersonDisplayName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if notesExcerpt}
|
||||
<p class="notes" data-testid="person-hover-card-notes">{notesExcerpt}</p>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
<a href="/persons/{personId}" class="open-link">{m.person_mention_open_link()} →</a>
|
||||
<span class="hint">{m.person_mention_hover_hint()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.person-hover-card {
|
||||
width: 320px;
|
||||
min-height: 180px;
|
||||
background-color: var(--c-surface);
|
||||
border: 1px solid var(--c-line);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
color: var(--c-ink);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* On touch devices the card is suppressed entirely — tap navigates directly. */
|
||||
@media (hover: none) {
|
||||
.person-hover-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton .bar {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--c-line);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton .bar:nth-child(1) {
|
||||
width: 60%;
|
||||
}
|
||||
.skeleton .bar:nth-child(2) {
|
||||
width: 40%;
|
||||
}
|
||||
.skeleton .bar:nth-child(3) {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton .bar {
|
||||
animation: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--c-ink);
|
||||
color: var(--c-surface);
|
||||
margin: -14px -16px 12px;
|
||||
padding: 12px 16px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dates,
|
||||
.maiden {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--c-surface) 75%, transparent);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
background-color: var(--c-accent-bg);
|
||||
color: var(--c-ink);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--c-ink-2);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--c-line);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.open-link {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--c-ink-3);
|
||||
}
|
||||
</style>
|
||||
348
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
348
frontend/src/lib/components/PersonHoverCard.svelte.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const POSITION = { top: 100, left: 200 };
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('PersonHoverCard — loading state', () => {
|
||||
it('shows the skeleton when state.status is loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three skeleton bars', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — error state', () => {
|
||||
it('shows a generic error message when state.status is error', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still allows the link footer to navigate (link present in error state)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'error' }
|
||||
});
|
||||
// The card root must show the footer link even when the body errored —
|
||||
// click navigation works regardless of fetch outcome.
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — loaded state', () => {
|
||||
it('renders the person displayName as the header name', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the life-date range when birthYear and deathYear are present', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the life-date line when both years are missing', async () => {
|
||||
const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: noDates, relationships: [] }
|
||||
});
|
||||
const dates = document.querySelector('[data-testid="person-hover-card-dates"]');
|
||||
expect(dates).toBeNull();
|
||||
});
|
||||
|
||||
it('renders "geb. <alias>" when alias is set', async () => {
|
||||
const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withAlias, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('geb. Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the maiden name line when alias is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]');
|
||||
expect(maiden).toBeNull();
|
||||
});
|
||||
|
||||
it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => {
|
||||
const relationships: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-spouse',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Otto Raddatz',
|
||||
relationType: 'SPOUSE_OF'
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-sibling',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Marie Sister',
|
||||
relationType: 'SIBLING_OF'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships }
|
||||
});
|
||||
await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Marie Sister')).toBeInTheDocument();
|
||||
// Non-family relationship type must be filtered out
|
||||
const friendChip = page.getByText('Karl Friend');
|
||||
await expect.element(friendChip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the chips section entirely when no family relationships', async () => {
|
||||
const onlyFriend: RelationshipDTO[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
personId: 'p-aug',
|
||||
relatedPersonId: 'p-friend',
|
||||
personDisplayName: 'Auguste',
|
||||
relatedPersonDisplayName: 'Karl Friend',
|
||||
relationType: 'FRIEND'
|
||||
}
|
||||
];
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend }
|
||||
});
|
||||
const chips = document.querySelector('[data-testid="person-hover-card-chips"]');
|
||||
expect(chips).toBeNull();
|
||||
});
|
||||
|
||||
it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => {
|
||||
const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withNotes, relationships: [] }
|
||||
});
|
||||
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => {
|
||||
// Single 150-char word with no spaces: word-boundary cut would yield nothing,
|
||||
// so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length).
|
||||
const long = 'x'.repeat(150);
|
||||
const withLongNotes = { ...AUGUSTE, notes: long } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
expect(notes.textContent).toBe('x'.repeat(120) + '…');
|
||||
});
|
||||
|
||||
it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => {
|
||||
// 150-char string with spaces — must cut at the last space, not mid-word.
|
||||
const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3);
|
||||
// length is 180, last space at idx ≤120
|
||||
const withLongNotes = { ...AUGUSTE, notes: sentence } as Person;
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: withLongNotes, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
|
||||
const text = notes.textContent ?? '';
|
||||
// Ends with ellipsis
|
||||
expect(text.endsWith('…')).toBe(true);
|
||||
// Last char before the ellipsis is NOT a half-word — verify by checking that
|
||||
// the position right before … is the end of a word (i.e., there's no letter
|
||||
// further along in the original text immediately after our cut point).
|
||||
const cut = text.slice(0, -1); // strip the …
|
||||
// Find this cut substring in the original sentence
|
||||
const idx = sentence.indexOf(cut);
|
||||
expect(idx).toBe(0);
|
||||
const charAfterCut = sentence[cut.length];
|
||||
// The next char should be a space — confirming we cut on a boundary
|
||||
expect(charAfterCut).toBe(' ');
|
||||
});
|
||||
|
||||
it('omits notes section when notes is null', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const notes = document.querySelector('[data-testid="person-hover-card-notes"]');
|
||||
expect(notes).toBeNull();
|
||||
});
|
||||
|
||||
it('footer renders an anchor link to /persons/{personId}', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const link = document.querySelector('a[href="/persons/p-aug"]')!;
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonHoverCard — accessibility', () => {
|
||||
it('uses aria-live="polite" so screen readers announce loaded content', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-live')).toBe('polite');
|
||||
});
|
||||
|
||||
it('sets aria-busy="true" while loading so SR announces the state change on load', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not set aria-busy when loaded (so the loaded content is announced)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// aria-busy is either absent or "false"
|
||||
const busy = root.getAttribute('aria-busy');
|
||||
expect(busy === null || busy === 'false').toBe(true);
|
||||
});
|
||||
|
||||
it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loaded', person: AUGUSTE, relationships: [] }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('names the region with a generic loading label while loading', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
// Region must have an accessible name in every state — axe-core flags
|
||||
// role="region" without aria-label / aria-labelledby.
|
||||
expect(root.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-xyz',
|
||||
position: POSITION,
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
const root = document.querySelector('[data-testid="person-hover-card"]')!;
|
||||
expect(root.id).toBe('card-xyz');
|
||||
});
|
||||
|
||||
it('positions itself absolutely at the given top/left', async () => {
|
||||
render(PersonHoverCard, {
|
||||
personId: 'p-aug',
|
||||
cardId: 'card-1',
|
||||
position: { top: 333, left: 444 },
|
||||
state: { status: 'loading' }
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||
import {
|
||||
renderTranscriptionBody,
|
||||
type SafeHtml,
|
||||
PERSON_MENTION_SELECTOR
|
||||
} from '$lib/utils/mention';
|
||||
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
blocks: TranscriptionBlockData[];
|
||||
@@ -11,9 +25,188 @@ interface Props {
|
||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
|
||||
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
|
||||
// shape lets simultaneous hovers share the same in-flight fetch.
|
||||
//
|
||||
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
|
||||
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
|
||||
// a person is rare in this read-only view, and a per-document/global cache would
|
||||
// complicate invalidation. If user reports on stale cards accumulate, revisit.
|
||||
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||
const deletedPersonIds = new SvelteSet<string>();
|
||||
|
||||
let activeCard: {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
state: LoadState;
|
||||
position: { top: number; left: number };
|
||||
} | null = $state(null);
|
||||
|
||||
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
||||
return splitByMarkers(block.text)
|
||||
.map((segment) => {
|
||||
if (segment.type === 'marker') {
|
||||
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||
// the brand contract.
|
||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||
}
|
||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||
})
|
||||
.join('') as SafeHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches person + relationships from the backend. 404 returns null
|
||||
* (deleted person — caller marks the link as tombstoned). Any other
|
||||
* non-OK response throws so the caller can render the error state.
|
||||
*/
|
||||
async function loadHoverData(personId: string): Promise<HoverData | null> {
|
||||
const personRes = await fetch(`/api/persons/${personId}`);
|
||||
if (personRes.status === 404) return null;
|
||||
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||
const person = (await personRes.json()) as Person;
|
||||
|
||||
const relRes = await fetch(`/api/persons/${personId}/relationships`);
|
||||
const relationships: RelationshipDTO[] = relRes.ok
|
||||
? ((await relRes.json()) as RelationshipDTO[])
|
||||
: [];
|
||||
return { person, relationships };
|
||||
}
|
||||
|
||||
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
|
||||
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
|
||||
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
||||
const cached = hoverCache.get(personId);
|
||||
if (cached) return cached;
|
||||
const promise = loadHoverData(personId);
|
||||
hoverCache.set(personId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function currentViewport() {
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMentionEnter(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) return;
|
||||
|
||||
const cardId = `person-hover-card-${personId}`;
|
||||
link.setAttribute('aria-describedby', cardId);
|
||||
|
||||
const rect = link.getBoundingClientRect();
|
||||
const position = computeHoverCardPosition(rect, currentViewport());
|
||||
|
||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||
|
||||
try {
|
||||
const data = await getOrFetchHoverData(personId);
|
||||
// Bail if a different mention is now active
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
|
||||
if (data === null) {
|
||||
deletedPersonIds.add(personId);
|
||||
link.setAttribute('data-person-deleted', 'true');
|
||||
activeCard = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeCard = {
|
||||
personId,
|
||||
cardId,
|
||||
position,
|
||||
state: { status: 'loaded', person: data.person, relationships: data.relationships }
|
||||
};
|
||||
} catch {
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
activeCard = { personId, cardId, position, state: { status: 'error' } };
|
||||
}
|
||||
}
|
||||
|
||||
function handleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
activeCard = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
|
||||
* the browser's default anchor behaviour so users can open the person page in
|
||||
* a new tab/window. Felix #7. Only the plain primary-button click navigates
|
||||
* via SPA goto().
|
||||
*/
|
||||
function isPlainPrimaryClick(event: MouseEvent): boolean {
|
||||
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||
}
|
||||
|
||||
async function handleMentionClick(event: MouseEvent) {
|
||||
if (!isPlainPrimaryClick(event)) return;
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await goto(`/persons/${personId}`);
|
||||
}
|
||||
|
||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||
// for the body means we cannot bind events declaratively to the injected
|
||||
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||
//
|
||||
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
|
||||
// mouseenter/mouseleave so users tabbing through transcribed text get the
|
||||
// same preview affordance.
|
||||
function attachMentionHandlers(node: HTMLElement) {
|
||||
function onEnter(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
|
||||
}
|
||||
// mouseenter does not bubble — capture it.
|
||||
node.addEventListener('mouseenter', onEnter, true);
|
||||
node.addEventListener('mouseleave', onLeave, true);
|
||||
// focusin/focusout do bubble — no capture phase needed.
|
||||
node.addEventListener('focusin', onEnter);
|
||||
node.addEventListener('focusout', onLeave);
|
||||
node.addEventListener('click', onClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', onEnter, true);
|
||||
node.removeEventListener('mouseleave', onLeave, true);
|
||||
node.removeEventListener('focusin', onEnter);
|
||||
node.removeEventListener('focusout', onLeave);
|
||||
node.removeEventListener('click', onClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="px-6 py-8">
|
||||
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||
{#each sorted as block (block.id)}
|
||||
<div
|
||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||
@@ -22,19 +215,25 @@ let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
onclick={() => onParagraphClick(block.annotationId)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
||||
}}
|
||||
>
|
||||
{#each splitByMarkers(block.text) as segment, i (i)}
|
||||
{#if segment.type === 'marker'}
|
||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||
{@html renderBlockHtml(block)}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
{#if activeCard}
|
||||
<PersonHoverCard
|
||||
personId={activeCard.personId}
|
||||
cardId={activeCard.cardId}
|
||||
position={activeCard.position}
|
||||
state={activeCard.state}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
@@ -152,3 +152,333 @@ describe('TranscriptionReadView', () => {
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionReadView — person-mention rendering', () => {
|
||||
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const mentionBlock: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Brief an @Auguste Raddatz vom Mai',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
|
||||
// Tests that need loaded data stub fetch themselves.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders a person mention as an anchor link with the person URL', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('strips the @ trigger from the rendered link text (read mode)', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).not.toContain('@Auguste Raddatz');
|
||||
expect(block.textContent).toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
|
||||
const block: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [block],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// Mention rendered as an anchor
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
|
||||
// Marker rendered as <em data-marker>
|
||||
const marker = document.querySelector('[data-marker]')!;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.textContent).toBe('[unleserlich]');
|
||||
|
||||
// Marker text is NOT inside the anchor — they are siblings, not nested
|
||||
expect(link.contains(marker)).toBe(false);
|
||||
|
||||
// No double-escape — text content reads cleanly
|
||||
const blockEl = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(blockEl.textContent).not.toContain('&');
|
||||
expect(blockEl.textContent).not.toContain('<');
|
||||
});
|
||||
|
||||
it('does not render mention link for plain text without the @ trigger', async () => {
|
||||
const plain: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Auguste Raddatz war hier',
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [plain],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention');
|
||||
expect(link).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
|
||||
const xss: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: '<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: []
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [xss],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// No raw <img> tag in DOM
|
||||
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
|
||||
// The escaped text is visible
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||
);
|
||||
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Two blocks both mention the same person
|
||||
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock, block2],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const links = document.querySelectorAll('a.person-mention');
|
||||
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
|
||||
// Plus a re-hover on the first
|
||||
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const personFetches = fetchMock.mock.calls.filter(
|
||||
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||
);
|
||||
expect(personFetches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('unmounts the hover card on mouseleave', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('unmounts the hover card on focusout', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await vi.waitFor(() => {
|
||||
// the card mounts even in 404 → loading → null path; assert it cleans up on blur
|
||||
});
|
||||
link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||
|
||||
// ctrl-click (Linux/Win "open in new tab")
|
||||
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
|
||||
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
|
||||
expect(ctrlPrevented).toBe(false);
|
||||
|
||||
// meta-click (macOS "open in new tab")
|
||||
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
|
||||
const metaPrevented = !link.dispatchEvent(metaClick);
|
||||
expect(metaPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('lets middle-click fall through so users can open in a background tab', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||
// button === 1 is middle mouse button
|
||||
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
|
||||
const prevented = !link.dispatchEvent(middleClick);
|
||||
expect(prevented).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||
const stillLink = document.querySelector('a.person-mention')!;
|
||||
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||
});
|
||||
|
||||
// 404 → no card mounted
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
23
frontend/src/lib/types/personHoverCard.ts
Normal file
23
frontend/src/lib/types/personHoverCard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
/**
|
||||
* Data the PersonHoverCard needs to render its loaded state.
|
||||
* Bundled here so the orchestrator (TranscriptionReadView) and the view
|
||||
* (PersonHoverCard) share one canonical shape.
|
||||
*/
|
||||
export type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
/**
|
||||
* The hover card's three visible states.
|
||||
*
|
||||
* `loading` — initial fetch in flight; skeleton is shown
|
||||
* `error` — fetch failed (non-404, non-OK); generic error message + footer link
|
||||
* `loaded` — fetch succeeded; person + relationships available
|
||||
*/
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
||||
152
frontend/src/lib/utils/hoverCardPosition.spec.ts
Normal file
152
frontend/src/lib/utils/hoverCardPosition.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeHoverCardPosition,
|
||||
CARD_WIDTH_PX,
|
||||
CARD_HEIGHT_PX,
|
||||
CARD_GAP_PX,
|
||||
BOTTOM_BAND_RATIO,
|
||||
RIGHT_FLIP_THRESHOLD_PX
|
||||
} from './hoverCardPosition';
|
||||
|
||||
const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
|
||||
const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 };
|
||||
const merged = { ...base, ...overrides };
|
||||
return {
|
||||
...merged,
|
||||
x: merged.left,
|
||||
y: merged.top,
|
||||
toJSON: () => merged
|
||||
} as DOMRect;
|
||||
};
|
||||
|
||||
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
|
||||
// in #5329 needs to drift with them. Felix's PR review #2 (named constants).
|
||||
expect(CARD_WIDTH_PX).toBe(320);
|
||||
expect(CARD_HEIGHT_PX).toBe(180);
|
||||
expect(CARD_GAP_PX).toBe(6);
|
||||
expect(BOTTOM_BAND_RATIO).toBe(0.7);
|
||||
expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||
expect(result.left).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flip-up rule (Leonie #5329)', () => {
|
||||
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
|
||||
});
|
||||
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
|
||||
});
|
||||
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flip-left rule', () => {
|
||||
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
|
||||
});
|
||||
// left = right - CARD_WIDTH = 1300 - 320 = 980
|
||||
expect(result.left).toBe(980);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
expect(result.left).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewport clamping (Leonie FINDING-05)', () => {
|
||||
it('clamps left so the card never overflows the right edge', () => {
|
||||
// On a 320px viewport, even with flip the card width equals the viewport.
|
||||
// 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
|
||||
});
|
||||
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
|
||||
});
|
||||
expect(result.top).toBeGreaterThanOrEqual(0);
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll offset', () => {
|
||||
it('adds window.scrollY to the absolute-positioned top', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('adds window.scrollX to the absolute-positioned left', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/utils/hoverCardPosition.ts
Normal file
69
frontend/src/lib/utils/hoverCardPosition.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pure positioning logic for the person-mention hover card.
|
||||
*
|
||||
* Pulled out of TranscriptionReadView so the four placement branches
|
||||
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
|
||||
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
|
||||
* Leonie's FINDING-05 (320px overflow) both land here.
|
||||
*/
|
||||
|
||||
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||
export const CARD_WIDTH_PX = 320;
|
||||
|
||||
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
|
||||
export const CARD_HEIGHT_PX = 180;
|
||||
|
||||
/** Gap between the mention rect and the card so they do not touch. */
|
||||
export const CARD_GAP_PX = 6;
|
||||
|
||||
/**
|
||||
* Mentions in the bottom 30% of the viewport flip the card up by default,
|
||||
* even if it would numerically fit below — keeping the eye-line stable
|
||||
* is more important than minimal travel (Leonie #5329).
|
||||
*/
|
||||
export const BOTTOM_BAND_RATIO = 0.7;
|
||||
|
||||
/**
|
||||
* Mentions within this distance of the right viewport edge flip the card
|
||||
* left so it stays fully visible.
|
||||
*/
|
||||
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 };
|
||||
|
||||
/**
|
||||
* Compute absolute-positioned top/left for the hover card, given a rect for
|
||||
* the mention anchor and the current viewport. Output is in document
|
||||
* coordinates (already includes scroll offsets).
|
||||
*/
|
||||
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
|
||||
let top = rect.bottom + CARD_GAP_PX;
|
||||
let left = rect.left;
|
||||
|
||||
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
|
||||
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
|
||||
if (overflowsBottom || inBottomBand) {
|
||||
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
|
||||
}
|
||||
|
||||
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
|
||||
left = rect.right - CARD_WIDTH_PX;
|
||||
}
|
||||
|
||||
// Clamp left so the card never extends past the right viewport edge
|
||||
// (FINDING-05: at 320px viewport the flip would otherwise produce a
|
||||
// negative left or right-side overflow).
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectMention, escapeHtml, extractContent, renderBody } from './mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import {
|
||||
detectMention,
|
||||
escapeHtml,
|
||||
extractContent,
|
||||
renderBody,
|
||||
renderTranscriptionBody
|
||||
} from './mention';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,3 +167,184 @@ describe('renderBody', () => {
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
|
||||
|
||||
describe('renderTranscriptionBody', () => {
|
||||
const auguste: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const hans: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
displayName: 'Hans'
|
||||
};
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(renderTranscriptionBody('', [])).toBe('');
|
||||
});
|
||||
|
||||
it('returns escaped plain text when no mentions', () => {
|
||||
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('escapes < and > in plain block text', () => {
|
||||
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in plain block text', () => {
|
||||
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
|
||||
expect(result).toContain('class="person-mention"');
|
||||
expect(result).toContain(`data-person-id="${auguste.personId}"`);
|
||||
expect(result).toContain('>Auguste Raddatz</a>');
|
||||
});
|
||||
|
||||
it('strips the @ prefix from rendered link text (read mode)', () => {
|
||||
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
|
||||
// The anchor body is the bare display name — no leading @
|
||||
expect(result).not.toMatch(/>@Auguste Raddatz</);
|
||||
expect(result).toMatch(/>Auguste Raddatz</);
|
||||
});
|
||||
|
||||
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
// No bare @ remains where the mention was
|
||||
expect(result).not.toMatch(/@<a/);
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not replace plain-text occurrences without the @ trigger', () => {
|
||||
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toBe('Auguste Raddatz war hier');
|
||||
});
|
||||
|
||||
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
||||
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const LONG_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
|
||||
const augusteLong: PersonMention = {
|
||||
personId: LONG_ID,
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
// Sidecar order is short-first; longer match must still win for the long text
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
|
||||
augusteShort,
|
||||
augusteLong
|
||||
]);
|
||||
expect(result).toContain(`href="/persons/${LONG_ID}"`);
|
||||
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
|
||||
// The "Raddatz" suffix must not leak inside the short-name anchor
|
||||
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
||||
});
|
||||
|
||||
it('does not match @ followed by extra word characters (word boundary)', () => {
|
||||
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
|
||||
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toContain('@HansM');
|
||||
});
|
||||
|
||||
it('first-sidecar-wins when two entries share the same displayName', () => {
|
||||
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
||||
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
|
||||
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
|
||||
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
|
||||
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
||||
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
|
||||
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes HTML in displayName to prevent stored XSS', () => {
|
||||
const xss: PersonMention = {
|
||||
personId: '55555555-5555-4555-8555-555555555555',
|
||||
displayName: '<script>alert(1)</script>'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes <img onerror=...> payloads in surrounding block text', () => {
|
||||
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
|
||||
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
||||
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
||||
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
||||
// — never silently decode pre-escaped entities.
|
||||
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
||||
expect(result).toBe('text &amp;lt;script&amp;gt;');
|
||||
});
|
||||
|
||||
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
||||
const tricky: PersonMention = {
|
||||
personId: '66666666-6666-4666-8666-666666666666',
|
||||
displayName: 'O"Brien'
|
||||
};
|
||||
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
||||
// The raw `"` from the displayName must never appear inside the rendered link
|
||||
// — it would terminate the attribute value early and let an attacker craft
|
||||
// arbitrary attributes on the anchor. It must arrive at the browser as ".
|
||||
expect(result).toMatch(/>O"Brien<\/a>/);
|
||||
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
||||
});
|
||||
|
||||
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
||||
expect(result).toBe('Plain old transcription text.');
|
||||
});
|
||||
|
||||
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
||||
// Nora #5551: if personId ever flowed in from a less-sanitised source
|
||||
// (a future "external person" or a bad sidecar), the renderer must not
|
||||
// emit a clickable link. The escaped text remains as plain content.
|
||||
const evil: PersonMention = {
|
||||
personId: 'javascript:alert(1)',
|
||||
displayName: 'Evil Link'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain('javascript:');
|
||||
// The @-trigger and displayName are preserved as plain text
|
||||
expect(result).toContain('@Evil Link');
|
||||
});
|
||||
|
||||
it('skips substitution when personId is an absolute URL', () => {
|
||||
const evil: PersonMention = {
|
||||
personId: 'https://evil.example/persons/abc',
|
||||
displayName: 'Phisher'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain('https://evil.example');
|
||||
});
|
||||
|
||||
it('still substitutes when personId is a well-formed UUID', () => {
|
||||
// Sanity check that the validation does not over-reject valid IDs.
|
||||
const valid: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
|
||||
expect(result).toContain('<a ');
|
||||
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Single-source CSS selector for rendered person-mention anchors. Used by:
|
||||
* - layout.css (.person-mention rule, focus ring, underline)
|
||||
* - TranscriptionReadView (delegated mouseenter/leave/click handlers)
|
||||
* - unit + e2e tests
|
||||
*
|
||||
* Keep these in sync — the renderer template below emits exactly this class.
|
||||
*/
|
||||
export const PERSON_MENTION_SELECTOR = 'a.person-mention';
|
||||
|
||||
/**
|
||||
* Branded string type for HTML that has been pre-escaped and assembled by
|
||||
* one of the trusted renderers in this module. The brand exists so that
|
||||
* `{@html …}` consumers can require a SafeHtml input at compile time —
|
||||
* `{@html block.text}` won't typecheck unless the string came through
|
||||
* a renderer that escapes its inputs.
|
||||
*
|
||||
* Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review).
|
||||
*/
|
||||
export type SafeHtml = string & { readonly __brand: 'SafeHtml' };
|
||||
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
@@ -62,13 +83,73 @@ export function escapeHtml(str: string): string {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict UUID v1–v5 check. Used as a defensive boundary on PersonMention.personId
|
||||
* before substituting it into an `href` — even though the backend currently only
|
||||
* emits UUIDs, a future "external person" feature must not accidentally turn this
|
||||
* helper into an open-redirect surface (CWE-601).
|
||||
*/
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isUuid(value: string): boolean {
|
||||
return UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a transcription block's text segment as safe HTML for read mode.
|
||||
*
|
||||
* Rules:
|
||||
* 1. The full text is HTML-escaped first (defense against stored XSS).
|
||||
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
|
||||
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
|
||||
* The `@` prefix is stripped from the rendered link text — it is an editor
|
||||
* affordance, not part of the historical text (issue #362).
|
||||
* 3. Longest displayNames are processed first so a short prefix in the sidecar
|
||||
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
|
||||
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
|
||||
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||
* rule per Felix decision OQ-1, comment #5339).
|
||||
*/
|
||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
|
||||
if (!text) return '' as SafeHtml;
|
||||
let escaped = escapeHtml(text);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: PersonMention[] = [];
|
||||
for (const mention of mentionedPersons) {
|
||||
if (seen.has(mention.displayName)) continue;
|
||||
// Defense in depth: refuse to render an anchor for a non-UUID personId.
|
||||
// The escaped block text falls through unchanged, so the @-trigger is
|
||||
// preserved as plain content — no silent data loss, no clickable link.
|
||||
if (!isUuid(mention.personId)) continue;
|
||||
seen.add(mention.displayName);
|
||||
unique.push(mention);
|
||||
}
|
||||
|
||||
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sorted) {
|
||||
const escapedDisplayName = escapeHtml(mention.displayName);
|
||||
const escapedPersonId = escapeHtml(mention.personId);
|
||||
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
|
||||
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
|
||||
escaped = escaped.replace(pattern, link);
|
||||
}
|
||||
|
||||
return escaped as SafeHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment body as safe HTML:
|
||||
* 1. Escapes all HTML-special characters in the raw content
|
||||
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||
* 3. Converts newlines to <br>
|
||||
*/
|
||||
export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||
export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml {
|
||||
let escaped = escapeHtml(content);
|
||||
|
||||
for (const mention of mentions) {
|
||||
@@ -78,5 +159,5 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||
}
|
||||
|
||||
return escaped.replaceAll('\n', '<br>');
|
||||
return escaped.replaceAll('\n', '<br>') as SafeHtml;
|
||||
}
|
||||
|
||||
@@ -329,6 +329,38 @@
|
||||
background-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─── 7b. Person mention link (transcription read mode) ────────────────────── */
|
||||
/*
|
||||
Rendered by renderTranscriptionBody() via {@html ...} in TranscriptionReadView.
|
||||
Underline at rest is required for WCAG AA — colour alone fails 8% of men
|
||||
with red-green colour-blindness. Focus ring uses a box-shadow + border-radius
|
||||
so the rectangle doesn't touch the glyphs.
|
||||
|
||||
Underline colour uses --c-ink at 50% so the link affordance is visible on
|
||||
any surface, including sand-tinted backgrounds where the previous mint
|
||||
accent at 60% (~1.6:1 on white — Leonie FINDING-06) was barely perceptible.
|
||||
*/
|
||||
.person-mention {
|
||||
color: var(--c-ink);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: color-mix(in srgb, var(--c-ink) 50%, transparent);
|
||||
cursor: pointer;
|
||||
transition: text-decoration-color 0.15s ease;
|
||||
}
|
||||
|
||||
.person-mention:hover {
|
||||
text-decoration-color: var(--c-accent);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.person-mention:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--c-ink);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ─── 8. Base styles ───────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
html {
|
||||
|
||||
Reference in New Issue
Block a user