fix(geschichte): raise journey reading text to the accessibility floor
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m49s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m7s
CI / fail2ban Regex (pull_request) Successful in 49s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s

Intro, letter titles, curator annotations, and interludes rendered at
12-14px — copied from the scaled mockup sizes in LR-2. Narrative text
is now 16-18px, meta lines and links 14px; the LR-2 impl-ref table is
updated to match.

Closes #800
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 22:38:59 +02:00
parent 3b58ac0457
commit 9c524443cf
7 changed files with 46 additions and 12 deletions

View File

@@ -636,19 +636,19 @@
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
<tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>

View File

@@ -14,5 +14,5 @@ let { note }: Props = $props();
class="my-4 rounded-r-sm border-l-2 border-orange-400 bg-orange-50 py-2 pr-3 pl-3"
>
<!-- plaintext — do NOT use {@html} here -->
<p class="text-xs leading-relaxed text-ink italic">{note}</p>
<p class="text-base leading-relaxed text-ink italic">{note}</p>
</div>

View File

@@ -35,6 +35,14 @@ describe('JourneyInterlude', () => {
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
});
it('note text uses readable body size (text-base, #800)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const text = document.querySelector('[role="note"] p');
expect(text!.className).toContain('text-base');
expect(text!.className).not.toContain('text-xs');
});
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
render(JourneyInterlude, {

View File

@@ -27,14 +27,14 @@ const hasNote = $derived(item.note != null && item.note.trim().length > 0);
<div class="mb-3">
<div class="rounded-sm border border-line bg-surface p-3">
<!-- plaintext — do NOT use {@html} here -->
<p class="mb-0.5 font-serif text-sm leading-snug text-ink">{doc.title}</p>
<p class="mb-0.5 font-serif text-base leading-snug text-ink">{doc.title}</p>
{#if metaLine}
<p class="mb-2 text-xs text-ink-3">{metaLine}</p>
<p class="mb-2 text-sm text-ink-3">{metaLine}</p>
{/if}
<a
href="/documents/{doc.id}"
aria-label={openAriaLabel}
class="inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg class="h-3 w-3 shrink-0" viewBox="0 0 10 12" fill="none">
<rect x="1" y="1" width="8" height="10" rx="1" stroke="currentColor" stroke-width="1" />
@@ -59,7 +59,7 @@ const hasNote = $derived(item.note != null && item.note.trim().length > 0);
{#if hasNote}
<!-- plaintext — do NOT use {@html} here -->
<div class="mt-3 rounded-r-sm border-l-2 border-brand-mint bg-muted py-1.5 pr-2 pl-3">
<p class="text-xs leading-relaxed text-ink-2 italic">{item.note}</p>
<p class="text-base leading-relaxed text-ink-2 italic">{item.note}</p>
</div>
{/if}
</div>

View File

@@ -134,6 +134,21 @@ describe('JourneyItemCard', () => {
expect(note!.className).toContain('bg-muted');
});
it('reading text sizes meet the accessibility floor (#800)', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
const title = page.getByText('Brief an Helene');
expect((await title.element()).className).toContain('text-base');
const link = await page.getByRole('link', { name: /öffnen/i }).element();
expect(link.className).toContain('text-sm');
expect(link.className).not.toContain('text-xs');
const noteText = document.querySelector('[class*="border-l-2"] p');
expect(noteText!.className).toContain('text-base');
expect(noteText!.className).not.toContain('text-xs');
});
it('omits annotation block when note is blank or whitespace', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });

View File

@@ -28,7 +28,7 @@ const validItems = $derived(
{#if introText}
<!-- plaintext — do NOT use {@html} here -->
<p
class="border-subtle mb-6 border-b border-dashed pb-4 font-serif text-sm leading-relaxed text-ink-2 italic"
class="border-subtle mb-6 border-b border-dashed pb-4 font-serif text-lg leading-relaxed text-ink-2 italic"
>
{introText}
</p>

View File

@@ -62,6 +62,17 @@ describe('JourneyReader', () => {
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
});
it('intro paragraph uses readable body size (text-lg, #800)', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }) }
});
const intro = document.querySelector('p');
expect(intro!.className).toContain('text-lg');
expect(intro!.className).not.toContain('text-sm');
});
it('omits intro paragraph when body is null', async () => {
render(JourneyReader, {
context: ctx(),