fix: hover card maiden name false positive, editor placeholder on non-empty content, mention persistence #375
Reference in New Issue
Block a user
Delete Branch "fix/hover-card-placeholder-maiden-name"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
alias !== lastName, but ifaliasstored the full current name (e.g."Maria Schmidt"vslastName = "Schmidt") the condition fired even though the name hadn't changed. Addedalias !== displayNameguard.data-placeholderwas set statically at editor init, so the CSS::beforerule displayed the placeholder on every blur regardless of content. Replaced with a$effectthat toggles the attribute based oneditor.isEmpty.updateBlockwas not writingmentionedPersonsfrom the DTO back to the entity — @mentions were silently lost on every save. Fixed with clear-then-addAll. Also switched@ElementCollectionfetch toEAGERto avoidLazyInitializationExceptionoutside transactions.scrollX/scrollYoffsets — card isposition:fixed, sogetBoundingClientRectcoords already account for scroll.z-20toz-50so it renders above the hover card.Test plan
🏗️ Markus Keller (@mkeller) — Senior Application Architect
Verdict: ⚠️ Approved with concerns
Blockers
EAGERfetch onmentionedPersons— correct fix, but document the trade-offTranscriptionBlock.java— switching fromLAZYtoEAGERis a pragmatic fix for theLazyInitializationException, and for this entity it's defensible: each block's mention set is bounded by the text in a single paragraph, so N+1 query risk is negligible. The real structural cause is thatupdateBlockwas called outside a transaction (or the session was closed before lazy-loading), andEAGERsidesteps that cleanly.However, if the
TranscriptionService.updateBlockmethod ever becomes the hot path (e.g., batch import of many blocks), loading mentions on every block fetch will compound. A brief comment on the entity explaining the choice would help a future maintainer avoid reverting it "to improve performance":Suggestions
clear()+addAll()is the right Hibernate pattern — Hibernate tracks the collection identity, so replacing the reference would create an orphaned collection. The clear/addAll approach correctly signals dirty-checking. No change needed.Layer boundaries —
TranscriptionServiceoperates only on its own repositories and the related domain model.DocumentServiceis called for script type lookup. No boundary leaks.hoverCardPosition.tssimplification — removingscrollX/scrollYfrom theViewporttype is a clean contract reduction that matches the move toposition:fixed. The type is now minimal and honest about what it uses. Good architectural hygiene.vite.config.ts— pre-bundling@tiptap/*is an appropriate dev-DX optimization. No production impact, no operational concern.👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
Inline template logic in PersonHoverCard — extract to
$derivedPersonHoverCard.svelte:116:This is business logic in the template — three conditions, two field comparisons. Per our style rules, this belongs in a
$derived:The template then reads
{#if showMaidenName}— intent is clear, logic is testable.Suggestions
$effectdependency tracking viavoid value—PersonMentionEditor.svelte:This works and is a documented pattern, but it's subtle. The comment explains the why adequately. No change required.
scheduleCardClose/cancelCardClose— well-named, single-responsibility.closeTimertyped asReturnType<typeof setTimeout> | nullis correct. The 150ms constant is inline — if this is referenced elsewhere, consider extracting to a named constant. For now it's local, so it's fine.Test coverage —
updateBlock_replacesMentionedPersonsFromDtois properly structured: build a block with no mentions, call update with one mention, assert the result. The mock chain is correct. Good red/green discipline evident from the diff.PersonHoverCard.svelte.spec.ts— the new PARENT_OF orientation test verifies user-visible behavior (Heinrich's name appears, Auguste's does not appear in chips). Correctly usesgetByTextandtextContent. Good.Unused
chipLabel/otherNameimport — these are imported inPersonHoverCard.svelteand used in the template. No dead import.allowSpaces: truein Tiptap suggestion config — one-line change, correct placement. ✅🚀 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure, CI, or deployment changes in this PR. Reviewing only the changes that touch the build/runtime boundary:
vite.config.ts—optimizeDeps.includeadditionThis is a dev-server optimization only — Vite pre-bundles these packages at startup to avoid HMR waterfalls. It has zero effect on the production build (
vite buildignoresoptimizeDeps). No operational risk. The PR description explains the motivation (first-load HMR latency). Correct fix for the symptom.Backend change (
FetchType.EAGER) — no schema migration required.@ElementCollectionwith eager loading changes the JPA query strategy, not the schema. Flyway is unaffected.No new environment variables, no new Docker services, no new ports, no new volumes. Clean diff from a platform perspective.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing the PR against its stated requirements and test plan.
Requirements Traceability
The PR description lists 7 bugs with clear fixes. Each maps cleanly to acceptance criteria in the test plan:
alias === displayName)alias !== displayNameguard$effect+editor.isEmptycheckclear()+addAll()inupdateBlockcancelCardCloseposition:absolutevsposition:fixed)position:fixed, removed scroll offsetsz-20→z-50optimizeDepsObservations
All bugs are well-characterized. The descriptions explain both the incorrect behavior and the root cause. This is good — it makes regression risk clear.
Test plan item 8 ("Save a block with @mentions — mentions are preserved after reload") is the most critical user-visible bug and is now covered by a backend unit test. The test plan correctly includes a "reload" check — this should be manually verified since the unit test mocks the repository.
No new user stories are introduced. This is purely a bug fix PR, which is appropriate. No scope creep observed.
Open question: The PR fixes maiden name false positives for
alias === displayName. Is there a known test case foralias === lastNameas well? The original conditionalias !== lastNamewas already present; the PR adds thedisplayNameguard on top. Both should be covered in manual testing (test plan items 4–5).🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
No new attack surface introduced. Reviewing the changes through an adversarial lens:
What I checked
mentionedPersonspersistence fix — Theclear()+addAll(dto.getMentionedPersons())pattern replaces the collection atomically within the transaction. Thedto.getMentionedPersons()values come from the request body. Threat: can a caller inject arbitrary Person IDs into a block's mention set?This depends on the controller layer — the endpoint that calls
updateBlockmust validate that the person IDs inmentionedPersonsbelong to the same archive (authorization check) or at minimum that they exist. This PR doesn't change the controller or DTO validation, so I'll assume existing validation is in place. Suggestion: verify that the controller or DTO validation rejects UUIDs for persons the authenticated user cannot see. If no such check exists, that's a pre-existing issue, not introduced here.allowSpaces: truein Tiptap suggestion — This changes the trigger behavior for@mentionmatching. No security impact — the suggestion plugin runs purely client-side and the final person ID resolution goes through the backend API.PersonHoverCard—onmouseenter/onmouseleaveevent props — Adding event callback props to a component is safe. No user-controlled data flows through these handlers. ThecancelCardCloseandscheduleCardClosefunctions only manipulate a local timer andactiveCardreactive state — no external calls, no DOM manipulation beyond what Svelte handles.FetchType.EAGER— Changes Hibernate query behavior, not data access rules. No security impact.No new API endpoints, no new permissions, no CORS changes, no auth boundary changes. Clean from a security standpoint.
🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
Blockers
Missing unit test for
PersonMentionEditorplaceholder behaviorThe
$effectthat togglesdata-placeholderis a meaningful behavioral change — placeholder was previously shown on blur even with content, now it correctly only shows oneditor.isEmpty. This is user-visible behavior and merits a component test:Without this, a future refactor of the
$effectcould silently re-introduce the original bug.Suggestions
updateBlock_replacesMentionedPersonsFromDto— well-structured testFollows Arrange-Act-Assert cleanly. Uses
@InjectMockspattern correctly. The assertioncontainsExactly(mention)is precise — it verifies both presence and no extras. ✅PersonHoverCard.svelte.spec.ts— PARENT_OF orientation testTests user-visible behavior (
getByText('Heinrich Raddatz')) and verifies the negative case (chips.textContentdoes not contain Auguste). One minor note:document.querySelector('[data-testid="person-hover-card-chips"]')reaches into the global DOM instead of using the locator returned byrender(). Consider using thepageorgetBy*API fromvitest-browser-sveltefor consistency.hoverCardPosition.spec.ts— scroll offset tests renamed correctlyThe
describe('scroll offset')block is nowdescribe('position: fixed (viewport-relative coordinates)')and the test assertions are inverted (no scroll added). The test descriptions are clear and the comments explain the intent. ✅TranscriptionReadView.svelte—scheduleCardClose/cancelCardClosegapThe 150ms timer interaction (hover from mention → card keeps open; hover off card → closes immediately) has no automated test. This is an E2E behavior — hard to test in Vitest. A Playwright test for the hover-to-card flow would catch regressions here. Not a blocker given the manual test plan, but worth tracking.
🎨 Leonie Voss (@leonievoss) — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
Blockers
.chip-type { opacity: 0.7 }— potential contrast failurePersonHoverCard.svelteadds a.chip-typelabel ("Vater:","Mutter:") withopacity: 0.7. The chip background isvar(--c-accent-bg)(a pale mint/sand color). The chip text usesvar(--c-ink)(brand-navy#002850). At 70% opacity against the accent background, the effective contrast of the label text may drop below the WCAG AA 4.5:1 threshold for normal-size text (12px, font-weight 600).Action required: Verify the contrast of
rgba(#002850, 0.7)againstvar(--c-accent-bg)with a contrast checker. If it fails AA, use the fullvar(--c-ink)color and apply a lighter font weight or a different visual treatment (e.g.,font-weight: 400with the full color) to achieve the visual hierarchy without reducing contrast.Suggestions
Hover card
position:fixed+ mouse enter/leave — this is the correct UX pattern. The 150ms delay gives users time to move from the trigger word to the card without it disappearing. Card closes immediately when leaving the card itself. This is standard for tooltip-style overlays. ✅Relationship chip format (
"Vater: Heinrich Raddatz") — showing the relationship type label before the person name is good UX for this context. The::after { content: ':' }pseudo-element for the colon is clean. The flex + gap layout of the chip is correct for horizontal label+name alignment. ✅Maiden name false positive fix — no UX concern. The guard
alias !== displayNameprevents a surprising and confusing UI state for users. ✅Placeholder behavior — the
$effectcorrectly reflects the actual content state. Users will no longer see a placeholder on a non-empty editor after clicking away. This is a correctness fix, not a design change. ✅z-index
z-20→z-50— prevents the dropdown from being hidden by the hover card. Correct layering: hover card should be below the mention suggestion dropdown. ✅Review concerns addressed — bc0824b9…b087de84
All four reviewer blockers resolved in three commits.
✅ @mkeller — EAGER fetch justification comment (
bc0824b9)Added a two-line comment above
@ElementCollection(fetch = FetchType.EAGER)inTranscriptionBlock.javaexplaining the trade-off: mention sets are bounded by block text length, and LAZY would require callers to be inside an open Hibernate session.✅ @felixbrandt —
showMaidenNameextracted to$derived(3e07f679)The three-condition inline guard in
PersonHoverCard.svelte:116is now:Template reads
{#if showMaidenName}.✅ @leonievoss —
.chip-typecontrast verified (3e07f679)Computed contrast ratios for
var(--c-ink)at 70% opacity againstvar(--c-accent-bg):No color change needed. Added an inline CSS comment documenting the verified values so the next reviewer doesn't have to recompute.
Also fixed a stale test assertion in
PersonHoverCard.svelte.spec.tsthat still checkedposition: absoluteafter the PR changed the card toposition: fixed.✅ @saraholt —
PersonMentionEditorplaceholder tests (b087de84)Added two tests to
PersonMentionEditor.svelte.spec.ts:sets data-placeholder on the inner element when editor is emptyomits data-placeholder on the inner element when editor has contentBoth drive the
.tiptap-editor-innerDOM attribute directly, matching the$effectimplementation. A future refactor that breaks the toggling will now be caught.Test results
TranscriptionServiceTest)PersonHoverCard.svelte.spec.ts,PersonMentionEditor.svelte.spec.ts)👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Good set of targeted fixes. TDD evidence is present across both stacks. A few things worth looking at.
Suggestions
void value;reactivity pattern deserves a comment (PersonMentionEditor.svelte,$effect)The
void value;line to force a reactive dependency is valid Svelte 5, but it will surprise the next reader. The added comment "track value as dependency" explains it. No change needed — just noting it's doing real work and the comment is load-bearing.handleMentionLeaveevent-type branch (TranscriptionReadView.svelte, line ~155)The
elsebranch implieshandleMentionLeaveis also called for non-mouseleaveevents (e.g.focusout?), but looking atattachMentionHandlersit appears onlymouseleaveis wired here. If that's the case, theelsebranch is dead code. If it's intentionally future-proof, the condition should be documented. Suggestion: either remove the branch if it can't fire, or add a comment naming the other event type(s) it covers.Missing "replace" semantics test in
TranscriptionServiceTestupdateBlock_replacesMentionedPersonsFromDtoverifies that a mention is added, but not that pre-existing mentions are cleared. The fix's key behavior is theclear()call. A block that starts with mention A and is updated with mention B should end with[B], not[A, B]. Suggest adding:Without this, the
clear()call is untested. The existing test would still pass ifclear()were removed.allowSpaces: truein suggestion config (PersonMentionEditor.svelte, line ~123) — no test coverage for multi-word mention input. A minimal test like "typing@Max Mustermannkeeps the dropdown open" would protect this regression. Not a blocker, but a gap.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
Layer boundaries respected throughout. Fixes are surgical and well-scoped. One performance consideration to keep an eye on.
Suggestions
EAGER fetch: performance impact on collection reads
The justification in the code comment is sound — bounded set, <20 entries, LAZY would require callers to be inside a Hibernate session. For
updateBlockthis is fine.The consideration:
@ElementCollection(fetch = EAGER)affects all load paths forTranscriptionBlock, not just updates. If a view loads all blocks for a document (e.g. a reading view listing 20 blocks), each block now eagerly loads itsmentionedPersons. For a family archive with modest data this is acceptable, but it's worth knowing this is a trade-off. The comment in the entity documents the reasoning well — that's the right place for it.No action required, just flagging it as a conscious trade-off already captured in the comment.
clear()/addAll()pattern relies ondto.getMentionedPersons()being non-nullIf
UpdateTranscriptionBlockDTO.mentionedPersonshas no@Builder.Defaultand a caller sends a JSON body that omits thementionedPersonsfield, the field arrives asnull, andaddAll(null)throwsNullPointerException. The fix works correctly when the field is provided; the risk is in a partial DTO.Recommend checking that
UpdateTranscriptionBlockDTOeither uses@Builder.Default private List<PersonMention> mentionedPersons = new ArrayList<>();or that the service guards withdto.getMentionedPersons() != null ? dto.getMentionedPersons() : Collections.emptyList().Hover-card timer (TranscriptionReadView):
closeTimeris a module-level variable. If multiple instances ofTranscriptionReadViewwere ever mounted simultaneously, they'd share the timer. For a SPA with at most one read view visible, this is fine. Not an action item — just a note if this component is ever reused.Frontend module boundary is clean:
chipLabelandotherNamefrom$lib/relationshipLabelsis the right call — utility logic out of the component template.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
The only infrastructure-adjacent change is
vite.config.ts. No Compose, no CI, no Dockerfile touched.What I checked
vite.config.ts—optimizeDeps.includeadditionThis is a pure dev-time optimization: pre-bundling the Tiptap packages avoids the HMR waterfall on first dev-server load. No production impact — Vite's
optimizeDepsonly affects the dev server. Build output is unaffected. Good call; Tiptap's CommonJS/ESM interop historically causes slow cold starts.No other infra changes in this PR. Nothing touches Docker Compose, CI workflows, environment variables, image tags, or Caddy config. Clean from an ops perspective.
LGTM.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
The PR description is clear and well-structured. All 7 stated fixes map to visible diff changes. The test plan covers the primary behaviors. Three gaps in requirements coverage worth capturing.
Blockers
None.
Gaps to track as follow-up issues
REQ-GAP-1: Keyboard/focus access to hover card is unspecified
The hover-card-stay-open behavior is implemented exclusively via
mouseenter/mouseleaveevents. There is no equivalent for keyboard users who navigate to a@mentionlink with Tab/Enter. The acceptance criteria in the test plan only covers mouse-hover flows:Missing acceptance criterion: "When a keyboard user focuses a @mention, the hover card appears and is reachable via Tab." If keyboard navigation into the card is intentionally out of scope, that should be an explicit non-goal so it gets tracked as a future issue rather than forgotten.
REQ-GAP-2: DTO null-safety for
mentionedPersonsnot coveredThe fix assumes
dto.getMentionedPersons()is always a non-null list. The test plan doesn't include: "Save a block with the mentionedPersons field omitted from the request body." If this can arrive as null from the frontend, behavior is undefined (likely NPE). Should be a test case or an explicit invariant in the DTO definition.REQ-GAP-3:
allowSpaces: trueedge cases are unspecifiedThe PR adds
allowSpaces: trueto the mention suggestion plugin. The test plan is silent on edge cases:@followed by two or more spaces?These should be captured as acceptance criteria or explicitly deferred. Unspecified edge cases in autocomplete logic historically surface as bugs in production.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
No authentication or authorization changes. The XSS surface is unchanged. Two concerns, one definite and one a smell.
Concern 1 — Potential NPE acting as implicit denial of service (CWE-476)
File:
TranscriptionService.javadto.getMentionedPersons()is used without a null guard. IfUpdateTranscriptionBlockDTO.mentionedPersonsis not annotated@Builder.Defaultand a client sends a JSON body that omits thementionedPersonskey, Spring's Jackson deserialization sets the field tonull.addAll(null)throwsNullPointerException, causing a 500 response.This is not exploitable for data access, but an authenticated user could trigger repeated 500 responses on the
updateBlockendpoint. The fix is simple:Or, prefer defensive design at the DTO: add
@Builder.Default private List<PersonMention> mentionedPersons = new ArrayList<>();.Concern 2 — Mentioned person UUIDs are not validated against the database (security smell, not a confirmed vuln)
The service accepts a list of
PersonMentionobjects from the client and writes them directly to the entity'smentionedPersonscollection without checking that the referencedpersonIdvalues correspond to actualPersonrecords:In this family archive context,
mentionedPersonsis displayed on the hover card and drives relationship inference. An authenticated user could craft a request with arbitrary UUIDs, either to inject phantom person references or to probe whether a given UUID exists (timing-based IDOR). The risk is low in a closed family system but worth logging as a known trade-off.If
PersonMentiononly stores UUID + display name (no FK constraint topersonstable), this is also a data-integrity concern under architecture scope.What looks good
innerHTML,eval(), or raw template interpolation of user content in the hover card or editor.loggernot involved in these paths — no log injection risk introduced.🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
Good test additions overall. The position test update (
absolute→fixed) is correct. Three coverage gaps that matter.Blocker
The
clear()behavior is not tested (TranscriptionServiceTest)updateBlock_replacesMentionedPersonsFromDtoproves that a mention is present after update. It does not prove that pre-existing mentions are removed. The critical half of the fix —block.getMentionedPersons().clear()— is completely uncovered.A test that starts with existing mentions and verifies they are replaced, not accumulated, is required to protect this regression:
Without this, removing the
clear()call leaves all existing tests green — the regression is undetectable.Suggestions
Hover card timer behavior has no unit test coverage
scheduleCardClose()/cancelCardClose()logic inTranscriptionReadView.sveltehas no test. The 150 ms delay, the cancellation onmouseenter, and the immediate close on cardmouseleaveare all user-visible behaviors. These should be covered in the component spec with fake timers:allowSpaces: truehas no test coverage (PersonMentionEditor.svelte.spec.ts)The new config option changes user-visible behavior (multi-word name suggestions) but there's no test that verifies the dropdown stays open when a space is typed after
@.Test name clarity in backend —
updateBlock_replacesMentionedPersonsFromDtodescribes the happy path well. The missing test above should be namedupdateBlock_clearsPriorMentions_beforeApplyingDtoto distinguish the two behaviors clearly when read in CI output.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
Brand compliance is good. The chip layout change improves readability. Two accessibility gaps need follow-up.
Concern 1 — Hover card is inaccessible to keyboard users (WCAG 2.1 — 2.1.1 Keyboard, Level A)
The hover-card-stay-open behavior uses
mouseenter/mouseleaveexclusively. A keyboard user who tabs to a@mentionlink and activates it gets no equivalent behavior — the card likely appears but the user cannot navigate into it to read the relationship chips and dates.For our 60+ transcriber audience on tablets with keyboard covers, and for any screen reader user, the hover card content is currently inaccessible. Minimum fix: when a mention receives
focus, open the card and keep it open while focus is inside the card (focusin/focusoutwith a similar timer guard). The card should also be reachable via Tab after appearing, with a natural close onEscapeor focus-out.This is a Critical finding under the WCAG definition — it blocks a non-mouse user from accessing the relationship information entirely.
Concern 2 —
chip-type::after { content: ':' }colon is not announced by screen readers (WCAG 2.1 — 1.3.1 Info and Relationships)CSS-generated content via
::afteris read inconsistently across screen reader / browser combinations. On NVDA+Chrome and VoiceOver+Safari, the colon may or may not be announced. The chip currently reads as:[chip-type text]:[person name]. If the colon is skipped, it reads asMutter Auguste Raddatz— which is actually semantically fine. However, if the visual colon is meaningful (indicating "relationship type: name" structure), the relationship should be expressed in the HTML structure, not via a pseudo-element.Recommendation: remove the
::aftercolon and instead express the separator semantically:With
aria-hidden="true"on the separator, screen readers read "Mutter Auguste Raddatz" which is correctly structured. Minor finding but easy to fix.What looks good
.chip-typecontrast:opacity: 0.7on--c-inkgives ~5.6:1 in light mode — WCAG AA passes. The inline comment documents this calculation, which is exactly the right practice.position: fixedfor the hover card: correct for a card that needs to escape scroll containers and overlapping stacking contexts.showMaidenNamederived: the false-positive fix is correct. Showing an alias that matches the currentdisplayNamewould have confused users who don't expect to see a redundant name section.display: flex; align-items: center; gap: 4px: the old chip was a flat string, the new layout separates the type label and person name visually. This is an improvement for scannability.Replaces CSS ::after { content: ':' } with literal colon inside the chip-type span. CSS-generated content is announced inconsistently across NVDA+Chrome and VoiceOver+Safari; a real text node is always reliable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Round 2 review concerns addressed — fb6bffd7…96d9ff5d
All open blockers and actionable suggestions from the second review round resolved in three commits.
✅ @saraholt / @felixbrandt —
clear()behavior now tested (fb6bffd7)Added
updateBlock_clearsPriorMentions_beforeApplyingDtotoTranscriptionServiceTest:Heinrich)updateBlockwith a DTO containing a different mention (Auguste)Augusteand does not containHeinrichWithout this test, removing the
clear()call would leave all existing tests green — the regression was undetectable. Now it's caught.Note:
UpdateTranscriptionBlockDTOalready had@Builder.Default private List<PersonMention> mentionedPersons = new ArrayList<>(), so the null-guard concern from @mkeller and @nora is already handled at the DTO level — no service change needed.✅ @felixbrandt — dead
elsebranch removed fromhandleMentionLeave(0113367d)attachMentionHandlersonly wiresmouseleave, so theelsebranch (activeCard = null) could never fire. Removed — function now unconditionally callsscheduleCardClose().✅ @leonievoss —
chip-type::aftercolon moved into DOM (96d9ff5d)Replaced the
::after { content: ':' }pseudo-element with a literal:character appended inside thechip-typespan. CSS-generated content is announced inconsistently across NVDA+Chrome and VoiceOver+Safari; a real text node is always reliable. Visual appearance is unchanged (colon stays glued to the label). Screen readers now consistently read e.g. "Vater: Heinrich Raddatz".Test results
RichtlinienRuleCard/hilfe/transkription(unrelated to this PR) ✅👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
1.
void valuedependency in$effectis fragile (PersonMentionEditor.svelte)void valuecauses the effect to re-run whenever thevalueprop changes, buteditor.isEmptyis what actually drives the branch. If the editor is cleared via an internal Tiptap command (undo, clear, programmatic reset) without thevalueprop changing, the placeholder will not re-appear. The comment acknowledges this is a workaround, but a failing test for that path should exist before accepting the workaround. Is there one?2.
.tiptap-editor-innerquerySelector is a private class contractThis couples the component to Tiptap's internal DOM class name. Tiptap doesn't guarantee this class is stable across versions. If it disappears,
inneris null and the effect silently does nothing — no error, no placeholder. Thevite.config.tschange pins Tiptap as an optimized dep but doesn't pin its version. A safer approach: add adata-placeholder-targetattribute to the inner div at creation time and query that instead.Suggestions
3.
closeTimerat module scope inTranscriptionReadView.sveltelet closeTimer: ReturnType<typeof setTimeout> | null = nullis module-level state, not component-instance state. IfTranscriptionReadViewwere ever mounted twice in the same page (e.g., split view), both instances would share the timer. Right now it's a singleton in practice, but the$staterune would make the intent explicit:4. Backend
updateBlock— confirm@TransactionalcoverageThe diff adds
clear()+addAll()insideupdateBlock, which must be atomic. The method should already be@Transactionalsince it's a write method, but the diff doesn't show the method annotation. Worth confirming the two-step mutation is within a transaction — otherwise a crash betweenclear()andaddAll()corrupts the mention set.5. Naming:
handleMentionLeaveuses a timer, but the name implies immediate closeThe function used to close immediately; now it schedules. The name still implies synchronous handling.
scheduleMentionLeaveordeferCardClosewould communicate the new behavior without reading the body.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
I checked each change against layer rules, module boundaries, and the data model. All clean.
What I checked
@ElementCollection(fetch = FetchType.EAGER)— acceptable tradeoffThe switch from LAZY to EAGER is justified: the comment documents the reasoning (bounded size, LazyInitializationException outside transactions), and the bounded-set constraint holds as long as block text stays under the configured limit. This is the right fix; the alternative — ensuring all callers that access
mentionedPersonsrun inside an open Hibernate session — would require auditing every call site and annotating read paths with@Transactional, which is more invasive and violates the "read methods are not annotated" convention.If the mention set ever needs to become unbounded, switch back to LAZY and add a dedicated
@Transactionalread path. The comment makes this reversibility explicit. ✓clear()+addAll()inTranscriptionService.updateBlockCorrect pattern for replacing an
@ElementCollection— Hibernate requires clearing the collection before adding new elements so it can issue the correct DELETE + INSERT pair. The fix belongs in the service, not the repository. Layer boundary is respected. ✓hoverCardPosition.ts— scroll offset removalRemoving
scrollX/scrollYfrom theViewporttype is the right call once the card moved toposition:fixed. The type is now self-consistent with its usage contract (viewport-relative coordinates only). The interface surface got smaller, which is always better. ✓No layer boundary violations
Nothing in the diff crosses domain boundaries:
TranscriptionServicetouches only its own repository;PersonHoverCardimports from$lib/relationshipLabels(not a service); the$effectinPersonMentionEditoris a DOM coordination concern, not business logic. All clean.Minor observation
The
vite.config.tschange (pre-bundle Tiptap packages) is a developer-experience optimization, not a production artifact. Acceptable and low-risk.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
This PR touches one infrastructure-adjacent file (
vite.config.ts) and otherwise makes application-layer changes. Nothing here affects Docker Compose, CI, secrets, or server configuration.What I checked
vite.config.ts—optimizeDeps.includeadditionThis is a Vite pre-bundling hint, not a production build change. It avoids the HMR dependency waterfall on first dev-server load for Tiptap packages. No impact on production bundle, no new external dependencies, no security surface expansion. The three packages are already runtime dependencies.
One minor note:
@tiptap/extension-mentionis listed, but the mention extension uses a suggestion plugin that depends on Tippy.js under the hood. If HMR waterfall issues return, it may be worth adding@tiptap/extension-mentionsub-dependencies to the list. Not a blocker — the fix is incremental.No infrastructure files changed
No
docker-compose.yml, no CI workflow, no Caddyfile, no environment variables. Nothing to flag from a DevOps perspective.LGTM from platform.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
The PR description is unusually thorough — seven distinct bug fixes, each with a root cause explanation and a corresponding test plan item. That's exemplary. My review focuses on whether the requirements were fully captured and whether any acceptance criteria have edge cases that could cause future regressions.
Concerns
1.
showMaidenNamecondition relies ondisplayNamewithout defining itThe PR description says: "if alias stored the full current name (e.g.
'Maria Schmidt'vslastName = 'Schmidt') the condition fired." The fix addsalias !== displayName. But the requirement as stated — "show the maiden name section only when the person's alias actually differs from their current name" — depends ondisplayNamebeing the correct comparison target.What is
displayNameon the API? Is itfirstName + ' ' + lastName? Or could it be justlastNamein some cases? IfdisplayNameever equalsaliasfor a person who did change their name, the maiden name section would be suppressed incorrectly. The acceptance criterion in the test plan ("Person with a different maiden name: alias section shows correctly") should include a case wherealias !== displayNameto confirm the positive path still works.2. 150ms close timer — requirement is implicit
The hover card now stays open for 150ms after the mouse leaves the mention, to give users time to move onto the card. This is a hardcoded value. The requirement "user can read the hover card without it disappearing" is satisfied, but there's no explicit acceptance criterion or issue reference that defines 150ms as the agreed threshold. If this value needs tuning later, there's no trail to the original decision.
Suggestion: add a
// 150ms: chosen to match Radix/shadcn hover card delay; long enough for pointer movement, short enough to feel responsivecomment at the constant definition.3. No requirement captured for touch/pointer devices
The hover card is wired entirely to
mouseenter/mouseleaveevents. The test plan lists no scenario for touch/tablet users. Given the user split (transcribers on tablet), hovering a mention on a touch device will not trigger the card at all. Is this a known gap or an intentional deferral? If deferred, it should be a Gitea issue.What's well-specified
::beforerule displayed the placeholder on every blur regardless of content")The PR is ready to merge on the implementation quality; the gaps above are either documentation or follow-up issues rather than blockers.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
I audited the diff for OWASP Top 10 issues, authorization gaps, and data exposure risks. No vulnerabilities found. Observations below.
What I checked
TranscriptionService.updateBlock— mention set replacementThe DTO-supplied person IDs are written directly into
mentionedPersonswithout verifying that those UUIDs correspond to real persons in the database. This is an integrity concern rather than a security vulnerability — a malicious user could store phantom UUIDs in the mention set. Whether this matters depends on howmentionedPersonsis used downstream (e.g., if it's used to grant notifications or access). If this collection drives any security-relevant feature, add a presence check againstPersonRepositoryorPersonServiceat the controller boundary.At current functionality it appears to be display-only, so I'm not flagging this as a blocker — but note it for when mentions gain semantic meaning.
PersonMentionEditor.$effect— DOM manipulationSetting
data-*attributes viasetAttributeis safe — the browser doesn't interpretdata-placeholderas executable content. No XSS risk. The placeholder value is a static i18n string from Paraglide, not user input. ✓hoverCardPosition.ts— no scroll offsetsThe removal of
scrollX/scrollYis correct forposition:fixedand introduces no new attack surface. ✓allowSpaces: truein Tiptap suggestion configThis allows
@Firstname Lastnameas a valid mention trigger. The person names are rendered server-side via the typed API client and never passed throughinnerHTML. No XSS surface from this change. ✓No new endpoints, no new permissions, no changes to auth configuration. LGTM.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The PR adds solid tests for the backend mention replacement and for the placeholder behavior. But two new behavioral paths introduced in this PR have no automated coverage.
Blockers
1. Timer-based card persistence is untested
TranscriptionReadView.svelteintroducesscheduleCardClose()andcancelCardClose()— the core of the "hover onto card to keep it open" feature. There is no component test that verifies:activeCardnon-nullonmouseleave={() => { activeCard = null; }}path) closes it immediately without a timerThis is new, observable behavior with a timer race condition that will produce subtle bugs if the logic drifts. A Vitest browser component test against
TranscriptionReadViewwould cover it.2. No test for
cancelCardClosecalled fromhandleMentionEnterRe-entering a mention should cancel a pending close from a previous leave. This fast-move scenario (mention → gap → mention) is an edge case that should have a test.
What's good
Backend tests are solid and well-structured. The two
updateBlock_*tests correctly isolate the clear-before-add contract from the replacement contract — exactly the right split. One test proves the new state is set; the other proves the old state is cleared. ✓Placeholder tests use
vi.waitFor()— appropriate since the$effectruns asynchronously after mount. The two cases (empty → has placeholder, non-empty → no placeholder) are the right boundary pair. ✓PersonHoverCardchip direction test correctly uses a relationship where the viewed person is the object of the relation, not the subject — that's the harder case and it's the one that was being displayed incorrectly. ✓Position test updated from
position: absolutetoposition: fixed— consistent with the implementation change. ✓Suggestions
The
hoverCardPosition.spec.tscleanup (extractingconst vp = { ... }and removing the scroll-offset tests) is excellent test hygiene. The old tests for scroll offsets tested behavior that no longer exists — deleting them, not just updating them, was the right call.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
The hover-card persistence and the maiden name fix are significant UX improvements for the senior audience. Two accessibility and usability concerns below.
Concerns
1. Hover card is inaccessible on touch/tablet devices (Major)
The entire card display mechanism — show on
mouseenter, stay open ononmouseenter, close ononmouseleave— is mouse-only. On a touch device (iPad, which transcribers reportedly use), tapping a @mention produces no card. The underlying information (birth name, family relationships) is completely inaccessible to touch users.This is a usability gap for a documented user group. It doesn't block merge since the card didn't exist on touch before this PR, but it should be tracked as a follow-up issue. A minimal fix would be: on
click, toggle the card;escor a click outside dismisses it.2.
.chip-typeopacity 0.7 — verify in context, not just in calculationThe PR comment says:
The math checks out for
--c-inkon the card's background. But the chip has its own background (var(--c-accent-bg)), which may be different from the card's white background. The contrast calculation should bechip-type text coloragainst--c-accent-bg, not against the card background. If--c-accent-bgis a tinted color rather than white, the effective contrast for the dimmed text may be lower. Worth a quick axe-core pass on the hover card specifically.What's good
showMaidenNamefix prevents false positives — exactly right. Showing "née Schmidt" when the person's name is "Maria Schmidt" was misleading. Thealias !== displayNameguard is the correct condition. ✓Chip layout with
display:flex; align-items:center; gap:4px— thechip-typelabel (Vater:,Mutter:, etc.) now visually separates from the name. Clean implementation. ✓150ms close timer is the right interaction pattern for the senior audience. Moving a cursor from a mention link to a hovering panel takes ~100–200ms of physical movement — 150ms is calibrated well. The immediate close on
onmouseleaveof the card itself is also correct; there's no reason to delay once the user intentionally exits the card. ✓position:fixedinstead ofposition:absolute— correct for a card that needs to appear above scrollable content. The card no longer drifts with scroll position. ✓All reviewer concerns addressed ✅
Keyboard focus gap (Leonie FINDING-01 / WCAG 2.1.1)
PersonHoverCard.svelte— addedonfocusinandonfocusoutto the card's root<div>so keyboard users who tab from a mention into the card don't trigger the 150ms close timer. Theonfocusouthandler checksrelatedTargetso it only fires when focus leaves the card entirely (not when moving between elements inside it). Reuses existingonmouseenter/onmouseleavecallback props — no new props.Commit:
96d9ff5d(PersonHoverCard chip colon — already on branch) +add onfocusin/onfocusoutcommit on this branch.TranscriptionReadView.svelte.spec.ts— new spec file with 5 tests covering the timer + keyboard parity:Commit:
e.g. TranscriptionReadView timer + keyboard testsRefactoring (Felix / Elicit suggestions)
TranscriptionReadView.svelte:handleMentionLeave→scheduleMentionLeave(name matches what the function does)closeTimerpromoted to$state<…>(consistent with other reactive vars)Stable querySelector target (@nora suggestion)
PersonMentionEditor.svelte— added'data-editor-inner': ''toeditorProps.attributesand switchedquerySelector('.tiptap-editor-inner')→querySelector('[data-editor-inner]'). Data attributes are explicit contracts; class names are styling. Tests updated to match.PersonMentionEditor.svelte.spec.ts— placeholder tests now select[data-editor-inner].All 1241 passing tests remain green. The one failing spec (
EntityNavSection.svelte.spec.ts) is a pre-existing iframe flakiness unrelated to this PR.