Compare commits

...

5 Commits

Author SHA1 Message Date
Marcel
ae868f4110 test(e2e): person-mention read mode hover (B20) and tap (B21)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m22s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m9s
CI / Unit & Component Tests (pull_request) Failing after 3m15s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
Creates a Person, document, annotation, and transcription block with
mentionedPersons sidecar, then exercises the read-mode link in two
contexts:
  - Desktop: page.hover() mounts the hover card; mouseleave unmounts.
  - Touch (Pixel 7 device): page.tap() navigates to /persons/{id}
    without the card ever mounting (tap opens the page directly).

Tests are sequential because they share a single document/person via
beforeAll/afterAll. The touch test spins up a separate browser context
with hasTouch=true reusing the stored auth state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:23:47 +02:00
Marcel
1fd38830fe feat(person-mention): TranscriptionReadView wires hover card and click nav
Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).

Hover card lifecycle on each .person-mention anchor:
  mouseenter → set aria-describedby, place card via getBoundingClientRect
               (default below-right; flip up if <200px from bottom or
                mention is in bottom 30% of viewport; flip left if
                <300px from right), fire fetch, mount card with
                skeleton state
  resolved   → swap card to loaded state with person + family
                relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
  404        → degrade: mark anchor with data-person-deleted="true",
                unmount card, suppress future hovers/clicks
  network    → swap card to error state — link still navigates
  mouseleave → drop aria-describedby, unmount card

Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.

Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:21:35 +02:00
Marcel
c9c395eb59 feat(person-mention): PersonHoverCard with skeleton/error/loaded states
The card has three render states:
  - loading  → 320×180 skeleton with three pulse-animated bars; respects
               prefers-reduced-motion (animation disabled, opacity dimmed)
  - error    → generic load-error message in the body; the footer link
               still navigates (click works regardless of fetch outcome)
  - loaded   → navy header with name, life-date range, and "geb. <alias>";
               family-only relationship chips (PARENT_OF / SPOUSE_OF /
               SIBLING_OF) — non-family types are filtered out;
               notes excerpt capped at 120 chars with ellipsis;
               footer with "Zur Person →" + hover hint

aria-live="polite" on the card root so screen readers announce loaded
content when the fetch resolves; the host's id is the cardId so the
parent anchor can use aria-describedby. The card is hidden via
@media (hover: none) on touch devices — tap navigates directly per
spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:16:51 +02:00
Marcel
c247e1e971 feat(person-mention): .person-mention global CSS for read-mode anchors
Underline-at-rest (WCAG AA) so the link affordance does not depend on
colour alone. focus-visible uses a 2px box-shadow ring on --c-ink with a
2px border-radius — the same focus-ring shape as the comment .mention
chip but rectangular instead of pill, since the anchor sits in flowing
text.

Lives next to the existing .mention rule because Svelte scoped styles
do not reach the HTML injected by {@html …} in TranscriptionReadView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:13:32 +02:00
Marcel
eb6e21f032 feat(person-mention): renderTranscriptionBody for safe read-mode HTML
Replaces every @DisplayName in a transcription block's text with an anchor
link to /persons/{personId}, sourced from the mentionedPersons sidecar.
The @ prefix is stripped from the rendered link text per spec — it is an
editor affordance, not part of the historical text.

Stored-XSS hardening: HTML-escapes block text, displayName, and personId
before injection. Word-boundary lookahead avoids prefix collisions
(@Hans vs @HansMüller). Longest-displayName-first + first-sidecar-wins
make rendering deterministic for the OQ-1 collision case (#5339).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:12:52 +02:00
8 changed files with 1316 additions and 14 deletions

View File

@@ -0,0 +1,154 @@
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.
const docRes = await request.post('/api/documents', {
multipart: { title: 'E2E Person Mention Read', 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,
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 });
await link.tap();
// The card never mounted — the tap navigated directly per spec
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0);
} finally {
await context.close();
}
});
});

View File

@@ -0,0 +1,240 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
export type LoadState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'loaded'; person: Person; relationships: 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)
: ''
);
const notesExcerpt = $derived.by(() => {
if (state.status !== 'loaded') return null;
const notes = state.person.notes;
if (!notes) return null;
if (notes.length <= NOTES_MAX) return notes;
return notes.slice(0, NOTES_MAX) + '…';
});
</script>
<div
class="person-hover-card"
data-testid="person-hover-card"
id={cardId}
role="region"
aria-live="polite"
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">
<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>

View File

@@ -0,0 +1,272 @@
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', async () => {
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!.length).toBeLessThanOrEqual(122);
expect(notes.textContent).toContain('…');
});
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('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');
});
});

View File

