Person mention: decouple display text from person name to preserve original wording #372

Closed
opened 2026-04-29 08:59:33 +02:00 by marcel · 20 comments
Owner

Context

When a transcriber marks up a person mention in the transcription editor, the current behaviour replaces the typed trigger text with the person's full name. This breaks historical fidelity: if the original letter says "Hallo Clara", the transcription currently reads "Hallo @Clara Cram" — awkward and inaccurate.

The root cause is a design conflation: display text (what the scribe wrote) and person identity (who they meant) are treated as the same thing. They are not.

User Story

As a transcriber, I want to link a person mention to a person record without changing the original wording in the text, so that the transcription faithfully preserves what the letter-writer actually wrote.

Proposed Behaviour

Authoring flow

  1. Transcriber types @ followed by the word(s) as they appear in the original text (e.g. @Clara)
  2. A dropdown appears pre-filled with the typed text as the search query
  3. Transcriber refines the search if needed and selects a person from the results
  4. The mention is locked as an atomic token: @[Clara](uuid-of-clara-cram)
  5. The token can only be removed by deleting it wholesale — it cannot be edited in-place

Unresolved mention

If the transcriber types @Clara but does not select anyone from the dropdown (person not yet in the database, or deliberate choice), the @Clara remains as plain text — the @ is preserved as a visible signal that a person reference was intended but not resolved.

Read mode

  • Resolved @[Clara](uuid): renders as Clara — a clickable anchor linking to the person's page. Hover card shows the person's full name (e.g. "Clara Cram") and summary details.
  • Unresolved @Clara (plain text): renders as literal @Clara with no link.

What This Fixes

Problem Before After
Awkward full name in transcription "Hallo @Clara Cram" "Hallo @Clara"
Rename breaks transcription display Yes No — display text is the original word
Rename sync logic needed Yes No longer needed

The rename-sync invariant is intentional: renaming "Clara Cram" to "Clara Cram-Schmidt" on her person detail page will never update transcription text. The display text is the scribe's original word, not a derived label. This is correct behaviour for an archival system.

Data Model

Format Display text source
Before @[Clara Cram](uuid) Person's full name
After @[Clara](uuid) What the author typed after @

Existing mentions stored with full names remain valid and do not require migration.

Acceptance Criteria

AC-1 — Resolved mention preserves original wording
Given a transcription containing the word "Clara",
When the transcriber types @Clara and selects "Clara Cram" from the dropdown,
Then the stored mention is @[Clara](uuid) and the rendered transcription reads "Clara" (not "Clara Cram").

AC-2 — Hover card shows full name
Given a resolved mention @[Clara](uuid),
When the reader hovers (desktop) or taps (mobile) the anchor,
Then the hover card header shows "Clara Cram" (the resolved person's full name).

AC-3 — Dropdown pre-fills with typed text
Given a transcriber types @Tante,
When the mention dropdown opens,
Then the search input is pre-filled with "Tante" and results are filtered accordingly.

AC-4 — Mention is atomic after selection
Given a transcriber has selected a person and locked a mention,
When they place the cursor inside the mention token,
Then the token is selected as a whole unit; individual characters cannot be edited.

AC-5 — Unresolved mention stays as plain text with @
Given a transcriber types @Fritz and dismisses the dropdown without selecting anyone,
Then the text @Fritz remains in the editor as plain (unlinked) text, including the @ symbol.

AC-6 — No migration required
Given existing transcriptions with mentions stored as @[Full Name](uuid),
When those transcriptions are rendered in read mode,
Then they display and link correctly without any data migration.

## Context When a transcriber marks up a person mention in the transcription editor, the current behaviour replaces the typed trigger text with the person's full name. This breaks historical fidelity: if the original letter says "Hallo Clara", the transcription currently reads "Hallo @Clara Cram" — awkward and inaccurate. The root cause is a design conflation: **display text** (what the scribe wrote) and **person identity** (who they meant) are treated as the same thing. They are not. ## User Story As a transcriber, I want to link a person mention to a person record without changing the original wording in the text, so that the transcription faithfully preserves what the letter-writer actually wrote. ## Proposed Behaviour ### Authoring flow 1. Transcriber types `@` followed by the word(s) as they appear in the original text (e.g. `@Clara`) 2. A dropdown appears pre-filled with the typed text as the search query 3. Transcriber refines the search if needed and selects a person from the results 4. The mention is locked as an **atomic token**: `@[Clara](uuid-of-clara-cram)` 5. The token can only be removed by deleting it wholesale — it cannot be edited in-place ### Unresolved mention If the transcriber types `@Clara` but does not select anyone from the dropdown (person not yet in the database, or deliberate choice), the `@Clara` remains as **plain text** — the `@` is preserved as a visible signal that a person reference was intended but not resolved. ### Read mode - **Resolved** `@[Clara](uuid)`: renders as `Clara` — a clickable anchor linking to the person's page. Hover card shows the person's **full name** (e.g. "Clara Cram") and summary details. - **Unresolved** `@Clara` (plain text): renders as literal `@Clara` with no link. ## What This Fixes | Problem | Before | After | |---|---|---| | Awkward full name in transcription | "Hallo @Clara Cram" | "Hallo @Clara" | | Rename breaks transcription display | Yes | No — display text is the original word | | Rename sync logic needed | Yes | No longer needed | **The rename-sync invariant is intentional:** renaming "Clara Cram" to "Clara Cram-Schmidt" on her person detail page will never update transcription text. The display text is the scribe's original word, not a derived label. This is correct behaviour for an archival system. ## Data Model | | Format | Display text source | |---|---|---| | **Before** | `@[Clara Cram](uuid)` | Person's full name | | **After** | `@[Clara](uuid)` | What the author typed after `@` | Existing mentions stored with full names remain valid and do not require migration. ## Acceptance Criteria **AC-1 — Resolved mention preserves original wording** Given a transcription containing the word "Clara", When the transcriber types `@Clara` and selects "Clara Cram" from the dropdown, Then the stored mention is `@[Clara](uuid)` and the rendered transcription reads "Clara" (not "Clara Cram"). **AC-2 — Hover card shows full name** Given a resolved mention `@[Clara](uuid)`, When the reader hovers (desktop) or taps (mobile) the anchor, Then the hover card header shows "Clara Cram" (the resolved person's full name). **AC-3 — Dropdown pre-fills with typed text** Given a transcriber types `@Tante`, When the mention dropdown opens, Then the search input is pre-filled with "Tante" and results are filtered accordingly. **AC-4 — Mention is atomic after selection** Given a transcriber has selected a person and locked a mention, When they place the cursor inside the mention token, Then the token is selected as a whole unit; individual characters cannot be edited. **AC-5 — Unresolved mention stays as plain text with @** Given a transcriber types `@Fritz` and dismisses the dropdown without selecting anyone, Then the text `@Fritz` remains in the editor as plain (unlinked) text, including the `@` symbol. **AC-6 — No migration required** Given existing transcriptions with mentions stored as `@[Full Name](uuid)`, When those transcriptions are rendered in read mode, Then they display and link correctly without any data migration.
marcel added this to the (deleted) milestone 2026-04-29 08:59:33 +02:00
marcel added the P1-highfeaturepersonui labels 2026-04-29 08:59:38 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

PersonMentionPropagationListener.java must be deleted — it becomes actively harmful under the new design.

Currently, the listener rewrites PersonMention.displayName AND the @OldName text in blocks when a person is renamed. Under the new design, displayName stores the author's original typed text (archival), not a derived copy of the person's DB name. If the listener runs on new-format blocks, it overwrites the archival text with the new person name — exactly what the issue says must never happen.

The issue explicitly states: "renaming 'Clara Cram' to 'Clara Cram-Schmidt' will never update transcription text. This is correct behaviour for an archival system." The listener should be deleted as part of this issue. Also check PersonDisplayNameChangedEvent — if the listener is its only consumer, delete the event and its publisher too.

The exact surgical change in PersonMentionEditor.svelte:

Lines 123–131 currently do:

const displayName = person.displayName ?? '';           // ← gets DB name
const replacement = `@${displayName} `;
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];

The fix is: substitute the typed query text, not the DB display name:

const typedText = currentQuery ?? person.displayName ?? '';   // ← keep what user typed
const replacement = `@${typedText} `;
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName: typedText }];

Where currentQuery is the result of detectPersonMention at the time of selection — it's already tracked as state in the component (the value passed to the search input). Confirm mentionStart is still in scope at selection time.

AC-4 (atomicity) is the technically hard part. The editor is a <textarea>. True atomic tokens in a textarea require beforeinput/keydown interception + cursor tracking. Minimum viable implementation: intercept beforeinput, detect if the edit range overlaps a mention span, and either snap the deletion to cover the whole mention or do nothing. The spec needs to define the exact expected behavior before implementation starts (see Open Decisions).

renderTranscriptionBody in mention.ts requires no changes — it already reads displayName from the sidecar and uses it as link text. Backward compat with old-format blocks (full names in sidecar) works automatically.

Recommendations

  • Delete PersonMentionPropagationListener and its associated PersonDisplayNameChangedEvent as part of this issue — not as a follow-up.
  • Change PersonMentionEditor.svelte lines 123–131 to capture the typed query, not person.displayName.
  • Write unit tests for PersonMentionEditor: (a) selected person stores typed text as displayName, not DB name; (b) mentionedPersons sidecar contains the typed text; (c) an explicit regression test: when a user types @Clara and selects "Clara Cram", the rendered text is @Clara, not @Clara Cram.

Open Decisions

  • AC-4 atomicity level: Define the exact UX before implementing. Options: (A) Full atomic — beforeinput intercept prevents any cursor entry; (B) Delete-triggers-full-removal — backspace from immediately after the mention removes the whole token + sidecar entry; (C) Soft — no enforcement, sidecar becomes inconsistent if user edits the mention manually. Option B is the lowest-effort implementation that satisfies the spec's intent without requiring a contenteditable rewrite.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations **`PersonMentionPropagationListener.java` must be deleted — it becomes actively harmful under the new design.** Currently, the listener rewrites `PersonMention.displayName` AND the `@OldName` text in blocks when a person is renamed. Under the new design, `displayName` stores the author's original typed text (archival), not a derived copy of the person's DB name. If the listener runs on new-format blocks, it overwrites the archival text with the new person name — exactly what the issue says must never happen. The issue explicitly states: *"renaming 'Clara Cram' to 'Clara Cram-Schmidt' will never update transcription text. This is correct behaviour for an archival system."* The listener should be deleted as part of this issue. Also check `PersonDisplayNameChangedEvent` — if the listener is its only consumer, delete the event and its publisher too. **The exact surgical change in `PersonMentionEditor.svelte`:** Lines 123–131 currently do: ```typescript const displayName = person.displayName ?? ''; // ← gets DB name const replacement = `@${displayName} `; mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }]; ``` The fix is: substitute the typed query text, not the DB display name: ```typescript const typedText = currentQuery ?? person.displayName ?? ''; // ← keep what user typed const replacement = `@${typedText} `; mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName: typedText }]; ``` Where `currentQuery` is the result of `detectPersonMention` at the time of selection — it's already tracked as state in the component (the value passed to the search input). Confirm `mentionStart` is still in scope at selection time. **AC-4 (atomicity) is the technically hard part.** The editor is a `<textarea>`. True atomic tokens in a textarea require `beforeinput`/`keydown` interception + cursor tracking. Minimum viable implementation: intercept `beforeinput`, detect if the edit range overlaps a mention span, and either snap the deletion to cover the whole mention or do nothing. The spec needs to define the exact expected behavior before implementation starts (see Open Decisions). **`renderTranscriptionBody` in `mention.ts` requires no changes** — it already reads `displayName` from the sidecar and uses it as link text. Backward compat with old-format blocks (full names in sidecar) works automatically. ### Recommendations - Delete `PersonMentionPropagationListener` and its associated `PersonDisplayNameChangedEvent` as part of this issue — not as a follow-up. - Change `PersonMentionEditor.svelte` lines 123–131 to capture the typed query, not `person.displayName`. - Write unit tests for `PersonMentionEditor`: (a) selected person stores typed text as `displayName`, not DB name; (b) `mentionedPersons` sidecar contains the typed text; (c) an explicit regression test: when a user types `@Clara` and selects "Clara Cram", the rendered text is `@Clara`, not `@Clara Cram`. ### Open Decisions - **AC-4 atomicity level**: Define the exact UX before implementing. Options: (A) Full atomic — `beforeinput` intercept prevents any cursor entry; (B) Delete-triggers-full-removal — backspace from immediately after the mention removes the whole token + sidecar entry; (C) Soft — no enforcement, sidecar becomes inconsistent if user edits the mention manually. Option B is the lowest-effort implementation that satisfies the spec's intent without requiring a contenteditable rewrite.
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

