feat: Themen-Inhaltsverzeichnis — Dashboard-Widget + dedizierte Seite /themen #662

Closed
opened 2026-05-25 14:33:36 +02:00 by marcel · 8 comments
Owner

Hintergrund

Nutzer beider Rollen (Leser und Editoren) haben keinen strukturierten Einstiegspunkt, um zu entdecken, welche Themen das Archiv enthält — ohne vorher wissen zu müssen, was sie suchen. Die Geschichten decken nur kuratierten Inhalt ab; die Suche erfordert einen bekannten Begriff. Die vorhandene Tag-Hierarchie (inkl. API-Endpunkt und Farbsystem) bildet eine vollständige Grundlage für ein browsebares Inhaltsverzeichnis.

Job-to-be-Done:
Wenn ich das Archiv ohne konkreten Auftrag öffne, will ich sehen, welche Themen es gibt, damit ich gezielt tiefer einsteigen kann.


Daten-Grundlage

Endpunkt (vorhanden): GET /api/tags/tree
Antwort-Typ: TagTreeNodeDTO { id, name, color, documentCount, children[], parentId }
Hinweis: color ist bereits der aufgelöste Farbwert des Root-Vorfahren (z. B. "sage"). Kinder erben automatisch.

Filterregel (Frontend): Ein Tag wird nur angezeigt, wenn er selbst oder mindestens ein Nachfahre documentCount > 0 hat. Leere Äste werden vollständig ausgeblendet.

Farbtoken: CSS-Variable --c-tag-{color} (z. B. --c-tag-sage). Diese Variablen sind palette-fest und ändern sich nicht zwischen Light und Dark Mode — nur der umgebende Kontrast wechselt.


Teil 1: Dashboard-Widget

Platzierung

Dashboard Position Breite
Reader Nach ReaderPersonChips, vor dem grid grid-cols-1 gap-1.5 sm:grid-cols-2 (RecentDocs / RecentStories) 100 % der Content-Spalte
Editor (Sidebar) In der Sidebar-Spalte (320 px fix), zwischen DashboardFamilyPulse und DashboardActivityFeed 320 px

Visuelle Struktur

┌────────────────────────────────────────────────────────────────┐
│  THEMEN                                          Alle Themen → │
│  ─────────────────────────────────────────────────────────────  │
│  ┌──────────────────────┐  ┌──────────────────────┐            │
│  │▓ Briefe         42 → │  │▓ Fotos          18 → │            │
│  └──────────────────────┘  └──────────────────────┘            │
│  ┌──────────────────────┐  ┌──────────────────────┐            │
│  │▓ Urkunden        7 → │  │▓ Tagebücher      3 → │            │
│  └──────────────────────┘  └──────────────────────┘            │
└────────────────────────────────────────────────────────────────┘

= farbiger Balken (4 px breit, volle Kartenhöhe), Farbe = --c-tag-{color}

In der 320 px-Sidebar: Grid wechselt auf 1-spaltig.

Impl-Ref: Widget

Element Tailwind-Klassen Px-Wert Light Mode Dark Mode
Äußere Karte rounded-sm border border-line bg-surface shadow-sm p-5 sand-getönte Oberfläche, grauer Rahmen dunkle Oberfläche, dunkler Rahmen
Abschnittstitel text-xs font-bold uppercase tracking-widest text-ink-3 font-sans mb-4 12 px / 700 gedämpftes Grau gedämpftes Hellgrau
„Alle Themen →" text-xs font-sans text-brand-mint hover:underline underline-offset-2 focus-visible:ring-2 focus-visible:ring-brand-navy outline-none 12 px mint mint (unverändert)
Tag-Karten-Grid grid grid-cols-2 gap-2 (in Sidebar: grid-cols-1)
Tag-Karte flex items-stretch rounded-sm border border-line bg-canvas hover:bg-surface cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-navy outline-none overflow-hidden min-h: 56 px canvas-Hintergrund, line-Rahmen dunkles Canvas, dunkler Rahmen
Farbbalken links w-1 flex-shrink-0 self-stretch + style="background: var(--c-tag-{color})" 4 px × 100 % Höhe Palette-Farbe (z. B. sage-grün) identisch — kein Dark-Mode-Variant
Tag-Inhalt-Wrapper flex flex-col justify-center px-3 py-3 gap-0.5 flex-1 min-w-0 padding: 12 px
Tag-Name text-sm font-serif font-semibold text-ink truncate 14 px / 600 dunkles Ink helles Ink
Dokumentzähler text-xs font-sans tabular-nums text-ink-3 12 px gedämpftes Grau gedämpftes Hellgrau

Breakpoints: Widget

Viewport Grid Verhalten
0–639 px (mobile) grid-cols-1 Eine Spalte, volle Breite
640–1023 px (tablet) grid-cols-2 Zwei Spalten
≥ 1024 px (desktop) grid-cols-2 Zwei Spalten
Sidebar (immer 320 px) grid-cols-1 Immer einspaltg — per Prop oder Wrapper-Klasse zu steuern

Teil 2: Dedizierte Seite /themen

Route: frontend/src/routes/themen/+page.svelte + +page.server.ts

Visuelle Struktur

Desktop (≥ 1024 px) — 3 Spalten:
┌─────────────────────────────────────────────────────────────────────┐
│  ← Zurück     Themen                                                │
│  ──────────────────────────────────────────────────────────────────  │
│  ┌──────────────────────┐  ┌──────────────────────┐  ┌──────────── │
│  │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│  │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│  │▓▓▓▓▓▓▓▓▓▓▓▓ │
│  │ Briefe          42 → │  │ Fotos           18 → │  │ Urkunden  7→│
│  │ ─────────────────── │  │ ─────────────────── │  │ ──────────── │
│  │ Brautbriefe     18→ │  │ Portraits        9→ │  │ Geburt    3→ │
│  │ Kriegsbriefe    12→ │  │ Familienfotos    6→ │  │ Heirat    2→ │
│  │ Familienbriefe   9→ │  │ Ortsaufnahmen    3→ │  │ Tod       2→ │
│  └──────────────────────┘  └──────────────────────┘  └──────────── │
└─────────────────────────────────────────────────────────────────────┘

Mobile (0–639 px) — 1 Spalte:
┌───────────────────────────────┐
│  ← Zurück   Themen            │
│  ─────────────────────────── │
│  ┌───────────────────────┐   │
│  │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│   │
│  │ Briefe            42→ │   │
│  │ ─────────────────── │   │
│  │ Brautbriefe      18→ │   │
│  │ Kriegsbriefe     12→ │   │
│  │ Familienbriefe    9→ │   │
│  └───────────────────────┘   │
│  ┌───────────────────────┐   │
│  │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│   │
│  │ Fotos             18→ │   │
│  └───────────────────────┘   │
└───────────────────────────────┘

▓▓ = farbiger Balken oben (6 px hoch, volle Kartenbreite), Farbe = --c-tag-{color}

Impl-Ref: Seite

