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

Merged
marcel merged 13 commits from worktree-feat+issue-662-themen-inhaltsverzeichnis into main 2026-05-27 09:41:40 +02:00
Owner

Closes #662

Summary

  • Adds ThemenWidget to the home dashboard — visible in the reader layout (after PersonChips) and the editor sidebar (between FamilyPulse and ActivityFeed, single-column compact mode)
  • Adds /themen page with root-tag cards (6 px top color bar), up to 5 child rows per card, and a + N weitere → overflow link
  • Navigation links use /?tag={encodeURIComponent(tag.name)} — consistent with existing DocumentMetadataDrawer pattern
  • hasAnyDocuments() recursive helper in $lib/shared/utils/tagUtils.ts filters tags with no documents in their subtree
  • Root-tags with documentCount = 0 but populated children are shown, but the 0 count is omitted
  • Pure frontend — backend GET /api/tags/tree was already complete

Test plan

  • cd frontend && npm run test -- --project=server tagUtils — 4 unit tests green
  • cd frontend && npm run test -- --project=server "themen/page.server" — 3 server tests green
  • cd frontend && npm run check — no TypeScript errors in new files
  • CI browser tests: ThemenWidget.svelte.spec.ts (6 tests) and themen/page.svelte.spec.ts (6 tests)
  • Open dashboard as reader → ThemenWidget appears after PersonChips
  • Open dashboard as editor → widget appears in sidebar between FamilyPulse and ActivityFeed (single column)
  • Click a tag card → lands on /?tag=<name> with filter active
  • Click "Alle Themen →" → /themen renders root-tag cards with color bars and child rows
  • Click a child row → lands on /?tag=<child-name> with filter active
  • Empty state: tags with documentCount=0 and no children with docs are hidden; if all filtered, "Noch keine Themen vergeben." shown

🤖 Generated with Claude Code

Closes #662 ## Summary - Adds `ThemenWidget` to the home dashboard — visible in the reader layout (after PersonChips) and the editor sidebar (between FamilyPulse and ActivityFeed, single-column compact mode) - Adds `/themen` page with root-tag cards (6 px top color bar), up to 5 child rows per card, and a `+ N weitere →` overflow link - Navigation links use `/?tag={encodeURIComponent(tag.name)}` — consistent with existing DocumentMetadataDrawer pattern - `hasAnyDocuments()` recursive helper in `$lib/shared/utils/tagUtils.ts` filters tags with no documents in their subtree - Root-tags with `documentCount = 0` but populated children are shown, but the 0 count is omitted - Pure frontend — backend `GET /api/tags/tree` was already complete ## Test plan - [ ] `cd frontend && npm run test -- --project=server tagUtils` — 4 unit tests green - [ ] `cd frontend && npm run test -- --project=server "themen/page.server"` — 3 server tests green - [ ] `cd frontend && npm run check` — no TypeScript errors in new files - [ ] CI browser tests: `ThemenWidget.svelte.spec.ts` (6 tests) and `themen/page.svelte.spec.ts` (6 tests) - [ ] Open dashboard as reader → ThemenWidget appears after PersonChips - [ ] Open dashboard as editor → widget appears in sidebar between FamilyPulse and ActivityFeed (single column) - [ ] Click a tag card → lands on `/?tag=<name>` with filter active - [ ] Click "Alle Themen →" → `/themen` renders root-tag cards with color bars and child rows - [ ] Click a child row → lands on `/?tag=<child-name>` with filter active - [ ] Empty state: tags with `documentCount=0` and no children with docs are hidden; if all filtered, "Noch keine Themen vergeben." shown 🤖 Generated with [Claude Code](https://claude.ai/claude-code)
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Reviewed module boundaries, documentation currency, and SSR architecture. The implementation is clean.

What I checked

Module boundarieshasAnyDocuments() was correctly moved to $lib/shared/utils/tagUtils.ts rather than $lib/tag/tagUtils.ts. This is the right call: placing it in $lib/tag/ and importing it from $lib/shared/dashboard/ThemenWidget.svelte would have crossed the domain boundary enforced by ESLint. The $lib/shared/utils/ location is the correct neutral zone for cross-domain utilities.

Layer compliance — Data flows correctly: +page.server.ts calls createApiClient(fetch)GET /api/tags/tree → passes tree prop down to +page.svelte. No direct repository access from the front end, no business logic in components.

SSR — Both the dashboard and /themen load their tag tree data server-side. The tag tree fetch is added to the Promise.allSettled block in the editor path and the readerFetches array in the reader path, consistent with the existing pattern.

Error resilience — The dashboard catches a failed tag tree fetch gracefully: tagTree: [] as TagTreeNodeDTO[] in the catch fallback. Empty array means the widget renders its empty state rather than crashing.

Documentation — Required updates per the documentation table:

Trigger Required Present?
New SvelteKit route /themen CLAUDE.md route table
New SvelteKit route /themen l3-frontend-3c-people-stories.puml

Both CLAUDE.md (route table entry for themen/) and the C4 Level 3 diagram (new Component(themen, ...) + Rel(themen, backend, ...)) are updated. The C4 description also notes that ThemenWidget is embedded in the home dashboard, which is good — the diagram captures the component's dual role.

One observation (not a blocker)

The ThemenWidget.svelte lives in $lib/shared/dashboard/. It renders TagTreeNodeDTO data, which is semantically a tag domain artifact. The location is defensible because the widget is used on both the home dashboard and /themen — it's genuinely shared across domains. If the project ever adopts a stricter domain split, this could move, but for now $lib/shared/dashboard/ is the correct home.

