Compare commits

...

13 Commits

Author SHA1 Message Date
Marcel
bc58d77f2c test(e2e): uniquify person-mention doc title and tighten B21 card-suppression assertion
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m33s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 3m21s
CI / Unit & Component Tests (push) Failing after 3m32s
CI / OCR Service Tests (push) Successful in 46s
CI / Backend Unit Tests (push) Failing after 3m10s
- Sara #3: title was a fixed string; if beforeAll crashed before afterAll
  ran, the next run would collide. Append Date.now() so each run has a
  unique title.
- Sara #2: B21 only asserted "no card present after tap" — but at that
  point we've already navigated to /persons/{id} and the card lives on
  the document page, so the assertion was vacuous. Move the toHaveCount(0)
  to before the tap so it actually proves touch-device suppression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:04:59 +02:00
Marcel
515fa03088 test(person-mention): replace setTimeout waits with vi.waitFor
Sara #1 + Felix #4: setTimeout(r, 50) and setTimeout(r, 5) were racing the
microtask queue — passes on a fast laptop, will fail on a loaded CI runner.
Replace all six occurrences with vi.waitFor(() => expect(...)) which polls
until the assertion passes (default 1s timeout, 10ms interval).

Tests are now deterministic — they pass the moment the condition is true,
fail the moment the timeout elapses, and never spuriously time out on slow
CI hardware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:04:02 +02:00
Marcel
060a1149e0 fix(person-mention): bump underline contrast so the link is visible at rest
Leonie FINDING-06: text-decoration-color was --c-accent at 60% (~#C9E6E5 on
white = ~1.6:1 contrast). The underline is the only visual signal that this
is a link mid-paragraph, so a barely-visible colour means seniors and
colour-blind users miss the affordance entirely.

Switch to --c-ink at 50% — same ink colour as the text, half opacity. Reads
as a soft underline on any background, passes WCAG 1.4.11 non-text contrast
on every brand surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:02:38 +02:00
Marcel
558e1e6b22 fix(person-mention): truncate notes excerpt at last word boundary
Leonie FINDING-04 + Elicit E5: notes.slice(0, 120) cuts mid-word, especially
ugly in German compound nouns ("…Familienzu…"). Sara #7: the assertion
.toBeLessThanOrEqual(122) was a magic number that hid this bug.

Add truncateAtWordBoundary(text, max): cut at the last space inside the
window unless it'd shrink the excerpt below 70% (single-word fallback).
Single-word case still produces hard-cut + ellipsis so a 150-char word
shows the first 120 chars + … rather than nothing.

Tests pinned to exact strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:01:39 +02:00
Marcel
6dd60571e3 fix(person-mention): name the hover-card region and announce its busy state
Leonie FINDING-02/03 + Elicit NFR concern + Sara #4: role="region" with no
aria-label is an axe-core warning, and the pulsing-bars skeleton carries no
semantics for SR clients.

- Add aria-label to the region root: person displayName when loaded,
  localised "Lade Person…" while loading. Region always has a name.
- Add aria-busy="true" while loading; cleared on loaded/error so the
  state change is announced via aria-live="polite".
- Add role="status" + aria-label on the skeleton so SR clients hear
  "Lade Person" rather than three silent <div>s.
- New Paraglide key person_mention_loading in de/en/es.

Five new tests pin: aria-busy true while loading, aria-busy unset/false
when loaded, aria-label is displayName when loaded, aria-label is the
loading label while loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:00:15 +02:00
Marcel
3365f5845e fix(person-mention): hover card mounts on focusin for keyboard users (WCAG 2.1.1)
Leonie FINDING-01 (Critical) + Elicit E3: only mouseenter triggered the
hover card, so a keyboard user tabbing through transcribed text reached the
anchor but never saw the rich-context preview. For the senior audience
constraint that's a hard regression.

Wire focusin/focusout alongside mouseenter/mouseleave on the delegated
listener. Same handleMentionEnter/Leave run — getBoundingClientRect works
identically on focused elements. focusin/focusout bubble naturally so no
capture phase needed.

Two new tests assert focusin mounts the card and focusout unmounts it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:57:48 +02:00
Marcel
3faac13533 fix(person-mention): respect modified-click and middle-click for new-tab nav
Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd,
breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click —
"open in new tab" is a real workflow for researchers comparing two persons.