Element Tailwind-Klassen Px-Wert Light Mode Dark Mode
Seiten-Wrapper max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8
Seitentitel text-2xl font-serif font-semibold text-ink mb-6 24 px / 600 dunkles Ink helles Ink
Zurück-Button <BackButton> (bestehende Komponente)
Themen-Grid grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3
Themen-Karte rounded-sm border border-line bg-surface shadow-sm overflow-hidden sand surface, grauer Rahmen dunkle Oberfläche, dunkler Rahmen
Farbbalken oben h-1.5 w-full flex-shrink-0 + style="background: var(--c-tag-{color})" 6 px × 100 % Breite Palette-Farbe identisch — kein Dark-Mode-Variant
Karten-Header (Link) flex items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset outline-none min-h: 56 px hover: dunkles Canvas
Root-Tag-Name text-base font-serif font-semibold text-ink 16 px / 600 dunkles Ink helles Ink
Root-Dokumentzähler text-sm font-sans tabular-nums text-ink-3 ml-auto mr-1 14 px gedämpftes Grau gedämpftes Hellgrau
Header-Pfeil h-3.5 w-3.5 text-brand-mint flex-shrink-0 + aria-hidden="true" 14 px mint mint (unverändert)
Trennlinie border-t border-line mx-4 helles Grau dunkles Grau
Kind-Zeile (Link) flex items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:outline-none min-h: 44 px via py-2.5 hover: dunkles Canvas
Kind-Name text-sm font-sans text-ink 14 px dunkles Ink helles Ink
Kind-Zähler text-xs font-sans tabular-nums text-ink-3 ml-auto mr-1 12 px gedämpftes Grau gedämpftes Hellgrau
Kind-Pfeil h-3 w-3 text-brand-mint flex-shrink-0 + aria-hidden="true" 12 px mint mint (unverändert)
„+ N weitere →" px-4 py-2.5 text-sm font-sans text-ink-3 hover:text-ink hover:bg-canvas block min-h: 44 px gedämpft gedämpft, hover heller

Breakpoints: Seite

Viewport Grid Max. sichtbare Kinder pro Karte
0–639 px (mobile) grid-cols-1 3
640–1023 px (tablet) sm:grid-cols-2 5
≥ 1024 px (desktop) lg:grid-cols-3 5

Bei mehr Kindern als das Maximum erscheint eine „+ N weitere →"-Zeile, die zur gefilterten Suche nach dem Root-Tag verlinkt.


Aktion Ziel
Widget „Alle Themen →" /themen
Widget-Tag-Karte (Klick) / mit Tag-ID als aktivem Suchfilter
/themen Karten-Header / mit Root-Tag-ID als aktivem Suchfilter
/themen Kind-Zeile / mit Kind-Tag-ID als aktivem Suchfilter

Die Suchseite expandiert Tags bereits auf Nachfahren (bestehende Logik in DocumentService).


Accessibility

  • Alle Tag-Karten und Kind-Zeilen sind <a href="...">, kein <button>
  • Root-Tag-Link: aria-label="{name}, {count} Dokumente"
  • Farbbalken und Pfeile: aria-hidden="true" (dekorativ)
  • Focus-Ring: focus-visible:ring-2 focus-visible:ring-brand-navy auf allen interaktiven Elementen
  • Tab-Reihenfolge: Header-Link zuerst, dann Kind-Zeilen von oben nach unten
  • Kontrastvorgaben: alle text-ink-Texte erfüllen ≥ 4.5:1 in Light und Dark Mode (semantische Tokens sichern das automatisch)

Edge Cases

Fall Verhalten
Root-Tag ohne Kinder Karte zeigt nur den Header-Bereich; keine Trennlinie, keine Kind-Liste
Root-Tag mit documentCount = 0, aber Kinder mit Docs Karte wird angezeigt; eigener Zähler zeigt 0 (oder wird weggelassen)
Alle Tags haben documentCount = 0 Seite und Widget zeigen Leer-Zustand: „Noch keine Themen vergeben."
API-Fehler Seite zeigt Fehlertext mit Retry-Hinweis; kein leeres Grid

Acceptance Criteria

Scenario: Reader sieht Themen-Widget auf dem Dashboard
  Given ich bin als Leser eingeloggt
  When ich die Startseite öffne
  Then sehe ich das Widget „Themen" nach den Personen-Chips
  And jede Tag-Karte zeigt Name und Dokumentanzahl
  And nur Tags mit mindestens einem Dokument (direkt oder via Nachfahren) werden angezeigt

Scenario: Editor sieht Themen-Widget in der Sidebar
  Given ich bin als Editor eingeloggt
  When ich die Startseite öffne
  Then sehe ich das Widget „Themen" in der Sidebar zwischen DashboardFamilyPulse und DashboardActivityFeed
  And das Widget ist einspaltg (Grid-cols-1)

Scenario: Navigation vom Widget zur Suche
  Given ich sehe das Themen-Widget
  When ich auf eine Tag-Karte klicke
  Then werde ich zur Suchseite mit dem entsprechenden Tag als aktivem Filter weitergeleitet

Scenario: Navigation zur Themenseite
  Given ich sehe das Themen-Widget
  When ich auf „Alle Themen →" klicke
  Then öffnet sich /themen mit allen Root-Tags als Karten in einem responsiven Grid

Scenario: Volle Hierarchie auf /themen
  Given ich bin auf /themen
  Then sehe ich pro Root-Tag eine Karte mit Farbbalken, Name und Dokumentanzahl
  And die ersten 5 Kind-Tags sind als Links gelistet
  And bei mehr als 5 Kindern erscheint „+ N weitere →" als letzter Eintrag

Scenario: Touch-Target auf Mobile (375 px)
  Given ich nutze ein Gerät mit 375 px Breite
  When ich /themen öffne
  Then hat jede interaktive Zeile eine Mindesthöhe von 44 px
  And das Grid ist einspaltg

Scenario: Dark Mode — Farbbalken
  Given Dark Mode ist aktiv
  When ich /themen oder das Dashboard-Widget öffne
  Then zeigen die Farbbalken dieselben Palette-Farben wie im Light Mode (--c-tag-{color} ist modus-neutral)
  And alle Texte haben ausreichend Kontrast auf dem dunklen Hintergrund

Scenario: Leer-Zustand
  Given alle Tags haben documentCount = 0
  When ich /themen öffne
  Then sehe ich den Text „Noch keine Themen vergeben." statt eines leeren Grids

Out of Scope

  • Eigener Eintrag in der Hauptnavigation (Seite ist nur über das Dashboard-Widget erreichbar)
  • Aufklappbare Bäume auf /themen (Kind-Tags sind immer sichtbar, kein Toggle)
  • Tags tiefer als Ebene 2 auf /themen (Kinder von Kindern werden nicht angezeigt)
  • Editor-Sidebar auf Mobile (kollabiert bereits im bestehenden Layout)
