feat(transcription): re-edit existing @mention by pre-filling the search input (#628) #717

Merged
marcel merged 9 commits from feat/issue-628-mention-reedit into main 2026-06-03 07:55:30 +02:00
9 changed files with 1073 additions and 108 deletions

View File

@@ -506,6 +506,9 @@
"person_mention_create_new": "Neue Person anlegen",
"person_mention_results_count_singular": "1 Person gefunden",
"person_mention_results_count_plural": "{count} Personen gefunden",
"person_mention_edit_label": "Erwähnung bearbeiten",
"person_mention_editing_announce": "Erwähnung wird bearbeitet: {displayName}",
"person_mention_dismiss_label": "Suche schließen",
"transcription_editor_aria_label": "Transkriptionstext",
"person_born_name_prefix": "geb.",
"page_title_home": "Archiv",

View File

@@ -506,6 +506,9 @@
"person_mention_create_new": "Create new person",
"person_mention_results_count_singular": "1 person found",
"person_mention_results_count_plural": "{count} persons found",
"person_mention_edit_label": "Edit mention",
"person_mention_editing_announce": "Editing mention: {displayName}",
"person_mention_dismiss_label": "Close search",
"transcription_editor_aria_label": "Transcription text",
"person_born_name_prefix": "née",
"page_title_home": "Archive",

View File

@@ -506,6 +506,9 @@
"person_mention_create_new": "Crear nueva persona",
"person_mention_results_count_singular": "1 persona encontrada",
"person_mention_results_count_plural": "{count} personas encontradas",
"person_mention_edit_label": "Editar mención",
"person_mention_editing_announce": "Editando mención: {displayName}",
"person_mention_dismiss_label": "Cerrar búsqueda",
"transcription_editor_aria_label": "Texto de transcripción",
"person_born_name_prefix": "n.",
"page_title_home": "Archivo",

View File

@@ -2,7 +2,7 @@
import type { components } from '$lib/generated/api';
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
// Layered defence cap on the @mention search query length (CWE-400
// amplification). The <input maxlength> attribute below caps direct
@@ -31,17 +31,47 @@ type DropdownState = {
let {
model,
editorQuery = '',
onSearch = () => {}
onSearch = () => {},
ondismiss = () => {},
focusOnMount = false,
editingDisplayName = ''
}: {
model: DropdownState;
/** Text typed after `@` in the host editor. Mirrors into the search input
* until the user takes manual ownership by typing into the input itself. */
editorQuery?: string;
onSearch?: (query: string) => void;
/** Closes the dropdown without touching the document — invoked by the visible
* × dismiss control and by Escape on the re-edit path (#628 AC-4). */
ondismiss?: () => void;
/** Re-edit (#628) opens with the search field focused; the fresh-@ path keeps
* focus in the editor so typing flows to the contenteditable. */
focusOnMount?: boolean;
/** When set (re-edit, #628), the persistent live region announces the editing
* context for this mention. Routed through the SAME aria-live region as the
* result count — never a second live region (avoids the double-announce bug). */
editingDisplayName?: string;
} = $props();
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
let userHasEdited = $state(false);
let searchInput: HTMLInputElement;
onMount(() => {
if (focusOnMount) searchInput?.focus();
});
// Re-edit has no Tiptap suggestion plugin to forward keys, so the search input
// handles its own navigation: Escape dismisses (and is prevented from clearing
// the native search field), Arrow/Enter reuse the exported selection logic.
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
ondismiss();
return;
}
if (onKeyDown(event)) event.preventDefault();
}
// Intent-revealing alias used by both the persistent aria-live announcer and
// the visible empty-state copy. Folding the duplicated rule into one $derived
@@ -184,6 +214,7 @@ function selectItem(item: Person) {
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
</svg>
<input
bind:this={searchInput}
id="mention-search"
type="search"
data-test-search-input
@@ -194,8 +225,34 @@ function selectItem(item: Person) {
oninput={() => {
userHasEdited = true;
}}
onkeydown={handleSearchKeydown}
onmousedown={(e) => e.stopPropagation()}
/>
<!--
Visible dismiss control (#628 AC-4) — shared by the fresh-@ and the
re-edit paths. onmousedown preventDefault keeps the editor selection
from blurring before onclick fires; onclick handles both pointer and
keyboard (Enter/Space) activation.
-->
<button
type="button"
data-test-dismiss
aria-label={m.person_mention_dismiss_label()}
class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm text-ink-2 hover:bg-canvas focus:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
onmousedown={(e) => e.preventDefault()}
onclick={() => ondismiss()}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-5 w-5"
>
<path d="M6 6l12 12M18 6 6 18" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
<!--
@@ -208,7 +265,11 @@ function selectItem(item: Person) {
-->
<p class="sr-only" aria-live="polite">
{#if model.items.length === 0}
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
{#if editingDisplayName && !userHasEdited}
{m.person_mention_editing_announce({ displayName: editingDisplayName })}
{:else}
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
{/if}
{:else if model.items.length === 1}
{m.person_mention_results_count_singular()}
{:else}

View File

@@ -9,6 +9,7 @@ import type { PersonMention } from '$lib/shared/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import { debounce } from '$lib/shared/utils/debounce';
import MentionDropdown from './MentionDropdown.svelte';
import { createMentionNodeView } from './mentionNodeView';
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
type Person = components['schemas']['Person'];
@@ -63,6 +64,184 @@ type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
type CommitFn = (item: Person) => void;
type RectGetter = (() => DOMRect | null) | null;
// Tiptap's SuggestionProps types `command` against the default MentionNodeAttrs
// (id/label). Our custom Mention extension uses personId/displayName, so the
// render() adapter casts the renderProps to this looser shape locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
// Single owner of the dropdown lifecycle. Both entry points route through one
// controller — Tiptap's fresh-`@` suggestion (via the render() adapter below)
// and (#628) the pencil re-edit affordance — so at most one dropdown is ever
// mounted (the AC-6 single-dropdown invariant). open() closes any prior
// dropdown first; render() is a thin adapter over open()/update()/close().
type OpenOptions = { focusOnMount?: boolean; editingDisplayName?: string };
type MentionController = {
open: (clientRect: RectGetter, query: string, commit: CommitFn, opts?: OpenOptions) => void;
update: (clientRect: RectGetter, query: string, commit: CommitFn) => void;
close: () => void;
onKeyDown: (event: KeyboardEvent) => boolean;
};
function createMentionController(): MentionController {
// Request-token guard: every search AND every open bumps `requestId`;
// runSearch captures the id active when its fetch starts and discards the
// response if a newer search/open has happened since. Without this, a late
// response can repopulate a dropdown the user already moved on from (e.g.
// open A → open B → A's stale response). Sara on PR #629.
let requestId = 0;
let exports: DropdownExports | null = null;
const runSearch = async (query: string) => {
const id = requestId;
try {
// Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629.
const res = await fetch(
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
);
if (id !== requestId) return;
if (!res.ok) {
dropdownState.items = [];
return;
}
const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return;
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch {
if (id !== requestId) return;
dropdownState.items = [];
}
};
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
// Hoisted so onDestroy can cancel any pending fetch even after the editor is
// gone — a trailing debounced search would otherwise pollute later tests.
cancelPendingSearch = () => debouncedSearch.cancel();
const onSearch = (query: string) => {
requestId++;
if (query.trim() === '') {
debouncedSearch.cancel();
dropdownState.items = [];
return;
}
debouncedSearch(query);
};
const close = () => {
debouncedSearch.cancel();
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
exports = null;
}
};
// Clip the query once so the dropdown's editor-mirror sees the capped value
// (CWE-400 amplification — Nora #1 / Felix #3 on PR #629). The commit closure
// is supplied by the caller, so the fresh-insert path keeps clipping the
// inserted displayName while the relink path (#628) preserves the stored
// displayName by construction.
const writeState = (clientRect: RectGetter, query: string, commit: CommitFn) => {
dropdownState.command = commit;
dropdownState.clientRect = clientRect;
dropdownState.editorQuery = query.slice(0, MAX_QUERY_LENGTH);
};
// Close the dropdown without touching the document and return focus to the
// editor — wired to the dropdown's × control and the re-edit Escape path.
const dismiss = () => {
close();
editor?.commands.focus();
};
const open = (
clientRect: RectGetter,
query: string,
commit: CommitFn,
opts: OpenOptions = {}
) => {
// Single-dropdown invariant: tear down any open dropdown before mounting a
// new one, and bump the request token so a previous open's in-flight fetch
// cannot repopulate this dropdown.
close();
requestId++;
dropdownState.items = [];
writeState(clientRect, query, commit);
const mounted = mount(MentionDropdown, {
target: document.body,
props: {
model: dropdownState,
ondismiss: dismiss,
focusOnMount: opts.focusOnMount ?? false,
editingDisplayName: opts.editingDisplayName ?? '',
// MentionDropdown reads `editorQuery` off the shared state proxy via
// this getter — Svelte 5's mount() does not expose settable prop
// accessors, so we route through the proxy (same pattern as items).
get editorQuery() {
return dropdownState.editorQuery;
},
onSearch
}
});
mountedDropdown = mounted as object;
exports = mounted as unknown as DropdownExports;
};
const update = (clientRect: RectGetter, query: string, commit: CommitFn) => {
writeState(clientRect, query, commit);
};
const onKeyDown = (event: KeyboardEvent) => exports?.onKeyDown(event) ?? false;
return { open, update, close, onKeyDown };
}
const controller = createMentionController();
// Re-link an existing mention in place (#628 AC-3/AC-5). Captures the node's
// pos when the pencil opens and swaps ONLY personId via setNodeMarkup, so the
// stored displayName is preserved by construction — never round-tripped through
// insertContentAt (which would rebind displayName to the search query). The new
// personId comes solely from the selected Person (anti-spoof — never from the
// reflected data-person-id or the search input).
function commitRelink(pos: number): CommitFn {
return (item: Person) => {
if (!editor) return;
editor
.chain()
.focus()
.command(({ tr, state }) => {
const node = state.doc.nodeAt(pos);
if (!node || node.type.name !== 'mention') return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, personId: item.id });
return true;
})
.run();
controller.close();
};
}
// Entry point handed to the mention NodeView's pencil. Opens the one dropdown
// (closing any other first — AC-6) anchored at the mention and pre-filled with
// the stored displayName.
function requestRelink(getRect: () => DOMRect | null, displayName: string, pos: number) {
if (!editor || !editor.isEditable) return;
controller.open(getRect, displayName, commitRelink(pos), {
focusOnMount: true,
editingDisplayName: displayName
});
}
onMount(() => {
// Custom Mention node: uses personId / displayName instead of the
// default id / label attribute names so the mentionSerializer can
@@ -88,6 +267,12 @@ onMount(() => {
})
}
};
},
// #628: host the mention as a NodeView so each token carries a pencil
// re-edit affordance. renderHTML/renderText below stay for serialization
// and clipboard; this only governs the live in-editor DOM.
addNodeView() {
return createMentionNodeView(requestRelink);
}
});
@@ -172,120 +357,32 @@ onMount(() => {
])
.run();
},
// Thin adapter over the single mention controller. The fresh-`@`
// commit clips the typed query into the inserted displayName
// (AC-1, #380); the controller owns the dropdown lifecycle and the
// debounced/request-token search.
render() {
let exports: DropdownExports | null = null;
// Tiptap's SuggestionProps types `command` against the default
// MentionNodeAttrs (id/label). Our custom Mention extension uses
// personId/displayName, so we cast the renderProps locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
const buildFreshCommit = (loose: LooseRenderProps): CommitFn => {
const clippedQuery = loose.query.slice(0, MAX_QUERY_LENGTH);
return (item: Person) =>
loose.command({ personId: item.id, displayName: clippedQuery });
};
// Request-token guard: every onSearch invocation bumps `requestId`;
// runSearch captures the id active when its fetch starts and discards
// the response if a newer onSearch has fired since. Without this, a
// late response can repopulate the dropdown after the user cleared
// the search input. Sara on PR #629.
let requestId = 0;
const runSearch = async (query: string) => {
const id = requestId;
try {
// Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629.
const res = await fetch(
`/api/persons?review=true&q=${encodeURIComponent(query)}&size=${SEARCH_RESULT_LIMIT}`
);
if (id !== requestId) return;
if (!res.ok) {
dropdownState.items = [];
return;
}
const body = (await res.json()) as { items?: Person[] };
if (id !== requestId) return;
dropdownState.items = (body.items ?? []).slice(0, SEARCH_RESULT_LIMIT);
} catch {
if (id !== requestId) return;
dropdownState.items = [];
}
};
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
cancelPendingSearch = () => debouncedSearch.cancel();
const onSearch = (query: string) => {
requestId++;
if (query.trim() === '') {
debouncedSearch.cancel();
dropdownState.items = [];
return;
}
debouncedSearch(query);
};
const updateState = (renderProps: LooseRenderProps) => {
// Clip once here so both the inserted displayName and the
// dropdown's editor-mirror see the same value. The dropdown
// already clips the mirror (Nora #1 CWE-400), but without
// clipping at the command boundary an unclipped query would
// still flow through as the inserted displayName — visible
// UI divergence between "what I searched" and "what was
// inserted". Felix #3 on PR #629.
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
// AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) =>
renderProps.command({
personId: item.id,
displayName: clippedQuery
});
dropdownState.clientRect = renderProps.clientRect ?? null;
dropdownState.editorQuery = clippedQuery;
};
return {
onStart(renderProps) {
const loose = renderProps as unknown as LooseRenderProps;
updateState(loose);
// MentionDropdown reads `editorQuery` off the shared state
// proxy via its `editorQuery` prop binding below — this is
// the same pattern as `model.items`. We do not pass it as a
// separate prop because Svelte 5's mount() does not expose
// settable prop accessors, so we route through the proxy.
const mounted = mount(MentionDropdown, {
target: document.body,
props: {
model: dropdownState,
get editorQuery() {
return dropdownState.editorQuery;
},
onSearch
}
});
mountedDropdown = mounted as object;
exports = mounted as unknown as DropdownExports;
controller.open(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
},
onUpdate(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
const loose = renderProps as unknown as LooseRenderProps;
controller.update(loose.clientRect ?? null, loose.query, buildFreshCommit(loose));
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return exports?.onKeyDown(event) ?? false;
return controller.onKeyDown(event);
},
onExit() {
// Cancel any pending debounce so a closed dropdown's trailing
// runSearch cannot fire against the *next* dropdown's state.
// The hoisted `cancelPendingSearch` would be overwritten by
// the next render()'s onStart before the trailing call fires,
// so we cancel locally via the closure-scoped debouncedSearch.
// Felix #1 on PR #629.
debouncedSearch.cancel();
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
exports = null;
}
controller.close();
}
};
}

