diff --git a/frontend/src/lib/utils/mentionSerializer.spec.ts b/frontend/src/lib/utils/mentionSerializer.spec.ts
new file mode 100644
index 00000000..19b4c4a2
--- /dev/null
+++ b/frontend/src/lib/utils/mentionSerializer.spec.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect } from 'vitest';
+import { deserialize, serialize } from './mentionSerializer';
+import type { PersonMention } from '$lib/types';
+
+// ─── deserialize ─────────────────────────────────────────────────────────────
+
+describe('deserialize', () => {
+ it('returns a valid Tiptap doc for an empty string', () => {
+ const doc = deserialize('', []);
+ expect(doc.type).toBe('doc');
+ expect(doc.content).toHaveLength(1);
+ expect(doc.content![0].type).toBe('paragraph');
+ });
+
+ it('returns plain text node for text with no mentions', () => {
+ const doc = deserialize('Hallo Welt', []);
+ const para = doc.content![0];
+ expect(para.content).toHaveLength(1);
+ expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' });
+ });
+
+ it('places a mention node at the correct position in the paragraph', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('Hallo @Clara Welt', sidecar);
+ const nodes = doc.content![0].content!;
+ expect(nodes).toHaveLength(3);
+ expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' });
+ expect(nodes[1]).toMatchObject({
+ type: 'mention',
+ attrs: { displayName: 'Clara', personId: 'uuid-x' }
+ });
+ expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' });
+ });
+
+ it('prefers the longer displayName when two sidecar entries share a prefix', () => {
+ const sidecar: PersonMention[] = [
+ { personId: 'uuid-short', displayName: 'Auguste' },
+ { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
+ ];
+ const doc = deserialize('@Auguste Raddatz', sidecar);
+ const nodes = doc.content![0].content!;
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toMatchObject({
+ type: 'mention',
+ attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
+ });
+ });
+
+ it('splits multiple paragraphs on newline', () => {
+ const doc = deserialize('Zeile 1\nZeile 2', []);
+ expect(doc.content).toHaveLength(2);
+ expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' });
+ expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' });
+ });
+});
+
+// ─── serialize ───────────────────────────────────────────────────────────────
+
+describe('serialize', () => {
+ it('serializes plain text unchanged', () => {
+ const doc = deserialize('Hallo Welt', []);
+ const { text, mentionedPersons } = serialize(doc);
+ expect(text).toBe('Hallo Welt');
+ expect(mentionedPersons).toEqual([]);
+ });
+
+ it('serializes mention node back to @displayName', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('Hallo @Clara Welt', sidecar);
+ const { text, mentionedPersons } = serialize(doc);
+ expect(text).toBe('Hallo @Clara Welt');
+ expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]);
+ });
+
+ it('joins multi-paragraph doc back with newlines', () => {
+ const doc = deserialize('Zeile 1\nZeile 2', []);
+ const { text } = serialize(doc);
+ expect(text).toBe('Zeile 1\nZeile 2');
+ });
+
+ it('de-duplicates repeated mentions in the sidecar', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('@Clara und @Clara', sidecar);
+ const { mentionedPersons } = serialize(doc);
+ expect(mentionedPersons).toHaveLength(1);
+ expect(mentionedPersons[0].personId).toBe('uuid-x');
+ });
+});
+
+// ─── Round-trip invariant ─────────────────────────────────────────────────────
+
+describe('round-trip invariant', () => {
+ it('text is preserved exactly through deserialize → serialize', () => {
+ const cases = [
+ ['', []],
+ ['Hallo Welt', []],
+ ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]],
+ ['Zeile 1\nZeile 2', []],
+ ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]]
+ ] as const;
+
+ for (const [text, sidecar] of cases) {
+ const doc = deserialize(text, sidecar as PersonMention[]);
+ const { text: out } = serialize(doc);
+ expect(out, `round-trip failed for: "${text}"`).toBe(text);
+ }
+ });
+});
+
+// ─── Backward compatibility (AC-6) ────────────────────────────────────────────
+
+describe('backward compatibility', () => {
+ it('old-format full-name sidecar entry still round-trips correctly', () => {
+ // Before this issue, displayName stored the person's full DB name.
+ // renderTranscriptionBody already handles this — so does the serializer.
+ const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }];
+ const text = 'Brief von @Auguste Raddatz';
+ const doc = deserialize(text, oldSidecar);
+ const { text: out, mentionedPersons } = serialize(doc);
+ expect(out).toBe(text);
+ expect(mentionedPersons).toEqual(oldSidecar);
+ });
+});
+
+// ─── Security ─────────────────────────────────────────────────────────────────
+
+describe('security', () => {
+ it('displayName containing HTML special chars is preserved as a string, not injected', () => {
+ const sidecar: PersonMention[] = [
+ { personId: 'uuid-x', displayName: '' }
+ ];
+ const text = '@';
+ const doc = deserialize(text, sidecar);
+ const { mentionedPersons } = serialize(doc);
+ // The displayName is stored verbatim — HTML escaping is the renderer's job
+ expect(mentionedPersons[0].displayName).toBe('');
+ });
+});
diff --git a/frontend/src/lib/utils/mentionSerializer.ts b/frontend/src/lib/utils/mentionSerializer.ts
new file mode 100644
index 00000000..b6213f25
--- /dev/null
+++ b/frontend/src/lib/utils/mentionSerializer.ts
@@ -0,0 +1,113 @@
+import type { JSONContent } from '@tiptap/core';
+import type { PersonMention } from '$lib/types';
+
+/**
+ * Converts stored block text + sidecar into a Tiptap ProseMirror document.
+ *
+ * The text is split by "\n" into paragraphs. Within each paragraph, sidecar
+ * entries are matched against "@displayName" tokens (longest first) and
+ * converted to mention nodes. Unmatched text becomes plain text nodes.
+ *
+ * Round-trip invariant: serialize(deserialize(text, sidecar)).text === text
+ */
+export function deserialize(text: string, sidecar: PersonMention[]): JSONContent {
+ const lines = text === '' ? [''] : text.split('\n');
+
+ // Sort sidecar by displayName length descending so longer names shadow
+ // shorter prefix matches (same heuristic as renderTranscriptionBody).
+ const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length);
+
+ return {
+ type: 'doc',
+ content: lines.map((line) => ({
+ type: 'paragraph',
+ content: parseLine(line, sorted)
+ }))
+ };
+}
+
+function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] {
+ if (text === '') return [];
+
+ if (sidecar.length === 0) {
+ return [{ type: 'text', text }];
+ }
+
+ // Build a list of mention ranges: { start, end, mention }
+ const ranges: Array<{ start: number; end: number; mention: PersonMention }> = [];
+
+ for (const mention of sidecar) {
+ const needle = `@${mention.displayName}`;
+ let idx = 0;
+ while (idx < text.length) {
+ const pos = text.indexOf(needle, idx);
+ if (pos === -1) break;
+ // Check that the range doesn't overlap an already-found range
+ const end = pos + needle.length;
+ const overlaps = ranges.some((r) => r.start < end && r.end > pos);
+ if (!overlaps) {
+ ranges.push({ start: pos, end, mention });
+ }
+ idx = pos + 1;
+ }
+ }
+
+ if (ranges.length === 0) {
+ return [{ type: 'text', text }];
+ }
+
+ // Sort by position
+ ranges.sort((a, b) => a.start - b.start);
+
+ const nodes: JSONContent[] = [];
+ let cursor = 0;
+
+ for (const { start, end, mention } of ranges) {
+ if (start > cursor) {
+ nodes.push({ type: 'text', text: text.slice(cursor, start) });
+ }
+ nodes.push({
+ type: 'mention',
+ attrs: { displayName: mention.displayName, personId: mention.personId }
+ });
+ cursor = end;
+ }
+
+ if (cursor < text.length) {
+ nodes.push({ type: 'text', text: text.slice(cursor) });
+ }
+
+ return nodes;
+}
+
+/**
+ * Converts a Tiptap ProseMirror document back to stored block text + sidecar.
+ *
+ * Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName"
+ * and collected into mentionedPersons (de-duplicated by personId).
+ */
+export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } {
+ const paragraphs = doc.content ?? [];
+ const mentionedPersons: PersonMention[] = [];
+ const seenIds = new Set();
+ const lines: string[] = [];
+
+ for (const para of paragraphs) {
+ let line = '';
+ for (const node of para.content ?? []) {
+ if (node.type === 'text') {
+ line += node.text ?? '';
+ } else if (node.type === 'mention') {
+ const { displayName, personId } = node.attrs ?? {};
+ line += `@${displayName}`;
+ if (!seenIds.has(personId)) {
+ seenIds.add(personId);
+ mentionedPersons.push({ personId, displayName });
+ }
+ }
+ }
+ lines.push(line);
+ }
+
+ return { text: lines.join('\n'), mentionedPersons };
+}