## Hintergrund Nutzer beider Rollen (Leser und Editoren) haben keinen strukturierten Einstiegspunkt, um zu entdecken, **welche Themen** das Archiv enthält — ohne vorher wissen zu müssen, was sie suchen. Die Geschichten decken nur kuratierten Inhalt ab; die Suche erfordert einen bekannten Begriff. Die vorhandene Tag-Hierarchie (inkl. API-Endpunkt und Farbsystem) bildet eine vollständige Grundlage für ein browsebares Inhaltsverzeichnis. **Job-to-be-Done:** Wenn ich das Archiv ohne konkreten Auftrag öffne, will ich sehen, welche Themen es gibt, damit ich gezielt tiefer einsteigen kann. --- ## Daten-Grundlage **Endpunkt (vorhanden):** `GET /api/tags/tree` **Antwort-Typ:** `TagTreeNodeDTO { id, name, color, documentCount, children[], parentId }` **Hinweis:** `color` ist bereits der aufgelöste Farbwert des Root-Vorfahren (z. B. `"sage"`). Kinder erben automatisch. **Filterregel (Frontend):** Ein Tag wird nur angezeigt, wenn er selbst oder mindestens ein Nachfahre `documentCount > 0` hat. Leere Äste werden vollständig ausgeblendet. **Farbtoken:** CSS-Variable `--c-tag-{color}` (z. B. `--c-tag-sage`). Diese Variablen sind palette-fest und ändern sich **nicht** zwischen Light und Dark Mode — nur der umgebende Kontrast wechselt. --- ## Teil 1: Dashboard-Widget ### Platzierung | Dashboard | Position | Breite | |---|---|---| | **Reader** | Nach `ReaderPersonChips`, vor dem `grid grid-cols-1 gap-1.5 sm:grid-cols-2` (RecentDocs / RecentStories) | 100 % der Content-Spalte | | **Editor (Sidebar)** | In der Sidebar-Spalte (320 px fix), zwischen `DashboardFamilyPulse` und `DashboardActivityFeed` | 320 px | ### Visuelle Struktur ``` ┌────────────────────────────────────────────────────────────────┐ │ THEMEN Alle Themen → │ │ ───────────────────────────────────────────────────────────── │ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │▓ Briefe 42 → │ │▓ Fotos 18 → │ │ │ └──────────────────────┘ └──────────────────────┘ │ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │▓ Urkunden 7 → │ │▓ Tagebücher 3 → │ │ │ └──────────────────────┘ └──────────────────────┘ │ └────────────────────────────────────────────────────────────────┘ ``` `▓` = farbiger Balken (4 px breit, volle Kartenhöhe), Farbe = `--c-tag-{color}` In der 320 px-Sidebar: Grid wechselt auf 1-spaltig. ### Impl-Ref: Widget | Element | Tailwind-Klassen | Px-Wert | Light Mode | Dark Mode | |---|---|---|---|---| | Äußere Karte | `rounded-sm border border-line bg-surface shadow-sm p-5` | — | sand-getönte Oberfläche, grauer Rahmen | dunkle Oberfläche, dunkler Rahmen | | Abschnittstitel | `text-xs font-bold uppercase tracking-widest text-ink-3 font-sans mb-4` | 12 px / 700 | gedämpftes Grau | gedämpftes Hellgrau | | „Alle Themen →" | `text-xs font-sans text-brand-mint hover:underline underline-offset-2 focus-visible:ring-2 focus-visible:ring-brand-navy outline-none` | 12 px | mint | mint (unverändert) | | Tag-Karten-Grid | `grid grid-cols-2 gap-2` (in Sidebar: `grid-cols-1`) | — | — | — | | Tag-Karte | `flex items-stretch rounded-sm border border-line bg-canvas hover:bg-surface cursor-pointer focus-visible:ring-2 focus-visible:ring-brand-navy outline-none overflow-hidden` | min-h: 56 px | canvas-Hintergrund, line-Rahmen | dunkles Canvas, dunkler Rahmen | | Farbbalken links | `w-1 flex-shrink-0 self-stretch` + `style="background: var(--c-tag-{color})"` | 4 px × 100 % Höhe | Palette-Farbe (z. B. sage-grün) | **identisch** — kein Dark-Mode-Variant | | Tag-Inhalt-Wrapper | `flex flex-col justify-center px-3 py-3 gap-0.5 flex-1 min-w-0` | padding: 12 px | — | — | | Tag-Name | `text-sm font-serif font-semibold text-ink truncate` | 14 px / 600 | dunkles Ink | helles Ink | | Dokumentzähler | `text-xs font-sans tabular-nums text-ink-3` | 12 px | gedämpftes Grau | gedämpftes Hellgrau | ### Breakpoints: Widget | Viewport | Grid | Verhalten | |---|---|---| | 0–639 px (mobile) | `grid-cols-1` | Eine Spalte, volle Breite | | 640–1023 px (tablet) | `grid-cols-2` | Zwei Spalten | | ≥ 1024 px (desktop) | `grid-cols-2` | Zwei Spalten | | Sidebar (immer 320 px) | `grid-cols-1` | Immer einspaltg — per Prop oder Wrapper-Klasse zu steuern | --- ## Teil 2: Dedizierte Seite `/themen` **Route:** `frontend/src/routes/themen/+page.svelte` + `+page.server.ts` ### Visuelle Struktur ``` Desktop (≥ 1024 px) — 3 Spalten: ┌─────────────────────────────────────────────────────────────────────┐ │ ← Zurück Themen │ │ ────────────────────────────────────────────────────────────────── │ │ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────── │ │ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ │▓▓▓▓▓▓▓▓▓▓▓▓ │ │ │ Briefe 42 → │ │ Fotos 18 → │ │ Urkunden 7→│ │ │ ─────────────────── │ │ ─────────────────── │ │ ──────────── │ │ │ Brautbriefe 18→ │ │ Portraits 9→ │ │ Geburt 3→ │ │ │ Kriegsbriefe 12→ │ │ Familienfotos 6→ │ │ Heirat 2→ │ │ │ Familienbriefe 9→ │ │ Ortsaufnahmen 3→ │ │ Tod 2→ │ │ └──────────────────────┘ └──────────────────────┘ └──────────── │ └─────────────────────────────────────────────────────────────────────┘ Mobile (0–639 px) — 1 Spalte: ┌───────────────────────────────┐ │ ← Zurück Themen │ │ ─────────────────────────── │ │ ┌───────────────────────┐ │ │ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ │ │ │ Briefe 42→ │ │ │ │ ─────────────────── │ │ │ │ Brautbriefe 18→ │ │ │ │ Kriegsbriefe 12→ │ │ │ │ Familienbriefe 9→ │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ │ │ │ Fotos 18→ │ │ │ └───────────────────────┘ │ └───────────────────────────────┘ ``` `▓▓` = farbiger Balken oben (6 px hoch, volle Kartenbreite), Farbe = `--c-tag-{color}` ### Impl-Ref: Seite | Element | Tailwind-Klassen | Px-Wert | Light Mode | Dark Mode | |---|---|---|---|---| | Seiten-Wrapper | `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8` | — | — | — | | Seitentitel | `text-2xl font-serif font-semibold text-ink mb-6` | 24 px / 600 | dunkles Ink | helles Ink | | Zurück-Button | `<BackButton>` (bestehende Komponente) | — | — | — | | Themen-Grid | `grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3` | — | — | — | | Themen-Karte | `rounded-sm border border-line bg-surface shadow-sm overflow-hidden` | — | sand surface, grauer Rahmen | dunkle Oberfläche, dunkler Rahmen | | Farbbalken oben | `h-1.5 w-full flex-shrink-0` + `style="background: var(--c-tag-{color})"` | 6 px × 100 % Breite | Palette-Farbe | **identisch** — kein Dark-Mode-Variant | | Karten-Header (Link) | `flex items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset outline-none` | min-h: 56 px | — | hover: dunkles Canvas | | Root-Tag-Name | `text-base font-serif font-semibold text-ink` | 16 px / 600 | dunkles Ink | helles Ink | | Root-Dokumentzähler | `text-sm font-sans tabular-nums text-ink-3 ml-auto mr-1` | 14 px | gedämpftes Grau | gedämpftes Hellgrau | | Header-Pfeil | `h-3.5 w-3.5 text-brand-mint flex-shrink-0` + `aria-hidden="true"` | 14 px | mint | mint (unverändert) | | Trennlinie | `border-t border-line mx-4` | — | helles Grau | dunkles Grau | | Kind-Zeile (Link) | `flex items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:outline-none` | **min-h: 44 px** via `py-2.5` | — | hover: dunkles Canvas | | Kind-Name | `text-sm font-sans text-ink` | 14 px | dunkles Ink | helles Ink | | Kind-Zähler | `text-xs font-sans tabular-nums text-ink-3 ml-auto mr-1` | 12 px | gedämpftes Grau | gedämpftes Hellgrau | | Kind-Pfeil | `h-3 w-3 text-brand-mint flex-shrink-0` + `aria-hidden="true"` | 12 px | mint | mint (unverändert) | | „+ N weitere →" | `px-4 py-2.5 text-sm font-sans text-ink-3 hover:text-ink hover:bg-canvas block` | min-h: 44 px | gedämpft | gedämpft, hover heller | ### Breakpoints: Seite | Viewport | Grid | Max. sichtbare Kinder pro Karte | |---|---|---| | 0–639 px (mobile) | `grid-cols-1` | 3 | | 640–1023 px (tablet) | `sm:grid-cols-2` | 5 | | ≥ 1024 px (desktop) | `lg:grid-cols-3` | 5 | Bei mehr Kindern als das Maximum erscheint eine „+ N weitere →"-Zeile, die zur gefilterten Suche nach dem Root-Tag verlinkt. --- ## Navigation / Links | Aktion | Ziel | |---|---| | Widget „Alle Themen →" | `/themen` | | Widget-Tag-Karte (Klick) | `/` mit Tag-ID als aktivem Suchfilter | | `/themen` Karten-Header | `/` mit Root-Tag-ID als aktivem Suchfilter | | `/themen` Kind-Zeile | `/` mit Kind-Tag-ID als aktivem Suchfilter | Die Suchseite expandiert Tags bereits auf Nachfahren (bestehende Logik in `DocumentService`). --- ## Accessibility - Alle Tag-Karten und Kind-Zeilen sind `<a href="...">`, kein `<button>` - Root-Tag-Link: `aria-label="{name}, {count} Dokumente"` - Farbbalken und Pfeile: `aria-hidden="true"` (dekorativ) - Focus-Ring: `focus-visible:ring-2 focus-visible:ring-brand-navy` auf allen interaktiven Elementen - Tab-Reihenfolge: Header-Link zuerst, dann Kind-Zeilen von oben nach unten - Kontrastvorgaben: alle `text-ink`-Texte erfüllen ≥ 4.5:1 in Light **und** Dark Mode (semantische Tokens sichern das automatisch) --- ## Edge Cases | Fall | Verhalten | |---|---| | Root-Tag ohne Kinder | Karte zeigt nur den Header-Bereich; keine Trennlinie, keine Kind-Liste | | Root-Tag mit `documentCount = 0`, aber Kinder mit Docs | Karte wird angezeigt; eigener Zähler zeigt 0 (oder wird weggelassen) | | Alle Tags haben `documentCount = 0` | Seite und Widget zeigen Leer-Zustand: „Noch keine Themen vergeben." | | API-Fehler | Seite zeigt Fehlertext mit Retry-Hinweis; kein leeres Grid | --- ## Acceptance Criteria ```gherkin Scenario: Reader sieht Themen-Widget auf dem Dashboard Given ich bin als Leser eingeloggt When ich die Startseite öffne Then sehe ich das Widget „Themen" nach den Personen-Chips And jede Tag-Karte zeigt Name und Dokumentanzahl And nur Tags mit mindestens einem Dokument (direkt oder via Nachfahren) werden angezeigt Scenario: Editor sieht Themen-Widget in der Sidebar Given ich bin als Editor eingeloggt When ich die Startseite öffne Then sehe ich das Widget „Themen" in der Sidebar zwischen DashboardFamilyPulse und DashboardActivityFeed And das Widget ist einspaltg (Grid-cols-1) Scenario: Navigation vom Widget zur Suche Given ich sehe das Themen-Widget When ich auf eine Tag-Karte klicke Then werde ich zur Suchseite mit dem entsprechenden Tag als aktivem Filter weitergeleitet Scenario: Navigation zur Themenseite Given ich sehe das Themen-Widget When ich auf „Alle Themen →" klicke Then öffnet sich /themen mit allen Root-Tags als Karten in einem responsiven Grid Scenario: Volle Hierarchie auf /themen Given ich bin auf /themen Then sehe ich pro Root-Tag eine Karte mit Farbbalken, Name und Dokumentanzahl And die ersten 5 Kind-Tags sind als Links gelistet And bei mehr als 5 Kindern erscheint „+ N weitere →" als letzter Eintrag Scenario: Touch-Target auf Mobile (375 px) Given ich nutze ein Gerät mit 375 px Breite When ich /themen öffne Then hat jede interaktive Zeile eine Mindesthöhe von 44 px And das Grid ist einspaltg Scenario: Dark Mode — Farbbalken Given Dark Mode ist aktiv When ich /themen oder das Dashboard-Widget öffne Then zeigen die Farbbalken dieselben Palette-Farben wie im Light Mode (--c-tag-{color} ist modus-neutral) And alle Texte haben ausreichend Kontrast auf dem dunklen Hintergrund Scenario: Leer-Zustand Given alle Tags haben documentCount = 0 When ich /themen öffne Then sehe ich den Text „Noch keine Themen vergeben." statt eines leeren Grids ``` --- ## Out of Scope - Eigener Eintrag in der Hauptnavigation (Seite ist nur über das Dashboard-Widget erreichbar) - Aufklappbare Bäume auf `/themen` (Kind-Tags sind immer sichtbar, kein Toggle) - Tags tiefer als Ebene 2 auf `/themen` (Kinder von Kindern werden nicht angezeigt) - Editor-Sidebar auf Mobile (kollabiert bereits im bestehenden Layout)
marcel added the P2-mediumfeatureui labels 2026-05-25 14:33:44 +02:00
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