LGTM. Ready to merge from an architecture perspective.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Reviewed module boundaries, documentation currency, and SSR architecture. The implementation is clean. ### What I checked **Module boundaries** — `hasAnyDocuments()` was correctly moved to `$lib/shared/utils/tagUtils.ts` rather than `$lib/tag/tagUtils.ts`. This is the right call: placing it in `$lib/tag/` and importing it from `$lib/shared/dashboard/ThemenWidget.svelte` would have crossed the domain boundary enforced by ESLint. The `$lib/shared/utils/` location is the correct neutral zone for cross-domain utilities. **Layer compliance** — Data flows correctly: `+page.server.ts` calls `createApiClient(fetch)` → `GET /api/tags/tree` → passes `tree` prop down to `+page.svelte`. No direct repository access from the front end, no business logic in components. ✅ **SSR** — Both the dashboard and `/themen` load their tag tree data server-side. The tag tree fetch is added to the `Promise.allSettled` block in the editor path and the `readerFetches` array in the reader path, consistent with the existing pattern. ✅ **Error resilience** — The dashboard catches a failed tag tree fetch gracefully: `tagTree: [] as TagTreeNodeDTO[]` in the catch fallback. Empty array means the widget renders its empty state rather than crashing. ✅ **Documentation** — Required updates per the documentation table: | Trigger | Required | Present? | |---|---|---| | New SvelteKit route `/themen` | CLAUDE.md route table | ✅ | | New SvelteKit route `/themen` | `l3-frontend-3c-people-stories.puml` | ✅ | Both `CLAUDE.md` (route table entry for `themen/`) and the C4 Level 3 diagram (new `Component(themen, ...)` + `Rel(themen, backend, ...)`) are updated. The C4 description also notes that `ThemenWidget` is embedded in the home dashboard, which is good — the diagram captures the component's dual role. ### One observation (not a blocker) The `ThemenWidget.svelte` lives in `$lib/shared/dashboard/`. It renders `TagTreeNodeDTO` data, which is semantically a tag domain artifact. The location is defensible because the widget is used on both the home dashboard and `/themen` — it's genuinely shared across domains. If the project ever adopts a stricter domain split, this could move, but for now `$lib/shared/dashboard/` is the correct home. LGTM. Ready to merge from an architecture perspective.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

TDD evidence is present, Svelte 5 runes are used correctly, and the component structure is clean. One suggestion worth addressing before merge.

What I checked

TDD evidence — Tests precede or accompany every implementation file:

  • tagUtils.test.ts — 4 unit tests for hasAnyDocuments()
  • themen/page.server.spec.ts — 3 server load tests
  • ThemenWidget.svelte.spec.ts — 6 browser component tests
  • themen/page.svelte.spec.ts — 6 browser component tests
  • routes/page.server.spec.ts — 3 existing dashboard tests updated with new mock

Svelte 5 runes$props(), $derived.by(), no export let, no $:, no onMount. Correct.

Keyed {#each}{#each visibleTree as tag (tag.id)} and {#each shownChildren as child (child.id)} — both keyed.

Component sizeThemenWidget.svelte at 64 lines . /themen/+page.svelte at 86 lines — at the upper end but acceptable for a page-level orchestrator.

Suggestion

In /themen/+page.svelte, the per-iteration derived values are computed inline with {@const}:

{#each visibleTree as tag (tag.id)}
    {@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)}
    {@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)}
    {@const hiddenCount = visibleChildren.length - shownChildren.length}

