Move `transcription_block_placeholder contains @ mention trigger` out of
`describe('PersonMentionEditor — placeholder behavior')` into a new
`describe('PersonMentionEditor — i18n message content')` block so each
describe group has a single, clear responsibility.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the generic "Type text here..." placeholder in TranscriptionBlock
with copy that teaches the @Name trigger inline (Leonie Voss design review,
issue #370). No new DOM, no new i18n keys — just the three existing
`transcription_block_placeholder` strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The multi-person filter e2e previously typed 'a' then 'b' into the
typeahead and trusted the dev seed to contain matching names.
If the seed ever changes, the test would silently degrade — both
calls might resolve to the same row, or the listbox might never
populate.
Refactor to use a single broadly-occurring probe vowel ('e') and
extract person ids straight from the listbox option DOM (the option
id encodes the person id as `${listboxId}-option-${personId}`).
For the second pick, iterate options and select the first whose
id differs from the first selection. The test now only depends on
the seed having ≥2 distinct persons whose name contains 'e' — a
much weaker, more durable assumption — and asserts on the URL
params with full equality instead of toHaveLength + first-element
spot checks.
Addresses Sara's iteration-3 concern #4 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Senior-author persona requires 44px minimum touch targets on every
interactive control. The /geschichten filter row had three pills
(All / chip / + Person wählen) at h-9 (36px), missing the rule that
the toolbar already follows. Bumped all three to h-11.
Test added in page.svelte.spec.ts asserts the className contains
h-11 on every pill variant.
Addresses Leonie's iteration-3 concern #6 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The story rows on the person detail page now match the
PersonDocumentList pattern: the entire row is a single anchor with a
hover background, and the title gets group-hover:underline. Author,
date, and body excerpt are all part of the same clickable area, so
the touch target matches the visual rhythm of the document panels
above.
Adds a Playwright flow that picks two persons through the typeahead,
asserts both ?personId= params end up in the URL with two chips on
screen, then removes the first chip and verifies only the second
person id remains.
Also extends .prettierignore so a stale root-owned test-results
directory left over from running tests inside Docker doesn't break
the pre-commit lint hook.
The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
Three e2e tests against the real stack:
- admin can navigate to /geschichten, create a draft, publish, and see the
story appear on the index
- a reader (or admin) can click a story card and reach the detail page
with an <article> landmark visible
- AxeBuilder scan of /geschichten reports no serious or critical WCAG
violations
Partial fix for Sara's review B1 on PR #382. The deeper 5-spec a11y suite
and visual-regression coverage are deferred to a follow-up issue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 browser-based component tests:
- title-empty disables both DRAFT save buttons
- inline title-required error appears after blur
- DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"
- PUBLISHED mode renders "Speichern" + "Zurück zu Entwurf"
- initialPersons / initialDocuments props render as chips on first paint
- title input is populated from a geschichte prop
- "Entwurf speichern" passes trimmed title + status=DRAFT to onSubmit
- "Veröffentlichen" passes status=PUBLISHED
- personIds / documentIds from initial props flow through onSubmit
Closes Felix's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec asserting:
- empty geschichten → no <section> rendered
- >= 1 story → heading + story link visible
- canWrite=false → no "+ Geschichte schreiben" link
- canWrite=true → link with /geschichten/new?personId pre-fill
- 0–2 stories → no footer link
- 3+ stories → "Alle Geschichten zu {name}" footer link to /geschichten?personId
- excerpt is plain text (no <strong>, no <script>)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Browser-based component spec mirroring PersonTypeahead.svelte.spec.ts:
renders empty input, surfaces pre-selected chips with formatted date,
emits hidden documentIds inputs for each chip, debounces the search
against /api/documents/search, adds a chip on click, hides already-
selected docs from new dropdown results, and removes a chip on × click.
Closes Felix's review B2 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Story-detail body now uses an explicit Tailwind block-element selector
ruleset instead of the `prose` plugin, so the body fills the full max-w-3xl
parent width — previously `prose` clamped to ~65ch inside an already narrow
page.
GeschichtenCard heading and the "+ Geschichte schreiben" link now use
text-ink-2 (#4b5563 = 7.6:1 on white, AAA-passable) instead of text-ink-3
or text-ink/60. Same fix on the "+ Geschichte anhängen" link in the
Document drawer column and on the Personen / Dokumente section headers
on the story detail page.
Closes Leonie's review B1, B2 and S4 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a module docstring at the top of extractText.ts spelling out that this
is text extraction, not XSS sanitisation, and that callers must rely on
safeHtml() (DOMPurify) for security. Adds a Vitest test block with classic
XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript:
href) asserting that no markup is re-emitted, even though the module is
explicitly not a sanitiser.
Updates the two callers (/geschichten index, GeschichtenCard) to import
from the new path. The collapse-whitespace pass also makes the regex
fallback's output saner for excerpt rendering.
Closes Nora's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both /admin/groups/new and /admin/groups/[id] now expose BLOG_WRITE in the
standard-permissions card so admins can grant Geschichten authoring through
the UI instead of running raw SQL. Adds Paraglide labels in de/en/es.
Closes Markus's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both lockfiles were updated on every npm install, creating a drift surface
for nothing. CI, Docker and dev all use npm, so yarn.lock has no consumer.
Add it to .gitignore so future yarn-curious developers don't accidentally
re-introduce it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /persons/[id] +page.server.ts now fetches geschichten in parallel with
the other endpoints. Each test in this spec mocks the typed-client's GET
call sequentially, so each chain needs one extra resolved value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Person detail (/persons/[id]):
- Server load fetches GET /api/geschichten?status=PUBLISHED&personId={id}
in parallel with the existing person/document queries.
- Renders <GeschichtenCard> below the received-documents list when the
person has at least one published story.
Document detail (/documents/[id]):
- Server load adds the same parallel call with documentId={id}.
- DocumentTopBar gains geschichten + canBlogWrite props that flow through
to DocumentMetadataDrawer.
- DocumentMetadataDrawer's grid expands to lg:grid-cols-4 when the
Geschichten column should appear (stories exist OR user can author),
and shows "+ Geschichte anhängen" / "Alle anzeigen" links following the
>= 3-story threshold from issue comment #5758.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /geschichten — published-stories index with filter pills + "+ Neue Geschichte"
for BLOG_WRITERs; supports ?personId and ?documentId pre-filtering
- /geschichten/[id] — reader detail with sanitised {@html} body, person and
document chip sections, BLOG_WRITER edit/delete with confirm dialog
- /geschichten/new — editor with optional ?personId and ?documentId pre-fill
(silent ignore on unknown IDs to avoid leaking entity existence)
- /geschichten/[id]/edit — editor populated from existing story; BLOG_WRITE
guard redirects readers to the detail page
All routes load via createApiClient(fetch) with !response.ok error handling
following the project pattern; PATCH/DELETE go through raw fetch which the
Vite dev proxy / Caddy production proxy authenticates via cookie.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stripHtml() strips tags via DOMParser (browser) with a regex fallback for
SSR. plainExcerpt() truncates at a word boundary with an ellipsis. Both
covered by Vitest specs.
GeschichtenCard renders the top 3 published stories about a person on
/persons/[id], with an editorial excerpt, publication date, author, and a
"+ Geschichte schreiben" link visible only to BLOG_WRITERs. Footer link to
/geschichten?personId=... appears once geschichten.length >= 3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tiptap StarterKit configured for B/I/¶/H2/H3/UL/OL/history; code, codeBlock,
blockquote, strike, horizontalRule and hardBreak disabled to keep output
matching the backend HTML allow-list. Two-column responsive layout with the
editor body on the left and Personen / Dokumente / Status sections in the
sidebar. Sticky save bar adapts to DRAFT vs PUBLISHED state. Title-required
guard with inline error and beforeNavigate dirty-state guard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors PersonMultiSelect for documents: chip-style multi-select backed by
GET /api/documents/search?q=. Used in the Geschichte editor sidebar to link
referenced documents to a story.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
for the Geschichten index, detail, editor, and confirmation copy in
de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
prop type-checks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds OWASP Java HTML Sanitizer on the backend and DOMPurify on the frontend.
Together with Tiptap on the writer side they form a defense-in-depth chain
against XSS in the new Geschichte body field (issue #381).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @felix — tick().then() had no error handler; console.error
is now logged on failure, matching the existing deep-link scroll pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On mount, reads the task query param before the comment deep-link handler.
When task=transcribe, opens the transcription panel, scrolls the close button
into view, moves focus to it, then strips the param from the URL via replaceState.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five new tests verify:
- Card stays open when mouse moves mention → card (cancels 150ms timer)
- Card closes immediately on card mouseleave (no timer)
- Re-entering a mention cancels a pending close
- Card stays open when keyboard focus moves mention → card (WCAG 2.1.1)
- Card closes when keyboard focus leaves the card entirely
The keyboard tests drove adding onfocusin/onfocusout to PersonHoverCard's
root div, reusing the existing onmouseenter/onmouseleave callbacks so that
screen-reader and keyboard users get the same stay-open affordance as
mouse users. relatedTarget check prevents spurious closes on intra-card
focus movement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
PersonHoverCard was showing the hovered person as their own parent when stored
as the object side of a PARENT_OF row — now uses chipLabel/otherName from
relationshipLabels (same helpers the person detail page uses) to resolve the
correct name and label from the caller's perspective.
PersonMentionEditor: add allowSpaces:true so typing a last name after a space
no longer exits mention mode mid-query.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PersonHoverCard: alias is compared against both `lastName` and `displayName`
before showing as maiden name — prevents false positive when alias is stored
as the full current name (e.g. "Maria Schmidt" ≠ "Schmidt" but name unchanged)
- PersonMentionEditor: data-placeholder was set statically so the CSS ::before
rule showed the placeholder on any blur even with content; now a $effect
toggles the attribute based on editor.isEmpty
- TranscriptionReadView: hovering onto the card itself cancels the 150ms close
timer so the card stays open while reading it; leaving the card closes it
immediately — onmouseenter/onmouseleave wired through PersonHoverCard props
- hoverCardPosition: removed scrollX/scrollY offset since the card is now
position:fixed (scroll is already baked into getBoundingClientRect coords)
- MentionDropdown: raised z-index from z-20 to z-50 to render above the hover card
- vite.config.ts: pre-bundle Tiptap packages to avoid HMR waterfall on first load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per Markus #5616, the leaf-component fetch in the Tiptap suggestion plugin
violates the project-wide rule from frontend/CLAUDE.md ("Data flows from
+page.server.ts via props — never client-side API fetch"). Add an inline
block-comment explaining why this exception is justified (suggestion runs
client-side per keystroke; same auth surface; no server-side reshape
benefit) and points future readers at the open ADR follow-up plus Nora's
PersonSummaryDTO response-shape audit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Tiptap rewrite dropped the inline "create new person" affordance the
textarea-era component used to render. Without it the workflow regresses:
transcriber must close the dropdown, navigate to /persons/new, come back,
re-type the query. The m.person_mention_create_new() key is still in all
three locale files — add the link back as a 44px-tall row with a top
border separating it from the empty-state message.
target=_blank keeps document/editor state intact; rel=noopener prevents
reverse-tabnabbing. mousedown preventDefault keeps the editor focused
(the dropdown row pattern used for option rows).
Test: empty-state renders a link to /persons/new with the localised label.
Leonie #5621 (Major) + Elicit OQ-373-04.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two non-text-contrast failures, both flagged by Leonie #5621:
1. PersonMentionEditor mention pill: decoration-brand-mint (#A6DAD8) on
white is ≈1.7:1 — fails the 3:1 minimum for meaningful UI indicators.
Switch to decoration-ink/50, which matches the read-mode .person-mention
rule (≈6.4:1) and keeps a unified underline language across modes.
2. MentionDropdown highlighted-row ring: ring-brand-mint on bg-brand-mint/20
is ≈2.5:1 — same failure class. Switch to ring-brand-navy (≈14.5:1
against the highlight background) so keyboard-driven selection has a
clearly visible indicator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The disabled-state effect calls editor.setEditable, which triggers a
ProseMirror transaction → onUpdate → bind:value/mentionedPersons writes →
host re-render → child prop pass-through → effect re-fires. Without an
idempotence check, this exceeds Svelte's effect_update_depth and crashes
every consuming spec (TranscriptionBlock 22/22). Compare editor.isEditable
against the desired value first; only call setEditable when it actually
needs to change.
Follow-up to 6ef888a1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a CWE-79 regression test: a sidecar entry whose displayName contains
an <img onerror=alert(1)> payload must round-trip through deserialize and
the Tiptap renderHTML without producing a real <img> element in the editor
DOM. Locks down the "renderHTML's third tuple entry is a text node, never
parsed as HTML" invariant so a future "use innerHTML for performance"
refactor cannot silently regress.
Nora #5618 detection-gap concern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wrapping the editor with pointer-events-none was visual-only — keyboard users
could still tab into the contenteditable and type. Wire `editable: !disabled`
on the Tiptap Editor and a reactive `$effect` that calls setEditable when the
prop flips after mount; expose `aria-disabled="true"` on the wrapper so
screen readers announce the deactivated state.
Tests assert contenteditable=false and aria-disabled=true when disabled;
contenteditable=true otherwise.
Closes WCAG 2.1.1 / 4.1.2 — Felix #5615 + Leonie #5621 + Nora #5618 BLOCKER.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
errors.ts no longer references this code (the rename-propagation listener
was deleted) and the matching ErrorCode value is gone from the backend.
The Paraglide-compiled message helpers should not include strings nothing
calls — drop the entries from de/en/es to keep the i18n surface honest.
Felix #5615 + Elicit #5624 blocker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The textarea-era detectPersonMention helper has no production callers since
the suggestion plugin's char: '@' mechanism replaced it. Per "Dead code is
deleted, not commented out", remove the source file and its spec — the spec
was running but tested a function nobody calls.
Felix #5615 blocker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Placeholder uses ::before pseudo-element on the contenteditable's
data-placeholder attribute, only visible when the editor is unfocused
and empty. Removes the default ProseMirror focus ring since the outer
wrapper provides its own.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces captureTextarea + handleTextareaMouseUp (which read selection
bounds off a real <textarea>) with an onSelectionChange callback prop
on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The
editor emits the selected text directly so the parent no longer needs
DOM access.
Tests are updated to drive the contenteditable via the Selection API
instead of the now-deleted textarea.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the textarea-based editor with a Tiptap v3 contenteditable.
The custom Mention node uses personId/displayName attrs (instead of
Tiptap's default id/label) so mentionSerializer round-trips cleanly.
AC-1 fix (issue #372): when the user types '@Aug' and selects
'Auguste Raddatz', the mention node stores displayName: 'Aug' (the
typed query) — not the person's DB display name. This preserves
archival fidelity of the original transcription.
The MentionDropdown is mounted imperatively on document.body via
Svelte 5's mount(). Its three pieces of dynamic state (items,
command, clientRect) are passed as a single $state proxy (model)
because Svelte 5's mount() does not return prop accessors.
Spec is fully rewritten — all old tests used document.querySelector
('textarea') which is dead after the migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Svelte 5's mount() does not return prop accessors — setting
'instance.items = newValue' is a no-op. Switching to a single $state
proxy passed as 'model' lets the parent mutate fields and have the
dropdown react. The prop is named 'model' (not 'state') because the
$state rune name shadows a 'state' identifier in Svelte 5 templates.
Position class also switches from absolute to fixed so viewport-
relative DOMRect coordinates from clientRect() work when the dropdown
is mounted on document.body.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>