1. Tag-Navigation-URL ist falsch spezifiziert — kritisch

Die Navigations-Tabelle sagt: / mit Tag-ID als aktivem Suchfilter". Das ist falsch. Der Such-Endpunkt nimmt Tag-Namen als String-Parameter:

// DocumentController.java
@RequestParam(required = false, name = "tag") List<String> tags

Bestehende Komponenten zeigen das korrekte Muster:

<!-- DocumentMetadataDrawer.svelte:180 -->
href="/?tag={encodeURIComponent(tag.name)}"

Im Spec muss „Tag-ID" durchgehend durch „Tag-Name (URL-encoded)" ersetzt werden. Alle vier Zeilen in der Navigations-Tabelle sind davon betroffen.

2. documentCount ist direkter Zähler, nicht rekursiv

TagService.buildTree() setzt documentCount = counts.getOrDefault(tag.getId(), 0L) — das ist COUNT(*) FROM document_tags WHERE tag_id = ?, nur direkte Dokumente. Ein Root-Tag mit 0 eigenen Docs aber 42 Kind-Docs hat documentCount = 0.

Die Issue-Filterregel lautet: „Tag wird angezeigt, wenn er selbst oder mindestens ein Nachfahre > 0 hat". Das erfordert eine rekursive Tree-Walk-Funktion auf dem Frontend — die nicht trivial ist und explizit als $derived.by() umgesetzt werden muss. Empfehlung: dies als eigene benannte Hilfsfunktion auslagern:

function hasAnyDocuments(node: TagTreeNodeDTO): boolean {
    return node.documentCount > 0 || node.children.some(hasAnyDocuments);
}

const visibleTree = $derived.by(() => tree.filter(hasAnyDocuments));

3. Sidebar-Prop nicht aufgelöst

Die Spec sagt „per Prop oder Wrapper-Klasse zu steuern" — das ist unentschieden. Für einen implementierenden Agenten braucht es eine klare Entscheidung. Empfehlung: compact: boolean = false als Prop auf ThemenWidget.svelte:

<script lang="ts">
  let { tags, compact = false }: { tags: TagTreeNodeDTO[], compact?: boolean } = $props();
