From fc67dfc3d54ca2ba5180d1af0ba8b4605b40eb10 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 10:45:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20prefix=20letter=20titles=20wi?= =?UTF-8?q?th=20the=20=E2=9C=89=20glyph=20(REQ-008/016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A present LetterCard title now reads "✉ {title}" with an aria-hidden glyph and an sr-only "Brief" label rendered as sibling nodes — never interpolated into the escaped user title, which keeps its own pre-line span for multi-line OCR text. No title → no glyph, no label (the row still shows sender → receiver and the date). An XSS regression pins the no-{@html} contract: an HTML-bearing title renders verbatim as text. Refs #833 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/LetterCard.svelte | 11 +++++-- .../lib/timeline/LetterCard.svelte.spec.ts | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte index 11a1b787..a093ab30 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte +++ b/frontend/src/lib/timeline/LetterCard.svelte @@ -29,9 +29,14 @@ const receiver = $derived( class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy" > {#if entry.title} - {entry.title} + + + + {m.timeline_letter_glyph_label()} + {entry.title} + {/if} {sender} diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts index 28df3886..f9c928bc 100644 --- a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; +import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import { timelineDateLabel } from './dateLabel'; import { makeEntry } from './test-factories'; @@ -55,4 +56,34 @@ describe('LetterCard', () => { const link = document.querySelector('a') as HTMLAnchorElement; expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); }); + + it('prefixes a present title with an aria-hidden ✉ and an sr-only "Brief" label (REQ-008)', () => { + render(LetterCard, { entry: makeEntry({ title: 'Brief aus Stettin', documentId: DOC_ID }) }); + const hidden = document.querySelector('[aria-hidden="true"]'); + expect(hidden?.textContent).toContain('✉'); + const srOnly = document.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label()); + // The glyph is decorative chrome — the document link is unchanged. + const link = document.querySelector('a') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`); + }); + + it('renders no ✉ glyph and no "Brief" label when the title is empty (REQ-016)', () => { + render(LetterCard, { + entry: makeEntry({ title: '', senderName: 'Karl', receiverName: 'Elfriede' }) + }); + expect(document.body.textContent).not.toContain('✉'); + expect(document.querySelector('.sr-only')).toBeNull(); + // The row still shows sender → receiver and the date. + expect(document.body.textContent).toContain('Karl'); + expect(document.body.textContent).toContain('Elfriede'); + expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull(); + }); + + it('renders an HTML-bearing title verbatim as text, never as markup (security, REQ-021)', () => { + const evil = ''; + render(LetterCard, { entry: makeEntry({ title: evil }) }); + expect(document.body.textContent).toContain(evil); + expect(document.querySelector('a script')).toBeNull(); + }); });