View File

@@ -771,3 +771,616 @@ describe('PersonMentionEditor — touch target', () => {
expect(option.className).toContain('min-h-[44px]');
});
});
// ─── #628: re-edit an existing @mention via the pencil affordance ─────────────
describe('PersonMentionEditor — #628 re-edit pencil', () => {
// A saved mention seeded into the editor: text "@Aug " + sidecar. The typed
// displayName is "Aug" (short form, #380 AC-1), distinct from the DB name.
const SAVED = { personId: 'p-aug', displayName: 'Aug' };
function editPencil() {
return page.getByRole('button', { name: m.person_mention_edit_label() });
}
async function renderSavedMention() {
const host = renderHost({ value: '@Aug ', mentionedPersons: [SAVED] });
await expect.element(editPencil()).toBeInTheDocument();
return host;
}
async function pencilElement(): Promise<HTMLElement> {
return (await editPencil().element()) as HTMLElement;
}
function clickPencil(btn: HTMLElement) {
// Native dispatch — CDP userEvent.click is unreliable for handlers attached
// imperatively inside a ProseMirror NodeView (same rationale as the option
// mousedown dispatch elsewhere in this file).
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
async function pickOption(name: RegExp) {
const el = (await page.getByRole('option', { name }).element()) as HTMLElement;
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
}
// AC-1 ----------------------------------------------------------------------
it('renders an edit pencil on a saved mention, labelled via aria-label', async () => {
await renderSavedMention();
await expect.element(editPencil()).toBeInTheDocument();
});
it('the pencil is keyboard-focusable (tabindex 0, focus lands on it)', async () => {
await renderSavedMention();
const btn = await pencilElement();
expect(btn.getAttribute('tabindex')).toBe('0');
btn.focus();
expect(document.activeElement).toBe(btn);
});
it('hides the pencil by default and reveals it on hover + focus-within (opacity swap)', async () => {
await renderSavedMention();
const btn = await pencilElement();
// Reveal is a class-driven instant opacity swap (no Tailwind CSS in the
// component test env — assert the mechanism structurally, as the existing
// touch-target test does for min-h-[44px]).
expect(btn.className).toContain('opacity-0');
expect(btn.className).toContain('group-hover/mention:opacity-100');
expect(btn.className).toContain('group-focus-within/mention:opacity-100');
});
it('renders the pencil in an always-present fixed-width slot (no reflow)', async () => {
await renderSavedMention();
const slot = document.querySelector('.mention-edit-slot') as HTMLElement | null;
expect(slot).not.toBeNull();
expect(slot!.className).toContain('w-4');
});
// AC-2 ----------------------------------------------------------------------
it('opens the dropdown pre-filled with the stored displayName when the pencil is clicked', async () => {
mockFetchEmpty();
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
});
it('opens the dropdown when the pencil is activated by keyboard (Enter)', async () => {
mockFetchEmpty();
await renderSavedMention();
const btn = await pencilElement();
btn.focus();
btn.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
});
// AC-3 ----------------------------------------------------------------------
it('relinks in place: picking a different person swaps data-person-id', async () => {
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
const token = document.querySelector('[data-type="mention"]') as HTMLElement;
expect(token.getAttribute('data-person-id')).toBe('p-anna');
});
});
it('relink preserves the displayed token text exactly (only personId changes)', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
});
});
// Serializer regression — text byte-identical, only personId swapped --------
it('serializer regression: re-link keeps the text byte-identical, swaps only personId', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-anna', displayName: 'Aug' }]);
});
});
// AC-5 — edited-then-picked (highest value): personId updates, text invariant
it('AC-5: editing the search input then picking sets the new personId', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await page.getByRole('searchbox').fill('Anna');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
});
});
it('AC-5: editing the search input then picking keeps the ORIGINAL displayName', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await page.getByRole('searchbox').fill('Anna');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
await pickOption(/Anna Schmidt/);
await vi.waitFor(() => {
// The #380 AC-1 invariant wins over the edited search input.
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.value).not.toContain('@Anna');
});
});
// AC-4 — dismiss leaves the node byte-identical on BOTH close paths ---------
function dismissButton() {
return page.getByRole('button', { name: m.person_mention_dismiss_label() });
}
it('AC-4: Escape closes the re-edit dropdown leaving the node byte-identical', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
it('AC-4: the visible dismiss control closes the re-edit dropdown leaving the node byte-identical', async () => {
mockFetchWithPersons();
const host = await renderSavedMention();
clickPencil(await pencilElement());
await expect.element(dismissButton()).toBeVisible();
const dismissEl = (await dismissButton().element()) as HTMLElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(async () => {
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
});
expect(host.snapshot.value).toBe('@Aug ');
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
// AC-9 parity — the re-edit dropdown is keyboard-operable on its own --------
it('re-edit open focuses the search input so keyboard users land in the field', async () => {
mockFetchEmpty();
await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
await vi.waitFor(() => {
expect(document.activeElement).toBe(search);
});
});
it('re-edit dropdown is keyboard-navigable: ArrowDown then Enter relinks', async () => {
mockFetchWithPersons(); // [AUGUSTE (p-aug, highlighted), ANNA (p-anna)]
const host = await renderSavedMention();
clickPencil(await pencilElement());
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLElement;
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
);
search.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
expect(host.snapshot.mentionedPersons[0].displayName).toBe('Aug');
});
});
// The × control is shared with the fresh-@ dropdown ------------------------
it('the fresh-@ dropdown also exposes the shared dismiss control', async () => {
mockFetchWithPersons();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await expect.element(dismissButton()).toBeVisible();
});
});
// ─── #628 AC-6: at most one mention dropdown open at a time ───────────────────
describe('PersonMentionEditor — #628 AC-6 single-dropdown invariant', () => {
const TWO_MENTIONS = [
{ personId: 'p-aug', displayName: 'Aug' },
{ personId: 'p-bert', displayName: 'Bert' }
];
function mentionButtons(): HTMLButtonElement[] {
return Array.from(
document.querySelectorAll('[data-type="mention"] button')
) as HTMLButtonElement[];
}
function click(el: HTMLElement) {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
function listboxCount() {
return document.querySelectorAll('[role="listbox"]').length;
}
it('pencil → pencil: opening a second mention pencil closes the first', async () => {
mockFetchEmpty();
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
const [pencilA, pencilB] = mentionButtons();
click(pencilA);
await vi.waitFor(() => expect(listboxCount()).toBe(1));
click(pencilB);
await vi.waitFor(async () => {
expect(listboxCount()).toBe(1);
await expect.element(page.getByRole('searchbox')).toHaveValue('Bert');
});
});
it('fresh-@ → pencil: activating a pencil closes the open fresh-@ dropdown', async () => {
mockFetchEmpty();
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
// Open a fresh-@ dropdown (prefilled with the typed query "x").
await userEvent.type(page.getByRole('textbox'), '@x');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('x');
});
// Now activate the saved mention's pencil — the fresh dropdown must give way.
click(mentionButtons()[0]);
await vi.waitFor(async () => {
expect(listboxCount()).toBe(1);
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
});
it('pencil → fresh-@: typing @ in the editor closes the open re-edit dropdown', async () => {
mockFetchEmpty();
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
await vi.waitFor(() => expect(mentionButtons().length).toBe(1));
click(mentionButtons()[0]);
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('Aug');
});
// Typing @ in the editor starts a fresh suggestion — the re-edit dropdown
// must give way to it (single owner, both orderings).
await userEvent.type(page.getByRole('textbox'), '@y');
await vi.waitFor(async () => {
expect(listboxCount()).toBe(1);
await expect.element(page.getByRole('searchbox')).toHaveValue('y');
});
});
it('regression: fresh-@ still inserts the typed text as displayName (#380 AC-1 intact)', async () => {
mockFetchWithPersons();
const host = renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
});
});
it('discards a stale fetch from a superseded open (open A → open B → A resolves)', async () => {
// A's fetch hangs; B's resolves empty. When A finally resolves with a
// person, the request-token guard must discard it so B's dropdown is not
// repopulated. Deterministic — no sleeps.
let resolveA!: (v: { ok: boolean; json: () => Promise<{ items: Person[] }> }) => void;
const pendingA = new Promise<{ ok: boolean; json: () => Promise<{ items: Person[] }> }>((r) => {
resolveA = r;
});
let call = 0;
const fetchMock = vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) return pendingA;
return Promise.resolve({ ok: true, json: () => Promise.resolve({ items: [] }) });
});
vi.stubGlobal('fetch', fetchMock);
renderHost({ value: '@Aug @Bert ', mentionedPersons: TWO_MENTIONS });
await vi.waitFor(() => expect(mentionButtons().length).toBe(2));
const [pencilA, pencilB] = mentionButtons();
click(pencilA);
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Aug'));
});
click(pencilB);
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Bert'));
});
// A's stale response arrives last — it must NOT repopulate B's dropdown.
resolveA({ ok: true, json: () => Promise.resolve({ items: [AUGUSTE] }) });
await tick();
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
});
});
// ─── #628 AC-7: pencil is inert when the editor is disabled (WCAG 2.1.1) ──────
describe('PersonMentionEditor — #628 AC-7 disabled editor', () => {
function mentionButton(): HTMLButtonElement | null {
return document.querySelector('[data-type="mention"] button');
}
it('renders the pencil disabled, aria-disabled and out of tab order when disabled', async () => {
renderHost({
value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
disabled: true
});
await vi.waitFor(() => {
const btn = mentionButton();
expect(btn).not.toBeNull();
expect(btn!.disabled).toBe(true);
expect(btn!.getAttribute('aria-disabled')).toBe('true');
expect(btn!.tabIndex).toBe(-1);
});
});
it('activating the disabled pencil (keyboard or pointer) mounts no dropdown', async () => {
mockFetchWithPersons();
renderHost({
value: '@Aug ',
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }],
disabled: true
});
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
const btn = mentionButton()!;
btn.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
// Give any (incorrectly) scheduled open a chance to mount before asserting.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
});
// ─── #628 AC-8: no pencil / dropdown where there is no mention under the caret ─
describe('PersonMentionEditor — #628 AC-8 no mention under caret', () => {
it('plain text shows no edit pencil and no dropdown', async () => {
renderHost({ value: 'Nur Text ohne Erwaehnung', mentionedPersons: [] });
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
expect(document.querySelector('[data-type="mention"]')).toBeNull();
expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(0);
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('two adjacent mentions each keep their own pencil and auto-open nothing', async () => {
renderHost({
value: '@Aug@Bert ',
mentionedPersons: [
{ personId: 'p-aug', displayName: 'Aug' },
{ personId: 'p-bert', displayName: 'Bert' }
]
});
await vi.waitFor(() =>
expect(document.querySelectorAll('[data-type="mention"]').length).toBe(2)
);
// One pencil per token (no spurious pencil for the gap between them) ...
expect(document.querySelectorAll('[data-type="mention"] button').length).toBe(2);
// ... and nothing opens just from caret position.
expect(document.querySelector('[role="listbox"]')).toBeNull();
});
it('a mention at the document start still renders its pencil', async () => {
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
await vi.waitFor(() =>
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
);
});
});
// ─── #628 security: query clip + personId provenance ──────────────────────────
describe('PersonMentionEditor — #628 security', () => {
const OVERSIZED = 'A'.repeat(150);
function mentionButton(): HTMLButtonElement {
return document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
}
it('clips an oversized stored displayName to MAX_QUERY_LENGTH in the search input, node text untouched', async () => {
mockFetchWithPersons();
const host = renderHost({
value: `@${OVERSIZED} `,
mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
});
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const search = (await page.getByRole('searchbox').element()) as HTMLInputElement;
expect(search.value.length).toBe(100);
// The preserved node text must NOT be truncated — only the search query is.
expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
});
it('re-link derives personId solely from the selected Person, never the DOM/search text', async () => {
mockFetchWithPersons(); // AUGUSTE (p-aug) + ANNA (p-anna)
const host = renderHost({
value: `@${OVERSIZED} `,
mentionedPersons: [{ personId: 'p-aug', displayName: OVERSIZED }]
});
await vi.waitFor(() => expect(mentionButton()).not.toBeNull());
mentionButton().dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Anna Schmidt/ })).toBeVisible();
});
const anna = (await page
.getByRole('option', { name: /Anna Schmidt/ })
.element()) as HTMLElement;
anna.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
// id comes from the picked Person (p-anna), not the reflected p-aug
// nor the clipped search text; the long displayName stays untouched.
expect(host.snapshot.mentionedPersons[0].personId).toBe('p-anna');
expect(host.snapshot.mentionedPersons[0].displayName.length).toBe(150);
});
});
});
// ─── #628 NFR a11y: editing context announced via the existing live region ───
describe('PersonMentionEditor — #628 editing announce', () => {
it('re-edit open announces the editing context through the single persistent live region', async () => {
mockFetchEmpty();
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
await vi.waitFor(() =>
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
);
(document.querySelector('[data-type="mention"] button') as HTMLElement).dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
await vi.waitFor(() => {
const liveRegions = document.querySelectorAll('[role="listbox"] [aria-live="polite"]');
// Exactly ONE live region (no second announcer — avoids double-announce).
expect(liveRegions.length).toBe(1);
expect(liveRegions[0].textContent ?? '').toContain(
m.person_mention_editing_announce({ displayName: 'Aug' })
);
});
});
});
// ─── #628 review fixes: hidden-pencil hit-testing + mid-session disable ───────
describe('PersonMentionEditor — #628 review fixes', () => {
it('keeps the hidden pencil out of hit-testing (pointer-events-none until revealed)', async () => {
// opacity-0 alone still hit-tests, and the 44px button overhangs adjacent
// text — so a click in the gap between mentions would otherwise land on the
// invisible pencil and open the dropdown (AC-8). It must be pointer-events-none
// while hidden and only become clickable together with the opacity reveal.
renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] });
await vi.waitFor(() =>
expect(document.querySelector('[data-type="mention"] button')).not.toBeNull()
);
const btn = document.querySelector('[data-type="mention"] button') as HTMLElement;
expect(btn.className).toContain('pointer-events-none');
expect(btn.className).toContain('group-hover/mention:pointer-events-auto');
expect(btn.className).toContain('group-focus-within/mention:pointer-events-auto');
});
it('re-syncs the pencil to inert when the editor is disabled mid-session', async () => {
// editor.setEditable() emits "update", not a transaction — the NodeView must
// listen on "update" or a runtime disable flip leaves the pencil stale.
let setDisabled!: (value: boolean) => void;
render(PersonMentionEditorHost, {
initialValue: '@Aug ',
initialMentions: [{ personId: 'p-aug', displayName: 'Aug' }],
disabled: false,
onChange: () => {},
onReady: (api: { setDisabled: (value: boolean) => void }) => {
setDisabled = api.setDisabled;
}
});
await vi.waitFor(() => {
const btn = document.querySelector(
'[data-type="mention"] button'
) as HTMLButtonElement | null;
expect(btn).not.toBeNull();
expect(btn!.disabled).toBe(false);
});
setDisabled(true);
await vi.waitFor(() => {
const btn = document.querySelector('[data-type="mention"] button') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.getAttribute('aria-disabled')).toBe('true');
expect(btn.tabIndex).toBe(-1);
});
});
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import PersonMentionEditor from './PersonMentionEditor.svelte';
import type { components } from '$lib/generated/api';
@@ -11,6 +11,9 @@ type Props = {
placeholder?: string;
disabled?: boolean;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
/** Hands the test a setter so it can flip `disabled` after mount — used to
* exercise the editor's reactive `disabled` $effect (mid-session toggle). */
onReady?: (api: { setDisabled: (value: boolean) => void }) => void;
};
let {
@@ -18,13 +21,19 @@ let {
initialMentions = [],
placeholder,
disabled = false,
onChange
onChange,
onReady
}: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
let value = $state(untrack(() => initialValue));
let mentionedPersons = $state<PersonMention[]>(untrack(() => [...initialMentions]));
let disabledState = $state(untrack(() => disabled));
onMount(() => {
onReady?.({ setDisabled: (next: boolean) => (disabledState = next) });
});
$effect(() => {
onChange({ value, mentionedPersons: [...mentionedPersons] });
@@ -35,5 +44,5 @@ $effect(() => {
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
disabled={disabled}
disabled={disabledState}
/>

View File

@@ -0,0 +1,167 @@
import type { Editor } from '@tiptap/core';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { m } from '$lib/paraglide/messages.js';
const SVG_NS = 'http://www.w3.org/2000/svg';
/**
* Opens the re-edit dropdown for a saved @mention. The host editor supplies
* this; the NodeView calls it when the pencil is activated.
*
* @param getRect anchor for the dropdown — the mention token's on-screen rect
* @param displayName the stored display text, used to pre-fill the search input
* @param pos the mention node's document position, captured at open time
* so the eventual re-link targets exactly this node
*/
export type RelinkRequest = (
getRect: () => DOMRect | null,
displayName: string,
pos: number
) => void;
type NodeViewArgs = {
node: ProseMirrorNode;
editor: Editor;
getPos: () => number | undefined;
};
// Static developer markup — no user data reaches the DOM here, so building the
// glyph element-by-element (never innerHTML) keeps the "user text is textContent
// only" rule honest across the whole NodeView.
function buildPencilIcon(): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'h-4 w-4');
const tip = document.createElementNS(SVG_NS, 'path');
tip.setAttribute('d', 'M12 20h9');
tip.setAttribute('stroke-linecap', 'round');
const body = document.createElementNS(SVG_NS, 'path');
body.setAttribute('d', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z');
body.setAttribute('stroke-linejoin', 'round');
svg.append(tip, body);
return svg;
}
/**
* Tiptap NodeView for a person @mention. Renders the `@displayName` token text
* (as `textContent` — the name is user-influenced, never `innerHTML`) plus a
* `contenteditable="false"` pencil button that opens the re-edit dropdown (#628).
*
* The pencil sits in a fixed-width slot revealed on whole-token hover and
* keyboard focus — an instant opacity swap (respects prefers-reduced-motion),
* so following words never shift and the caret model stays stable.
*/
export function createMentionNodeView(requestRelink: RelinkRequest) {
return ({ node, editor, getPos }: NodeViewArgs) => {
// Tracks the node's current attrs across update()s so the pencil always
// opens with the live displayName (relink only swaps personId today, but
// this keeps openRelink honest if displayName ever becomes mutable).
let currentNode = node;
const dom = document.createElement('span');
dom.className = 'mention-token group/mention';
dom.setAttribute('data-type', 'mention');
dom.setAttribute('data-person-id', node.attrs.personId ?? '');
dom.setAttribute('data-display-name', node.attrs.displayName ?? '');
const text = document.createElement('span');
text.className = 'underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium';
text.textContent = `@${node.attrs.displayName}`;
dom.appendChild(text);
// Fixed-width slot — always present so revealing the pencil never reflows
// the following text (NFR no-reflow). Only opacity changes on reveal.
const slot = document.createElement('span');
slot.className =
'mention-edit-slot ml-0.5 inline-flex w-4 shrink-0 items-center justify-center align-middle';
const button = document.createElement('button');
button.type = 'button';
button.contentEditable = 'false';
button.tabIndex = 0;
button.setAttribute('aria-label', m.person_mention_edit_label());
// Visible glyph stays ~16px; the 44px tap target comes from padding pulled
// back with negative margin so the larger hit box does not reflow the line.
// While hidden the button must be pointer-events-none — opacity-0 alone
// still hit-tests, and the 44px box overhangs the adjacent text, so a click
// in the gap between two mentions would otherwise land on this invisible
// button and spuriously open the dropdown (AC-8). Pointer events are
// re-enabled together with the opacity reveal on hover/focus.
button.className = [
'mention-edit-btn',
'-mx-3 -my-2 inline-flex h-11 w-11 items-center justify-center rounded-sm text-ink-2',
'pointer-events-none opacity-0 transition-none',
'group-hover/mention:pointer-events-auto group-hover/mention:opacity-100',
'group-focus-within/mention:pointer-events-auto group-focus-within/mention:opacity-100',
'focus:pointer-events-auto focus:opacity-100',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy',
'disabled:cursor-not-allowed'
].join(' ');
button.appendChild(buildPencilIcon());
slot.appendChild(button);
dom.appendChild(slot);
const openRelink = (event: Event) => {
event.preventDefault();
event.stopPropagation();
// AC-7: inert when the editor is disabled — independent of the wrapper's
// pointer-events-none, on both the pointer and the keyboard path.
if (!editor.isEditable) return;
const pos = getPos();
if (pos === undefined) return;
requestRelink(() => text.getBoundingClientRect(), currentNode.attrs.displayName ?? '', pos);
};
// Prevent mousedown from moving the editor selection before the click
// fires — keeps the captured pos valid. click + Enter/Space activate.
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('click', openRelink);
button.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') openRelink(event);
});
const syncEditable = () => {
const editable = editor.isEditable;
button.disabled = !editable;
if (editable) {
button.removeAttribute('aria-disabled');
button.tabIndex = 0;
} else {
button.setAttribute('aria-disabled', 'true');
button.tabIndex = -1;
}
};
syncEditable();
// editor.setEditable() emits "update" (not a ProseMirror transaction), so
// we listen on "update" to re-sync the pencil's inert state when `disabled`
// flips mid-session. "update" also skips selection-only changes, so this
// fires far less often than a "transaction" listener would.
editor.on('update', syncEditable);
return {
dom,
// The mention is an atom leaf — its DOM is fully owned here, so PM must
// ignore our mutations (token textContent / disabled attr) rather than
// trying to read them back into the document.
ignoreMutation: () => true,
// Pencil events are ours; everything else (caret placement on the token)
// flows to ProseMirror.
stopEvent: (event: Event) => button.contains(event.target as Node),
update(updatedNode: ProseMirrorNode) {
if (updatedNode.type.name !== 'mention') return false;
currentNode = updatedNode;
text.textContent = `@${updatedNode.attrs.displayName}`;
dom.setAttribute('data-person-id', updatedNode.attrs.personId ?? '');
dom.setAttribute('data-display-name', updatedNode.attrs.displayName ?? '');
return true;
},
destroy() {
editor.off('update', syncEditable);
}
};
};
}

View File

@@ -74,7 +74,16 @@ export default defineConfig({
'src/lib/document/**',
'src/hooks.server.ts'
],
exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'],
exclude: [
'**/*.svelte',
'**/*.svelte.ts',
'**/__mocks__/**',
// Tiptap NodeView — builds live ProseMirror DOM and only runs inside
// the browser editor, so it is exercised by the client project's
// browser tests, not this node server-coverage run. Browser-only UI
// .ts file, excluded like the actions/hooks this config measures around.
'src/lib/shared/discussion/mentionNodeView.ts'
],
thresholds: {
lines: 80,
functions: 80,