{@const} inside {#each} is valid Svelte and scoped correctly to the iteration. The computation runs on each render for each tag. For the expected scale (a few dozen root tags max), this is not a performance concern. I'd accept this as-is — it's readable and idiomatic Svelte 5 for per-item derived state.

The one thing I want to confirm: hasAnyDocuments is imported from $lib/shared/utils/tagUtils in both ThemenWidget.svelte and themen/+page.svelte. That import path is consistent throughout the PR.

Minor note

The data-compact={compact} attribute on the ThemenWidget grid div exists to enable test selection (checking grid layout via attribute). This is pragmatic but couples the test to an implementation detail rather than a user-visible behavior. A better pattern would be checking the rendered class list or CSS grid column count. Not a blocker given the CI-only nature of the browser tests.

Overall: clean implementation, TDD discipline followed, Svelte 5 patterns correct. Approved.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** TDD evidence is present, Svelte 5 runes are used correctly, and the component structure is clean. One suggestion worth addressing before merge. ### What I checked **TDD evidence** — Tests precede or accompany every implementation file: - `tagUtils.test.ts` — 4 unit tests for `hasAnyDocuments()` ✅ - `themen/page.server.spec.ts` — 3 server load tests ✅ - `ThemenWidget.svelte.spec.ts` — 6 browser component tests ✅ - `themen/page.svelte.spec.ts` — 6 browser component tests ✅ - `routes/page.server.spec.ts` — 3 existing dashboard tests updated with new mock ✅ **Svelte 5 runes** — `$props()`, `$derived.by()`, no `export let`, no `$:`, no `onMount`. Correct. ✅ **Keyed `{#each}`** — `{#each visibleTree as tag (tag.id)}` and `{#each shownChildren as child (child.id)}` — both keyed. ✅ **Component size** — `ThemenWidget.svelte` at 64 lines ✅. `/themen/+page.svelte` at 86 lines — at the upper end but acceptable for a page-level orchestrator. ### Suggestion In `/themen/+page.svelte`, the per-iteration derived values are computed inline with `{@const}`: ```svelte {#each visibleTree as tag (tag.id)} {@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)} {@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)} {@const hiddenCount = visibleChildren.length - shownChildren.length} ``` `{@const}` inside `{#each}` is valid Svelte and scoped correctly to the iteration. The computation runs on each render for each tag. For the expected scale (a few dozen root tags max), this is not a performance concern. I'd accept this as-is — it's readable and idiomatic Svelte 5 for per-item derived state. The one thing I want to confirm: `hasAnyDocuments` is imported from `$lib/shared/utils/tagUtils` in both `ThemenWidget.svelte` and `themen/+page.svelte`. That import path is consistent throughout the PR. ✅ ### Minor note The `data-compact={compact}` attribute on the ThemenWidget grid div exists to enable test selection (checking grid layout via attribute). This is pragmatic but couples the test to an implementation detail rather than a user-visible behavior. A better pattern would be checking the rendered class list or CSS grid column count. Not a blocker given the CI-only nature of the browser tests. Overall: clean implementation, TDD discipline followed, Svelte 5 patterns correct. Approved.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Pure frontend feature. No infrastructure changes. Nothing to flag.

What I checked

  • Docker Compose — No new services, no changed images, no new volumes, no port changes.
  • CI pipeline — No workflow file changes. The existing test jobs pick up the new spec files automatically (Vitest glob patterns cover *.spec.ts and *.test.ts).
  • New npm dependencies — None added. All new code uses existing dependencies (openapi-fetch, @sveltejs/kit, paraglide).
  • Environment variables — No new env vars introduced.
  • Build impact — The new SvelteKit route at /themen is a standard SSR page. No adapter changes, no static export changes, no build config changes.

One note for future

The tag tree is now fetched on every dashboard load (reader + editor paths). At the current scale this adds ~1 API call to the dashboard. If dashboard load time becomes a concern, GET /api/tags/tree is an excellent candidate for HTTP caching (Cache-Control: max-age=300, stale-while-revalidate) since tag trees change infrequently. Not a concern today, but worth noting when the observability dashboards start showing dashboard load times.

LGTM from infrastructure perspective.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Pure frontend feature. No infrastructure changes. Nothing to flag. ### What I checked - **Docker Compose** — No new services, no changed images, no new volumes, no port changes. ✅ - **CI pipeline** — No workflow file changes. The existing test jobs pick up the new spec files automatically (Vitest glob patterns cover `*.spec.ts` and `*.test.ts`). ✅ - **New npm dependencies** — None added. All new code uses existing dependencies (`openapi-fetch`, `@sveltejs/kit`, `paraglide`). ✅ - **Environment variables** — No new env vars introduced. ✅ - **Build impact** — The new SvelteKit route at `/themen` is a standard SSR page. No adapter changes, no static export changes, no build config changes. ✅ ### One note for future The tag tree is now fetched on every dashboard load (reader + editor paths). At the current scale this adds ~1 API call to the dashboard. If dashboard load time becomes a concern, `GET /api/tags/tree` is an excellent candidate for HTTP caching (`Cache-Control: max-age=300, stale-while-revalidate`) since tag trees change infrequently. Not a concern today, but worth noting when the observability dashboards start showing dashboard load times. LGTM from infrastructure perspective.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approved

All acceptance criteria from issue #662 are implemented and verifiable.

Requirements coverage

Requirement Status
Dashboard widget on home page (reader path) ThemenWidget placed after ReaderPersonChips
Dashboard widget in editor sidebar (compact) ThemenWidget compact={true} between DashboardFamilyPulse and DashboardActivityFeed
Dedicated /themen route +page.svelte + +page.server.ts created
Root tag cards with color bars h-1.5 bar with var(--c-tag-{color})
Child rows (up to 5 per card) MAX_VISIBLE_CHILDREN = 5, slice(0, MAX_VISIBLE_CHILDREN)
"+ N weitere →" overflow link Links to root tag search
Navigation via tag name (not ID) /?tag={encodeURIComponent(tag.name)} throughout
hasAnyDocuments() recursive filtering Root tags with no documents (direct or inherited) are hidden
Empty state m.themen_leer() shown when visibleTree.length === 0
i18n: German, English, Spanish 5 keys in all 3 message files

NFR notes

  • Responsive layout: /themen uses grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 . The widget uses grid-cols-1 (compact) or grid-cols-1 sm:grid-cols-2 (normal) .
  • Accessibility: See the UI review for specific findings on focus states.
  • Error handling: Failed tag tree load on dashboard → widget receives empty array → empty state rendered. Graceful degradation.
  • Performance: Tag tree added to existing Promise.allSettled pattern — doesn't block other dashboard data if it fails.

All requirements met. The scope is correctly bounded to what issue #662 specified — no scope creep observed.

## 📋 Elicit — Requirements Engineer **Verdict: ✅ Approved** All acceptance criteria from issue #662 are implemented and verifiable. ### Requirements coverage | Requirement | Status | |---|---| | Dashboard widget on home page (reader path) | ✅ `ThemenWidget` placed after `ReaderPersonChips` | | Dashboard widget in editor sidebar (compact) | ✅ `ThemenWidget compact={true}` between `DashboardFamilyPulse` and `DashboardActivityFeed` | | Dedicated `/themen` route | ✅ `+page.svelte` + `+page.server.ts` created | | Root tag cards with color bars | ✅ `h-1.5` bar with `var(--c-tag-{color})` | | Child rows (up to 5 per card) | ✅ `MAX_VISIBLE_CHILDREN = 5`, `slice(0, MAX_VISIBLE_CHILDREN)` | | "+ N weitere →" overflow link | ✅ Links to root tag search | | Navigation via tag name (not ID) | ✅ `/?tag={encodeURIComponent(tag.name)}` throughout | | `hasAnyDocuments()` recursive filtering | ✅ Root tags with no documents (direct or inherited) are hidden | | Empty state | ✅ `m.themen_leer()` shown when `visibleTree.length === 0` | | i18n: German, English, Spanish | ✅ 5 keys in all 3 message files | ### NFR notes - **Responsive layout**: `/themen` uses `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` ✅. The widget uses `grid-cols-1` (compact) or `grid-cols-1 sm:grid-cols-2` (normal) ✅. - **Accessibility**: See the UI review for specific findings on focus states. - **Error handling**: Failed tag tree load on dashboard → widget receives empty array → empty state rendered. Graceful degradation. ✅ - **Performance**: Tag tree added to existing `Promise.allSettled` pattern — doesn't block other dashboard data if it fails. ✅ All requirements met. The scope is correctly bounded to what issue #662 specified — no scope creep observed.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved with concerns

No exploitable vulnerabilities. One CSS injection smell worth documenting.

What I checked

Data flow — All tag data is fetched server-side via createApiClient(fetch) in +page.server.ts and flows to components via SSR props. No client-side fetch, no API route exposure to the browser.

URL construction — Navigation links use encodeURIComponent(tag.name) throughout:

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

encodeURIComponent correctly encodes all characters that could affect URL parsing.

AuthenticationGET /api/tags/tree is an existing, auth-guarded endpoint. The frontend load functions inherit the session cookie via SvelteKit's SSR fetch. No new auth surface introduced.

XSS — Tag names are rendered as text content (not innerHTML), and Svelte's template engine HTML-encodes by default.

Security smell (not a blocker, but document it)

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

tag.color is interpolated into an inline style attribute. CSS custom property references via var() cannot execute JavaScript — this is not an XSS vector. However, a malicious color value like slate; background: url(https://evil.com) could potentially exfiltrate the fact that a user visited the page via a CSS-based timing attack (CSS injection).

Risk level: Very low. Tag colors are admin-controlled (not arbitrary user input), and CSS injection via style attributes is largely theoretical at this risk level.

Mitigation if desired: Whitelist valid color names at the component boundary:

const VALID_COLORS = new Set(['slate', 'sage', 'navy', 'mint', 'sand', 'rose', 'ochre']);
const safeColor = VALID_COLORS.has(tag.color ?? '') ? tag.color : 'slate';

Not required for merge, but worth noting in a future security hardening task.

Not present (as expected)

  • No @RequirePermission needed — this is a read-only feature using an existing endpoint
  • No new ErrorCode values
  • No new cookies or session data

Clean. Approved.

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved with concerns** No exploitable vulnerabilities. One CSS injection smell worth documenting. ### What I checked **Data flow** — All tag data is fetched server-side via `createApiClient(fetch)` in `+page.server.ts` and flows to components via SSR props. No client-side fetch, no API route exposure to the browser. ✅ **URL construction** — Navigation links use `encodeURIComponent(tag.name)` throughout: ```svelte href="/?tag={encodeURIComponent(tag.name)}" href="/?tag={encodeURIComponent(child.name)}" ``` `encodeURIComponent` correctly encodes all characters that could affect URL parsing. ✅ **Authentication** — `GET /api/tags/tree` is an existing, auth-guarded endpoint. The frontend load functions inherit the session cookie via SvelteKit's SSR fetch. No new auth surface introduced. ✅ **XSS** — Tag names are rendered as text content (not `innerHTML`), and Svelte's template engine HTML-encodes by default. ✅ ### Security smell (not a blocker, but document it) ```svelte style="background: var(--c-tag-{tag.color ?? 'slate'})" ``` `tag.color` is interpolated into an inline `style` attribute. CSS custom property references via `var()` **cannot execute JavaScript** — this is not an XSS vector. However, a malicious color value like `slate; background: url(https://evil.com)` could potentially exfiltrate the fact that a user visited the page via a CSS-based timing attack (CSS injection). **Risk level**: Very low. Tag colors are admin-controlled (not arbitrary user input), and CSS injection via `style` attributes is largely theoretical at this risk level. **Mitigation if desired**: Whitelist valid color names at the component boundary: ```typescript const VALID_COLORS = new Set(['slate', 'sage', 'navy', 'mint', 'sand', 'rose', 'ochre']); const safeColor = VALID_COLORS.has(tag.color ?? '') ? tag.color : 'slate'; ``` Not required for merge, but worth noting in a future security hardening task. ### Not present (as expected) - No `@RequirePermission` needed — this is a read-only feature using an existing endpoint - No new `ErrorCode` values - No new cookies or session data Clean. Approved.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Verdict: Approved

Test pyramid is well-covered. The right tests are at the right layers.

Test coverage by layer

Unit (Vitest Node):

  • tagUtils.test.ts — 4 cases for hasAnyDocuments(): leaf=0 → false, leaf=3 → true, root=0+child=5 → true, root=0+all-zero → false. All four boundary conditions covered.

Server integration (Vitest Node):

  • themen/page.server.spec.ts — 3 cases: returns tree array, returns empty array when API sends [], throws 500 when API call fails. Happy path + error path covered.
  • routes/page.server.spec.ts — 3 existing dashboard tests updated with the new api.GET('/api/tags/tree') mock. Would have broken CI without this fix. Good catch.

Browser component (Vitest + Playwright, CI-only):

  • ThemenWidget.svelte.spec.ts — 6 cases including: renders card per visible tag, hides tags where hasAnyDocuments is false, empty state (all filtered), empty state (empty array), compact grid attribute, "Alle Themen" link href.
  • themen/page.svelte.spec.ts — 6 cases including: renders one card per visible root tag, child rows visible up to 5, "+ N weitere" appears when children > 5, empty state.

What I like

The makeLoadEvent() helper in themen/page.server.spec.ts with a full { fetch, request: new Request(...), url: new URL(...) } event object is the correct pattern for Sentry-wrapped load functions. The simpler { fetch } pattern breaks because Sentry's wrapper reads request.method. Good fix, and it's now documented by the test itself.

Minor gaps (not blockers)

  1. The hasAnyDocuments() tests cover depth-1 children but not depth-2 grandchildren. The function is recursive, so depth-2 would exercise the recursion more thoroughly. That said, the current 4 cases are sufficient for the current data model where trees are 2 levels deep.

  2. No test verifies the document count display logic: "show count only when > 0". This is rendered in the template as {#if tag.documentCount > 0}{tag.documentCount}{/if}. A browser test asserting that zero-count tags show no number would close this gap. Not a blocker.

  3. The + N weitere test in themen/page.svelte.spec.ts — confirm it checks the count value displayed, not just presence of the element. If it's checking getByText(/\+ \d+ weitere/) that's sufficient.

Overall test coverage is appropriate for the feature scope. Ready to merge from a QA perspective.

## 🧪 Sara Holt — QA Engineer & Test Strategist **Verdict: ✅ Approved** Test pyramid is well-covered. The right tests are at the right layers. ### Test coverage by layer **Unit (Vitest Node):** - `tagUtils.test.ts` — 4 cases for `hasAnyDocuments()`: leaf=0 → false, leaf=3 → true, root=0+child=5 → true, root=0+all-zero → false. All four boundary conditions covered. ✅ **Server integration (Vitest Node):** - `themen/page.server.spec.ts` — 3 cases: returns tree array, returns empty array when API sends `[]`, throws 500 when API call fails. Happy path + error path covered. ✅ - `routes/page.server.spec.ts` — 3 existing dashboard tests updated with the new `api.GET('/api/tags/tree')` mock. Would have broken CI without this fix. Good catch. ✅ **Browser component (Vitest + Playwright, CI-only):** - `ThemenWidget.svelte.spec.ts` — 6 cases including: renders card per visible tag, hides tags where `hasAnyDocuments` is false, empty state (all filtered), empty state (empty array), compact grid attribute, "Alle Themen" link href. ✅ - `themen/page.svelte.spec.ts` — 6 cases including: renders one card per visible root tag, child rows visible up to 5, "+ N weitere" appears when children > 5, empty state. ✅ ### What I like The `makeLoadEvent()` helper in `themen/page.server.spec.ts` with a full `{ fetch, request: new Request(...), url: new URL(...) }` event object is the correct pattern for Sentry-wrapped load functions. The simpler `{ fetch }` pattern breaks because Sentry's wrapper reads `request.method`. Good fix, and it's now documented by the test itself. ### Minor gaps (not blockers) 1. The `hasAnyDocuments()` tests cover depth-1 children but not depth-2 grandchildren. The function is recursive, so depth-2 would exercise the recursion more thoroughly. That said, the current 4 cases are sufficient for the current data model where trees are 2 levels deep. 2. No test verifies the document count display logic: "show count only when `> 0`". This is rendered in the template as `{#if tag.documentCount > 0}{tag.documentCount}{/if}`. A browser test asserting that zero-count tags show no number would close this gap. Not a blocker. 3. The `+ N weitere` test in `themen/page.svelte.spec.ts` — confirm it checks the count value displayed, not just presence of the element. If it's checking `getByText(/\+ \d+ weitere/)` that's sufficient. Overall test coverage is appropriate for the feature scope. Ready to merge from a QA perspective.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: 🚫 Changes requested

The card and color bar design is excellent. Touch targets meet requirements. The root tag links have proper focus rings. However, two keyboard accessibility violations must be fixed before merge.


<a
    href="/?tag={encodeURIComponent(child.name)}"
    class="flex min-h-[44px] items-center justify-between px-4 py-2.5 
           hover:bg-canvas focus-visible:bg-canvas focus-visible:outline-none"
>

focus-visible:outline-none removes the browser's native focus ring. focus-visible:bg-canvas (a background color change) is not a visible focus indicator — it likely fails the 3:1 contrast ratio required by WCAG 2.4.11 (Focus Appearance, Level AA in WCAG 2.2). Keyboard users navigating by Tab cannot see where they are.

Compare with the parent tag link, which correctly uses:

focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset

Fix:

class="flex min-h-[44px] items-center justify-between px-4 py-2.5 
       hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 
       focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"

<a
    href="/?tag={encodeURIComponent(tag.name)}"
    class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 
           hover:bg-canvas hover:text-ink"
>
    {m.themen_weitere({ count: hiddenCount })}</a>

No focus-visible:* classes at all. This link is completely invisible to keyboard navigation. WCAG 2.4.7 (Focus Visible, Level AA) violation.

Fix:

class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 
       hover:bg-canvas hover:text-ink focus-visible:ring-2 
       focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"

What's working well

  • Root tag link focus ring: focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset — correct and consistent with the rest of the codebase
  • Touch targets: root tag min-h-[56px], children min-h-[44px] — meets the 44px WCAG 2.2 minimum for our 60+ user base
  • Color bars: aria-hidden="true" on decorative color bar
  • Root tag aria-label: includes document count in the accessible label when count > 0
  • Decorative arrow: aria-hidden="true" on the character
  • Responsive grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 on /themen, single-column in compact widget mode
  • Typography: font-serif for tag names, font-sans for counts/children — consistent with design system
  • CSS token: var(--c-tag-{color}) is mode-neutral (same in light and dark)

Verification after fix

The same focus ring pattern should also be checked in ThemenWidget.svelte for any child links or "Alle Themen" links — apply the same focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset pattern there if it's not already present.

The fixes are two class additions. Two minutes of work. Please fix and push before merging.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: 🚫 Changes requested** The card and color bar design is excellent. Touch targets meet requirements. The root tag links have proper focus rings. However, **two keyboard accessibility violations** must be fixed before merge. --- ### 🔴 Blocker 1 — Child links remove focus outline without replacing it (`/themen/+page.svelte`) ```svelte <a href="/?tag={encodeURIComponent(child.name)}" class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:outline-none" > ``` `focus-visible:outline-none` removes the browser's native focus ring. `focus-visible:bg-canvas` (a background color change) is not a visible focus indicator — it likely fails the 3:1 contrast ratio required by **WCAG 2.4.11 (Focus Appearance, Level AA in WCAG 2.2)**. Keyboard users navigating by Tab cannot see where they are. Compare with the parent tag link, which correctly uses: ```svelte focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset ``` **Fix:** ```svelte class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" ``` --- ### 🔴 Blocker 2 — "+ N weitere" link has no focus style at all (`/themen/+page.svelte`) ```svelte <a href="/?tag={encodeURIComponent(tag.name)}" class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink" > {m.themen_weitere({ count: hiddenCount })} → </a> ``` No `focus-visible:*` classes at all. This link is completely invisible to keyboard navigation. **WCAG 2.4.7 (Focus Visible, Level AA)** violation. **Fix:** ```svelte class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" ``` --- ### What's working well ✅ - **Root tag link focus ring**: `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset` — correct and consistent with the rest of the codebase ✅ - **Touch targets**: root tag `min-h-[56px]`, children `min-h-[44px]` — meets the 44px WCAG 2.2 minimum for our 60+ user base ✅ - **Color bars**: `aria-hidden="true"` on decorative color bar ✅ - **Root tag aria-label**: includes document count in the accessible label when count > 0 ✅ - **Decorative arrow**: `aria-hidden="true"` on the `›` character ✅ - **Responsive grid**: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` on `/themen`, single-column in compact widget mode ✅ - **Typography**: `font-serif` for tag names, `font-sans` for counts/children — consistent with design system ✅ - **CSS token**: `var(--c-tag-{color})` is mode-neutral (same in light and dark) ✅ --- ### Verification after fix The same focus ring pattern should also be checked in `ThemenWidget.svelte` for any child links or "Alle Themen" links — apply the same `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset` pattern there if it's not already present. The fixes are two class additions. Two minutes of work. Please fix and push before merging.
Author
Owner

🏛️ Markus Keller — Senior Application Architect (Round 2)

Verdict: Approved

Architecture is clean throughout. No blockers.

Module boundaries

hasAnyDocuments() lives in $lib/shared/utils/tagUtils.ts — the correct neutral zone. ThemenWidget.svelte imports from $lib/shared/utils/, staying within the shared domain boundary and respecting the ESLint cross-domain rule.

Layer compliance

All data loading is server-side. +page.server.ts (dashboard) and themen/+page.server.ts both use createApiClient(fetch)GET /api/tags/tree. Components receive data via $props(). No direct API calls from client-side code.

Error resilience

  • Dashboard: tagTree fetch is inside Promise.allSettled, so a failure doesn't block other dashboard data. The catch fallback explicitly returns tagTree: [] as TagTreeNodeDTO[].
  • /themen: throws a SvelteKit error(500, ...) if the API fails, which renders an error page. Appropriate for a dedicated route where the data is the whole point.

Documentation currency

Trigger Required doc Present?
New SvelteKit route /themen CLAUDE.md route table
New SvelteKit route /themen l3-frontend-3c-people-stories.puml

C4 diagram correctly adds both Component(themen, ...) and Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON"). The description also captures that ThemenWidget is embedded on the home dashboard — the diagram reflects the dual use.

Nothing to flag from an architecture perspective. Merge when ready.

## 🏛️ Markus Keller — Senior Application Architect (Round 2) **Verdict: ✅ Approved** Architecture is clean throughout. No blockers. ### Module boundaries `hasAnyDocuments()` lives in `$lib/shared/utils/tagUtils.ts` — the correct neutral zone. `ThemenWidget.svelte` imports from `$lib/shared/utils/`, staying within the `shared` domain boundary and respecting the ESLint cross-domain rule. ✅ ### Layer compliance All data loading is server-side. `+page.server.ts` (dashboard) and `themen/+page.server.ts` both use `createApiClient(fetch)` → `GET /api/tags/tree`. Components receive data via `$props()`. No direct API calls from client-side code. ✅ ### Error resilience - Dashboard: `tagTree` fetch is inside `Promise.allSettled`, so a failure doesn't block other dashboard data. The catch fallback explicitly returns `tagTree: [] as TagTreeNodeDTO[]`. ✅ - `/themen`: throws a SvelteKit `error(500, ...)` if the API fails, which renders an error page. Appropriate for a dedicated route where the data is the whole point. ✅ ### Documentation currency | Trigger | Required doc | Present? | |---|---|---| | New SvelteKit route `/themen` | `CLAUDE.md` route table | ✅ | | New SvelteKit route `/themen` | `l3-frontend-3c-people-stories.puml` | ✅ | C4 diagram correctly adds both `Component(themen, ...)` and `Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")`. The description also captures that `ThemenWidget` is embedded on the home dashboard — the diagram reflects the dual use. ✅ Nothing to flag from an architecture perspective. Merge when ready.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer (Round 2)

Verdict: Approved

TDD, Svelte 5 runes, keyed iteration, component sizing — all correct. No blockers remaining.

Svelte 5 compliance (full check)

  • $props() used in both ThemenWidget.svelte and /themen/+page.svelte
  • $derived.by() visibleTags and visibleTree are computed reactively
  • No export let, no $:, no onMount
  • Keyed {#each ... (tag.id)} in both components; child iteration also keyed (child.id)

Clean code

hasAnyDocuments() is a 3-line pure function with a self-documenting name — exactly right. No business logic leaked into templates beyond the {@const} per-iteration derived values in the {#each} block, which is idiomatic Svelte 5 for item-scoped computation.

ThemenWidget.svelte at 64 lines and /themen/+page.svelte at 85 lines — within acceptable ranges. Each does one thing.

The accessibility fix commit

The last commit (d1d0acf0) adds focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset to the child links and + N weitere link. Minimal, targeted, correct. The ring-inset variant is the right choice here since these links are inside a card border — inset rings don't overflow the card boundary.

Remaining suggestions (not blockers)

data-compact={compact} on the grid div is used by the browser test to verify compact mode. This couples the test to an implementation attribute rather than user-visible state. A future improvement would be asserting that the grid has the grid-cols-1 class, but given the CI-only nature of the browser tests, this is a minor papercut.

Ready to merge.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer (Round 2) **Verdict: ✅ Approved** TDD, Svelte 5 runes, keyed iteration, component sizing — all correct. No blockers remaining. ### Svelte 5 compliance (full check) - `$props()` — ✅ used in both `ThemenWidget.svelte` and `/themen/+page.svelte` - `$derived.by()` — ✅ `visibleTags` and `visibleTree` are computed reactively - No `export let`, no `$:`, no `onMount` — ✅ - Keyed `{#each ... (tag.id)}` — ✅ in both components; child iteration also keyed `(child.id)` ✅ ### Clean code `hasAnyDocuments()` is a 3-line pure function with a self-documenting name — exactly right. No business logic leaked into templates beyond the `{@const}` per-iteration derived values in the `{#each}` block, which is idiomatic Svelte 5 for item-scoped computation. `ThemenWidget.svelte` at 64 lines and `/themen/+page.svelte` at 85 lines — within acceptable ranges. Each does one thing. ### The accessibility fix commit The last commit (`d1d0acf0`) adds `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset` to the child links and `+ N weitere` link. Minimal, targeted, correct. The ring-inset variant is the right choice here since these links are inside a card border — inset rings don't overflow the card boundary. ✅ ### Remaining suggestions (not blockers) `data-compact={compact}` on the grid div is used by the browser test to verify compact mode. This couples the test to an implementation attribute rather than user-visible state. A future improvement would be asserting that the grid has the `grid-cols-1` class, but given the CI-only nature of the browser tests, this is a minor papercut. Ready to merge.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer (Round 2)

Verdict: Approved

Pure frontend feature, no infrastructure changes. LGTM.

Checked: Docker Compose (no changes), CI pipeline (no changes), npm dependencies (no new packages added), env vars (none new), SvelteKit adapter config (unchanged).

The fix commit adds 2 CSS class strings. Zero operational impact. Merge when ready.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer (Round 2) **Verdict: ✅ Approved** Pure frontend feature, no infrastructure changes. LGTM. Checked: Docker Compose (no changes), CI pipeline (no changes), npm dependencies (no new packages added), env vars (none new), SvelteKit adapter config (unchanged). The fix commit adds 2 CSS class strings. Zero operational impact. Merge when ready.
Author
Owner

📋 Elicit — Requirements Engineer (Round 2)

Verdict: Approved

All requirements from issue #662 are implemented. No gaps.

Full requirements trace

Requirement Evidence in diff
Dashboard widget — reader layout <ThemenWidget tags={data.tagTree ?? []} /> after <ReaderPersonChips>
Dashboard widget — editor sidebar, compact <ThemenWidget tags={data.tagTree ?? []} compact={true} /> in sidebar
/themen dedicated route themen/+page.server.ts + themen/+page.svelte
Root-tag cards with color bar h-1.5 div + style="background: var(--c-tag-{tag.color ?? 'slate'})"
Up to 5 child rows per card MAX_VISIBLE_CHILDREN = 5 + visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)
"+ N weitere →" overflow {m.themen_weitere({ count: hiddenCount })} → linking to root tag search
Navigate by tag name /?tag={encodeURIComponent(tag.name)} throughout
Hide tags with no subtree documents hasAnyDocuments() + $derived.by(() => data.tree.filter(hasAnyDocuments))
Root count omitted when 0 {#if tag.documentCount > 0}{tag.documentCount}{/if}
Empty state message {m.themen_leer()} when visibleTree.length === 0
i18n de/en/es 5 keys in all three message files
Keyboard accessibility All links have focus-visible:ring-2 (including post-fix child + weitere links)

Scope is correctly bounded to issue #662. No scope creep observed. Ready to close the issue on merge.

## 📋 Elicit — Requirements Engineer (Round 2) **Verdict: ✅ Approved** All requirements from issue #662 are implemented. No gaps. ### Full requirements trace | Requirement | Evidence in diff | ✓ | |---|---|---| | Dashboard widget — reader layout | `<ThemenWidget tags={data.tagTree ?? []} />` after `<ReaderPersonChips>` | ✅ | | Dashboard widget — editor sidebar, compact | `<ThemenWidget tags={data.tagTree ?? []} compact={true} />` in sidebar | ✅ | | `/themen` dedicated route | `themen/+page.server.ts` + `themen/+page.svelte` | ✅ | | Root-tag cards with color bar | `h-1.5` div + `style="background: var(--c-tag-{tag.color ?? 'slate'})"` | ✅ | | Up to 5 child rows per card | `MAX_VISIBLE_CHILDREN = 5` + `visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)` | ✅ | | "+ N weitere →" overflow | `{m.themen_weitere({ count: hiddenCount })} →` linking to root tag search | ✅ | | Navigate by tag name | `/?tag={encodeURIComponent(tag.name)}` throughout | ✅ | | Hide tags with no subtree documents | `hasAnyDocuments()` + `$derived.by(() => data.tree.filter(hasAnyDocuments))` | ✅ | | Root count omitted when 0 | `{#if tag.documentCount > 0}{tag.documentCount}{/if}` | ✅ | | Empty state message | `{m.themen_leer()}` when `visibleTree.length === 0` | ✅ | | i18n de/en/es | 5 keys in all three message files | ✅ | | Keyboard accessibility | All links have `focus-visible:ring-2` (including post-fix child + weitere links) | ✅ | Scope is correctly bounded to issue #662. No scope creep observed. Ready to close the issue on merge.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer (Round 2)

Verdict: Approved

No security issues. The fix commit is CSS-only and introduces no new attack surface.

Full security check on the diff

Data flow: Server-side SSR throughout. createApiClient(fetch) in load functions — the session cookie is forwarded by SvelteKit's server-side fetch. No API exposure to the browser.

URL encoding: encodeURIComponent(tag.name) and encodeURIComponent(child.name) used consistently on every navigation link.

Svelte template output: All tag names and child names rendered as text content in Svelte template expressions {} — Svelte HTML-encodes these by default. No {@html} anywhere in the diff.

CSS variable interpolation: var(--c-tag-{tag.color ?? 'slate'}) — CSS custom properties cannot execute JavaScript, so this is not an XSS vector. Tag colors are admin-controlled, low-risk. Flagged in round 1 for awareness; no regression from the fix.

Auth: GET /api/tags/tree is an existing auth-guarded endpoint. No new permissions or permission checks needed — this is a read-only feature.

The focus ring fix: Purely presentational Tailwind class additions. Zero security impact.

Clean. Approved.

## 🔒 Nora "NullX" Steiner — Application Security Engineer (Round 2) **Verdict: ✅ Approved** No security issues. The fix commit is CSS-only and introduces no new attack surface. ### Full security check on the diff **Data flow**: Server-side SSR throughout. `createApiClient(fetch)` in load functions — the session cookie is forwarded by SvelteKit's server-side fetch. No API exposure to the browser. ✅ **URL encoding**: `encodeURIComponent(tag.name)` and `encodeURIComponent(child.name)` used consistently on every navigation link. ✅ **Svelte template output**: All tag names and child names rendered as text content in Svelte template expressions `{}` — Svelte HTML-encodes these by default. No `{@html}` anywhere in the diff. ✅ **CSS variable interpolation**: `var(--c-tag-{tag.color ?? 'slate'})` — CSS custom properties cannot execute JavaScript, so this is not an XSS vector. Tag colors are admin-controlled, low-risk. Flagged in round 1 for awareness; no regression from the fix. ✅ **Auth**: `GET /api/tags/tree` is an existing auth-guarded endpoint. No new permissions or permission checks needed — this is a read-only feature. ✅ **The focus ring fix**: Purely presentational Tailwind class additions. Zero security impact. ✅ Clean. Approved.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist (Round 2)

Verdict: Approved

Test pyramid is solid. The accessibility fix requires no new tests — it's a pure CSS class change with no new conditional logic.

Test coverage summary

Layer File Cases Status
Unit tagUtils.test.ts 4
Server integration themen/page.server.spec.ts 3
Server integration routes/page.server.spec.ts (updated) 3 updated
Browser component ThemenWidget.svelte.spec.ts 6 CI-only
Browser component themen/page.svelte.spec.ts 6 CI-only

Specific observations

tagUtils.test.ts — 4 cases covering the key boundary conditions for hasAnyDocuments(). The recursive case (root=0, child=5) is explicitly tested.

themen/page.server.spec.ts — The makeLoadEvent() helper correctly provides { fetch, request: new Request(...), url: new URL(...) }. This is the right pattern for Sentry-wrapped SvelteKit load functions, which crash if request.method is undefined. Documents a non-obvious pattern that future contributors will benefit from.

page.server.spec.ts (dashboard) — All 3 existing tests correctly received an additional mockResolvedValueOnce for the new GET /api/tags/tree call. Without this fix, these tests would have failed in CI with "unexpected call" errors.

themen/page.svelte.spec.ts — The + N weitere test uses expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/) — checks the count value displayed, not just the element's existence.

The fix commit

No tests needed for the accessibility fix. Adding focus-visible:ring-2 to a link has no conditional logic, no new code paths, and the visual behavior is verified by axe-core accessibility checks in E2E (future work).

Ready to merge.

## 🧪 Sara Holt — QA Engineer & Test Strategist (Round 2) **Verdict: ✅ Approved** Test pyramid is solid. The accessibility fix requires no new tests — it's a pure CSS class change with no new conditional logic. ### Test coverage summary | Layer | File | Cases | Status | |---|---|---|---| | Unit | `tagUtils.test.ts` | 4 | ✅ | | Server integration | `themen/page.server.spec.ts` | 3 | ✅ | | Server integration | `routes/page.server.spec.ts` (updated) | 3 updated | ✅ | | Browser component | `ThemenWidget.svelte.spec.ts` | 6 | ✅ CI-only | | Browser component | `themen/page.svelte.spec.ts` | 6 | ✅ CI-only | ### Specific observations **`tagUtils.test.ts`** — 4 cases covering the key boundary conditions for `hasAnyDocuments()`. The recursive case (root=0, child=5) is explicitly tested. ✅ **`themen/page.server.spec.ts`** — The `makeLoadEvent()` helper correctly provides `{ fetch, request: new Request(...), url: new URL(...) }`. This is the right pattern for Sentry-wrapped SvelteKit load functions, which crash if `request.method` is undefined. Documents a non-obvious pattern that future contributors will benefit from. ✅ **`page.server.spec.ts` (dashboard)** — All 3 existing tests correctly received an additional `mockResolvedValueOnce` for the new `GET /api/tags/tree` call. Without this fix, these tests would have failed in CI with "unexpected call" errors. ✅ **`themen/page.svelte.spec.ts`** — The `+ N weitere` test uses `expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/)` — checks the count value displayed, not just the element's existence. ✅ ### The fix commit No tests needed for the accessibility fix. Adding `focus-visible:ring-2` to a link has no conditional logic, no new code paths, and the visual behavior is verified by axe-core accessibility checks in E2E (future work). ✅ Ready to merge.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist (Round 2)

Verdict: Approved

Both blockers from round 1 are resolved. All interactive elements now have consistent, visible focus rings.

Blockers resolved

Blocker 1 — Child links FIXED

class="flex min-h-[44px] items-center justify-between px-4 py-2.5 
       hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 
       focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"

focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset added. The ring-inset variant is correct here — it keeps the focus indicator within the card's border, preventing visual bleed across the card boundary.

Blocker 2 — "+ N weitere" link FIXED

class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 
       hover:bg-canvas hover:text-ink focus-visible:ring-2 
       focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"

Complete focus ring added. Keyboard users can now see the "more items" link when it has focus.

Full accessibility check (post-fix)

Element Focus treatment Pass?
ThemenWidget "Alle Themen →" link ring-2 ring-brand-navy outline-none
ThemenWidget tag card links ring-2 ring-brand-navy outline-none
/themen root tag <a> ring-2 ring-brand-navy outline-none ring-inset
/themen child <a> ring-2 ring-brand-navy outline-none ring-inset FIXED
/themen "+ N weitere" <a> ring-2 ring-brand-navy outline-none ring-inset FIXED

Touch targets: root min-h-[56px], children min-h-[44px], "weitere" min-h-[44px] — all meet the 44px WCAG 2.2 minimum.

Screen reader support: root tag <a> carries an aria-label that includes the name and document count when count > 0. Decorative characters are aria-hidden="true". Color bars are aria-hidden="true".

Responsive: /themen scales from single-column → 2-column (sm) → 3-column (lg). Widget scales from single-column (compact) or 1→2 column (normal).

Typography: tag names in font-serif, counts and metadata in font-sans — consistent with the design system.

All accessibility concerns resolved. Approved for merge.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist (Round 2) **Verdict: ✅ Approved** Both blockers from round 1 are resolved. All interactive elements now have consistent, visible focus rings. ### Blockers resolved **Blocker 1 — Child links** ✅ FIXED ```svelte class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" ``` `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset` added. The ring-inset variant is correct here — it keeps the focus indicator within the card's border, preventing visual bleed across the card boundary. ✅ **Blocker 2 — "+ N weitere" link** ✅ FIXED ```svelte class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset" ``` Complete focus ring added. Keyboard users can now see the "more items" link when it has focus. ✅ ### Full accessibility check (post-fix) | Element | Focus treatment | Pass? | |---|---|---| | ThemenWidget "Alle Themen →" link | `ring-2 ring-brand-navy outline-none` | ✅ | | ThemenWidget tag card links | `ring-2 ring-brand-navy outline-none` | ✅ | | /themen root tag `<a>` | `ring-2 ring-brand-navy outline-none ring-inset` | ✅ | | /themen child `<a>` | `ring-2 ring-brand-navy outline-none ring-inset` | ✅ FIXED | | /themen "+ N weitere" `<a>` | `ring-2 ring-brand-navy outline-none ring-inset` | ✅ FIXED | **Touch targets**: root `min-h-[56px]`, children `min-h-[44px]`, "weitere" `min-h-[44px]` — all meet the 44px WCAG 2.2 minimum. ✅ **Screen reader support**: root tag `<a>` carries an `aria-label` that includes the name and document count when count > 0. Decorative `›` characters are `aria-hidden="true"`. Color bars are `aria-hidden="true"`. ✅ **Responsive**: `/themen` scales from single-column → 2-column (sm) → 3-column (lg). Widget scales from single-column (compact) or 1→2 column (normal). ✅ **Typography**: tag names in `font-serif`, counts and metadata in `font-sans` — consistent with the design system. ✅ All accessibility concerns resolved. Approved for merge.
marcel added 9 commits 2026-05-25 18:52:40 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(themen): add focus rings to child and 'weitere' links (WCAG 2.4.7)
Some checks failed
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
80d77a53e9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel force-pushed worktree-feat+issue-662-themen-inhaltsverzeichnis from d1d0acf029 to 80d77a53e9 2026-05-25 18:52:40 +02:00 Compare
marcel added 1 commit 2026-05-25 19:03:54 +02:00
feat(dashboard): move ThemenWidget to full-width position
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m27s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 4m5s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
e6a0c2f6d6
Editor view: lifted out of sidebar, now spans full width between
DashboardResumeStrip and EnrichmentBlock.
Reader view: already below ReaderPersonChips, no change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-05-25 19:07:13 +02:00
feat(themen): cap ThemenWidget at 6 tags — link to /themen for full list
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
264d60c855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-05-25 19:30:15 +02:00
fix(themen): correct link color and tag navigation route
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m18s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
5dac1d993c
- Match "Alle Themen →" link style to other reader dashboard widgets (text-ink-2, font-semibold, no-underline)
- Fix tag card hrefs from /?tag= to /documents?tag= — the home page does not handle tag filtering, /documents does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-05-25 19:45:54 +02:00
test(dashboard): add missing tag tree mock to recentDocs reader test
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m42s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m40s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
CI / Unit & Component Tests (push) Successful in 4m5s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m38s
CI / fail2ban Regex (push) Successful in 42s
CI / Semgrep Security Scan (push) Successful in 19s
CI / Compose Bucket Idempotency (push) Successful in 1m2s
3f3d5e530c
The sequential mock chain in the recentDocs test was missing a 6th call
for /api/tags/tree added in the tag tree fetch. Without it the mock
returned undefined, causing settled() to throw and the outer catch to
return an empty recentDocs array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel merged commit 3f3d5e530c into main 2026-05-27 09:41:40 +02:00
marcel deleted branch worktree-feat+issue-662-themen-inhaltsverzeichnis 2026-05-27 09:41:41 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#664