</script>

<div class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}">

4. i18n-Keys fehlen im Spec

Folgende Strings müssen in messages/{de,en,es}.json ergänzt werden — das Spec nennt sie weder noch delegiert es die Entscheidung:

Key de en es
themen_widget_title Themen Topics Temas
themen_alle Alle Themen All Topics Todos los temas
themen_leer Noch keine Themen vergeben. No topics assigned yet. Aún no hay temas.
themen_weitere + {n} weitere + {n} more + {n} más
themen_dokumente {n} Dokumente {n} documents {n} documentos

5. Dokumentation muss mit aktualisiert werden

Neue SvelteKit-Route → per CLAUDE.md-Konvention:

  • CLAUDE.md Routen-Tabelle: /themen eintragen
  • docs/architecture/c4/l3-frontend-*.puml: neue Route ergänzen

Recommendations

  • Im Spec überall „Tag-ID" → „Tag-Name (URL-encoded via encodeURIComponent(tag.name))" ersetzen
  • Den compact-Prop explizit in den Spec aufnehmen und die Sidebar-Variante damit auflösen
  • Die hasAnyDocuments()-Funktion explizit benennen — verhindert, dass der Agent Business-Logik in Template-Markup schreibt
  • i18n-Keys-Liste in den Spec aufnehmen
  • Dokumentations-Anforderungen in die Definition of Done des Issues aufnehmen
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations **1. Tag-Navigation-URL ist falsch spezifiziert — kritisch** Die Navigations-Tabelle sagt: _„`/` mit Tag-**ID** als aktivem Suchfilter"_. Das ist falsch. Der Such-Endpunkt nimmt Tag-**Namen** als String-Parameter: ```java // DocumentController.java @RequestParam(required = false, name = "tag") List<String> tags ``` Bestehende Komponenten zeigen das korrekte Muster: ```svelte <!-- DocumentMetadataDrawer.svelte:180 --> href="/?tag={encodeURIComponent(tag.name)}" ``` Im Spec muss „Tag-ID" durchgehend durch „Tag-Name (URL-encoded)" ersetzt werden. Alle vier Zeilen in der Navigations-Tabelle sind davon betroffen. **2. `documentCount` ist direkter Zähler, nicht rekursiv** `TagService.buildTree()` setzt `documentCount = counts.getOrDefault(tag.getId(), 0L)` — das ist `COUNT(*) FROM document_tags WHERE tag_id = ?`, nur direkte Dokumente. Ein Root-Tag mit 0 eigenen Docs aber 42 Kind-Docs hat `documentCount = 0`. Die Issue-Filterregel lautet: _„Tag wird angezeigt, wenn er selbst oder mindestens ein Nachfahre > 0 hat"_. Das erfordert eine rekursive Tree-Walk-Funktion auf dem Frontend — die nicht trivial ist und explizit als `$derived.by()` umgesetzt werden muss. Empfehlung: dies als eigene benannte Hilfsfunktion auslagern: ```typescript function hasAnyDocuments(node: TagTreeNodeDTO): boolean { return node.documentCount > 0 || node.children.some(hasAnyDocuments); } const visibleTree = $derived.by(() => tree.filter(hasAnyDocuments)); ``` **3. Sidebar-Prop nicht aufgelöst** Die Spec sagt „per Prop oder Wrapper-Klasse zu steuern" — das ist unentschieden. Für einen implementierenden Agenten braucht es eine klare Entscheidung. Empfehlung: `compact: boolean = false` als Prop auf `ThemenWidget.svelte`: ```svelte <script lang="ts"> let { tags, compact = false }: { tags: TagTreeNodeDTO[], compact?: boolean } = $props(); </script> <div class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"> ``` **4. i18n-Keys fehlen im Spec** Folgende Strings müssen in `messages/{de,en,es}.json` ergänzt werden — das Spec nennt sie weder noch delegiert es die Entscheidung: | Key | de | en | es | |---|---|---|---| | `themen_widget_title` | Themen | Topics | Temas | | `themen_alle` | Alle Themen | All Topics | Todos los temas | | `themen_leer` | Noch keine Themen vergeben. | No topics assigned yet. | Aún no hay temas. | | `themen_weitere` | + {n} weitere | + {n} more | + {n} más | | `themen_dokumente` | {n} Dokumente | {n} documents | {n} documentos | **5. Dokumentation muss mit aktualisiert werden** Neue SvelteKit-Route → per CLAUDE.md-Konvention: - `CLAUDE.md` Routen-Tabelle: `/themen` eintragen - `docs/architecture/c4/l3-frontend-*.puml`: neue Route ergänzen ### Recommendations - Im Spec überall „Tag-ID" → „Tag-Name (URL-encoded via `encodeURIComponent(tag.name)`)" ersetzen - Den `compact`-Prop explizit in den Spec aufnehmen und die Sidebar-Variante damit auflösen - Die `hasAnyDocuments()`-Funktion explizit benennen — verhindert, dass der Agent Business-Logik in Template-Markup schreibt - i18n-Keys-Liste in den Spec aufnehmen - Dokumentations-Anforderungen in die Definition of Done des Issues aufnehmen
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

1. Backend ist komplett fertig — kein einziger Backend-Commit nötig

GET /api/tags/tree ist vorhanden, sicher (Auth-Guard über globale Spring Security), und bereits in den generierten TypeScript-Typen (api.ts:935). Das ist eine reine Frontend-Aufgabe. Das spart erheblich Zeit und Risiko.

2. documentCount ist per-Tag (direkt), nicht aggregiert

Ich habe TagService.buildTree() gelesen:

int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();

Die counts-Map kommt von SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id — ausschließlich direkte Beziehungen. Ein Root-Tag Briefe mit Kindern Brautbriefe (18), Kriegsbriefe (12) aber 0 eigenen Docs hat documentCount = 0 im DTO.

Das hat zwei Konsequenzen für die Spec:

  • Die Filterregel „zeige Tag wenn er selbst oder Nachfahre > 0 hat" ist korrekt beschrieben
  • Der angezeigte Zähler auf dem Root-Tag-Header zeigt dann 0 — was für Nutzer verwirrend ist (Karte sichtbar, aber Header zeigt 0). Das Issue-Edge-Case sagt: „eigener Zähler zeigt 0 (oder wird weggelassen)" — das „oder wird weggelassen" sollte explizit entschieden werden. Empfehlung: den Zähler weglassen wenn documentCount = 0, um Verwirrung zu vermeiden.

3. Datenfluss: zwei unabhängige Aufrufe sind richtig

Dashboard +page.server.ts und /themen/+page.server.ts rufen beide GET /api/tags/tree auf — unabhängig. Das ist korrekt. Ein geteilter Store oder ein „einmaliges Laden" wäre Overengineering für diesen Anwendungsfall. Die Antwort ist klein (flacher Tag-Baum einer Familiengröße) und der Endpunkt ist bereits gecacht durch HTTP connection reuse.

4. Dokumentations-Pflicht

Per Architektur-Konvention gilt bei neuen SvelteKit-Routen:

  • CLAUDE.md Routen-Tabelle → /themen ergänzen
  • docs/architecture/c4/l3-frontend-*.puml → neue Route und die neuen Komponenten (ThemenWidget, ThemenPage) ergänzen

Kein ADR nötig — keine Entscheidung mit dauerhafter struktureller Konsequenz.

5. Kein Caching-Problem

Der Tag-Baum einer Familienarchiv-Instanz hat typischerweise 10–50 Tags. Der Tree-Endpunkt gibt ein paar Kilobyte JSON zurück. Kein Caching, keine Pagination, keine Virtualisierung nötig. Die Einfachheit ist richtig.

Recommendations

  • Den Edge-Case „Root-Tag mit documentCount = 0 aber Kindern mit Docs" explizit entscheiden: Zähler weglassen (empfohlen) oder 0 anzeigen
  • Dokumentations-Aufgaben explizit in die DoD aufnehmen: CLAUDE.md + C4-Diagramm
  • Kein Backend-Ticket erstellen — der Endpunkt ist bereit
## 🏗️ Markus Keller — Application Architect ### Observations **1. Backend ist komplett fertig — kein einziger Backend-Commit nötig** `GET /api/tags/tree` ist vorhanden, sicher (Auth-Guard über globale Spring Security), und bereits in den generierten TypeScript-Typen (`api.ts:935`). Das ist eine reine Frontend-Aufgabe. Das spart erheblich Zeit und Risiko. **2. `documentCount` ist per-Tag (direkt), nicht aggregiert** Ich habe `TagService.buildTree()` gelesen: ```java int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue(); ``` Die `counts`-Map kommt von `SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id` — ausschließlich direkte Beziehungen. Ein Root-Tag `Briefe` mit Kindern `Brautbriefe (18)`, `Kriegsbriefe (12)` aber 0 eigenen Docs hat `documentCount = 0` im DTO. Das hat zwei Konsequenzen für die Spec: - Die Filterregel „zeige Tag wenn er selbst oder Nachfahre > 0 hat" ist korrekt beschrieben - Der **angezeigte Zähler** auf dem Root-Tag-Header zeigt dann `0` — was für Nutzer verwirrend ist (Karte sichtbar, aber Header zeigt 0). Das Issue-Edge-Case sagt: _„eigener Zähler zeigt 0 (oder wird weggelassen)"_ — das „oder wird weggelassen" sollte explizit entschieden werden. Empfehlung: den Zähler weglassen wenn `documentCount = 0`, um Verwirrung zu vermeiden. **3. Datenfluss: zwei unabhängige Aufrufe sind richtig** Dashboard `+page.server.ts` und `/themen/+page.server.ts` rufen beide `GET /api/tags/tree` auf — unabhängig. Das ist korrekt. Ein geteilter Store oder ein „einmaliges Laden" wäre Overengineering für diesen Anwendungsfall. Die Antwort ist klein (flacher Tag-Baum einer Familiengröße) und der Endpunkt ist bereits gecacht durch HTTP connection reuse. **4. Dokumentations-Pflicht** Per Architektur-Konvention gilt bei neuen SvelteKit-Routen: - `CLAUDE.md` Routen-Tabelle → `/themen` ergänzen - `docs/architecture/c4/l3-frontend-*.puml` → neue Route und die neuen Komponenten (`ThemenWidget`, `ThemenPage`) ergänzen Kein ADR nötig — keine Entscheidung mit dauerhafter struktureller Konsequenz. **5. Kein Caching-Problem** Der Tag-Baum einer Familienarchiv-Instanz hat typischerweise 10–50 Tags. Der Tree-Endpunkt gibt ein paar Kilobyte JSON zurück. Kein Caching, keine Pagination, keine Virtualisierung nötig. Die Einfachheit ist richtig. ### Recommendations - Den Edge-Case „Root-Tag mit `documentCount = 0` aber Kindern mit Docs" explizit entscheiden: **Zähler weglassen** (empfohlen) oder 0 anzeigen - Dokumentations-Aufgaben explizit in die DoD aufnehmen: CLAUDE.md + C4-Diagramm - Kein Backend-Ticket erstellen — der Endpunkt ist bereit
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

1. Auth-Status des Endpunkts: korrekt

GET /api/tags/tree trägt kein @RequirePermission — das ist beabsichtigt und richtig. Die globale Spring Security Konfiguration erzwingt anyRequest().authenticated(), d. h. der Endpunkt ist für alle eingeloggten Nutzer (Leser und Editoren) erreichbar, ohne eine Schreibberechtigung zu verlangen. Tags sind Metadaten, die alle sehen dürfen.

2. Tag-Name im URL: encodeURIComponent ist Pflicht

Der Such-Link zu /?tag={tag.name} ist sicher, wenn er korrekt kodiert wird. Bestehende Komponenten machen das bereits richtig:

href="/?tag={encodeURIComponent(tag.name)}"

Die Navigation-Tabelle im Issue referenziert aber fälschlich „Tag-ID" — wenn ein Implementierer das als UUID-String übernimmt, fehlt das URL-Encoding für Namen mit Sonderzeichen. Die Korrektur ist ohnehin nötig (Tag-ID → Tag-Name), und encodeURIComponent muss explizit im Spec stehen.

3. Inline-Style mit CSS-Variable: kein XSS-Risiko

style="background: var(--c-tag-{tag.color})"

tag.color kommt aus einem Backend-Enum mit 10 fixen Werten (sage, sienna, amber usw.). TagService.validateColor() wirft eine DomainException bei unbekannten Werten. Der interpolierte String landet in einer CSS-Variablen-Referenz, nicht als ausführbarer Code. Kein XSS-Vektor.

4. Keine neuen Angriffsflächen

Das Feature ist rein lesend, verwendet einen bestehenden Endpunkt, fügt keine Eingaben hinzu und schreibt keine Daten. Aus Security-Perspektive ist das ein Low-Risk-Feature.

Recommendations

  • encodeURIComponent(tag.name) explizit in den Navigations-Spec schreiben — nicht als Impl-Detail dem Agenten überlassen
  • Kurze Notiz in den Spec: GET /api/tags/tree erfordert Authentifizierung (Standard-Auth-Guard), kein separates @RequirePermission nötig

Open Decisions (omit this section entirely if none)

Keine. Alle Security-Aspekte sind durch bestehende Muster abgedeckt.

## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations **1. Auth-Status des Endpunkts: korrekt** `GET /api/tags/tree` trägt kein `@RequirePermission` — das ist beabsichtigt und richtig. Die globale Spring Security Konfiguration erzwingt `anyRequest().authenticated()`, d. h. der Endpunkt ist für alle eingeloggten Nutzer (Leser und Editoren) erreichbar, ohne eine Schreibberechtigung zu verlangen. Tags sind Metadaten, die alle sehen dürfen. **2. Tag-Name im URL: `encodeURIComponent` ist Pflicht** Der Such-Link zu `/?tag={tag.name}` ist sicher, wenn er korrekt kodiert wird. Bestehende Komponenten machen das bereits richtig: ```svelte href="/?tag={encodeURIComponent(tag.name)}" ``` Die Navigation-Tabelle im Issue referenziert aber fälschlich „Tag-ID" — wenn ein Implementierer das als UUID-String übernimmt, fehlt das URL-Encoding für Namen mit Sonderzeichen. Die Korrektur ist ohnehin nötig (Tag-ID → Tag-Name), und `encodeURIComponent` muss explizit im Spec stehen. **3. Inline-Style mit CSS-Variable: kein XSS-Risiko** ```svelte style="background: var(--c-tag-{tag.color})" ``` `tag.color` kommt aus einem Backend-Enum mit 10 fixen Werten (`sage`, `sienna`, `amber` usw.). `TagService.validateColor()` wirft eine `DomainException` bei unbekannten Werten. Der interpolierte String landet in einer CSS-Variablen-Referenz, nicht als ausführbarer Code. Kein XSS-Vektor. **4. Keine neuen Angriffsflächen** Das Feature ist rein lesend, verwendet einen bestehenden Endpunkt, fügt keine Eingaben hinzu und schreibt keine Daten. Aus Security-Perspektive ist das ein Low-Risk-Feature. ### Recommendations - `encodeURIComponent(tag.name)` explizit in den Navigations-Spec schreiben — nicht als Impl-Detail dem Agenten überlassen - Kurze Notiz in den Spec: `GET /api/tags/tree` erfordert Authentifizierung (Standard-Auth-Guard), kein separates `@RequirePermission` nötig ### Open Decisions _(omit this section entirely if none)_ Keine. Alle Security-Aspekte sind durch bestehende Muster abgedeckt.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

1. Fehlende Gherkin-Szenarien für spezifizierte Edge Cases

Die Edge-Case-Tabelle enthält „API-Fehler → Seite zeigt Fehlertext mit Retry-Hinweis" — aber kein einziges Gherkin-Szenario deckt diesen Fall ab. Das ist ein Widerspruch: Edge Case definiert, Test fehlt.

Außerdem fehlen Szenarien für:

  • Sidebar-Variante (compact-Prop): Widget im Editor-Dashboard zeigt 1-spaltig
  • Root-Tag mit documentCount = 0 aber Kind-Docs > 0: Karte ist sichtbar

2. Rekursive Filter-Logik braucht Unit-Test

Die Filterregel „zeige Tag wenn er oder ein Nachfahre > 0 hat" ist Business-Logik, die separat testbar sein muss — als TypeScript-Funktion mit klaren Eingaben:

// Testfälle die ich erwarte:
// - Root mit documentCount=0, Kind mit documentCount=5 → sichtbar
// - Root mit documentCount=0, alle Kinder 0 → unsichtbar
// - Root mit documentCount=3, kein Kind → sichtbar
// - Leerer Baum → leeres Ergebnis

Diese Funktion in einem $derived.by() zu verstecken macht sie untestbar. Sie sollte als exportierte Hilfsfunktion in einer eigenen Datei leben.

3. load-Funktion für /themen/+page.server.ts braucht Integration-Test

Das bestehende Muster (import { load } from './+page.server' + vi.fn() als Mock-Fetch) deckt folgende Fälle ab:

it('returns tag tree from API', ...)
it('returns empty array when API returns empty list', ...)
it('handles API failure gracefully — returns null or throws error(500)', ...)

Ohne diesen Test ist das API-Fehler-Edge-Case nur dokumentiert, nicht verifiziert.

4. Touch-Target-Szenario ist nicht automatisierbar wie geschrieben

Then hat jede interaktive Zeile eine Mindesthöhe von 44 px — Playwright kann keine berechneten CSS-Höhen direkt assertieren. Das Szenario sollte entweder:

  • als CSS-Snapshot-Test (Visual Regression) umgesetzt werden, oder
  • als getBoundingClientRect()-Assertion in Playwright:
const rows = page.locator('[data-testid="themen-child-row"]');
for (const row of await rows.all()) {
  const box = await row.boundingBox();
  expect(box!.height).toBeGreaterThanOrEqual(44);
}

Ohne data-testid oder eine klare Selektor-Strategie ist dieses Szenario für den Agenten nicht umsetzbar.

Recommendations

  • Fehlende Gherkin-Szenarien ergänzen: API-Fehler, Sidebar-Variante, Root-ohne-direkte-Docs
  • Rekursive Filterfunktion als exportierte Hilfsfunktion spezifizieren (nicht als inline $derived)
  • Selektor-Strategie für Touch-Target-Test festlegen: data-testid="themen-child-row" auf Kind-Zeilen und Karten-Header

Open Decisions

  • Selektor-Strategie: data-testid-Attribute auf interaktiven Elementen oder Playwright-Rollen-Selektoren? Die bestehende Codebase nutzt keine data-testid-Konvention — wenn wir sie hier einführen, sollte das bewusst entschieden werden.
## 🧪 Sara Holt — QA Engineer ### Observations **1. Fehlende Gherkin-Szenarien für spezifizierte Edge Cases** Die Edge-Case-Tabelle enthält „API-Fehler → Seite zeigt Fehlertext mit Retry-Hinweis" — aber kein einziges Gherkin-Szenario deckt diesen Fall ab. Das ist ein Widerspruch: Edge Case definiert, Test fehlt. Außerdem fehlen Szenarien für: - Sidebar-Variante (compact-Prop): Widget im Editor-Dashboard zeigt 1-spaltig - Root-Tag mit `documentCount = 0` aber Kind-Docs > 0: Karte ist sichtbar **2. Rekursive Filter-Logik braucht Unit-Test** Die Filterregel „zeige Tag wenn er oder ein Nachfahre > 0 hat" ist Business-Logik, die separat testbar sein muss — als TypeScript-Funktion mit klaren Eingaben: ```typescript // Testfälle die ich erwarte: // - Root mit documentCount=0, Kind mit documentCount=5 → sichtbar // - Root mit documentCount=0, alle Kinder 0 → unsichtbar // - Root mit documentCount=3, kein Kind → sichtbar // - Leerer Baum → leeres Ergebnis ``` Diese Funktion in einem `$derived.by()` zu verstecken macht sie untestbar. Sie sollte als exportierte Hilfsfunktion in einer eigenen Datei leben. **3. `load`-Funktion für `/themen/+page.server.ts` braucht Integration-Test** Das bestehende Muster (`import { load } from './+page.server'` + `vi.fn()` als Mock-Fetch) deckt folgende Fälle ab: ```typescript it('returns tag tree from API', ...) it('returns empty array when API returns empty list', ...) it('handles API failure gracefully — returns null or throws error(500)', ...) ``` Ohne diesen Test ist das API-Fehler-Edge-Case nur dokumentiert, nicht verifiziert. **4. Touch-Target-Szenario ist nicht automatisierbar wie geschrieben** `Then hat jede interaktive Zeile eine Mindesthöhe von 44 px` — Playwright kann keine berechneten CSS-Höhen direkt assertieren. Das Szenario sollte entweder: - als CSS-Snapshot-Test (Visual Regression) umgesetzt werden, oder - als `getBoundingClientRect()`-Assertion in Playwright: ```typescript const rows = page.locator('[data-testid="themen-child-row"]'); for (const row of await rows.all()) { const box = await row.boundingBox(); expect(box!.height).toBeGreaterThanOrEqual(44); } ``` Ohne `data-testid` oder eine klare Selektor-Strategie ist dieses Szenario für den Agenten nicht umsetzbar. ### Recommendations - Fehlende Gherkin-Szenarien ergänzen: API-Fehler, Sidebar-Variante, Root-ohne-direkte-Docs - Rekursive Filterfunktion als exportierte Hilfsfunktion spezifizieren (nicht als inline `$derived`) - Selektor-Strategie für Touch-Target-Test festlegen: `data-testid="themen-child-row"` auf Kind-Zeilen und Karten-Header ### Open Decisions - **Selektor-Strategie**: `data-testid`-Attribute auf interaktiven Elementen oder Playwright-Rollen-Selektoren? Die bestehende Codebase nutzt keine `data-testid`-Konvention — wenn wir sie hier einführen, sollte das bewusst entschieden werden.
Author
Owner