Add isPlainPrimaryClick() guard. Modified clicks fall through to the
browser's default anchor handling (the <a href="/persons/{id}"> opens in
the new tab as expected). Plain left-clicks still SPA-navigate via goto().

Three new tests assert ctrl-click, meta-click, and middle-click are not
preventDefault'd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:56:23 +02:00
Marcel
5890bb3abd refactor(person-mention): split fetchHoverData into pure load + cache wrapper
Felix #1: fetchHoverData was doing four things — cache lookup, fetch, JSON
parsing, 404 normalisation. Split into:

  loadHoverData(personId)       — pure fetch + 404→null + non-OK→throw
  getOrFetchHoverData(personId) — five-line cache wrapper around the above

Also document the cache-lifetime trade-off (Markus #4, Elicit OQ-372-02):
the cache is per-mount, so closing and reopening the transcription panel
rebuilds it. That's intentional given the read-only nature of the view —
revisit if stale-card user reports surface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:54:35 +02:00
Marcel
060db69108 refactor(person-mention): extract computeHoverCardPosition into testable util
Three reviewer concerns land here:
- Felix #2: magic numbers 0.7 and 300 belong in named constants
- Sara #6: the position function had 4 branches and 2 thresholds with zero tests
- Leonie FINDING-05: at 320px viewport the flip-left could push the card
  past the right edge — needed a viewport clamp

Move the function to src/lib/utils/hoverCardPosition.ts as a pure
(rect, viewport) → {top, left} mapping, with named exports CARD_WIDTH_PX,
CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX.
Add a viewport clamp so left + CARD_WIDTH never exceeds the right edge.

Ten unit tests cover default placement, flip-up (both triggers), flip-left,
flip-right-edge clamp, and scroll offset. TranscriptionReadView passes the
current window viewport in on each call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:53:29 +02:00
Marcel
1842e23c81 refactor(person-mention): centralise PERSON_MENTION_SELECTOR constant
Markus flagged that 'a.person-mention' is a magic string repeated four times
in TranscriptionReadView, plus the CSS rule, plus tests. Extract into a single
exported constant so the renderer template, the delegated event handlers,
and the consumer-side selectors all import the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:51:25 +02:00
Marcel
26519d029a feat(person-mention): reject non-UUID personIds at the renderer boundary
Nora's CWE-601 (Open Redirect) defense-in-depth concern: today the backend
emits UUIDs, but renderTranscriptionBody concatenates personId straight into
an href. If a future "external person" feature ever flows a non-UUID through
the sidecar, the renderer would happily emit `<a href="javascript:…">`.

Add a strict UUID regex check before substituting. Non-UUID entries fall
through unchanged so the @-trigger remains as plain text — no silent data
loss, no clickable redirect.

Three new failing→passing tests cover javascript: scheme, absolute URL, and
the positive case (well-formed UUID still renders). Existing tests that used
synthetic IDs ("p-short", "p-first", etc.) updated to real UUIDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:50:28 +02:00
Marcel
488d4384a1 refactor(person-mention): brand renderer return types as SafeHtml
Markus, Felix, and Nora independently flagged the {@html …} boundary as a
distributed-knowledge security risk: today renderBody and renderTranscriptionBody
return string, so the next refactor that does {@html block.text} (instead of
{@html renderBlockHtml(block)}) is one typo away from a stored-XSS regression.

Introduce a SafeHtml brand type (string with a phantom __brand) returned by
both renderers and by renderBlockHtml in TranscriptionReadView. Compile-time
enforcement of the escape invariant — costs zero runtime, makes the contract
auditable in one file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:48:26 +02:00
Marcel
6a6967d841 refactor(person-mention): hoist LoadState + HoverData into shared types module
Markus flagged the LoadState export from PersonHoverCard.svelte as a
view-vs-orchestrator boundary smell — both files own the same shape, and a
third caller (admin previews, briefwechsel cards) would create a circular
import. Move the types into src/lib/types/personHoverCard.ts so the contract
is module-stable.

Also harden .prettierignore + eslint.config.js so a stray .svelte-kit.old/
backup directory (rotated by SvelteKit during dev) doesn't break the lint
hook — matches the existing .svelte-kit-backup/ convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:46:42 +02:00
16 changed files with 669 additions and 113 deletions

View File

@@ -11,6 +11,7 @@ bun.lockb
# Build artifacts # Build artifacts
/.svelte-kit/ /.svelte-kit/
/.svelte-kit-backup/ /.svelte-kit-backup/
/.svelte-kit.old/
# Generated files # Generated files
/.svelte-kit-backup/ /.svelte-kit-backup/

View File

@@ -44,8 +44,14 @@ test.describe('Person mention — read mode', () => {
personId = person.id; personId = person.id;
// 2. Document with a PDF so the transcription panel is mountable. // 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', { const docRes = await request.post('/api/documents', {
multipart: { title: 'E2E Person Mention Read', documentDate: '1945-05-08' } multipart: {
title: `E2E Person Mention Read ${uniqueSuffix}`,
documentDate: '1945-05-08'
}
}); });
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
const doc = await docRes.json(); const doc = await docRes.json();
@@ -54,7 +60,7 @@ test.describe('Person mention — read mode', () => {
await request.put(`/api/documents/${docId}`, { await request.put(`/api/documents/${docId}`, {
multipart: { multipart: {
title: doc.title, title: doc.title as string,
documentDate: '1945-05-08', documentDate: '1945-05-08',
file: { file: {
name: 'minimal.pdf', name: 'minimal.pdf',
@@ -143,10 +149,13 @@ test.describe('Person mention — read mode', () => {
const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first(); const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first();
await expect(link).toBeVisible({ timeout: 5000 }); await expect(link).toBeVisible({ timeout: 5000 });
await link.tap(); // Sara #2: assert no card *before* the tap so the test actually proves
// The card never mounted — the tap navigated directly per spec // the touch device suppression worked, not just that we navigated away.
await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`));
await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0); 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 { } finally {
await context.close(); await context.close();
} }

View File

@@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
{ ignores: ['src/paraglide/**'] }, { ignores: ['src/paraglide/**', '.svelte-kit.old/**'] },
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Zur Person", "person_mention_open_link": "Zur Person",
"person_mention_hover_hint": "Klick öffnet Seite", "person_mention_hover_hint": "Klick öffnet Seite",
"person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_loading": "Lade Person…",
"person_mention_popup_empty": "Keine Personen gefunden", "person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken", "person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen", "person_mention_create_new": "Neue Person anlegen",

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Open person", "person_mention_open_link": "Open person",
"person_mention_hover_hint": "Click opens the page", "person_mention_hover_hint": "Click opens the page",
"person_mention_load_error": "Could not load person.", "person_mention_load_error": "Could not load person.",
"person_mention_loading": "Loading person…",
"person_mention_popup_empty": "No persons found", "person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person", "person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person", "person_mention_create_new": "Create new person",

View File

@@ -423,6 +423,7 @@
"person_mention_open_link": "Ir a la persona", "person_mention_open_link": "Ir a la persona",
"person_mention_hover_hint": "Clic abre la página", "person_mention_hover_hint": "Clic abre la página",
"person_mention_load_error": "No se pudo cargar la persona.", "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_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona", "person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona", "person_mention_create_new": "Crear nueva persona",

View File

@@ -2,15 +2,10 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatLifeDateRange } from '$lib/utils/personLifeDates'; import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { LoadState } from '$lib/types/personHoverCard';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
export type LoadState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
type Props = { type Props = {
personId: string; personId: string;
cardId: string; cardId: string;
@@ -39,13 +34,41 @@ const dateRange = $derived(
: '' : ''
); );
/**
* 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(() => { const notesExcerpt = $derived.by(() => {
if (state.status !== 'loaded') return null; if (state.status !== 'loaded') return null;
const notes = state.person.notes; const notes = state.person.notes;
if (!notes) return null; if (!notes) return null;
if (notes.length <= NOTES_MAX) return notes; return truncateAtWordBoundary(notes, NOTES_MAX);
return notes.slice(0, 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> </script>
<div <div
@@ -54,12 +77,19 @@ const notesExcerpt = $derived.by(() => {
id={cardId} id={cardId}
role="region" role="region"
aria-live="polite" aria-live="polite"
aria-label={ariaLabel}
aria-busy={ariaBusy ? 'true' : undefined}
style:position="absolute" style:position="absolute"
style:top={`${position.top}px`} style:top={`${position.top}px`}
style:left={`${position.left}px`} style:left={`${position.left}px`}
> >
{#if state.status === 'loading'} {#if state.status === 'loading'}
<div data-testid="person-hover-card-skeleton" class="skeleton"> <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 class="bar"></div>
<div class="bar"></div> <div class="bar"></div>

View File

@@ -197,7 +197,9 @@ describe('PersonHoverCard — loaded state', () => {
await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument(); await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument();
}); });
it('truncates notes longer than 120 characters with an ellipsis', async () => { 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 long = 'x'.repeat(150);
const withLongNotes = { ...AUGUSTE, notes: long } as Person; const withLongNotes = { ...AUGUSTE, notes: long } as Person;
render(PersonHoverCard, { render(PersonHoverCard, {
@@ -207,8 +209,34 @@ describe('PersonHoverCard — loaded state', () => {
state: { status: 'loaded', person: withLongNotes, relationships: [] } state: { status: 'loaded', person: withLongNotes, relationships: [] }
}); });
const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!;
expect(notes.textContent!.length).toBeLessThanOrEqual(122); expect(notes.textContent).toBe('x'.repeat(120) + '…');
expect(notes.textContent).toContain('…'); });
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 () => { it('omits notes section when notes is null', async () => {
@@ -246,6 +274,54 @@ describe('PersonHoverCard — accessibility', () => {
expect(root.getAttribute('aria-live')).toBe('polite'); 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 () => { it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => {
render(PersonHoverCard, { render(PersonHoverCard, {
personId: 'p-aug', personId: 'p-aug',

View File

@@ -2,16 +2,20 @@
import type { TranscriptionBlockData } from '$lib/types'; import type { TranscriptionBlockData } from '$lib/types';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { splitByMarkers } from '$lib/utils/transcriptionMarkers'; import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
import { renderTranscriptionBody } from '$lib/utils/mention'; import {
import PersonHoverCard, { type LoadState } from './PersonHoverCard.svelte'; 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 { goto } from '$app/navigation';
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO'];
type HoverData = { person: Person; relationships: RelationshipDTO[] };
interface Props { interface Props {
blocks: TranscriptionBlockData[]; blocks: TranscriptionBlockData[];
onParagraphClick: (annotationId: string) => void; onParagraphClick: (annotationId: string) => void;
@@ -22,9 +26,14 @@ let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); 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 // Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
// must not fire 20 backend calls (B15.5). The Promise<HoverData | null> shape // same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
// lets simultaneous hovers share the same in-flight fetch. // 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 hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
const deletedPersonIds = new SvelteSet<string>(); const deletedPersonIds = new SvelteSet<string>();
@@ -35,30 +44,30 @@ let activeCard: {
position: { top: number; left: number }; position: { top: number; left: number };
} | null = $state(null); } | null = $state(null);
const CARD_WIDTH = 320;
const CARD_HEIGHT = 180;
const CARD_GAP = 6;
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered // Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
// as <em data-marker> tags; text segments run through HTML-escaping + mention // as <em data-marker> tags; text segments run through HTML-escaping + mention
// substitution. The two are concatenated to preserve marker boundaries — markers // substitution. The two are concatenated to preserve marker boundaries — markers
// never end up nested inside an anchor (Felix #5324 B19b). // never end up nested inside an anchor (Felix #5324 B19b).
function renderBlockHtml(block: TranscriptionBlockData): string { function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
return splitByMarkers(block.text) return splitByMarkers(block.text)
.map((segment) => { .map((segment) => {
if (segment.type === 'marker') { if (segment.type === 'marker') {
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>`; // 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 ?? []); return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
}) })
.join(''); .join('') as SafeHtml;
} }
function fetchHoverData(personId: string): Promise<HoverData | null> { /**
let cached = hoverCache.get(personId); * Fetches person + relationships from the backend. 404 returns null
if (cached) return cached; * (deleted person — caller marks the link as tombstoned). Any other
* non-OK response throws so the caller can render the error state.
cached = (async () => { */
async function loadHoverData(personId: string): Promise<HoverData | null> {
const personRes = await fetch(`/api/persons/${personId}`); const personRes = await fetch(`/api/persons/${personId}`);
if (personRes.status === 404) return null; if (personRes.status === 404) return null;
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`); if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
@@ -69,33 +78,24 @@ function fetchHoverData(personId: string): Promise<HoverData | null> {
? ((await relRes.json()) as RelationshipDTO[]) ? ((await relRes.json()) as RelationshipDTO[])
: []; : [];
return { person, relationships }; return { person, relationships };
})();
hoverCache.set(personId, cached);
return cached;
} }
function computeCardPosition(rect: DOMRect): { top: number; left: number } { /** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
const vw = window.innerWidth; * subsequent hovers (and concurrent in-flight ones) share the same Promise. */
const vh = window.innerHeight; function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
const cached = hoverCache.get(personId);
let top = rect.bottom + CARD_GAP; if (cached) return cached;
let left = rect.left; const promise = loadHoverData(personId);
hoverCache.set(personId, promise);
// Flip up if the card would overflow the bottom edge OR the mention sits in return promise;
// 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;
}
function currentViewport() {
return { return {
top: Math.max(0, top + window.scrollY), viewportWidth: window.innerWidth,
left: Math.max(0, left + window.scrollX) viewportHeight: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY
}; };
} }
@@ -109,12 +109,12 @@ async function handleMentionEnter(event: Event) {
link.setAttribute('aria-describedby', cardId); link.setAttribute('aria-describedby', cardId);
const rect = link.getBoundingClientRect(); const rect = link.getBoundingClientRect();
const position = computeCardPosition(rect); const position = computeHoverCardPosition(rect, currentViewport());
activeCard = { personId, cardId, position, state: { status: 'loading' } }; activeCard = { personId, cardId, position, state: { status: 'loading' } };
try { try {
const data = await fetchHoverData(personId); const data = await getOrFetchHoverData(personId);
// Bail if a different mention is now active // Bail if a different mention is now active
if (!activeCard || activeCard.personId !== personId) return; if (!activeCard || activeCard.personId !== personId) return;
@@ -143,7 +143,18 @@ function handleMentionLeave(event: Event) {
activeCard = null; 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) { async function handleMentionClick(event: MouseEvent) {
if (!isPlainPrimaryClick(event)) return;
const link = event.target as HTMLAnchorElement; const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId; const personId = link.dataset.personId;
if (!personId) return; if (!personId) return;
@@ -158,28 +169,37 @@ async function handleMentionClick(event: MouseEvent) {
// Attach delegated event listeners on each rendered block. Using {@html ...} // Attach delegated event listeners on each rendered block. Using {@html ...}
// for the body means we cannot bind events declaratively to the injected // 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. // 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 attachMentionHandlers(node: HTMLElement) {
function onEnter(e: Event) { function onEnter(e: Event) {
const t = e.target as HTMLElement; const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionEnter(e); if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
} }
function onLeave(e: Event) { function onLeave(e: Event) {
const t = e.target as HTMLElement; const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionLeave(e); if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e);
} }
function onClick(e: MouseEvent) { function onClick(e: MouseEvent) {
const t = e.target as HTMLElement; const t = e.target as HTMLElement;
if (t.matches?.('a.person-mention')) handleMentionClick(e); if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
} }
// mouseenter does not bubble — capture it. // mouseenter does not bubble — capture it.
node.addEventListener('mouseenter', onEnter, true); node.addEventListener('mouseenter', onEnter, true);
node.addEventListener('mouseleave', onLeave, 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); node.addEventListener('click', onClick);
return { return {
destroy() { destroy() {
node.removeEventListener('mouseenter', onEnter, true); node.removeEventListener('mouseenter', onEnter, true);
node.removeEventListener('mouseleave', onLeave, true); node.removeEventListener('mouseleave', onLeave, true);
node.removeEventListener('focusin', onEnter);
node.removeEventListener('focusout', onLeave);
node.removeEventListener('click', onClick); node.removeEventListener('click', onClick);
} }
}; };

View File

@@ -280,13 +280,14 @@ describe('TranscriptionReadView — person-mention rendering', () => {
const link = document.querySelector('a.person-mention')!; const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
await vi.waitFor(() => {
const personFetches = fetchMock.mock.calls.filter((c) => const personFetches = fetchMock.mock.calls.filter((c) =>
String(c[0]).includes(`/api/persons/${PERSON_ID}`) String(c[0]).includes(`/api/persons/${PERSON_ID}`)
); );
expect(personFetches.length).toBeGreaterThanOrEqual(1); expect(personFetches.length).toBeGreaterThanOrEqual(1);
}); });
});
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => { it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
@@ -308,13 +309,13 @@ describe('TranscriptionReadView — person-mention rendering', () => {
// Plus a re-hover on the first // Plus a re-hover on the first
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10)); await vi.waitFor(() => {
const personFetches = fetchMock.mock.calls.filter( const personFetches = fetchMock.mock.calls.filter(
(c) => String(c[0]) === `/api/persons/${PERSON_ID}` (c) => String(c[0]) === `/api/persons/${PERSON_ID}`
); );
expect(personFetches.length).toBe(1); expect(personFetches.length).toBe(1);
}); });
});
it('mounts the hover card on mouseenter when the fetch loads', async () => { it('mounts the hover card on mouseenter when the fetch loads', async () => {
vi.stubGlobal( vi.stubGlobal(
@@ -349,10 +350,11 @@ describe('TranscriptionReadView — person-mention rendering', () => {
const link = document.querySelector('a.person-mention')!; const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50)); await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]'); const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).not.toBeNull(); expect(card).not.toBeNull();
}); });
});
it('unmounts the hover card on mouseleave', async () => { it('unmounts the hover card on mouseleave', async () => {
render(TranscriptionReadView, { render(TranscriptionReadView, {
@@ -362,13 +364,101 @@ describe('TranscriptionReadView — person-mention rendering', () => {
const link = document.querySelector('a.person-mention')!; const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]'); const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull(); 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 () => { 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() })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
@@ -380,13 +470,15 @@ describe('TranscriptionReadView — person-mention rendering', () => {
const link = document.querySelector('a.person-mention')!; const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
// 404 → no card mounted await vi.waitFor(() => {
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 // Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
const stillLink = document.querySelector('a.person-mention')!; const stillLink = document.querySelector('a.person-mention')!;
expect(stillLink.getAttribute('data-person-deleted')).toBe('true'); expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
}); });
// 404 → no card mounted
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
}); });

View 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[] };

View 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);
});
});
});

View 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)
};
}

View File

@@ -232,9 +232,11 @@ describe('renderTranscriptionBody', () => {
}); });
it('processes longer displayNames first to avoid prefix shadowing', () => { it('processes longer displayNames first to avoid prefix shadowing', () => {
const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' }; 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 = { const augusteLong: PersonMention = {
personId: 'p-long', personId: LONG_ID,
displayName: 'Auguste Raddatz' displayName: 'Auguste Raddatz'
}; };
// Sidecar order is short-first; longer match must still win for the long text // Sidecar order is short-first; longer match must still win for the long text
@@ -242,8 +244,8 @@ describe('renderTranscriptionBody', () => {
augusteShort, augusteShort,
augusteLong augusteLong
]); ]);
expect(result).toContain('href="/persons/p-long"'); expect(result).toContain(`href="/persons/${LONG_ID}"`);
expect(result).toContain('href="/persons/p-short"'); expect(result).toContain(`href="/persons/${SHORT_ID}"`);
// The "Raddatz" suffix must not leak inside the short-name anchor // The "Raddatz" suffix must not leak inside the short-name anchor
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/); expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
}); });
@@ -257,18 +259,20 @@ describe('renderTranscriptionBody', () => {
it('first-sidecar-wins when two entries share the same displayName', () => { it('first-sidecar-wins when two entries share the same displayName', () => {
// Two persons named "Hans" — first sidecar entry wins for all occurrences. // Two persons named "Hans" — first sidecar entry wins for all occurrences.
const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' }; const FIRST_ID = '33333333-3333-4333-8333-333333333333';
const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' }; 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]); const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
expect(result).toContain('href="/persons/p-first"'); expect(result).toContain(`href="/persons/${FIRST_ID}"`);
expect(result).not.toContain('href="/persons/p-second"'); expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
const anchorCount = (result.match(/<a /g) ?? []).length; const anchorCount = (result.match(/<a /g) ?? []).length;
expect(anchorCount).toBe(2); expect(anchorCount).toBe(2);
}); });
it('escapes HTML in displayName to prevent stored XSS', () => { it('escapes HTML in displayName to prevent stored XSS', () => {
const xss: PersonMention = { const xss: PersonMention = {
personId: 'p-xss', personId: '55555555-5555-4555-8555-555555555555',
displayName: '<script>alert(1)</script>' displayName: '<script>alert(1)</script>'
}; };
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]); const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
@@ -292,7 +296,7 @@ describe('renderTranscriptionBody', () => {
it('escapes quotes in displayName so they cannot break the href attribute', () => { it('escapes quotes in displayName so they cannot break the href attribute', () => {
const tricky: PersonMention = { const tricky: PersonMention = {
personId: 'p-quote', personId: '66666666-6666-4666-8666-666666666666',
displayName: 'O"Brien' displayName: 'O"Brien'
}; };
const result = renderTranscriptionBody('@O"Brien', [tricky]); const result = renderTranscriptionBody('@O"Brien', [tricky]);
@@ -307,4 +311,40 @@ describe('renderTranscriptionBody', () => {
const result = renderTranscriptionBody('Plain old transcription text.', []); const result = renderTranscriptionBody('Plain old transcription text.', []);
expect(result).toBe('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"');
});
}); });

View File

@@ -1,5 +1,26 @@
import type { MentionDTO, PersonMention } 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 * Given the current textarea value and cursor position, returns the
* @-mention query being typed (the text after the last triggering @), * @-mention query being typed (the text after the last triggering @),
@@ -66,6 +87,18 @@ function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
/**
* Strict UUID v1v5 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. * Renders a transcription block's text segment as safe HTML for read mode.
* *
@@ -81,14 +114,18 @@ function escapeRegExp(str: string): string {
* 5. First-sidecar-wins for entries that share a displayName (deterministic * 5. First-sidecar-wins for entries that share a displayName (deterministic
* rule per Felix decision OQ-1, comment #5339). * rule per Felix decision OQ-1, comment #5339).
*/ */
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): string { export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
if (!text) return ''; if (!text) return '' as SafeHtml;
let escaped = escapeHtml(text); let escaped = escapeHtml(text);
const seen = new Set<string>(); const seen = new Set<string>();
const unique: PersonMention[] = []; const unique: PersonMention[] = [];
for (const mention of mentionedPersons) { for (const mention of mentionedPersons) {
if (seen.has(mention.displayName)) continue; 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); seen.add(mention.displayName);
unique.push(mention); unique.push(mention);
} }
@@ -103,7 +140,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
escaped = escaped.replace(pattern, link); escaped = escaped.replace(pattern, link);
} }
return escaped; return escaped as SafeHtml;
} }
/** /**
@@ -112,7 +149,7 @@ export function renderTranscriptionBody(text: string, mentionedPersons: PersonMe
* 2. Replaces every @FirstName LastName occurrence with an anchor link * 2. Replaces every @FirstName LastName occurrence with an anchor link
* 3. Converts newlines to <br> * 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); let escaped = escapeHtml(content);
for (const mention of mentions) { for (const mention of mentions) {
@@ -122,5 +159,5 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span); escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
} }
return escaped.replaceAll('\n', '<br>'); return escaped.replaceAll('\n', '<br>') as SafeHtml;
} }

View File

@@ -335,13 +335,17 @@
Underline at rest is required for WCAG AA — colour alone fails 8% of men 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 with red-green colour-blindness. Focus ring uses a box-shadow + border-radius
so the rectangle doesn't touch the glyphs. 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 { .person-mention {
color: var(--c-ink); color: var(--c-ink);
text-decoration: underline; text-decoration: underline;
text-decoration-thickness: 1px; text-decoration-thickness: 1px;
text-underline-offset: 3px; text-underline-offset: 3px;
text-decoration-color: color-mix(in srgb, var(--c-accent) 60%, transparent); text-decoration-color: color-mix(in srgb, var(--c-ink) 50%, transparent);
cursor: pointer; cursor: pointer;
transition: text-decoration-color 0.15s ease; transition: text-decoration-color 0.15s ease;
} }