The semantic contract of PersonMention.displayName changes fundamentally. Currently it stores a denormalized copy of Person.displayName — a derived value, kept in sync by PersonMentionPropagationListener. Under this issue it becomes archival data: the word the transcriber originally typed. These are different concerns. The current field name displayName is now misleading — it implies "how this person displays their name" rather than "what the scribe wrote."

Checking the model:

@Embeddable
public class PersonMention {
    private UUID personId;
    @Size(max = 200)
    private String displayName;  // ← semantics change: was denormalized name, now archival typed text
}

No schema change is needed (same column, same constraints — 200 chars is ample). But renaming this field to transcribedText or authorText in the backend model and the TypeScript PersonMention type would make the new contract explicit and prevent the next developer from treating it as a DB-name cache again. A rename is a migration (ALTER COLUMN) plus type regeneration — scope it as a follow-up unless the team prefers to do it atomically here.

PersonMentionPropagationListener violates the new design invariant and must be removed. The listener holds a TranscriptionBlockRepository reference and joins the person-rename transaction. Once removed, PersonDisplayNameChangedEvent may have no other consumers — check the event publisher in PersonService and remove the event dispatch if so.

No new architectural layer violations. The change is confined to: PersonMentionEditor.svelte (editor behavior), PersonMentionPropagationListener.java (deletion), and optionally a column rename. The rest of the stack (read rendering, API, storage) is unchanged.

Recommendations

  • Delete PersonMentionPropagationListener in this issue — keeping it with a conditional would add dead complexity.
  • Rename displayNametranscribedText (or authorText) in a follow-up issue to lock in the semantic boundary. Do not include the rename in this PR — it adds a migration and type regeneration without changing behavior.
  • Document the new invariant in PersonMention.java as a one-line comment: // Archival: the text the transcriber typed after @. Never updated on person rename.
## 🏛️ Markus Keller — Application Architect ### Observations **The semantic contract of `PersonMention.displayName` changes fundamentally.** Currently it stores a denormalized copy of `Person.displayName` — a derived value, kept in sync by `PersonMentionPropagationListener`. Under this issue it becomes archival data: the word the transcriber originally typed. These are different concerns. The current field name `displayName` is now misleading — it implies "how this person displays their name" rather than "what the scribe wrote." Checking the model: ```java @Embeddable public class PersonMention { private UUID personId; @Size(max = 200) private String displayName; // ← semantics change: was denormalized name, now archival typed text } ``` No schema change is needed (same column, same constraints — 200 chars is ample). But renaming this field to `transcribedText` or `authorText` in the backend model and the TypeScript `PersonMention` type would make the new contract explicit and prevent the next developer from treating it as a DB-name cache again. A rename is a migration (`ALTER COLUMN`) plus type regeneration — scope it as a follow-up unless the team prefers to do it atomically here. **`PersonMentionPropagationListener` violates the new design invariant and must be removed.** The listener holds a `TranscriptionBlockRepository` reference and joins the person-rename transaction. Once removed, `PersonDisplayNameChangedEvent` may have no other consumers — check the event publisher in `PersonService` and remove the event dispatch if so. **No new architectural layer violations.** The change is confined to: `PersonMentionEditor.svelte` (editor behavior), `PersonMentionPropagationListener.java` (deletion), and optionally a column rename. The rest of the stack (read rendering, API, storage) is unchanged. ### Recommendations - Delete `PersonMentionPropagationListener` in this issue — keeping it with a conditional would add dead complexity. - Rename `displayName` → `transcribedText` (or `authorText`) in a follow-up issue to lock in the semantic boundary. Do not include the rename in this PR — it adds a migration and type regeneration without changing behavior. - Document the new invariant in `PersonMention.java` as a one-line comment: `// Archival: the text the transcriber typed after @. Never updated on person rename.`
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

No new attack surfaces introduced. The change substitutes the source of displayName in the sidecar: instead of coming from the DB (person.displayName), it comes from user-typed input. The security properties are unchanged because:

  1. XSS path: renderTranscriptionBody calls escapeHtml(mention.displayName) before injecting into the anchor tag (line 136 in mention.ts). This was already true for the DB name; it equally covers the typed text. The existing test 'escapes HTML in displayName to prevent stored XSS' in mention.spec.ts covers this path.

  2. personId validation: The isUuid() guard in renderTranscriptionBody already rejects non-UUID personId values before rendering anchors. The user cannot inject a personId via the typed text — personId comes from the API response (person.id), not from what the user types.

  3. Backend @Size(max = 200) constraint on display_name column still applies. The typed text is bounded by this. The detectPersonMention query text is limited by what can be typed before a newline or second @, which in practice is well under 200 chars.

One thing to verify: the PersonMentionEditor.svelte dropdown search input sets currentQuery from detectPersonMention(value, cursorPos). This returns the raw typed string including any special characters. Confirm that when the user types @<script>, the mentionedPersons sidecar entry's displayName carries <script> as a string — which is then HTML-escaped by renderTranscriptionBody before injection. The escaping happens at render time, not at store time, which is fine.

PersonMentionPropagationListener deletion removes a write path from the rename transaction — this is a security-neutral change. Fewer writes in a transaction is strictly better.

Recommendations

  • Add a unit test in mention.spec.ts confirming renderTranscriptionBody escapes a <script> tag in displayName — even though it already works, it explicitly documents the security invariant for the new "typed text as displayName" scenario.
  • No other security changes needed.
## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations **No new attack surfaces introduced.** The change substitutes the source of `displayName` in the sidecar: instead of coming from the DB (`person.displayName`), it comes from user-typed input. The security properties are unchanged because: 1. **XSS path**: `renderTranscriptionBody` calls `escapeHtml(mention.displayName)` before injecting into the anchor tag (line 136 in `mention.ts`). This was already true for the DB name; it equally covers the typed text. The existing test `'escapes HTML in displayName to prevent stored XSS'` in `mention.spec.ts` covers this path. 2. **`personId` validation**: The `isUuid()` guard in `renderTranscriptionBody` already rejects non-UUID `personId` values before rendering anchors. The user cannot inject a `personId` via the typed text — `personId` comes from the API response (`person.id`), not from what the user types. 3. **Backend `@Size(max = 200)` constraint** on `display_name` column still applies. The typed text is bounded by this. The `detectPersonMention` query text is limited by what can be typed before a newline or second `@`, which in practice is well under 200 chars. **One thing to verify**: the `PersonMentionEditor.svelte` dropdown search input sets `currentQuery` from `detectPersonMention(value, cursorPos)`. This returns the raw typed string including any special characters. Confirm that when the user types `@<script>`, the `mentionedPersons` sidecar entry's `displayName` carries `<script>` as a string — which is then HTML-escaped by `renderTranscriptionBody` before injection. The escaping happens at render time, not at store time, which is fine. **`PersonMentionPropagationListener` deletion removes a write path from the rename transaction** — this is a security-neutral change. Fewer writes in a transaction is strictly better. ### Recommendations - Add a unit test in `mention.spec.ts` confirming `renderTranscriptionBody` escapes a `<script>` tag in `displayName` — even though it already works, it explicitly documents the security invariant for the new "typed text as displayName" scenario. - No other security changes needed.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

AC-4 lacks a verifiable acceptance criterion. "The token is selected as a whole unit; individual characters cannot be edited" is a statement of mechanism, not a behavioral assertion. A test cannot assert "individual characters cannot be edited" — it can only assert an observable outcome (e.g., pressing Backspace removes the whole mention). The AC needs a Given-When-Then before implementation:

Given a transcriber has locked a mention "@Clara" (personId = uuid-x) in the editor
When they place the cursor anywhere inside "@Clara" and press Backspace once
Then the entire "@Clara" text is deleted AND the sidecar entry for uuid-x is removed from mentionedPersons

Without this, implementation can ship "soft atomicity" (cursor can enter the mention but the sidecar becomes stale) and still claim AC-4 passes.

AC-5 (unresolved @Fritz stays) is already correct behaviorrenderTranscriptionBody only replaces @DisplayName when there's a sidecar entry. No sidecar entry for @Fritz → it stays as plain text including the @. This should get a regression test in mention.spec.ts since it's now a named invariant.

Backward compat (AC-6) needs an explicit regression test. The spec says old-format blocks (@[Clara Cram](uuid) — actually in this codebase, old format is @Clara Cram in text + {personId, displayName: "Clara Cram"} in sidecar) continue to render correctly. This works automatically because renderTranscriptionBody matches whatever is in displayName against the text. But a unit test confirming this would prevent a future refactor from breaking AC-6 silently.

PersonMentionPropagationListener deletion needs tests cleaned up. There should be tests for the listener in PersonMentionPropagationListenerTest.java — these should be deleted along with the listener, not left in a dead state.

The @Fritz → plain text path in read mode: confirm that @Fritz in the rendered HTML doesn't get a stray anchor around it. Currently renderTranscriptionBody returns the escaped text with @Fritz intact (no anchor). A test in TranscriptionReadView.svelte.test.ts would confirm the rendered DOM has no <a> tag.

Recommendations

  • Rewrite AC-4 into a concrete Given-When-Then before implementation (see Open Decisions on which atomicity level to target).
  • Add regression tests for: AC-5 (unresolved mention stays as plain @ text), AC-6 (old-format full-name sidecar still renders link correctly), and XSS in typed text (already exists — verify it still covers the new flow).
  • Delete PersonMentionPropagationListenerTest.java alongside the listener.
