docs: round-3 currency pass
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m17s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 4m9s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s

- extractText.ts states the real safety invariant (output is text-only;
  JOURNEY intros arrive unsanitised by design)
- geschichte README: stale glyph/pill cells updated, formatAuthorName no
  longer claims an email fallback, formatDocumentMetaLine documented
- reader spec HTMLs: bg-white list-card cell and the mobile BottomSheet
  rows struck with the implemented decision (inline metabar actions)

Review round 3: Nora (2), Markus (2), Felix, Elicit (3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-11 08:31:17 +02:00
parent 66e9309d8a
commit ac6da154da
4 changed files with 23 additions and 17 deletions

View File

@@ -421,7 +421,7 @@
<tbody> <tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr> <tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr> <tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
<tr><td>Editorial list card</td><td>bg-white shadow-sm border border-brand-sand rounded-sm</td><td>wraps alle Zeilen</td></tr> <tr><td>Editorial list card</td><td><s>bg-white shadow-sm border border-brand-sand rounded-sm</s> <em>(implementiert: bg-surface border-line — semantische Tokens für Dark Mode)</em></td><td>wraps alle Zeilen</td></tr>
<tr class="grp"><td colspan="3">Listenzeile</td></tr> <tr class="grp"><td colspan="3">Listenzeile</td></tr>
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr> <tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
<tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr> <tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr>
@@ -658,7 +658,7 @@
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr> <tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr> <tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr> <tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>… Menü (Mobile)</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr> <tr><td>… Menü (Mobile)</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: Bearbeiten/Löschen bleiben inline in der Metazeile auf allen Breiten — kein BottomSheet)</em></td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr> <tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr> <tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
</tbody> </tbody>
@@ -713,7 +713,7 @@
<ul> <ul>
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li> <li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li> <li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
<li>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</li> <li><s>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets, kein BottomSheet)</em></li>
</ul> </ul>
<h3>Barrierefreiheit</h3> <h3>Barrierefreiheit</h3>

View File

@@ -634,7 +634,7 @@
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr> <tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr> <tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
<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>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><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr> <tr class="grp"><td colspan="3">Intro-Absatz</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><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 class="grp"><td colspan="3">Dokument-Item</td></tr>
@@ -650,7 +650,7 @@
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr> <tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-base 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 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>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></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> <tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
</tbody> </tbody>
</table> </table>
@@ -711,7 +711,7 @@
<h3>Berechtigungen</h3> <h3>Berechtigungen</h3>
<ul> <ul>
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li> <li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li> <li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
</ul> </ul>
<h3>Barrierefreiheit</h3> <h3>Barrierefreiheit</h3>

View File

@@ -23,16 +23,17 @@ Utilities: `utils.ts`.
| `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm | | `JourneyItemRow.svelte` | `JourneyEditor.svelte` | Item row: drag handle, move-up/down, note textarea (PATCH on blur), inline remove confirm |
| `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form | | `JourneyAddBar.svelte` | `JourneyEditor.svelte` | Two add buttons: document picker (`DocumentPickerDropdown`) and interlude draft form |
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | | `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | | `GeschichteListRow.svelte` | `/geschichten` (list) | Editorial list row: meta column (avatar, author, date, REISE badge), title + excerpt content column |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | | `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | | `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph | | `JourneyItemCard.svelte` | `JourneyReader.svelte` | Card per document item: title, meta line (date · von X an Y), "Brief öffnen →" link, mint-border note |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | | `JourneyInterlude.svelte` | `JourneyReader.svelte` | Left-accent interlude box between letters (mode-aware tokens); `aria-label="Kuratorennotiz"` |
## utils.ts ## utils.ts
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to `email` (for list/summary shapes). `formatAuthorName(author)` — joins `firstName + lastName`, falls back to the localized `person_unknown` key (for list/summary shapes; email is not exposed).
`formatAuthorDisplayName(author)` — returns `displayName` (for detail `AuthorView` shape). `formatAuthorDisplayName(author)` — returns `displayName`, localizing the server's `[Unbekannt]` fallback (for detail `AuthorView` shape).
`formatDocumentMetaLine(doc)``"12.07.1938 · von Franz an Emma"`; shared by `JourneyItemCard`, `JourneyItemRow`, and the story doc-reference cards.
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail). `formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
## Public list is PUBLISHED-only ## Public list is PUBLISHED-only

View File

@@ -1,13 +1,18 @@
/** /**
* **Not a sanitizer.** This module extracts visible text from a (presumed * **Not a sanitizer.** This module extracts visible text from an HTML (or
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY * plain-text) string for excerpt rendering. The safety invariant is: the
* because the Geschichte body is sanitised against the OWASP allow-list * OUTPUT must only ever be rendered via Svelte text interpolation — never
* on the server before persistence, and via DOMPurify on render. * `{@html}`. The DOMParser document is inert (scripts don't execute), but
* the returned string is whatever text the input carried.
*
* Note on inputs: STORY bodies are additionally sanitised against the OWASP
* allow-list on the server; JOURNEY intros are stored VERBATIM (unsanitised
* by design — see GeschichteService.bodyForType) and arrive here untrusted.
* *
* Do not use these helpers to defend against XSS — `safeHtml()` in * Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on * `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against * untrusted input does not protect against `javascript:` URLs,
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads. * event-handler attributes, or `<svg/onload>` payloads.
*/ */
/** /**