@@ -1,6 +1,16 @@
<script lang="ts">
import type { TranscriptionBlockData } from '$lib/types';
import type { components } from '$lib/generated/api';
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
import { renderTranscriptionBody } from '$lib/utils/mention';
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte';
import { goto } from '$app/navigation';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type HoverData = { person: Person; relationships: RelationshipDTO[] };
interface Props {
blocks: TranscriptionBlockData[];
@@ -11,9 +21,172 @@ interface Props {
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
// Per-page 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.
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);
const CARD_WIDTH = 320;
const CARD_HEIGHT = 180;
const CARD_GAP = 6;
// 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): string {
return splitByMarkers(block.text)
.map((segment) => {
if (segment.type === 'marker') {
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`;
}
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
})
.join('');
}
function fetchHoverData(personId: string): Promise<HoverData | null> {
let cached = hoverCache.get(personId);
if (cached) return cached;
cached = (async () => {
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 };
})();
hoverCache.set(personId, cached);
return cached;
}
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = rect.bottom + CARD_GAP;
let left = rect.left;
// Flip up if the card would overflow the bottom edge OR the mention sits in
// the bottom 30% of the viewport (Leonie #5329).
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
top = rect.top - CARD_HEIGHT - CARD_GAP;
}
// Flip left if <300px from the right edge.
if (vw - rect.left < 300) {
left = rect.right - CARD_WIDTH;
}
return {
top: Math.max(0, top + window.scrollY),
left: Math.max(0, left + window.scrollX)
};
}
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 = computeCardPosition(rect);
activeCard = { personId, cardId, position, state: { status: 'loading' } };
try {
const data = await fetchHoverData(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;
}
async function handleMentionClick(event: MouseEvent) {
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.
function attachMentionHandlers(node: HTMLElement) {
function onEnter(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionEnter(e);
}
function onLeave(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionLeave(e);
}
function onClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionClick(e);
}
// mouseenter does not bubble — capture it.
node.addEventListener('mouseenter', onEnter, true);
node.addEventListener('mouseleave', onLeave, true);
node.addEventListener('click', onClick);
return {
destroy() {
node.removeEventListener('mouseenter', onEnter, true);
node.removeEventListener('mouseleave', onLeave, true);
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 +195,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% {

View File

@@ -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,241 @@ 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('&amp;');
expect(blockEl.textContent).not.toContain('&lt;');
});
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 new Promise((r) => setTimeout(r, 10));
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 new Promise((r) => setTimeout(r, 10));
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 new Promise((r) => setTimeout(r, 50));
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 }));
await new Promise((r) => setTimeout(r, 5));
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
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 new Promise((r) => setTimeout(r, 50));
// 404 → no card mounted
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
// 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');
});
});

View File

@@ -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,144 @@ 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('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(result).not.toContain('<script>');
});
it('escapes & in plain block text', () => {
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&amp;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 augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' };
const augusteLong: PersonMention = {
personId: 'p-long',
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/p-long"');
expect(result).toContain('href="/persons/p-short"');
// 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 hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' };
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' };
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
expect(result).toContain('href="/persons/p-first"');
expect(result).not.toContain('href="/persons/p-second"');
const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2);
});
it('escapes HTML in displayName to prevent stored XSS', () => {
const xss: PersonMention = {
personId: 'p-xss',
displayName: '<script>alert(1)</script>'
};
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;script&gt;');
});
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('&lt;img');
});
it('does not double-encode HTML-entity-already-encoded payloads', () => {
// `&amp;lt;script&amp;gt;` is already-escaped HTML in the source text.
// renderTranscriptionBody must escape the literal & once → `&amp;amp;lt;...`
// — never silently decode pre-escaped entities.
const result = renderTranscriptionBody('text &amp;lt;script&amp;gt;', []);
expect(result).toBe('text &amp;amp;lt;script&amp;amp;gt;');
});
it('escapes quotes in displayName so they cannot break the href attribute', () => {
const tricky: PersonMention = {
personId: 'p-quote',
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 &quot;.
expect(result).toMatch(/>O&quot;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.');
});
});

View File

@@ -1,4 +1,4 @@
import type { MentionDTO } from '$lib/types';
import type { MentionDTO, PersonMention } from '$lib/types';
/**
* Given the current textarea value and cursor position, returns the
@@ -62,6 +62,50 @@ export function escapeHtml(str: string): string {
.replaceAll("'", '&#39;');
}
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 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[]): string {
if (!text) return '';
let escaped = escapeHtml(text);
const seen = new Set<string>();
const unique: PersonMention[] = [];
for (const mention of mentionedPersons) {
if (seen.has(mention.displayName)) 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;
}
/**
* Renders a comment body as safe HTML:
* 1. Escapes all HTML-special characters in the raw content

View File

@@ -329,6 +329,34 @@
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.
*/
.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-accent) 60%, 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 {