## 🧪 Sara Holt — QA Engineer ### Observations **AC-4 lacks a verifiable acceptance criterion.** "The token is selected as a whole unit; individual characters cannot be edited" is a statement of mechanism, not a behavioral assertion. A test cannot assert "individual characters cannot be edited" — it can only assert an observable outcome (e.g., pressing Backspace removes the whole mention). The AC needs a Given-When-Then before implementation: ``` Given a transcriber has locked a mention "@Clara" (personId = uuid-x) in the editor When they place the cursor anywhere inside "@Clara" and press Backspace once Then the entire "@Clara" text is deleted AND the sidecar entry for uuid-x is removed from mentionedPersons ``` Without this, implementation can ship "soft atomicity" (cursor can enter the mention but the sidecar becomes stale) and still claim AC-4 passes. **AC-5 (unresolved `@Fritz` stays) is already correct behavior** — `renderTranscriptionBody` only replaces `@DisplayName` when there's a sidecar entry. No sidecar entry for `@Fritz` → it stays as plain text including the `@`. This should get a regression test in `mention.spec.ts` since it's now a named invariant. **Backward compat (AC-6) needs an explicit regression test.** The spec says old-format blocks (`@[Clara Cram](uuid)` — actually in this codebase, old format is `@Clara Cram` in text + `{personId, displayName: "Clara Cram"}` in sidecar) continue to render correctly. This works automatically because `renderTranscriptionBody` matches whatever is in `displayName` against the text. But a unit test confirming this would prevent a future refactor from breaking AC-6 silently. **`PersonMentionPropagationListener` deletion needs tests cleaned up.** There should be tests for the listener in `PersonMentionPropagationListenerTest.java` — these should be deleted along with the listener, not left in a dead state. **The `@Fritz` → plain text path in read mode:** confirm that `@Fritz` in the rendered HTML doesn't get a stray anchor around it. Currently `renderTranscriptionBody` returns the escaped text with `@Fritz` intact (no anchor). A test in `TranscriptionReadView.svelte.test.ts` would confirm the rendered DOM has no `<a>` tag. ### Recommendations - Rewrite AC-4 into a concrete Given-When-Then before implementation (see Open Decisions on which atomicity level to target). - Add regression tests for: AC-5 (unresolved mention stays as plain `@` text), AC-6 (old-format full-name sidecar still renders link correctly), and XSS in typed text (already exists — verify it still covers the new flow). - Delete `PersonMentionPropagationListenerTest.java` alongside the listener.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

AC-4 (atomic token) vs current textarea UX — The editor is a <textarea>. There's no visual distinction between mention tokens and plain text — @Clara after locking looks identical to manually typed @Clara. This creates two UX gaps:

  1. The user cannot visually tell which @Clara instances are locked mentions vs plain text.
  2. The "cannot be edited in-place" rule has no visual affordance — the user has no cue that the token behaves differently.

At minimum, the locked mention should have a visual indicator in the edit view (e.g., subtle background highlight via a custom input or overlay). If the editor stays as <textarea>, this is technically difficult. A lightweight approach: show a chip-style list of locked mentions below the textarea (similar to the current mentionedPersons display in the UI) so users know which mentions are resolved.

AC-5 (unresolved @Fritz) — @ symbol must be preserved. renderTranscriptionBody currently returns the escaped text with @Fritz intact when no sidecar match is found. The @ is a meaningful archival signal ("person reference was intended"). Confirm the CSS for .person-mention (underline, mint accent) does NOT apply to unresolved @Fritz text. It shouldn't — unresolved text is plain, no anchor, no class.