🚢 Tobias Wendt — DevOps & Platform Engineer

Observations

Rein Frontend — keine Infrastruktur-Änderungen nötig

Dieses Feature berührt ausschließlich SvelteKit-Routen und Dashboard-Komponenten. Keine neuen Docker-Services, keine Compose-Änderungen, keine CI-Pipeline-Anpassungen, keine neuen Umgebungsvariablen.

GET /api/tags/tree — kein Performance-Problem

Der Endpunkt gibt den vollständigen Tag-Baum zurück. Für ein Familienarchiv (10–50 Tags, kleine JSON-Antwort) ist das kein Problem. Kein Caching, keine Pagination nötig.

CI-Pipeline läuft automatisch durch

Die Gitea CI-Pipeline baut und testet das Frontend bei jedem Commit. Die neuen Komponenten und die /themen-Route fallen automatisch unter die bestehenden Build- und Test-Stages.

Eine Anmerkung zur Beobachtbarkeit

Die /themen-Route ist eine neue SSR-Seite mit einem Backend-Aufruf. Wenn der Endpunkt langsam wird oder fehlschlägt, sieht man das in Loki über die bestehende SvelteKit-Request-Logging-Middleware. Kein separates Monitoring nötig.

Keine Bedenken aus Infrastruktur-Perspektive

