feat(timeline): prefix letter titles with the ✉ glyph (REQ-008/016)

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 10:45:47 +02:00
parent 08d8896cd1
commit fc67dfc3d5
2 changed files with 39 additions and 3 deletions

View File

@@ -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" 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} {#if entry.title}
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink" <!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
>{entry.title}</span interpolated into the escaped user title; the title keeps its own
> pre-line span for multi-line OCR text (REQ-008/016/021). -->
<span class="font-serif text-sm font-bold break-words text-ink">
<span aria-hidden="true"></span>
<span class="sr-only">{m.timeline_letter_glyph_label()}</span>
<span class="whitespace-pre-line">{entry.title}</span>
</span>
{/if} {/if}
<span class="mt-0.5 font-sans text-xs break-words text-ink-3"> <span class="mt-0.5 font-sans text-xs break-words text-ink-3">
<span class="font-serif whitespace-pre-line">{sender}</span> <span class="font-serif whitespace-pre-line">{sender}</span>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte'; import LetterCard from './LetterCard.svelte';
import { timelineDateLabel } from './dateLabel'; import { timelineDateLabel } from './dateLabel';
import { makeEntry } from './test-factories'; import { makeEntry } from './test-factories';
@@ -55,4 +56,34 @@ describe('LetterCard', () => {
const link = document.querySelector('a') as HTMLAnchorElement; const link = document.querySelector('a') as HTMLAnchorElement;
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); 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 = '<script>alert(1)</script>';
render(LetterCard, { entry: makeEntry({ title: evil }) });
expect(document.body.textContent).toContain(evil);
expect(document.querySelector('a script')).toBeNull();
});
}); });