Hover card geb. is hardcoded German — In PersonHoverCard.svelte (PR #371), the maiden name line renders geb. {state.person.alias}. The geb. prefix is German ("geborene"). This should be a Paraglide key ({m.person_mention_born_name_prefix()}) so the EN and ES interfaces don't show German text. Raise this now before it's deployed.

Touch behavior (AC per PR #371) — On touch devices, the CSS @media (hover: none) { .person-hover-card { display: none; } } ensures tap navigates directly. Good call. No concern here.

The hint text .hint in the hover card footer — The spec shows {m.person_mention_hover_hint()} which should explain how to navigate (e.g., "Klicken zum Öffnen"). Confirm this is a real Paraglide key and not a placeholder before shipping.

Recommendations

  • Add a Paraglide key for geb. prefix in PersonHoverCard.svelte before or alongside this issue.
  • Decide on a visual treatment for locked mention tokens in the editor (chips below textarea is the quick win; rich text editor is the long-term answer).
  • If no visual locked-mention indicator is added in this issue, explicitly mark AC-4's "cannot be edited in-place" as deferred — don't ship a rule with no visible affordance.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations **AC-4 (atomic token) vs current textarea UX** — The editor is a `<textarea>`. There's no visual distinction between mention tokens and plain text — `@Clara` after locking looks identical to manually typed `@Clara`. This creates two UX gaps: 1. The user cannot visually tell which `@Clara` instances are locked mentions vs plain text. 2. The "cannot be edited in-place" rule has no visual affordance — the user has no cue that the token behaves differently. At minimum, the locked mention should have a visual indicator in the edit view (e.g., subtle background highlight via a custom input or overlay). If the editor stays as `<textarea>`, this is technically difficult. A lightweight approach: show a chip-style list of locked mentions *below* the textarea (similar to the current `mentionedPersons` display in the UI) so users know which mentions are resolved. **AC-5 (unresolved `@Fritz`) — `@` symbol must be preserved.** `renderTranscriptionBody` currently returns the escaped text with `@Fritz` intact when no sidecar match is found. The `@` is a meaningful archival signal ("person reference was intended"). Confirm the CSS for `.person-mention` (underline, mint accent) does NOT apply to unresolved `@Fritz` text. It shouldn't — unresolved text is plain, no anchor, no class. **Hover card `geb.` is hardcoded German** — In `PersonHoverCard.svelte` (PR #371), the maiden name line renders `geb. {state.person.alias}`. The `geb.` prefix is German ("geborene"). This should be a Paraglide key (`{m.person_mention_born_name_prefix()}`) so the EN and ES interfaces don't show German text. Raise this now before it's deployed. **Touch behavior (AC per PR #371)** — On touch devices, the CSS `@media (hover: none) { .person-hover-card { display: none; } }` ensures tap navigates directly. Good call. No concern here. **The hint text `.hint` in the hover card footer** — The spec shows `{m.person_mention_hover_hint()}` which should explain how to navigate (e.g., "Klicken zum Öffnen"). Confirm this is a real Paraglide key and not a placeholder before shipping. ### Recommendations - Add a Paraglide key for `geb.` prefix in `PersonHoverCard.svelte` before or alongside this issue. - Decide on a visual treatment for locked mention tokens in the editor (chips below textarea is the quick win; rich text editor is the long-term answer). - If no visual locked-mention indicator is added in this issue, explicitly mark AC-4's "cannot be edited in-place" as deferred — don't ship a rule with no visible affordance.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

PersonMentionPropagationListener deletion simplifies the rename transaction. The listener joins the person-rename transaction synchronously — one less DB write per block per rename. Clean removal. No infra change needed.

No schema migration. display_name column stays as-is. No Flyway file required. ✓

Rolling deployment is safe. Old code writes displayName: person.displayName (full DB name). New code writes displayName: typedText. Both formats render correctly in renderTranscriptionBody because both match the @text pattern in the block text. Old instances running old code, new instances running new code — both produce consistent read-mode rendering.

Nothing to flag from my side. No new services, no new infrastructure, no config changes.

Recommendations

  • None. This is a pure application logic change with no platform implications.
## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations **`PersonMentionPropagationListener` deletion simplifies the rename transaction.** The listener joins the person-rename transaction synchronously — one less DB write per block per rename. Clean removal. No infra change needed. **No schema migration.** `display_name` column stays as-is. No Flyway file required. ✓ **Rolling deployment is safe.** Old code writes `displayName: person.displayName` (full DB name). New code writes `displayName: typedText`. Both formats render correctly in `renderTranscriptionBody` because both match the `@text` pattern in the block text. Old instances running old code, new instances running new code — both produce consistent read-mode rendering. **Nothing to flag from my side.** No new services, no new infrastructure, no config changes. ### Recommendations - None. This is a pure application logic change with no platform implications.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

AC-3 ("dropdown pre-fills with typed text") is already implemented and does not need to be built. detectPersonMention returns the current query, which is passed to the search input. The spec should note this as "no change required" to prevent the implementer from re-engineering a working system.

AC-4 is the scope risk. "The token can only be removed by deleting it wholesale — it cannot be edited in-place" implies constraint enforcement in a <textarea>-based editor. Three options exist:

Option What it means Effort
A: Full atomic beforeinput intercept blocks cursor entry; any edit attempt inside a mention span is suppressed Medium — textarea beforeinput + cursor tracking
B: Delete-triggers-removal Backspace from cursor-immediately-after-mention removes whole token from text AND sidecar Low — one keydown handler checking if cursor is at end of a known mention
C: Advisory only No enforcement; sidecar can become inconsistent if user manually edits; render mode degrades gracefully (unmatched mention becomes plain text) Trivial

The spec as written implies Option A. Option B satisfies the practical goal with far less complexity. Option C is what ships today if this AC is never implemented.

Sidecar-text consistency invariant is implicit but not specified. If a user types @Clar (editing the mention) and then saves, the sidecar still has {personId, displayName: "Clara"} but the text has @Clar. renderTranscriptionBody won't find @Clar in the mention list and will render it as plain unlinked text. This is a graceful failure, but it's worth stating explicitly: "If the text and sidecar become inconsistent, read mode renders the @text as plain text with no link." This should be a named invariant in the spec, not an accidental behavior.

"No migration required" (AC-6) should be stated as a constraint, not just an acceptance criterion. The implementation must not introduce any code that backfills or transforms existing mentionedPersons data.

Recommendations

  • Convert AC-4 to a concrete Given-When-Then (see Sara's comment for the template).
  • Choose and commit to one atomicity level (A, B, or C) before implementation starts — this is the only genuine open decision. Option B is recommended: lowest effort, satisfies practical goal.
  • Add an explicit "invariant: text and sidecar may become inconsistent; read mode degrades to plain text" to the issue description or a new AC-7.
  • Mark AC-3 as "already implemented — no change required."
## 📋 Elicit — Requirements Engineer ### Observations **AC-3 ("dropdown pre-fills with typed text") is already implemented and does not need to be built.** `detectPersonMention` returns the current query, which is passed to the search input. The spec should note this as "no change required" to prevent the implementer from re-engineering a working system. **AC-4 is the scope risk.** "The token can only be removed by deleting it wholesale — it cannot be edited in-place" implies constraint enforcement in a `<textarea>`-based editor. Three options exist: | Option | What it means | Effort | |--------|---------------|--------| | **A: Full atomic** | `beforeinput` intercept blocks cursor entry; any edit attempt inside a mention span is suppressed | Medium — textarea `beforeinput` + cursor tracking | | **B: Delete-triggers-removal** | Backspace from cursor-immediately-after-mention removes whole token from text AND sidecar | Low — one keydown handler checking if cursor is at end of a known mention | | **C: Advisory only** | No enforcement; sidecar can become inconsistent if user manually edits; render mode degrades gracefully (unmatched mention becomes plain text) | Trivial | The spec as written implies Option A. Option B satisfies the practical goal with far less complexity. Option C is what ships today if this AC is never implemented. **Sidecar-text consistency invariant is implicit but not specified.** If a user types `@Clar` (editing the mention) and then saves, the sidecar still has `{personId, displayName: "Clara"}` but the text has `@Clar`. `renderTranscriptionBody` won't find `@Clar` in the mention list and will render it as plain unlinked text. This is a graceful failure, but it's worth stating explicitly: "If the text and sidecar become inconsistent, read mode renders the `@text` as plain text with no link." This should be a named invariant in the spec, not an accidental behavior. **"No migration required" (AC-6) should be stated as a constraint, not just an acceptance criterion.** The implementation must not introduce any code that backfills or transforms existing `mentionedPersons` data. ### Recommendations - Convert AC-4 to a concrete Given-When-Then (see Sara's comment for the template). - Choose and commit to one atomicity level (A, B, or C) before implementation starts — this is the only genuine open decision. Option B is recommended: lowest effort, satisfies practical goal. - Add an explicit "invariant: text and sidecar may become inconsistent; read mode degrades to plain text" to the issue description or a new AC-7. - Mark AC-3 as "already implemented — no change required."
Author
Owner

🗳️ Decision Queue — Action Required

1 decision needs your input before implementation starts.

Editor UX

  • AC-4 atomicity level for locked mention tokens — The editor is <textarea>-based. The spec says tokens "cannot be edited in-place" but the mechanism is undefined. Three options, pick one:

    Option Behavior Effort
    A: Full atomic beforeinput intercept prevents cursor from entering the mention span Medium
    B: Delete-triggers-removal (recommended) Backspace when cursor is immediately after @Clara deletes the whole token and removes it from mentionedPersons; cursor can still enter the span otherwise Low
    C: Advisory only / defer No enforcement this issue; sidecar silently becomes stale if user edits the mention text manually; render mode degrades to plain unlinked text Trivial — defer to a future issue

    Option B covers the core use case (accidental deletion of a mention should remove it cleanly), avoids a contenteditable rewrite, and is testable. (Raised by: Felix, Leonie, Elicit)

## 🗳️ Decision Queue — Action Required _1 decision needs your input before implementation starts._ ### Editor UX - **AC-4 atomicity level for locked mention tokens** — The editor is `<textarea>`-based. The spec says tokens "cannot be edited in-place" but the mechanism is undefined. Three options, pick one: | Option | Behavior | Effort | |--------|----------|--------| | **A: Full atomic** | `beforeinput` intercept prevents cursor from entering the mention span | Medium | | **B: Delete-triggers-removal** _(recommended)_ | Backspace when cursor is immediately after `@Clara` deletes the whole token and removes it from `mentionedPersons`; cursor can still enter the span otherwise | Low | | **C: Advisory only / defer** | No enforcement this issue; sidecar silently becomes stale if user edits the mention text manually; render mode degrades to plain unlinked text | Trivial — defer to a future issue | Option B covers the core use case (accidental deletion of a mention should remove it cleanly), avoids a contenteditable rewrite, and is testable. _(Raised by: Felix, Leonie, Elicit)_
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Discussion summary: resolved the open contenteditable question and locked in the implementation approach for this issue.

Resolved

  • Visual distinction is a blocker — The target audience (non-technical transcribers, 60+) cannot work without a visual affordance distinguishing locked mention tokens from plain text. The current <textarea> cannot provide this. Option B from the decision queue (delete-triggers-removal only) is ruled out — it defers the visual problem without a committed path back to it.

  • contenteditable threshold crossed — The visual distinction requirement is the criterion that tips the scales toward a rich-text editor. We've crossed it. The textarea approach is off the table for this feature.

  • Tiptap v3.22.5 chosen — First-class atomic inline node support, mention extension pattern, TypeScript, and Svelte integration. The existing sidecar shape (mentionedPersons: { personId, displayName }) and serialization format (@[text](uuid)) stay unchanged — Tiptap mention node attributes map directly onto them.

  • Tiptap migration absorbed into this issue — The displayName source fix (typed text instead of DB name) ships as part of the Tiptap editor rewrite, not as a standalone textarea patch. Shipping the behavioral fix without the visual treatment would leave transcribers in a broken UX — the two changes are coupled.

Implementation shape (for the implementer)

PersonMentionEditor.svelte becomes a Tiptap editor with a custom mention extension. The mention node carries { displayName: typedText, personId } as node attributes — displayName is set from currentQuery at selection time, not from person.displayName. The mentionedPersons sidecar is derived from the document's mention nodes. AC-4 atomicity is handled by the Tiptap node model for free — atomic nodes cannot be partially edited.

Overall read

The previous decision queue correctly identified AC-4 as the scope risk. This discussion confirms that the right resolution is a Tiptap migration, not a beforeinput workaround. The migration is self-contained to PersonMentionEditor.svelte; the rest of the stack (serialization, rendering, backend) is unaffected.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer Discussion summary: resolved the open `contenteditable` question and locked in the implementation approach for this issue. ### Resolved - **Visual distinction is a blocker** — The target audience (non-technical transcribers, 60+) cannot work without a visual affordance distinguishing locked mention tokens from plain text. The current `<textarea>` cannot provide this. Option B from the decision queue (delete-triggers-removal only) is ruled out — it defers the visual problem without a committed path back to it. - **`contenteditable` threshold crossed** — The visual distinction requirement is the criterion that tips the scales toward a rich-text editor. We've crossed it. The textarea approach is off the table for this feature. - **Tiptap v3.22.5 chosen** — First-class atomic inline node support, mention extension pattern, TypeScript, and Svelte integration. The existing sidecar shape (`mentionedPersons: { personId, displayName }`) and serialization format (`@[text](uuid)`) stay unchanged — Tiptap mention node attributes map directly onto them. - **Tiptap migration absorbed into this issue** — The `displayName` source fix (typed text instead of DB name) ships as part of the Tiptap editor rewrite, not as a standalone textarea patch. Shipping the behavioral fix without the visual treatment would leave transcribers in a broken UX — the two changes are coupled. ### Implementation shape (for the implementer) `PersonMentionEditor.svelte` becomes a Tiptap editor with a custom mention extension. The mention node carries `{ displayName: typedText, personId }` as node attributes — `displayName` is set from `currentQuery` at selection time, not from `person.displayName`. The `mentionedPersons` sidecar is derived from the document's mention nodes. AC-4 atomicity is handled by the Tiptap node model for free — atomic nodes cannot be partially edited. ### Overall read The previous decision queue correctly identified AC-4 as the scope risk. This discussion confirms that the right resolution is a Tiptap migration, not a `beforeinput` workaround. The migration is self-contained to `PersonMentionEditor.svelte`; the rest of the stack (serialization, rendering, backend) is unaffected.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Follow-up discussion: Tiptap integration design decisions.

Resolved

  • Storage format — keep @[text](uuid) + sidecar, write a serializer — No backend changes, renderTranscriptionBody stays untouched, AC-6 stays clean. Two pure functions: deserialize(text, mentionedPersons) → Tiptap JSON on load (converts sidecar entries to mention nodes), serialize(doc) → { text, mentionedPersons } on save (walks the Tiptap doc tree to extract mention nodes). No Tiptap JSON stored in the database.

  • Dropdown — new MentionDropdown.svelte, PersonTypeahead untouchedPersonTypeahead is an autonomous form widget (owns its input, open/close state, anchor-relative positioning, on:change callback). Tiptap's suggestion system is externally driven: Tiptap owns the query, open/close lifecycle, keyboard events, and expects command(item) on selection with cursor-relative clientRect positioning. Adapting PersonTypeahead to this contract means overriding every aspect of its design. A new MentionDropdown.svelte is built to the contract directly: floating list, arrow/Enter/Escape keyboard handling via an exposed onKeyDown, same person search endpoint. Visual consistency via shared Tailwind tokens.

Implementation shape

Editor mount (Svelte 5):

<script lang="ts">
  import { onMount } from 'svelte';
  import { Editor } from '@tiptap/core';
  import StarterKit from '@tiptap/starter-kit';
  import Mention from '@tiptap/extension-mention';

  let editorEl: HTMLDivElement;
  let editor: Editor;

  onMount(() => {
    editor = new Editor({
      element: editorEl,
      extensions: [StarterKit, Mention.configure({ suggestion })],
      content: deserialize(initialContent, mentionedPersons),
    });
    return () => editor.destroy();
  });
</script>
<div bind:this={editorEl} />

Mention node insertion (on person select):

editor.chain().focus()
  .insertContentAt(range, [{
    type: 'mention',
    attrs: { displayName: props.query, personId: props.person.id }
  }])
  .run()

displayName is set from the typed query, not person.displayName — this is the AC-1 fix.

Dropdown imperative mount (Svelte 5):

import { mount, unmount } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte';

render() {
  let component;
  return {
    onStart(props) { component = mount(MentionDropdown, { target: document.body, props }) },
    onUpdate(props) { /* update props */ },
    onKeyDown({ event }) { return component.onKeyDown(event) },
    onExit() { unmount(component) },
  };
}
## 👨‍💻 Felix Brandt — Senior Fullstack Developer Follow-up discussion: Tiptap integration design decisions. ### Resolved - **Storage format — keep `@[text](uuid)` + sidecar, write a serializer** — No backend changes, `renderTranscriptionBody` stays untouched, AC-6 stays clean. Two pure functions: `deserialize(text, mentionedPersons) → Tiptap JSON` on load (converts sidecar entries to mention nodes), `serialize(doc) → { text, mentionedPersons }` on save (walks the Tiptap doc tree to extract mention nodes). No Tiptap JSON stored in the database. - **Dropdown — new `MentionDropdown.svelte`, `PersonTypeahead` untouched** — `PersonTypeahead` is an autonomous form widget (owns its input, open/close state, anchor-relative positioning, `on:change` callback). Tiptap's suggestion system is externally driven: Tiptap owns the query, open/close lifecycle, keyboard events, and expects `command(item)` on selection with cursor-relative `clientRect` positioning. Adapting `PersonTypeahead` to this contract means overriding every aspect of its design. A new `MentionDropdown.svelte` is built to the contract directly: floating list, arrow/Enter/Escape keyboard handling via an exposed `onKeyDown`, same person search endpoint. Visual consistency via shared Tailwind tokens. ### Implementation shape **Editor mount (Svelte 5):** ```svelte <script lang="ts"> import { onMount } from 'svelte'; import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Mention from '@tiptap/extension-mention'; let editorEl: HTMLDivElement; let editor: Editor; onMount(() => { editor = new Editor({ element: editorEl, extensions: [StarterKit, Mention.configure({ suggestion })], content: deserialize(initialContent, mentionedPersons), }); return () => editor.destroy(); }); </script> <div bind:this={editorEl} /> ``` **Mention node insertion (on person select):** ```ts editor.chain().focus() .insertContentAt(range, [{ type: 'mention', attrs: { displayName: props.query, personId: props.person.id } }]) .run() ``` `displayName` is set from the typed query, not `person.displayName` — this is the AC-1 fix. **Dropdown imperative mount (Svelte 5):** ```ts import { mount, unmount } from 'svelte'; import MentionDropdown from './MentionDropdown.svelte'; render() { let component; return { onStart(props) { component = mount(MentionDropdown, { target: document.body, props }) }, onUpdate(props) { /* update props */ }, onKeyDown({ event }) { return component.onKeyDown(event) }, onExit() { unmount(component) }, }; } ```
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Design decisions resolved after the Tiptap migration discussion. All five items are agreed and in scope for this issue.


Resolved

1. Mention token visual design (edit mode)
Use underline decoration-brand-mint text-brand-navy font-medium on the Tiptap mention node's rendered <span>. Echoes the read-mode anchor style — no extra visual weight, consistent with existing brand patterns.

2. MentionDropdown.svelte visual spec

  • Follow the same visual pattern as PersonTypeahead's existing results list
  • Each item: primary line = full name (font-serif text-brand-navy), secondary line = * birthYear † deathYear using genealogical symbols (language-neutral, no Paraglide keys needed for the symbols)
  • Fallback: show only available years; show name only when neither year is known
  • Minimum 44px touch target height per list item

3. geb. hardcoded German in PersonHoverCard.svelte
Fix is in scope for this issue. Replace the hardcoded geb. prefix with {m.person_born_name_prefix()} and add translations:

  • de.json: "geb."
  • en.json: "née"
  • es.json: "n." (confirm Spanish convention)

4. Mobile dropdown positioning — flip strategy
In scope. The MentionDropdown must measure available space below the cursor rect and open upward when spaceBelow < 200px:

const spaceBelow = window.innerHeight - rect.bottom;
const openUpward = spaceBelow < 200;

Covers the primary transcription device (tablet) where the virtual keyboard reduces visible viewport height.

5. Tiptap editor container styling
The contenteditable div replaces a <textarea> and must be explicitly styled to match. Baseline:

<div
  bind:this={editorEl}
  class="w-full rounded-sm border border-brand-sand bg-white px-3 py-2
         text-base font-serif text-brand-navy
         focus-within:ring-2 focus-within:ring-brand-navy focus-within:ring-offset-1
         min-h-[120px]"
/>

Use focus-within (not focus) — the inner contenteditable receives focus, not the wrapper div.


Overall read

The Tiptap migration decision was the right call — the visual affordance requirement was always going to be a blocker for the 60+ transcriber audience. The five items above close the remaining design gaps before implementation starts. No unresolved items from my side.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist Design decisions resolved after the Tiptap migration discussion. All five items are agreed and in scope for this issue. --- ### Resolved **1. Mention token visual design (edit mode)** Use `underline decoration-brand-mint text-brand-navy font-medium` on the Tiptap mention node's rendered `<span>`. Echoes the read-mode anchor style — no extra visual weight, consistent with existing brand patterns. **2. `MentionDropdown.svelte` visual spec** - Follow the same visual pattern as `PersonTypeahead`'s existing results list - Each item: primary line = full name (`font-serif text-brand-navy`), secondary line = `* birthYear † deathYear` using genealogical symbols (language-neutral, no Paraglide keys needed for the symbols) - Fallback: show only available years; show name only when neither year is known - Minimum 44px touch target height per list item **3. `geb.` hardcoded German in `PersonHoverCard.svelte`** Fix is in scope for this issue. Replace the hardcoded `geb.` prefix with `{m.person_born_name_prefix()}` and add translations: - `de.json`: `"geb."` - `en.json`: `"née"` - `es.json`: `"n."` (confirm Spanish convention) **4. Mobile dropdown positioning — flip strategy** In scope. The `MentionDropdown` must measure available space below the cursor rect and open upward when `spaceBelow < 200px`: ```ts const spaceBelow = window.innerHeight - rect.bottom; const openUpward = spaceBelow < 200; ``` Covers the primary transcription device (tablet) where the virtual keyboard reduces visible viewport height. **5. Tiptap editor container styling** The `contenteditable` div replaces a `<textarea>` and must be explicitly styled to match. Baseline: ```svelte <div bind:this={editorEl} class="w-full rounded-sm border border-brand-sand bg-white px-3 py-2 text-base font-serif text-brand-navy focus-within:ring-2 focus-within:ring-brand-navy focus-within:ring-offset-1 min-h-[120px]" /> ``` Use `focus-within` (not `focus`) — the inner `contenteditable` receives focus, not the wrapper div. --- ### Overall read The Tiptap migration decision was the right call — the visual affordance requirement was always going to be a blocker for the 60+ transcriber audience. The five items above close the remaining design gaps before implementation starts. No unresolved items from my side.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Review of the locked-in Tiptap migration plan.

Observations

The entire existing test suite is dead code the moment migration lands.

Every test in PersonMentionEditor.svelte.spec.ts calls document.querySelector('textarea') via the getTextarea() helper. After the Tiptap migration that function returns null and every single test throws. This isn't a suite that needs updating — it's a full rewrite of all 12 tests. Plan this before touching PersonMentionEditor.svelte, not after.

captureTextarea is in active production use — not optional dead code.

TranscriptionBlock.svelte:86-91 defines captureTextarea() and passes it to PersonMentionEditor at line 194. handleTextareaMouseUp (line 105) reads textareaEl.selectionStart / textareaEl.selectionEnd to implement quote selection — the user highlights text and the block captures it as selectedQuote. This feature will silently break if the prop is just removed.

The correct replacement: remove captureTextarea + handleTextareaMouseUp from TranscriptionBlock.svelte and instead register a selectionUpdate handler on the Tiptap editor:

editor.on('selectionUpdate', ({ editor }) => {
  const { from, to } = editor.state.selection;
  selectedQuote = from !== to ? editor.state.doc.textBetween(from, to) : null;
});

This should be done in the same PR as the editor rewrite — not a follow-up. Shipping the Tiptap editor without quote selection is a regression.

onUpdate in the suggestion render() needs a concrete Svelte 5 implementation.

The discussion shows onStart mounting the component but onUpdate as a comment stub. With Svelte 5's mount, you can't update props imperatively after mounting without either: (a) exposing a $bindable or $state export from MentionDropdown.svelte that onUpdate writes to, or (b) calling unmount + mount each time (wasteful). The canonical approach for Svelte 5:

// MentionDropdown.svelte exposes:
export let items = $state([]);
export let command = $state<(item) => void>(() => {});

// render() in suggestion config:
let component: ReturnType<typeof mount> | null = null;
render() {
  return {
    onStart(props) {
      component = mount(MentionDropdown, { target: document.body, props: { items: props.items, command: props.command, ... } });
    },
    onUpdate(props) {
      // Update the reactive state directly on the mounted instance
      component!.items = props.items;
      component!.command = props.command;
    },
    onKeyDown({ event }) { return component?.onKeyDown(event) ?? false; },
    onExit() { if (component) { unmount(component); component = null; } },
  };
}

This needs to be spelled out in the implementation shape — "update props" is ambiguous in Svelte 5.

resizeTextarea() and the $effect(() => { void value; resizeTextarea(); }) pattern disappears. Tiptap's contenteditable grows with content via CSS — no JS height logic needed. The old implementation was a workaround for <textarea> not auto-growing.

Recommendations

  • Write serialize/deserialize as pure functions in a new src/lib/utils/mentionSerializer.ts. TDD them first with Vitest node tests before any editor code — they're the most testable piece and have no DOM dependencies.
  • Plan the rewritten test suite before implementation: target page.getByRole('textbox') for the Tiptap contenteditable (once ARIA attributes are set — see Leonie's comment), userEvent.type() for text input, and page.getByRole('option') for dropdown items.
  • Include the TranscriptionBlock.svelte quote-selection migration in this PR scope.

Open Decisions

  • captureTextarea migration scope: The prop is live in TranscriptionBlock. Options: (A) Replace captureTextarea with a selectionUpdate approach in this PR (keeps the feature); (B) File a follow-up and ship with broken quote selection temporarily. Option A is the right call — the scope is a 10-line change in TranscriptionBlock.svelte.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer _Review of the locked-in Tiptap migration plan._ ### Observations **The entire existing test suite is dead code the moment migration lands.** Every test in `PersonMentionEditor.svelte.spec.ts` calls `document.querySelector('textarea')` via the `getTextarea()` helper. After the Tiptap migration that function returns `null` and every single test throws. This isn't a suite that needs updating — it's a full rewrite of all 12 tests. Plan this before touching `PersonMentionEditor.svelte`, not after. **`captureTextarea` is in active production use — not optional dead code.** `TranscriptionBlock.svelte:86-91` defines `captureTextarea()` and passes it to `PersonMentionEditor` at line 194. `handleTextareaMouseUp` (line 105) reads `textareaEl.selectionStart` / `textareaEl.selectionEnd` to implement quote selection — the user highlights text and the block captures it as `selectedQuote`. This feature will silently break if the prop is just removed. The correct replacement: remove `captureTextarea` + `handleTextareaMouseUp` from `TranscriptionBlock.svelte` and instead register a `selectionUpdate` handler on the Tiptap editor: ```ts editor.on('selectionUpdate', ({ editor }) => { const { from, to } = editor.state.selection; selectedQuote = from !== to ? editor.state.doc.textBetween(from, to) : null; }); ``` This should be done in the same PR as the editor rewrite — not a follow-up. Shipping the Tiptap editor without quote selection is a regression. **`onUpdate` in the suggestion `render()` needs a concrete Svelte 5 implementation.** The discussion shows `onStart` mounting the component but `onUpdate` as a comment stub. With Svelte 5's `mount`, you can't update props imperatively after mounting without either: (a) exposing a `$bindable` or `$state` export from `MentionDropdown.svelte` that `onUpdate` writes to, or (b) calling `unmount` + `mount` each time (wasteful). The canonical approach for Svelte 5: ```ts // MentionDropdown.svelte exposes: export let items = $state([]); export let command = $state<(item) => void>(() => {}); // render() in suggestion config: let component: ReturnType<typeof mount> | null = null; render() { return { onStart(props) { component = mount(MentionDropdown, { target: document.body, props: { items: props.items, command: props.command, ... } }); }, onUpdate(props) { // Update the reactive state directly on the mounted instance component!.items = props.items; component!.command = props.command; }, onKeyDown({ event }) { return component?.onKeyDown(event) ?? false; }, onExit() { if (component) { unmount(component); component = null; } }, }; } ``` This needs to be spelled out in the implementation shape — "update props" is ambiguous in Svelte 5. **`resizeTextarea()` and the `$effect(() => { void value; resizeTextarea(); })` pattern disappears.** Tiptap's `contenteditable` grows with content via CSS — no JS height logic needed. The old implementation was a workaround for `<textarea>` not auto-growing. ### Recommendations - Write `serialize`/`deserialize` as pure functions in a new `src/lib/utils/mentionSerializer.ts`. TDD them first with Vitest node tests before any editor code — they're the most testable piece and have no DOM dependencies. - Plan the rewritten test suite before implementation: target `page.getByRole('textbox')` for the Tiptap contenteditable (once ARIA attributes are set — see Leonie's comment), `userEvent.type()` for text input, and `page.getByRole('option')` for dropdown items. - Include the `TranscriptionBlock.svelte` quote-selection migration in this PR scope. ### Open Decisions - **`captureTextarea` migration scope**: The prop is live in `TranscriptionBlock`. Options: (A) Replace `captureTextarea` with a `selectionUpdate` approach in this PR (keeps the feature); (B) File a follow-up and ship with broken quote selection temporarily. Option A is the right call — the scope is a 10-line change in `TranscriptionBlock.svelte`.
Author
Owner

🏛️ Markus Keller — Application Architect

Review of the Tiptap migration plan.

Observations

Bundle size: ProseMirror is a significant dependency addition.

@tiptap/core pulls in the full ProseMirror stack: prosemirror-model, prosemirror-state, prosemirror-view, prosemirror-transform, and more. Combined with @tiptap/starter-kit and @tiptap/extension-mention, this adds approximately 200–350 KB gzipped to the JS payload. The transcription panel is mounted on every document detail page — a route used by transcribers on 60+ tablets. Measure the before/after chunk size with npm run build and report it in the PR description.

If the increase is material (>100KB on the critical path), the right response is dynamic import at the call site in TranscriptionBlock.svelte:

{#await import('$lib/components/PersonMentionEditor.svelte') then { default: Editor }}
  <svelte:component this={Editor} bind:value bind:mentionedPersons ... />
{/await}

This code-splits the Tiptap bundle into its own lazy chunk. The transcription panel only loads it when the block is in edit mode, not on first paint.

SSR compatibility: verify the usage path is safe.

Tiptap calls document.createElement during instantiation — it cannot run during SvelteKit SSR. The onMount guard in the proposed code is correct. But PersonMentionEditor is used inside TranscriptionBlock.svelte, which is used inside the transcription panel. Verify that the route rendering the panel either has export const ssr = false or that TranscriptionBlock is only rendered conditionally on the client. A server-side render hitting Tiptap's new Editor(...) will throw at runtime.

mentionSerializer.ts is a new module boundary — give it an explicit contract.

The discussion resolves to "write serialize/deserialize functions" but the contract is not documented in the issue. Before implementation, the module's interface should be defined:

// Input: plain text with @[name](uuid) tokens + sidecar array
// Output: Tiptap ProseMirror JSON document
deserialize(text: string, sidecar: PersonMention[]): JSONContent

// Input: Tiptap ProseMirror JSON document
// Output: { text: string, mentionedPersons: PersonMention[] }
serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] }

The round-trip invariant: serialize(deserialize(text, sidecar)).text === text for all valid inputs. This is independently testable without a browser.

@tiptap/* packages share ProseMirror peer dependencies and must version-lock together.

@tiptap/core, @tiptap/starter-kit, and @tiptap/extension-mention are all part of the same release train. A mismatch (e.g., core@2.x with starter-kit@3.x) causes silent runtime breakage. Renovate needs a package group rule before the PR lands, otherwise the next automated bump will update only one package.

Recommendations

  • Add Renovate grouping for @tiptap/* before or alongside the PR. If no renovate.json exists, create one with a minimal @tiptap group rule.
  • Run the build before and after and include chunk size delta in the PR description. If the delta exceeds 100KB gzip on the critical path, implement the dynamic import pattern.
  • Define the mentionSerializer.ts interface in the issue (input type, output type, round-trip invariant) before implementation starts.

No open decisions from my side — all items have clear answers.

## 🏛️ Markus Keller — Application Architect _Review of the Tiptap migration plan._ ### Observations **Bundle size: ProseMirror is a significant dependency addition.** `@tiptap/core` pulls in the full ProseMirror stack: `prosemirror-model`, `prosemirror-state`, `prosemirror-view`, `prosemirror-transform`, and more. Combined with `@tiptap/starter-kit` and `@tiptap/extension-mention`, this adds approximately 200–350 KB gzipped to the JS payload. The transcription panel is mounted on every document detail page — a route used by transcribers on 60+ tablets. Measure the before/after chunk size with `npm run build` and report it in the PR description. If the increase is material (>100KB on the critical path), the right response is dynamic import at the call site in `TranscriptionBlock.svelte`: ```svelte {#await import('$lib/components/PersonMentionEditor.svelte') then { default: Editor }} <svelte:component this={Editor} bind:value bind:mentionedPersons ... /> {/await} ``` This code-splits the Tiptap bundle into its own lazy chunk. The transcription panel only loads it when the block is in edit mode, not on first paint. **SSR compatibility: verify the usage path is safe.** Tiptap calls `document.createElement` during instantiation — it cannot run during SvelteKit SSR. The `onMount` guard in the proposed code is correct. But `PersonMentionEditor` is used inside `TranscriptionBlock.svelte`, which is used inside the transcription panel. Verify that the route rendering the panel either has `export const ssr = false` or that `TranscriptionBlock` is only rendered conditionally on the client. A server-side render hitting Tiptap's `new Editor(...)` will throw at runtime. **`mentionSerializer.ts` is a new module boundary — give it an explicit contract.** The discussion resolves to "write serialize/deserialize functions" but the contract is not documented in the issue. Before implementation, the module's interface should be defined: ```ts // Input: plain text with @[name](uuid) tokens + sidecar array // Output: Tiptap ProseMirror JSON document deserialize(text: string, sidecar: PersonMention[]): JSONContent // Input: Tiptap ProseMirror JSON document // Output: { text: string, mentionedPersons: PersonMention[] } serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } ``` The round-trip invariant: `serialize(deserialize(text, sidecar)).text === text` for all valid inputs. This is independently testable without a browser. **`@tiptap/*` packages share ProseMirror peer dependencies and must version-lock together.** `@tiptap/core`, `@tiptap/starter-kit`, and `@tiptap/extension-mention` are all part of the same release train. A mismatch (e.g., `core@2.x` with `starter-kit@3.x`) causes silent runtime breakage. Renovate needs a package group rule before the PR lands, otherwise the next automated bump will update only one package. ### Recommendations - Add Renovate grouping for `@tiptap/*` before or alongside the PR. If no `renovate.json` exists, create one with a minimal `@tiptap` group rule. - Run the build before and after and include chunk size delta in the PR description. If the delta exceeds 100KB gzip on the critical path, implement the dynamic import pattern. - Define the `mentionSerializer.ts` interface in the issue (input type, output type, round-trip invariant) before implementation starts. No open decisions from my side — all items have clear answers.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Review of the Tiptap migration plan.

Observations

Tiptap's mention node rendering pipeline is XSS-safe by construction — but verify the toDOM spec.

ProseMirror's NodeSpec.toDOM() creates DOM elements programmatically (document.createElement), not via innerHTML. The mention node tuple ['span', { class: '...', 'data-person-id': attrs.personId }, 0] produces a real DOM node, with text content inserted as a text node by ProseMirror's renderer. This is safe as long as attrs.displayName is inserted as node content (the 0 slot in the toDOM tuple), not as a raw HTML attribute that could contain <script> payloads. The 0 slot is always escaped by ProseMirror. Confirm the NodeSpec implementation follows this pattern rather than using innerHTML or a custom render function.

MentionDropdown mounted to document.body — verify cleanup is guaranteed on all exit paths.

The Svelte 5 mount(MentionDropdown, { target: document.body, props }) appends the dropdown to document.body. If onExit is not called — e.g., when the Tiptap editor is destroyed mid-suggestion (user navigates away, block is deleted) — the dropdown leaks into the DOM.

Tiptap's suggestion plugin calls onExit when the plugin state transitions out of an active suggestion AND when the plugin is destroyed. However, if editor.destroy() is called while a suggestion is in progress, verify that the suggestion plugin teardown reliably fires onExit. Add a guard:

onExit() {
  if (component) {
    unmount(component);
    component = null;
  }
}

The null check prevents a double-unmount if onExit is called more than once.

CSP: ProseMirror applies inline styles — check the header config.

ProseMirror sets inline style attributes on the contenteditable element for cursor positioning (e.g., style="white-space: break-spaces"). If the project's Caddy config or Spring Security applies a Content-Security-Policy with style-src 'self' (no 'unsafe-inline'), ProseMirror will fail silently to style the editor correctly in production. This is a deployment concern to check before going live.

The fetch('/api/persons?q=...') client-side call moves to MentionDropdown.svelte — the security comment from the current PersonMentionEditor.svelte (lines 100–104) documents why this client-side fetch is safe. That comment must move to MentionDropdown.svelte. Don't lose the rationale in the migration.

No new XSS vectors from the displayName change — the existing escapeHtml(mention.displayName) in mention.ts (renderTranscriptionBody) continues to cover stored XSS. For the Tiptap editor specifically, attrs.displayName is inserted as a text node (safe). The security invariant is unchanged.

Recommendations

  • Explicitly define the mention NodeSpec.toDOM() in the implementation shape: use the ['span', attrs, 0] tuple pattern where 0 is the content slot — do NOT use innerHTML in any custom render logic.
  • Add the if (component) null guard to onExit in the suggestion render() function.
  • Check the Caddy CSP headers for style-src before shipping to production — add 'unsafe-inline' to style-src if needed, or confirm ProseMirror doesn't trigger violations in the actual production header config.
  • Move the fetch security comment to MentionDropdown.svelte.

No open decisions from my side.

## 🔒 Nora "NullX" Steiner — Security Engineer _Review of the Tiptap migration plan._ ### Observations **Tiptap's mention node rendering pipeline is XSS-safe by construction — but verify the toDOM spec.** ProseMirror's `NodeSpec.toDOM()` creates DOM elements programmatically (`document.createElement`), not via `innerHTML`. The mention node tuple `['span', { class: '...', 'data-person-id': attrs.personId }, 0]` produces a real DOM node, with text content inserted as a text node by ProseMirror's renderer. This is safe as long as `attrs.displayName` is inserted as node content (the `0` slot in the toDOM tuple), not as a raw HTML attribute that could contain `<script>` payloads. The `0` slot is always escaped by ProseMirror. **Confirm the `NodeSpec` implementation follows this pattern rather than using `innerHTML` or a custom render function.** **`MentionDropdown` mounted to `document.body` — verify cleanup is guaranteed on all exit paths.** The Svelte 5 `mount(MentionDropdown, { target: document.body, props })` appends the dropdown to `document.body`. If `onExit` is not called — e.g., when the Tiptap editor is destroyed mid-suggestion (user navigates away, block is deleted) — the dropdown leaks into the DOM. Tiptap's suggestion plugin calls `onExit` when the plugin state transitions out of an active suggestion AND when the plugin is destroyed. However, if `editor.destroy()` is called while a suggestion is in progress, verify that the suggestion plugin teardown reliably fires `onExit`. Add a guard: ```ts onExit() { if (component) { unmount(component); component = null; } } ``` The `null` check prevents a double-unmount if `onExit` is called more than once. **CSP: ProseMirror applies inline styles — check the header config.** ProseMirror sets inline `style` attributes on the `contenteditable` element for cursor positioning (e.g., `style="white-space: break-spaces"`). If the project's Caddy config or Spring Security applies a `Content-Security-Policy` with `style-src 'self'` (no `'unsafe-inline'`), ProseMirror will fail silently to style the editor correctly in production. This is a deployment concern to check before going live. **The `fetch('/api/persons?q=...')` client-side call moves to `MentionDropdown.svelte`** — the security comment from the current `PersonMentionEditor.svelte` (lines 100–104) documents why this client-side fetch is safe. That comment must move to `MentionDropdown.svelte`. Don't lose the rationale in the migration. **No new XSS vectors from the `displayName` change** — the existing `escapeHtml(mention.displayName)` in `mention.ts` (`renderTranscriptionBody`) continues to cover stored XSS. For the Tiptap editor specifically, `attrs.displayName` is inserted as a text node (safe). The security invariant is unchanged. ### Recommendations - Explicitly define the mention `NodeSpec.toDOM()` in the implementation shape: use the `['span', attrs, 0]` tuple pattern where `0` is the content slot — do NOT use `innerHTML` in any custom render logic. - Add the `if (component)` null guard to `onExit` in the suggestion `render()` function. - Check the Caddy CSP headers for `style-src` before shipping to production — add `'unsafe-inline'` to `style-src` if needed, or confirm ProseMirror doesn't trigger violations in the actual production header config. - Move the `fetch` security comment to `MentionDropdown.svelte`. No open decisions from my side.
Author
Owner

🧪 Sara Holt — QA Engineer

Review of the Tiptap migration plan.

Observations

All 12 existing component tests are broken by this migration — plan the rewrite before starting.

PersonMentionEditor.svelte.spec.ts is built entirely around document.querySelector('textarea') via getTextarea(). After migration that returns null. Beyond the helper, every test directly manipulates ta.value, ta.selectionStart, ta.selectionEnd, and dispatches new Event('input', ...) on the textarea element. None of this applies to a Tiptap contenteditable. The test file is a full rewrite, not a patch.

Good news: vitest-browser-svelte uses real Playwright/Chromium, so Tiptap's DOM APIs work correctly. The new tests should drive the editor via userEvent.type() (simulates real keystrokes including Tiptap's input event handling) rather than directly manipulating DOM state.

New test approach per spec section:

Old pattern New pattern
ta.value = '@Aug'; ta.dispatchEvent(new Event('input')) await userEvent.type(editor, '@Aug')
expect(host.snapshot.value).toBe('@Auguste Raddatz ') Inspect serialize(editor.getJSON()) or read the contenteditable text
getByRole('textbox').toHaveValue(...) getByRole('textbox').toHaveText(...) (contenteditable uses text, not value)
ta.selectionStart = 4 Not needed — userEvent.type positions cursor naturally

The serializer tests are missing from the issue scope entirely.

mentionSerializer.ts (or equivalent) will contain serialize and deserialize — pure functions with no DOM dependency. These should have their own Vitest node test file (mentionSerializer.spec.ts) covering:

  • deserialize('') → valid empty Tiptap document
  • deserialize('Hello @Clara world', [{ personId: 'uuid-x', displayName: 'Clara' }]) → doc with mention node at correct position
  • serialize(doc) round-trips the text and sidecar correctly
  • serialize(deserialize(oldFormatText, oldSidecar)) → backward compat for full-name sidecar entries

These run in <5ms and can be written before any editor code (TDD the serializer first).

PersonMentionPropagationListenerTest.java must be deleted in the same PR as the listener.

The test class is referenced in backend/src/test/.../PersonMentionPropagationListenerTest.java. Leaving dead test code is not acceptable — a passing test for a deleted listener gives false coverage signals.

The suggestion render() lifecycle is difficult to test at the component level — write at least one integration smoke test.

Define one integration test that proves the end-to-end flow works:

Given the editor is mounted with an existing transcription
When the user types @Clara and selects "Clara Cram" from the dropdown
Then the editor's serialized output contains { text: '...@Clara...', mentionedPersons: [{ personId: uuid, displayName: 'Clara' }] }

This test exercises onStart, onKeyDown, onExit, insertContentAt, and serialize together. It's the regression test for AC-1.

Recommendations

  • Write mentionSerializer.spec.ts at the Vitest node layer before any editor code — these tests define the serializer contract.
  • Define the new PersonMentionEditor.svelte.spec.ts test names before implementation (as a stub file with it.todo(...)) so the test scope is agreed before the PR opens.
  • Delete PersonMentionPropagationListenerTest.java and PersonServiceTest.java tests that set up PersonMentionPropagationListener (lines 308–338 in that file) in the same commit as the listener deletion.

No open decisions from my side.

## 🧪 Sara Holt — QA Engineer _Review of the Tiptap migration plan._ ### Observations **All 12 existing component tests are broken by this migration — plan the rewrite before starting.** `PersonMentionEditor.svelte.spec.ts` is built entirely around `document.querySelector('textarea')` via `getTextarea()`. After migration that returns `null`. Beyond the helper, every test directly manipulates `ta.value`, `ta.selectionStart`, `ta.selectionEnd`, and dispatches `new Event('input', ...)` on the textarea element. None of this applies to a Tiptap `contenteditable`. The test file is a full rewrite, not a patch. Good news: `vitest-browser-svelte` uses real Playwright/Chromium, so Tiptap's DOM APIs work correctly. The new tests should drive the editor via `userEvent.type()` (simulates real keystrokes including Tiptap's input event handling) rather than directly manipulating DOM state. New test approach per spec section: | Old pattern | New pattern | |---|---| | `ta.value = '@Aug'; ta.dispatchEvent(new Event('input'))` | `await userEvent.type(editor, '@Aug')` | | `expect(host.snapshot.value).toBe('@Auguste Raddatz ')` | Inspect `serialize(editor.getJSON())` or read the contenteditable text | | `getByRole('textbox').toHaveValue(...)` | `getByRole('textbox').toHaveText(...)` (contenteditable uses text, not value) | | `ta.selectionStart = 4` | Not needed — `userEvent.type` positions cursor naturally | **The serializer tests are missing from the issue scope entirely.** `mentionSerializer.ts` (or equivalent) will contain `serialize` and `deserialize` — pure functions with no DOM dependency. These should have their own Vitest node test file (`mentionSerializer.spec.ts`) covering: - `deserialize('')` → valid empty Tiptap document - `deserialize('Hello @Clara world', [{ personId: 'uuid-x', displayName: 'Clara' }])` → doc with mention node at correct position - `serialize(doc)` round-trips the text and sidecar correctly - `serialize(deserialize(oldFormatText, oldSidecar))` → backward compat for full-name sidecar entries These run in <5ms and can be written before any editor code (TDD the serializer first). **`PersonMentionPropagationListenerTest.java` must be deleted in the same PR as the listener.** The test class is referenced in `backend/src/test/.../PersonMentionPropagationListenerTest.java`. Leaving dead test code is not acceptable — a passing test for a deleted listener gives false coverage signals. **The suggestion `render()` lifecycle is difficult to test at the component level — write at least one integration smoke test.** Define one integration test that proves the end-to-end flow works: ``` Given the editor is mounted with an existing transcription When the user types @Clara and selects "Clara Cram" from the dropdown Then the editor's serialized output contains { text: '...@Clara...', mentionedPersons: [{ personId: uuid, displayName: 'Clara' }] } ``` This test exercises `onStart`, `onKeyDown`, `onExit`, `insertContentAt`, and `serialize` together. It's the regression test for AC-1. ### Recommendations - Write `mentionSerializer.spec.ts` at the Vitest node layer before any editor code — these tests define the serializer contract. - Define the new `PersonMentionEditor.svelte.spec.ts` test names before implementation (as a stub file with `it.todo(...)`) so the test scope is agreed before the PR opens. - Delete `PersonMentionPropagationListenerTest.java` and `PersonServiceTest.java` tests that set up `PersonMentionPropagationListener` (lines 308–338 in that file) in the same commit as the listener deletion. No open decisions from my side.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Review of the Tiptap migration plan.

Observations

Tiptap's contenteditable needs explicit ARIA attributes — they are NOT added automatically.

The Tiptap editor renders a <div contenteditable="true"> with no role attribute by default. Screen readers announce this as a generic editable region without a role name, aria-multiline, or accessible label. This is a Critical accessibility gap for our audience. Fix via editorProps.attributes:

editor = new Editor({
  editorProps: {
    attributes: {
      role: 'textbox',
      'aria-multiline': 'true',
      'aria-label': m.transcription_editor_aria_label()
    }
  }
})

Add a Paraglide key transcription_editor_aria_label with value "Transkriptionstext" / "Transcription text" / "Texto de transcripción".

focus-within ring works correctly with ProseMirror's focus model — but verify the placeholder.

The decided CSS uses focus-within:ring-2 on the wrapper <div>. This correctly propagates from ProseMirror's inner contenteditable to the wrapper — no issue there. However, the current <textarea> had a placeholder attribute. Tiptap's contenteditable does NOT support the placeholder attribute. Implement via CSS + Tiptap's editorProps:

attributes: {
  'data-placeholder': m.transcription_block_placeholder()
}
/* layout.css or component style */
.tiptap-editor[data-empty="true"]::before {
  content: attr(data-placeholder);
  color: var(--color-ink-3);
  pointer-events: none;
  float: left;
  height: 0;
}

Where data-empty is toggled via Tiptap's onUpdate callback: editorEl.dataset.empty = String(editor.isEmpty). This is the canonical Tiptap placeholder approach.

Mention <span> in edit mode needs a screen reader label.

The decided visual is underline decoration-brand-mint text-brand-navy font-medium on the mention span. Screen readers will read only the visible text (Clara). For a user navigating by keyboard, knowing that "Clara" is a resolved person mention (not just a word) requires an accessible annotation. Options:

  • aria-description="Verknüpfte Person: {fullName}" on the span (if the full name is available in node attrs)
  • title="{fullName}" as a tooltip fallback

The mention NodeSpec.toDOM() has access to attrs.personId but not the full name at render time in the editor. The simplest accessible solution: render the typed text as visible, and append a visually hidden <span class="sr-only"> (Person)</span> as a child node.

Dropdown cursor rect: rect.left is the anchor X position, rect.bottom/rect.top is the Y.

The decided flip logic const openUpward = spaceBelow < 200 uses rect.bottom from Tiptap's clientRect(). This is correct. One edge case: on some browsers, the caret DOMRect has width: 0. Position the dropdown using rect.left (not rect.right) for X, and handle rect.width === 0 by falling back to rect.x.

Recommendations

  • Add role="textbox", aria-multiline="true", and aria-label to editorProps.attributes — this is a Critical accessibility requirement, not optional polish.
  • Implement placeholder via data-placeholder attribute + CSS ::before pseudo-element, not the placeholder HTML attribute (which doesn't work on contenteditable).
  • Add a Paraglide key transcription_editor_aria_label (de/en/es) before the PR opens.
  • Consider a visually hidden (Person) annotation on mention spans for screen reader users who navigate by text.

No open decisions from my side.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist _Review of the Tiptap migration plan._ ### Observations **Tiptap's `contenteditable` needs explicit ARIA attributes — they are NOT added automatically.** The Tiptap editor renders a `<div contenteditable="true">` with no `role` attribute by default. Screen readers announce this as a generic editable region without a role name, aria-multiline, or accessible label. This is a Critical accessibility gap for our audience. Fix via `editorProps.attributes`: ```ts editor = new Editor({ editorProps: { attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-label': m.transcription_editor_aria_label() } } }) ``` Add a Paraglide key `transcription_editor_aria_label` with value `"Transkriptionstext"` / `"Transcription text"` / `"Texto de transcripción"`. **`focus-within` ring works correctly with ProseMirror's focus model — but verify the placeholder.** The decided CSS uses `focus-within:ring-2` on the wrapper `<div>`. This correctly propagates from ProseMirror's inner `contenteditable` to the wrapper — no issue there. However, the current `<textarea>` had a `placeholder` attribute. Tiptap's `contenteditable` does NOT support the `placeholder` attribute. Implement via CSS + Tiptap's `editorProps`: ```ts attributes: { 'data-placeholder': m.transcription_block_placeholder() } ``` ```css /* layout.css or component style */ .tiptap-editor[data-empty="true"]::before { content: attr(data-placeholder); color: var(--color-ink-3); pointer-events: none; float: left; height: 0; } ``` Where `data-empty` is toggled via Tiptap's `onUpdate` callback: `editorEl.dataset.empty = String(editor.isEmpty)`. This is the canonical Tiptap placeholder approach. **Mention `<span>` in edit mode needs a screen reader label.** The decided visual is `underline decoration-brand-mint text-brand-navy font-medium` on the mention span. Screen readers will read only the visible text (`Clara`). For a user navigating by keyboard, knowing that "Clara" is a resolved person mention (not just a word) requires an accessible annotation. Options: - `aria-description="Verknüpfte Person: {fullName}"` on the span (if the full name is available in node attrs) - `title="{fullName}"` as a tooltip fallback The mention `NodeSpec.toDOM()` has access to `attrs.personId` but not the full name at render time in the editor. The simplest accessible solution: render the typed text as visible, and append a visually hidden `<span class="sr-only"> (Person)</span>` as a child node. **Dropdown cursor rect: `rect.left` is the anchor X position, `rect.bottom`/`rect.top` is the Y.** The decided flip logic `const openUpward = spaceBelow < 200` uses `rect.bottom` from Tiptap's `clientRect()`. This is correct. One edge case: on some browsers, the caret DOMRect has `width: 0`. Position the dropdown using `rect.left` (not `rect.right`) for X, and handle `rect.width === 0` by falling back to `rect.x`. ### Recommendations - Add `role="textbox"`, `aria-multiline="true"`, and `aria-label` to `editorProps.attributes` — this is a Critical accessibility requirement, not optional polish. - Implement placeholder via `data-placeholder` attribute + CSS `::before` pseudo-element, not the `placeholder` HTML attribute (which doesn't work on `contenteditable`). - Add a Paraglide key `transcription_editor_aria_label` (de/en/es) before the PR opens. - Consider a visually hidden `(Person)` annotation on mention spans for screen reader users who navigate by text. No open decisions from my side.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Review of the Tiptap migration plan.

Observations

Four new npm packages — they must be version-grouped in Renovate.

@tiptap/core, @tiptap/starter-kit, @tiptap/extension-mention, and likely @tiptap/pm (ProseMirror types) all share ProseMirror peer dependencies internally. A version mismatch — e.g., Renovate bumping only @tiptap/core while leaving @tiptap/extension-mention behind — causes runtime errors that are silent at install time but break at runtime. Before or alongside this PR, add a Renovate group rule:

{
  "packageRules": [
    {
      "matchPackagePatterns": ["^@tiptap/"],
      "groupName": "tiptap",
      "automerge": false
    }
  ]
}

If there's no renovate.json yet in the repo root, create one. This is a one-time setup.

Bundle size: measure before landing the PR.

ProseMirror + Tiptap adds material JavaScript weight. The transcription panel is on a hot path. Run:

cd frontend && npm run build 2>&1 | grep -E "chunks|kB"

...before and after adding the Tiptap packages, and include the delta in the PR description. If the critical-path chunk grows by more than ~100KB gzip, consider a dynamic import() at the call site (Markus's comment covers this). I just need the number to know whether the current CX32 VPS (8GB RAM) tablet load time changes materially.

No CI changes needed. No new Docker services, no new env vars, no new Playwright browsers. The existing vitest-browser-svelte Chromium setup handles Tiptap's DOM requirements. Clean.

No infrastructure impact from PersonMentionPropagationListener deletion. Fewer DB writes per rename transaction is strictly better — no operational concern.

Recommendations

  • Add the Renovate @tiptap group rule before the PR merges.
  • Record the chunk size delta in the PR description so we have a baseline for future regressions.
  • Nothing else from my side — this is a pure application-layer change.
## 🚀 Tobias Wendt — DevOps & Platform Engineer _Review of the Tiptap migration plan._ ### Observations **Four new npm packages — they must be version-grouped in Renovate.** `@tiptap/core`, `@tiptap/starter-kit`, `@tiptap/extension-mention`, and likely `@tiptap/pm` (ProseMirror types) all share ProseMirror peer dependencies internally. A version mismatch — e.g., Renovate bumping only `@tiptap/core` while leaving `@tiptap/extension-mention` behind — causes runtime errors that are silent at install time but break at runtime. Before or alongside this PR, add a Renovate group rule: ```json { "packageRules": [ { "matchPackagePatterns": ["^@tiptap/"], "groupName": "tiptap", "automerge": false } ] } ``` If there's no `renovate.json` yet in the repo root, create one. This is a one-time setup. **Bundle size: measure before landing the PR.** ProseMirror + Tiptap adds material JavaScript weight. The transcription panel is on a hot path. Run: ```bash cd frontend && npm run build 2>&1 | grep -E "chunks|kB" ``` ...before and after adding the Tiptap packages, and include the delta in the PR description. If the critical-path chunk grows by more than ~100KB gzip, consider a dynamic `import()` at the call site (Markus's comment covers this). I just need the number to know whether the current CX32 VPS (8GB RAM) tablet load time changes materially. **No CI changes needed.** No new Docker services, no new env vars, no new Playwright browsers. The existing `vitest-browser-svelte` Chromium setup handles Tiptap's DOM requirements. Clean. **No infrastructure impact from `PersonMentionPropagationListener` deletion.** Fewer DB writes per rename transaction is strictly better — no operational concern. ### Recommendations - Add the Renovate `@tiptap` group rule before the PR merges. - Record the chunk size delta in the PR description so we have a baseline for future regressions. - Nothing else from my side — this is a pure application-layer change.
Author
Owner

📋 Elicit — Requirements Engineer

Review of the Tiptap migration plan.

Observations

"Tiptap v3.22.5" must be resolved to a verified NPM version before implementation starts.

The discussion commits to a specific version. The implementer must confirm the exact package name(s) and versions on npmjs.com before npm install. Pin them exactly in package.json (e.g., "@tiptap/core": "3.22.5" not "^3.22.5") to prevent drift between the agreed version and whatever the resolver picks. If the version turns out not to exist, the issue needs updating with the correct latest stable version before any code is written.

captureTextarea has no acceptance criterion covering its migration — this is a regression risk.

The prop is actively used by TranscriptionBlock.svelte for quote selection (the user selects text in the editor, and selectedQuote is set for a citation feature). The issue currently has no AC that covers this feature's behavior after the migration. Add:

AC-7 — Quote selection works in Tiptap editor

Given a transcriber selects a range of text inside the transcription editor,
When they release the mouse button,
Then the selected text is available to the parent component (TranscriptionBlock)
as the selectedQuote value, matching what was visually selected.

Without this AC, the implementer may not know the quote-selection feature exists, and it will regress silently.

serialize/deserialize have no acceptance criterion — add AC-8.

The discussion resolves to "write a serializer" but the issue body doesn't define what correct behavior looks like. This leaves the implementer without a testable target. Add:

AC-8 — Serializer round-trip preserves text and sidecar

Given any transcription text containing zero or more resolved mentions (@[name](uuid)),
When the text and sidecar are deserialized into a Tiptap document and immediately serialized back,
Then the output text and mentionedPersons sidecar are identical to the inputs.

The SSR constraint is an implicit NFR with no specification.

Tiptap's Editor constructor calls document.createElement and will throw during SvelteKit's server-side render. The current implementation correctly uses onMount, but this constraint should be stated explicitly so future maintainers don't accidentally move initialization outside onMount:

NFR: PersonMentionEditor must not throw or access browser globals during server-side render. Initialization must be deferred to onMount.

AC-3 ("dropdown pre-fills with typed text") is still met, but the mechanism changes.

In the Tiptap architecture, props.query from the suggestion system replaces detectPersonMention(). The AC is still correct as a requirement, but the implementation note in the issue should reference the new mechanism so QA can verify it correctly.

Recommendations

  • Add AC-7 (quote-selection regression) and AC-8 (serializer round-trip) to the issue body before implementation starts.
  • Resolve "Tiptap v3.22.5" to a concrete verified NPM version and update the issue body.
  • Add the SSR NFR to the issue body.
  • Update AC-3 with a parenthetical noting the new mechanism (props.query from Tiptap's suggestion system).

Open Decisions

  • captureTextarea migration scope: Does AC-7 land in this PR or as a follow-up? Based on Felix's analysis, the TranscriptionBlock change is ~10 lines. Deferring it means shipping a regression. Recommend including it in scope with AC-7 as the acceptance criterion.
## 📋 Elicit — Requirements Engineer _Review of the Tiptap migration plan._ ### Observations **"Tiptap v3.22.5" must be resolved to a verified NPM version before implementation starts.** The discussion commits to a specific version. The implementer must confirm the exact package name(s) and versions on npmjs.com before `npm install`. Pin them exactly in `package.json` (e.g., `"@tiptap/core": "3.22.5"` not `"^3.22.5"`) to prevent drift between the agreed version and whatever the resolver picks. If the version turns out not to exist, the issue needs updating with the correct latest stable version before any code is written. **`captureTextarea` has no acceptance criterion covering its migration — this is a regression risk.** The prop is actively used by `TranscriptionBlock.svelte` for quote selection (the user selects text in the editor, and `selectedQuote` is set for a citation feature). The issue currently has no AC that covers this feature's behavior after the migration. Add: **AC-7 — Quote selection works in Tiptap editor** ``` Given a transcriber selects a range of text inside the transcription editor, When they release the mouse button, Then the selected text is available to the parent component (TranscriptionBlock) as the selectedQuote value, matching what was visually selected. ``` Without this AC, the implementer may not know the quote-selection feature exists, and it will regress silently. **`serialize`/`deserialize` have no acceptance criterion — add AC-8.** The discussion resolves to "write a serializer" but the issue body doesn't define what correct behavior looks like. This leaves the implementer without a testable target. Add: **AC-8 — Serializer round-trip preserves text and sidecar** ``` Given any transcription text containing zero or more resolved mentions (@[name](uuid)), When the text and sidecar are deserialized into a Tiptap document and immediately serialized back, Then the output text and mentionedPersons sidecar are identical to the inputs. ``` **The SSR constraint is an implicit NFR with no specification.** Tiptap's `Editor` constructor calls `document.createElement` and will throw during SvelteKit's server-side render. The current implementation correctly uses `onMount`, but this constraint should be stated explicitly so future maintainers don't accidentally move initialization outside `onMount`: > NFR: `PersonMentionEditor` must not throw or access browser globals during server-side render. Initialization must be deferred to `onMount`. **AC-3 ("dropdown pre-fills with typed text") is still met, but the mechanism changes.** In the Tiptap architecture, `props.query` from the suggestion system replaces `detectPersonMention()`. The AC is still correct as a requirement, but the implementation note in the issue should reference the new mechanism so QA can verify it correctly. ### Recommendations - Add AC-7 (quote-selection regression) and AC-8 (serializer round-trip) to the issue body before implementation starts. - Resolve "Tiptap v3.22.5" to a concrete verified NPM version and update the issue body. - Add the SSR NFR to the issue body. - Update AC-3 with a parenthetical noting the new mechanism (`props.query` from Tiptap's suggestion system). ### Open Decisions - **`captureTextarea` migration scope**: Does AC-7 land in this PR or as a follow-up? Based on Felix's analysis, the `TranscriptionBlock` change is ~10 lines. Deferring it means shipping a regression. Recommend including it in scope with AC-7 as the acceptance criterion.
Author
Owner

🗳️ Decision Queue — Action Required

1 decision needs your input before implementation starts.

Scope

  • captureTextarea migrationTranscriptionBlock.svelte:86-194 uses the captureTextarea prop to capture a reference to the textarea element and read selectionStart/selectionEnd on mouseup for the quote-selection feature (selectedQuote). The Tiptap migration removes the underlying <textarea>, so this feature silently breaks unless addressed. Three options:

    Option Behavior Effort
    A: Fix in this PR (recommended) Replace captureTextarea + handleTextareaMouseUp with a selectionUpdate listener on the Tiptap editor in TranscriptionBlock.svelte; add AC-7 to the issue ~10 lines in one file
    B: Defer with follow-up issue Merge without quote selection; file a follow-up; quote selection is broken in production until the follow-up ships Zero effort now, regression until then
    C: Delete the feature Remove quote selection entirely from TranscriptionBlock.svelte Small, but permanent UX loss

    Option A is recommended: the scope is a single short function swap in TranscriptionBlock.svelte, and quote selection is currently working — shipping a regression for a feature that users have is not a good trade-off. (Raised by: Felix, Elicit)

## 🗳️ Decision Queue — Action Required _1 decision needs your input before implementation starts._ ### Scope - **`captureTextarea` migration** — `TranscriptionBlock.svelte:86-194` uses the `captureTextarea` prop to capture a reference to the textarea element and read `selectionStart`/`selectionEnd` on mouseup for the quote-selection feature (`selectedQuote`). The Tiptap migration removes the underlying `<textarea>`, so this feature silently breaks unless addressed. Three options: | Option | Behavior | Effort | |--------|----------|--------| | **A: Fix in this PR** _(recommended)_ | Replace `captureTextarea` + `handleTextareaMouseUp` with a `selectionUpdate` listener on the Tiptap editor in `TranscriptionBlock.svelte`; add AC-7 to the issue | ~10 lines in one file | | **B: Defer with follow-up issue** | Merge without quote selection; file a follow-up; quote selection is broken in production until the follow-up ships | Zero effort now, regression until then | | **C: Delete the feature** | Remove quote selection entirely from `TranscriptionBlock.svelte` | Small, but permanent UX loss | Option A is recommended: the scope is a single short function swap in `TranscriptionBlock.svelte`, and quote selection is currently working — shipping a regression for a feature that users have is not a good trade-off. _(Raised by: Felix, Elicit)_
Author
Owner

We fix the quote in this PR

We fix the quote in this PR
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#372