Alles abgedeckt durch bestehende Infrastruktur. Grünes Licht von meiner Seite.

## 🚢 Tobias Wendt — DevOps & Platform Engineer ### Observations **Rein Frontend — keine Infrastruktur-Änderungen nötig** Dieses Feature berührt ausschließlich SvelteKit-Routen und Dashboard-Komponenten. Keine neuen Docker-Services, keine Compose-Änderungen, keine CI-Pipeline-Anpassungen, keine neuen Umgebungsvariablen. **`GET /api/tags/tree` — kein Performance-Problem** Der Endpunkt gibt den vollständigen Tag-Baum zurück. Für ein Familienarchiv (10–50 Tags, kleine JSON-Antwort) ist das kein Problem. Kein Caching, keine Pagination nötig. **CI-Pipeline läuft automatisch durch** Die Gitea CI-Pipeline baut und testet das Frontend bei jedem Commit. Die neuen Komponenten und die `/themen`-Route fallen automatisch unter die bestehenden Build- und Test-Stages. **Eine Anmerkung zur Beobachtbarkeit** Die `/themen`-Route ist eine neue SSR-Seite mit einem Backend-Aufruf. Wenn der Endpunkt langsam wird oder fehlschlägt, sieht man das in Loki über die bestehende SvelteKit-Request-Logging-Middleware. Kein separates Monitoring nötig. ### Keine Bedenken aus Infrastruktur-Perspektive Alles abgedeckt durch bestehende Infrastruktur. Grünes Licht von meiner Seite.
Author
Owner

