From 558e1e6b22b16127641de855db820558836faecb Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 09:01:39 +0200 Subject: [PATCH] fix(person-mention): truncate notes excerpt at last word boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leonie FINDING-04 + Elicit E5: notes.slice(0, 120) cuts mid-word, especially ugly in German compound nouns ("…Familienzu…"). Sara #7: the assertion .toBeLessThanOrEqual(122) was a magic number that hid this bug. Add truncateAtWordBoundary(text, max): cut at the last space inside the window unless it'd shrink the excerpt below 70% (single-word fallback). Single-word case still produces hard-cut + ellipsis so a 150-char word shows the first 120 chars + … rather than nothing. Tests pinned to exact strings. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonHoverCard.svelte | 21 ++++++++++-- .../components/PersonHoverCard.svelte.spec.ts | 34 +++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 74c36260..36a4989d 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -34,12 +34,29 @@ const dateRange = $derived( : '' ); +/** + * Cut the notes excerpt at the last word boundary inside the NOTES_MAX + * window. Mid-word truncation is especially ugly in German compound nouns + * ("…Familienzu…"), so prefer the previous space if there is one within + * a reasonable distance. Fall back to a hard cut for strings with no + * spaces at all (e.g. a single 150-char word). Leonie FINDING-04 / Elicit E5. + */ +function truncateAtWordBoundary(text: string, max: number): string { + if (text.length <= max) return text; + const window = text.slice(0, max); + const lastSpace = window.lastIndexOf(' '); + // If the last space is too close to the start (< 70% of the window) we'd + // produce a near-empty excerpt — fall back to the hard cut instead. + const minBoundary = Math.floor(max * 0.7); + const cut = lastSpace >= minBoundary ? window.slice(0, lastSpace) : window; + return cut + '…'; +} + const notesExcerpt = $derived.by(() => { if (state.status !== 'loaded') return null; const notes = state.person.notes; if (!notes) return null; - if (notes.length <= NOTES_MAX) return notes; - return notes.slice(0, NOTES_MAX) + '…'; + return truncateAtWordBoundary(notes, NOTES_MAX); }); // Accessible name for the region landmark — required by WCAG 1.3.1. diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts index 0d9dd955..f68bfdfc 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -197,7 +197,9 @@ describe('PersonHoverCard — loaded state', () => { await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument(); }); - it('truncates notes longer than 120 characters with an ellipsis', async () => { + it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => { + // Single 150-char word with no spaces: word-boundary cut would yield nothing, + // so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length). const long = 'x'.repeat(150); const withLongNotes = { ...AUGUSTE, notes: long } as Person; render(PersonHoverCard, { @@ -207,8 +209,34 @@ describe('PersonHoverCard — loaded state', () => { state: { status: 'loaded', person: withLongNotes, relationships: [] } }); const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; - expect(notes.textContent!.length).toBeLessThanOrEqual(122); - expect(notes.textContent).toContain('…'); + expect(notes.textContent).toBe('x'.repeat(120) + '…'); + }); + + it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => { + // 150-char string with spaces — must cut at the last space, not mid-word. + const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3); + // length is 180, last space at idx ≤120 + const withLongNotes = { ...AUGUSTE, notes: sentence } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withLongNotes, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; + const text = notes.textContent ?? ''; + // Ends with ellipsis + expect(text.endsWith('…')).toBe(true); + // Last char before the ellipsis is NOT a half-word — verify by checking that + // the position right before … is the end of a word (i.e., there's no letter + // further along in the original text immediately after our cut point). + const cut = text.slice(0, -1); // strip the … + // Find this cut substring in the original sentence + const idx = sentence.indexOf(cut); + expect(idx).toBe(0); + const charAfterCut = sentence[cut.length]; + // The next char should be a space — confirming we cut on a boundary + expect(charAfterCut).toBe(' '); }); it('omits notes section when notes is null', async () => {