🗳️ Decision Queue — Action Required

1 Entscheidung braucht dein Input, bevor die Implementierung startet.

Testing

  • Selektor-Strategie für E2E/Touch-Target-Tests — Die bestehende Codebase hat keine data-testid-Konvention. Zwei Optionen: (A) data-testid="themen-child-row" einführen — explizit, stabil, aber neue Konvention; (B) Playwright-Rollen-Selektoren (getByRole('link', { name: ... })) — kein neues Attribut nötig, aber fragiler bei mehreren gleichnamigen Links. (Raised by: Sara)
## 🗳️ Decision Queue — Action Required _1 Entscheidung braucht dein Input, bevor die Implementierung startet._ ### Testing - **Selektor-Strategie für E2E/Touch-Target-Tests** — Die bestehende Codebase hat keine `data-testid`-Konvention. Zwei Optionen: (A) `data-testid="themen-child-row"` einführen — explizit, stabil, aber neue Konvention; (B) Playwright-Rollen-Selektoren (`getByRole('link', { name: ... })`) — kein neues Attribut nötig, aber fragiler bei mehreren gleichnamigen Links. _(Raised by: Sara)_
Author
Owner

we skip E2E for now

we skip E2E for now
Author
Owner

Implementation complete — branch worktree-feat+issue-662-themen-inhaltsverzeichnis

What was built

8 commits, pure frontend — no backend changes needed.

# Commit What
1 aad8382b hasAnyDocuments(node) recursive helper + 4 unit tests in $lib/shared/utils/tagUtils.ts
2 94d5e696 i18n keys for de/en/es: themen_widget_title, themen_alle, themen_leer, themen_weitere, themen_dokumente
3 f376fae6 /themen/+page.server.ts — loads GET /api/tags/tree, throws 500 on failure + 3 server tests
4 41754fc0 Dashboard +page.server.ts — tag tree added to both reader and editor fetch batches
5 1d032f52 ThemenWidget.svelte — card grid with 4 px color bars, compact prop for sidebar, empty state + browser tests
6 9d8e9c45 +page.svelte — widget wired into reader layout (after PersonChips) and editor sidebar (between FamilyPulse and ActivityFeed, compact={true})
7 9084c8dd /themen/+page.svelte — root-tag cards with 6 px top color bar, up to 5 child rows, + N weitere → overflow + browser tests
8 fc53c69a CLAUDE.md route table + C4 diagram updated

Key decisions applied from review comments

  • Navigation uses /?tag={encodeURIComponent(tag.name)} (tag names, not IDs)
  • hasAnyDocuments() is an exported function in $lib/shared/utils/tagUtils.ts (not inline $derived)
  • Root-tag with documentCount = 0 but children with docs: count is omitted (not shown as 0)
  • compact: boolean = false prop on ThemenWidget controls sidebar vs. full-width layout
  • E2E tests skipped per decision

Tests

  • tagUtils.test.ts — 4 unit tests
  • themen/page.server.spec.ts — 3 server tests
  • ThemenWidget.svelte.spec.ts — 6 browser tests (CI)
  • themen/page.svelte.spec.ts — 6 browser tests (CI)
## ✅ Implementation complete — branch `worktree-feat+issue-662-themen-inhaltsverzeichnis` ### What was built **8 commits, pure frontend — no backend changes needed.** | # | Commit | What | |---|--------|------| | 1 | `aad8382b` | `hasAnyDocuments(node)` recursive helper + 4 unit tests in `$lib/shared/utils/tagUtils.ts` | | 2 | `94d5e696` | i18n keys for de/en/es: `themen_widget_title`, `themen_alle`, `themen_leer`, `themen_weitere`, `themen_dokumente` | | 3 | `f376fae6` | `/themen/+page.server.ts` — loads `GET /api/tags/tree`, throws 500 on failure + 3 server tests | | 4 | `41754fc0` | Dashboard `+page.server.ts` — tag tree added to both reader and editor fetch batches | | 5 | `1d032f52` | `ThemenWidget.svelte` — card grid with 4 px color bars, `compact` prop for sidebar, empty state + browser tests | | 6 | `9d8e9c45` | `+page.svelte` — widget wired into reader layout (after PersonChips) and editor sidebar (between FamilyPulse and ActivityFeed, `compact={true}`) | | 7 | `9084c8dd` | `/themen/+page.svelte` — root-tag cards with 6 px top color bar, up to 5 child rows, `+ N weitere →` overflow + browser tests | | 8 | `fc53c69a` | CLAUDE.md route table + C4 diagram updated | ### Key decisions applied from review comments - Navigation uses `/?tag={encodeURIComponent(tag.name)}` (tag names, not IDs) - `hasAnyDocuments()` is an exported function in `$lib/shared/utils/tagUtils.ts` (not inline `$derived`) - Root-tag with `documentCount = 0` but children with docs: count is **omitted** (not shown as 0) - `compact: boolean = false` prop on `ThemenWidget` controls sidebar vs. full-width layout - E2E tests skipped per decision ### Tests - `tagUtils.test.ts` — 4 unit tests ✅ - `themen/page.server.spec.ts` — 3 server tests ✅ - `ThemenWidget.svelte.spec.ts` — 6 browser tests (CI) - `themen/page.svelte.spec.ts` — 6 browser tests (CI)
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#662