As a user I want route-specific skeleton loaders so I see the page layout while data is still being fetched #292

Open
opened 2026-04-20 18:13:41 +02:00 by marcel · 10 comments
Owner

Problem

The global navigation progress bar (#289) tells users something is happening, but on data-heavy routes the page remains blank until the server load function resolves. A skeleton that mirrors the final layout makes perceived load time feel shorter and prevents layout shift when data arrives.

Proposed solution

Introduce per-route skeleton loaders for the slowest / most-visited pages.

Candidates (priority order)

  1. /chronik — unified activity feed, often slow
  2. /documents — search + list
  3. /documents/[id] — file preview + metadata
  4. /persons — list with stats
  5. /persons/[id] — detail + related documents
  6. /conversations — timeline

Approach options to evaluate

  • Streaming with Promise in load: SvelteKit supports returning promises from load that stream in. Pair with {#await} blocks + skeleton fallback. Pro: shell renders immediately. Con: requires splitting slow queries in each +page.server.ts.
  • navigating store + per-route skeleton components: show skeleton while navigating.to.route.id === '/chronik'. Pro: no backend changes. Con: duplicates page shell.

Shared building blocks

  • Skeleton.svelte primitive — animated shimmer block (width/height/rounded props)
  • Compose per-route skeletons from this primitive
  • Shimmer uses brand-sand base with a subtle brand-mint sweep

Acceptance criteria

  • Decision recorded on approach (streaming vs navigating store) — see brainstorm phase
  • Skeleton.svelte primitive exists with a unit test
  • Skeleton rendered for at least /chronik, /documents, /documents/[id] (others as follow-ups)
  • Skeleton shape matches the final layout closely enough to avoid jarring layout shift
  • Accessibility: skeleton container has aria-busy="true", decorative shimmer is aria-hidden
  • Visual regression / Playwright proof shot per covered route

Dependencies

  • Should land AFTER #289 so the progress bar is available as the baseline feedback for routes without skeletons yet.

Out of scope

  • Progress bar itself (#289)
  • Server-side caching / query optimization — separate concern
## Problem The global navigation progress bar (#289) tells users something is happening, but on data-heavy routes the page remains blank until the server `load` function resolves. A skeleton that mirrors the final layout makes perceived load time feel shorter and prevents layout shift when data arrives. ## Proposed solution Introduce per-route skeleton loaders for the slowest / most-visited pages. ### Candidates (priority order) 1. `/chronik` — unified activity feed, often slow 2. `/documents` — search + list 3. `/documents/[id]` — file preview + metadata 4. `/persons` — list with stats 5. `/persons/[id]` — detail + related documents 6. `/conversations` — timeline ### Approach options to evaluate - **Streaming with `Promise` in load**: SvelteKit supports returning promises from `load` that stream in. Pair with `{#await}` blocks + skeleton fallback. Pro: shell renders immediately. Con: requires splitting slow queries in each `+page.server.ts`. - **`navigating` store + per-route skeleton components**: show skeleton while `navigating.to.route.id === '/chronik'`. Pro: no backend changes. Con: duplicates page shell. ### Shared building blocks - `Skeleton.svelte` primitive — animated shimmer block (width/height/rounded props) - Compose per-route skeletons from this primitive - Shimmer uses `brand-sand` base with a subtle `brand-mint` sweep ## Acceptance criteria - [ ] Decision recorded on approach (streaming vs `navigating` store) — see brainstorm phase - [ ] `Skeleton.svelte` primitive exists with a unit test - [ ] Skeleton rendered for at least `/chronik`, `/documents`, `/documents/[id]` (others as follow-ups) - [ ] Skeleton shape matches the final layout closely enough to avoid jarring layout shift - [ ] Accessibility: skeleton container has `aria-busy="true"`, decorative shimmer is `aria-hidden` - [ ] Visual regression / Playwright proof shot per covered route ## Dependencies - Should land AFTER #289 so the progress bar is available as the baseline feedback for routes without skeletons yet. ## Out of scope - Progress bar itself (#289) - Server-side caching / query optimization — separate concern
marcel added the featureui labels 2026-04-20 18:13:46 +02:00
Author
Owner

📋 Elicit — Requirements Engineer

Requirements review discussion — recording agreed decisions and updated scope before implementation.


Resolved items

  • Success criterion — Skeleton must appear within 50 ms of navigation start (before any server response). Layout shift when content arrives must score CLS ≤ 0.1 (Google Core Web Vitals "Good"). Playwright proof shots per route serve as the shape-accuracy quality gate.

  • Approach decisionnavigating store + per-route skeleton components (not SvelteKit streaming). Rationale: all user-triggered loading events (first load, search param changes, post-save redirects) are navigation events that navigating catches. No +page.server.ts changes required.

  • Trigger scope — User-initiated actions only: navigation, search, form submit. Silent background invalidations (invalidate() without navigation) are explicitly out of scope.

  • Route scope (expanded) — Skeletons required for all user-facing pages:

    • / — Dashboard
    • /documents — search + list
    • /documents/[id] — file preview + metadata
    • /persons — list with stats
    • /persons/[id] — detail + related documents
    • /briefwechsel — conversation timeline

    Admin pages excluded. No deferred follow-ups — this is the complete set.

  • Responsive — All skeleton components must render correctly at mobile (≤768 px), tablet, and desktop (≥1024 px). Both transcriber (laptop/tablet) and reader (mobile) personas must be covered.

  • Unit test scope for Skeleton.svelte — Test must verify: default props render; custom width, height, rounded props apply correctly; shimmer element carries aria-hidden="true"; wrapper carries aria-busy="true".

  • #289 dependency — Hard prerequisite. Implementation must not start until #289 is merged.


Overall this issue is well-structured. The main gap was scope (now expanded to all 6 user-facing routes) and missing measurable thresholds for "perceived improvement" — both addressed above. Ready for implementation.

## 📋 Elicit — Requirements Engineer Requirements review discussion — recording agreed decisions and updated scope before implementation. --- ### Resolved items - **Success criterion** — Skeleton must appear within 50 ms of navigation start (before any server response). Layout shift when content arrives must score CLS ≤ 0.1 (Google Core Web Vitals "Good"). Playwright proof shots per route serve as the shape-accuracy quality gate. - **Approach decision** — `navigating` store + per-route skeleton components (not SvelteKit streaming). Rationale: all user-triggered loading events (first load, search param changes, post-save redirects) are navigation events that `navigating` catches. No `+page.server.ts` changes required. - **Trigger scope** — User-initiated actions only: navigation, search, form submit. Silent background invalidations (`invalidate()` without navigation) are explicitly out of scope. - **Route scope (expanded)** — Skeletons required for all user-facing pages: - `/` — Dashboard - `/documents` — search + list - `/documents/[id]` — file preview + metadata - `/persons` — list with stats - `/persons/[id]` — detail + related documents - `/briefwechsel` — conversation timeline Admin pages excluded. No deferred follow-ups — this is the complete set. - **Responsive** — All skeleton components must render correctly at mobile (≤768 px), tablet, and desktop (≥1024 px). Both transcriber (laptop/tablet) and reader (mobile) personas must be covered. - **Unit test scope for `Skeleton.svelte`** — Test must verify: default props render; custom `width`, `height`, `rounded` props apply correctly; shimmer element carries `aria-hidden="true"`; wrapper carries `aria-busy="true"`. - **#289 dependency** — Hard prerequisite. Implementation must not start until #289 is merged. --- Overall this issue is well-structured. The main gap was scope (now expanded to all 6 user-facing routes) and missing measurable thresholds for "perceived improvement" — both addressed above. Ready for implementation.
Author
Owner

Implementation Plan — Skeleton Loaders

Approach Decision: navigating store (Option B)

Use navigating.to?.route?.id in +layout.svelte to swap the page slot for the destination skeleton while data loads. No backend changes required. The navigating store is already used in documents/+page.svelte:230 as a loading signal, so the pattern is established.

Why not streaming: requires splitting every slow +page.server.ts query — significant backend churn for a UI-only concern.


Route name mapping

Issue name Actual SvelteKit route
/chronik /aktivitaeten
/conversations /briefwechsel

1. Skeleton.svelte primitive

File: src/lib/components/Skeleton.svelte

Single animated shimmer block. All sizing and shape come from the caller via class.

  • Base: var(--c-muted) — brand-sand in light, dark surface in dark mode (automatic)
  • Sweep: var(--c-accent)/15 — brand-mint shimmer at low opacity
  • Uses @keyframes slide already defined in layout.css:382
  • aria-hidden="true" — purely decorative, callers wrap in aria-busy="true"

Unit test: src/lib/components/Skeleton.svelte.spec.ts


2. Skeleton shapes (Phase 1 — AC minimum)

ChronikSkeleton — /aktivitaeten

File: src/routes/aktivitaeten/ChronikSkeleton.svelte

<main> max-w-3xl  [aria-busy="true"]

  Skeleton h-8 w-48                    ← "Chronik" heading

  Notification inbox card  (border bg-surface shadow-sm p-4 mb-6)
  └─ Skeleton h-4 w-32                 ← "Für dich" caption
  └─ ×3: [Skeleton h-10 w-10 rounded-full] + [h-4 w-3/4 + h-3 w-1/2]

  Filter pills  ×5
  └─ Skeleton h-8 w-20 rounded-full    ← "alle / fuer-dich / …"

  Bucket section  ×2
  ├─ Skeleton h-9 w-full               ← "Heute" bucket header
  └─ ×5 rows: [h-10 w-10 circle] + [h-4 w-3/4 + h-3 w-1/2]

DocumentListSkeleton — /documents

File: src/routes/documents/DocumentListSkeleton.svelte

Search bar: [h-10 flex-1 input] + [h-10 w-28 sort] + [h-10 w-10 filter]

Group ×3:
├─ Skeleton h-8 w-full                 ← group header ("2024")
└─ ×5 rows:
   ├─ Skeleton w-[120px] h-[168px]     ← thumbnail (5:7 ratio)
   └─ [h-6 w-3/4 title] + [h-4 w-full + h-4 w-2/3 snippet] + [2× tag chips]

DocumentDetailSkeleton — /documents/[id]

File: src/routes/documents/[id]/DocumentDetailSkeleton.svelte

TopBar  h-[75px] border-b bg-surface
├─ Skeleton h-10 w-10 rounded-full     ← back button
├─ [h-5 w-56 title] + [h-3 w-32 date]
└─ [h-8 w-24] + [h-8 w-24]            ← action buttons

Content  flex flex-1
├─ PDF area  flex-1
│  └─ Skeleton h-full w-full           ← full-height PDF placeholder
└─ Transcription panel  md:w-[400px] border-l p-4
   ├─ Skeleton h-[44px] w-full         ← mode toggle header
   └─ ×10 Skeleton h-4 w-full          ← text lines (ragged right)

Layout integration

src/routes/+layout.svelte — wrap {@render children()}:

{#if navigating.to?.route?.id === '/aktivitaeten'}
  <ChronikSkeleton />
{:else if navigating.to?.route?.id === '/documents/[id]'}
  <DocumentDetailSkeleton />
{:else if navigating.to?.route?.id === '/documents'}
  <DocumentListSkeleton />
{:else}
  {@render children()}
{/if}

Phase 2 (follow-up skeletons)

Route Key shape
/persons Stats bar + 4-col grid of avatar+text cards
/persons/[id] 35%/65% split: PersonCard + NameHistory left; CoCorrespondents chips + 2× doc list right
/briefwechsel Filter card (2 typeahead inputs) + timeline (year dividers + ThumbnailRow blocks)

Files to create/modify (Phase 1)

Path Change
src/lib/components/Skeleton.svelte New primitive
src/lib/components/Skeleton.svelte.spec.ts New unit test
src/routes/aktivitaeten/ChronikSkeleton.svelte New
src/routes/documents/DocumentListSkeleton.svelte New
src/routes/documents/[id]/DocumentDetailSkeleton.svelte New
src/routes/+layout.svelte Add navigating branch + imports

Accessibility

  • Every skeleton wrapper: aria-busy="true" aria-label="Wird geladen…"
  • Every shimmer <div> inside Skeleton.svelte: aria-hidden="true"
## Implementation Plan — Skeleton Loaders ### Approach Decision: `navigating` store (Option B) Use `navigating.to?.route?.id` in `+layout.svelte` to swap the page slot for the destination skeleton while data loads. No backend changes required. The `navigating` store is already used in `documents/+page.svelte:230` as a loading signal, so the pattern is established. **Why not streaming:** requires splitting every slow `+page.server.ts` query — significant backend churn for a UI-only concern. --- ### Route name mapping | Issue name | Actual SvelteKit route | |---|---| | `/chronik` | `/aktivitaeten` | | `/conversations` | `/briefwechsel` | --- ### 1. `Skeleton.svelte` primitive **File:** `src/lib/components/Skeleton.svelte` Single animated shimmer block. All sizing and shape come from the caller via `class`. - Base: `var(--c-muted)` — brand-sand in light, dark surface in dark mode (automatic) - Sweep: `var(--c-accent)/15` — brand-mint shimmer at low opacity - Uses `@keyframes slide` already defined in `layout.css:382` - `aria-hidden="true"` — purely decorative, callers wrap in `aria-busy="true"` Unit test: `src/lib/components/Skeleton.svelte.spec.ts` --- ### 2. Skeleton shapes (Phase 1 — AC minimum) #### ChronikSkeleton — `/aktivitaeten` File: `src/routes/aktivitaeten/ChronikSkeleton.svelte` ``` <main> max-w-3xl [aria-busy="true"] Skeleton h-8 w-48 ← "Chronik" heading Notification inbox card (border bg-surface shadow-sm p-4 mb-6) └─ Skeleton h-4 w-32 ← "Für dich" caption └─ ×3: [Skeleton h-10 w-10 rounded-full] + [h-4 w-3/4 + h-3 w-1/2] Filter pills ×5 └─ Skeleton h-8 w-20 rounded-full ← "alle / fuer-dich / …" Bucket section ×2 ├─ Skeleton h-9 w-full ← "Heute" bucket header └─ ×5 rows: [h-10 w-10 circle] + [h-4 w-3/4 + h-3 w-1/2] ``` #### DocumentListSkeleton — `/documents` File: `src/routes/documents/DocumentListSkeleton.svelte` ``` Search bar: [h-10 flex-1 input] + [h-10 w-28 sort] + [h-10 w-10 filter] Group ×3: ├─ Skeleton h-8 w-full ← group header ("2024") └─ ×5 rows: ├─ Skeleton w-[120px] h-[168px] ← thumbnail (5:7 ratio) └─ [h-6 w-3/4 title] + [h-4 w-full + h-4 w-2/3 snippet] + [2× tag chips] ``` #### DocumentDetailSkeleton — `/documents/[id]` File: `src/routes/documents/[id]/DocumentDetailSkeleton.svelte` ``` TopBar h-[75px] border-b bg-surface ├─ Skeleton h-10 w-10 rounded-full ← back button ├─ [h-5 w-56 title] + [h-3 w-32 date] └─ [h-8 w-24] + [h-8 w-24] ← action buttons Content flex flex-1 ├─ PDF area flex-1 │ └─ Skeleton h-full w-full ← full-height PDF placeholder └─ Transcription panel md:w-[400px] border-l p-4 ├─ Skeleton h-[44px] w-full ← mode toggle header └─ ×10 Skeleton h-4 w-full ← text lines (ragged right) ``` --- ### Layout integration `src/routes/+layout.svelte` — wrap `{@render children()}`: ```svelte {#if navigating.to?.route?.id === '/aktivitaeten'} <ChronikSkeleton /> {:else if navigating.to?.route?.id === '/documents/[id]'} <DocumentDetailSkeleton /> {:else if navigating.to?.route?.id === '/documents'} <DocumentListSkeleton /> {:else} {@render children()} {/if} ``` --- ### Phase 2 (follow-up skeletons) | Route | Key shape | |---|---| | `/persons` | Stats bar + 4-col grid of avatar+text cards | | `/persons/[id]` | 35%/65% split: PersonCard + NameHistory left; CoCorrespondents chips + 2× doc list right | | `/briefwechsel` | Filter card (2 typeahead inputs) + timeline (year dividers + ThumbnailRow blocks) | --- ### Files to create/modify (Phase 1) | Path | Change | |---|---| | `src/lib/components/Skeleton.svelte` | New primitive | | `src/lib/components/Skeleton.svelte.spec.ts` | New unit test | | `src/routes/aktivitaeten/ChronikSkeleton.svelte` | New | | `src/routes/documents/DocumentListSkeleton.svelte` | New | | `src/routes/documents/[id]/DocumentDetailSkeleton.svelte` | New | | `src/routes/+layout.svelte` | Add `navigating` branch + imports | --- ### Accessibility - Every skeleton wrapper: `aria-busy="true" aria-label="Wird geladen…"` - Every shimmer `<div>` inside `Skeleton.svelte`: `aria-hidden="true"`
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • Import API drift — use $app/state, not $app/stores: documents/+page.svelte:3 and aktivitaeten/+page.svelte already import navigating from $app/state (Svelte 5 runes). EnrichmentBlock.svelte still uses the old $app/stores API — that's existing debt. The implementation plan must use $app/state exclusively. Using stores in new code is a regression that will cause runtime warnings in Svelte 5.

  • motion-reduce is absent from the Skeleton.svelte spec: EnrichmentBlock.svelte applies motion-reduce:animate-none to stop shimmer animation for users with vestibular motion disorders. The new Skeleton.svelte plan describes the shimmer using @keyframes slide but makes no mention of prefers-reduced-motion. This is a gap, not a polish item — it must be in the primitive's default behavior.

  • aria-label="Wird geladen…" is a hardcoded string: The app uses Paraglide for all user-visible strings. This must use a translation key (e.g., m.skeleton_loading()) — hardcoded German breaks the i18n contract and will fail a svelte-check pass if the Paraglide lint rules are enforced.

  • Two shimmer strategies in one codebase: EnrichmentBlock.svelte uses Tailwind animate-pulse. The plan proposes @keyframes slide from layout.css:382. Both are valid, but shipping both means inconsistent loading UX. Pick one. animate-pulse has built-in motion-reduce support; @keyframes slide requires manual prefers-reduced-motion handling.

  • Phase 1 skeleton components have no unit tests listed: The plan specifies a unit test for Skeleton.svelte (the primitive) but nothing for ChronikSkeleton, DocumentListSkeleton, or DocumentDetailSkeleton. Per TDD: each composed skeleton needs at minimum a render test asserting aria-busy="true" exists on the wrapper.

Recommendations

  • Use import { navigating } from '$app/state' everywhere. Add EnrichmentBlock.svelte store migration to a separate cleanup issue.
  • Pick animate-pulse as the shimmer strategy. It's established in the codebase, respects motion-reduce via motion-reduce:animate-none for free, and avoids adding a custom animation dependency. If the visual sweep of @keyframes slide is specifically desired, keep it — but add motion-reduce:opacity-0 on the shimmer element.
  • Replace aria-label="Wird geladen…" with a Paraglide key on all three skeleton wrapper components. Create m.skeleton_loading() in messages/de.json, en.json, es.json.
  • Write a failing render test for each Phase 1 skeleton component before implementing it: render(ChronikSkeleton) → assert aria-busy="true". Red → green → refactor.
  • The navigating.to?.route?.id pattern is correct for Svelte 5 — confirmed against documents/+page.svelte which uses navigating.to !== null. The route ID strings (e.g., '/aktivitaeten', '/documents/[id]') must be verified against actual SvelteKit route IDs during implementation — log them once in dev to confirm before committing.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - **Import API drift — use `$app/state`, not `$app/stores`**: `documents/+page.svelte:3` and `aktivitaeten/+page.svelte` already import `navigating` from `$app/state` (Svelte 5 runes). `EnrichmentBlock.svelte` still uses the old `$app/stores` API — that's existing debt. The implementation plan must use `$app/state` exclusively. Using stores in new code is a regression that will cause runtime warnings in Svelte 5. - **`motion-reduce` is absent from the Skeleton.svelte spec**: `EnrichmentBlock.svelte` applies `motion-reduce:animate-none` to stop shimmer animation for users with vestibular motion disorders. The new `Skeleton.svelte` plan describes the shimmer using `@keyframes slide` but makes no mention of `prefers-reduced-motion`. This is a gap, not a polish item — it must be in the primitive's default behavior. - **`aria-label="Wird geladen…"` is a hardcoded string**: The app uses Paraglide for all user-visible strings. This must use a translation key (e.g., `m.skeleton_loading()`) — hardcoded German breaks the i18n contract and will fail a `svelte-check` pass if the Paraglide lint rules are enforced. - **Two shimmer strategies in one codebase**: `EnrichmentBlock.svelte` uses Tailwind `animate-pulse`. The plan proposes `@keyframes slide` from `layout.css:382`. Both are valid, but shipping both means inconsistent loading UX. Pick one. `animate-pulse` has built-in `motion-reduce` support; `@keyframes slide` requires manual `prefers-reduced-motion` handling. - **Phase 1 skeleton components have no unit tests listed**: The plan specifies a unit test for `Skeleton.svelte` (the primitive) but nothing for `ChronikSkeleton`, `DocumentListSkeleton`, or `DocumentDetailSkeleton`. Per TDD: each composed skeleton needs at minimum a render test asserting `aria-busy="true"` exists on the wrapper. ### Recommendations - Use `import { navigating } from '$app/state'` everywhere. Add `EnrichmentBlock.svelte` store migration to a separate cleanup issue. - **Pick `animate-pulse` as the shimmer strategy.** It's established in the codebase, respects `motion-reduce` via `motion-reduce:animate-none` for free, and avoids adding a custom animation dependency. If the visual sweep of `@keyframes slide` is specifically desired, keep it — but add `motion-reduce:opacity-0` on the shimmer element. - Replace `aria-label="Wird geladen…"` with a Paraglide key on all three skeleton wrapper components. Create `m.skeleton_loading()` in `messages/de.json`, `en.json`, `es.json`. - Write a failing render test for each Phase 1 skeleton component before implementing it: `render(ChronikSkeleton)` → assert `aria-busy="true"`. Red → green → refactor. - The `navigating.to?.route?.id` pattern is correct for Svelte 5 — confirmed against `documents/+page.svelte` which uses `navigating.to !== null`. The route ID strings (e.g., `'/aktivitaeten'`, `'/documents/[id]'`) must be verified against actual SvelteKit route IDs during implementation — log them once in dev to confirm before committing.
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

  • +layout.svelte becomes a skeleton router: The plan adds a chain of {#if navigating.to?.route?.id === '...'} branches in the root layout. Phase 1 adds 3 branches; Phase 2 adds 3 more — 6 route-to-skeleton mappings in the global layout. This works at 6 but scales poorly. Each new skeleton requires editing the root layout, which is a shared file with broad impact.

  • Co-located skeleton files are the right call: ChronikSkeleton.svelte in src/routes/aktivitaeten/ and DocumentListSkeleton.svelte in src/routes/documents/ — correct. The skeleton lives adjacent to the real layout it mirrors and gets updated alongside it. This is good structure.

  • Route ID strings are fragile if wrong: navigating.to?.route?.id returns the SvelteKit file-system route path (e.g., '/documents/[id]' with literal brackets). If any string in the {#if} chain is wrong, the skeleton silently never appears — no error, no warning. The implementation plan's route name table (/chronik → /aktivitaeten, /conversations → /briefwechsel) is essential context. Verify each string by logging navigating.to?.route?.id in dev before committing the {#if} chain.

  • Children() behavior during non-skeleton navigation is correct: When navigating between routes with no skeleton (e.g., /admin → /login), the {:else} branch renders {@render children()} — which shows the old page content until the new route resolves. This is existing SvelteKit behavior, not a regression introduced by this feature.

  • Phase 2 skeletons tracked in this issue's checklist: Deferred work tracked as checkboxes in the same issue tends to stay deferred indefinitely. The Phase 2 routes (/persons, /persons/[id], /briefwechsel) have no timeline or AC.

Recommendations

  • Extract a route→skeleton map before Phase 2 lands to prevent boilerplate accumulation. Instead of 6 {#if} branches, use a component map:

    const skeletons: Record<string, Component> = {
      '/aktivitaeten': ChronikSkeleton,
      '/documents': DocumentListSkeleton,
      '/documents/[id]': DocumentDetailSkeleton,
    };
    const ActiveSkeleton = $derived(
      navigating.to ? skeletons[navigating.to.route?.id ?? ''] : null
    );
    

    Then the template is a single {#if ActiveSkeleton}<ActiveSkeleton />{:else}{@render children()}{/if}. Adding Phase 2 becomes a one-line map entry. This is the cleaner boundary.

  • Open a follow-up issue for Phase 2 skeletons rather than tracking them as deferred checkboxes here. Deferred items in a closed issue are invisible in backlog triage.

  • No backend changes, no service boundary changes, no new infrastructure. This is a clean frontend-only delivery with minimal architectural risk.

## 🏗️ Markus Keller — Application Architect ### Observations - **`+layout.svelte` becomes a skeleton router**: The plan adds a chain of `{#if navigating.to?.route?.id === '...'}` branches in the root layout. Phase 1 adds 3 branches; Phase 2 adds 3 more — 6 route-to-skeleton mappings in the global layout. This works at 6 but scales poorly. Each new skeleton requires editing the root layout, which is a shared file with broad impact. - **Co-located skeleton files are the right call**: `ChronikSkeleton.svelte` in `src/routes/aktivitaeten/` and `DocumentListSkeleton.svelte` in `src/routes/documents/` — correct. The skeleton lives adjacent to the real layout it mirrors and gets updated alongside it. This is good structure. - **Route ID strings are fragile if wrong**: `navigating.to?.route?.id` returns the SvelteKit file-system route path (e.g., `'/documents/[id]'` with literal brackets). If any string in the `{#if}` chain is wrong, the skeleton silently never appears — no error, no warning. The implementation plan's route name table (`/chronik → /aktivitaeten`, `/conversations → /briefwechsel`) is essential context. Verify each string by logging `navigating.to?.route?.id` in dev before committing the `{#if}` chain. - **Children() behavior during non-skeleton navigation is correct**: When navigating between routes with no skeleton (e.g., `/admin → /login`), the `{:else}` branch renders `{@render children()}` — which shows the old page content until the new route resolves. This is existing SvelteKit behavior, not a regression introduced by this feature. - **Phase 2 skeletons tracked in this issue's checklist**: Deferred work tracked as checkboxes in the same issue tends to stay deferred indefinitely. The Phase 2 routes (`/persons`, `/persons/[id]`, `/briefwechsel`) have no timeline or AC. ### Recommendations - **Extract a route→skeleton map** before Phase 2 lands to prevent boilerplate accumulation. Instead of 6 `{#if}` branches, use a component map: ```svelte const skeletons: Record<string, Component> = { '/aktivitaeten': ChronikSkeleton, '/documents': DocumentListSkeleton, '/documents/[id]': DocumentDetailSkeleton, }; const ActiveSkeleton = $derived( navigating.to ? skeletons[navigating.to.route?.id ?? ''] : null ); ``` Then the template is a single `{#if ActiveSkeleton}<ActiveSkeleton />{:else}{@render children()}{/if}`. Adding Phase 2 becomes a one-line map entry. This is the cleaner boundary. - **Open a follow-up issue for Phase 2 skeletons** rather than tracking them as deferred checkboxes here. Deferred items in a closed issue are invisible in backlog triage. - No backend changes, no service boundary changes, no new infrastructure. This is a clean frontend-only delivery with minimal architectural risk.
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Observations

  • This is a pure frontend display feature: static decorative HTML, no user input, no API calls, no server-side changes. Attack surface is zero.
  • Skeleton components render hardcoded shimmer shapes — no dynamic content, no XSS or injection risk.
  • aria-busy="true" and aria-hidden="true" usage is correct and does not create accessibility-as-security concerns.
  • Marginal observation: skeleton shapes that closely mirror real page layouts could theoretically hint at data structure to a passive DOM inspector. For a private family archive with no adversarial user base, this is not a meaningful risk.

Recommendations

  • No security changes required. The implementation is clean from a security perspective.
  • One CI hygiene note: Playwright proof-shot screenshots committed as baseline artifacts must use seeded test fixtures, not real user data snapshots. If CI runs against a database with actual family documents, screenshots could capture real content and persist in git history. Use the existing dev admin credentials (admin@familyarchive.local) against a fixture-only test database, not production data.
## 🔒 Nora Steiner — Application Security Engineer ### Observations - This is a pure frontend display feature: static decorative HTML, no user input, no API calls, no server-side changes. Attack surface is zero. - Skeleton components render hardcoded shimmer shapes — no dynamic content, no XSS or injection risk. - `aria-busy="true"` and `aria-hidden="true"` usage is correct and does not create accessibility-as-security concerns. - Marginal observation: skeleton shapes that closely mirror real page layouts could theoretically hint at data structure to a passive DOM inspector. For a private family archive with no adversarial user base, this is not a meaningful risk. ### Recommendations - No security changes required. The implementation is clean from a security perspective. - One CI hygiene note: Playwright proof-shot screenshots committed as baseline artifacts must use seeded test fixtures, not real user data snapshots. If CI runs against a database with actual family documents, screenshots could capture real content and persist in git history. Use the existing dev admin credentials (`admin@familyarchive.local`) against a fixture-only test database, not production data.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • CLS ≤ 0.1 is an AC without a test plan: The Elicit comment specifies CLS ≤ 0.1 (Core Web Vitals "Good") as a measurable threshold. This is testable via page.evaluate(() => new Promise(r => new PerformanceObserver(l => r(l.getEntries())).observe({ type: 'layout-shift' }))) in Playwright, or via Lighthouse CI (lhci autorun). Without specifying HOW to measure it, this AC cannot be confirmed closed.

  • 50ms skeleton appearance threshold is not deterministically testable: Playwright can't intercept SvelteKit's internal navigation timing reliably enough to assert "skeleton appeared within 50ms." The practical proxy: use a Playwright route intercept (page.route()) to delay the server response by 500ms, then take a screenshot and assert the skeleton is rendered. This is deterministic and achieves the intent.

  • Proof shot viewports not specified: The Elicit comment says "all 3 responsive breakpoints" but gives no widths. Given the project's dual-audience split (transcribers on laptop/tablet, readers on mobile), the minimum set is: 375px (mobile reader), 768px (tablet/transcriber), 1280px (desktop). These three must be captured for each Phase 1 route.

  • Navigation cancellation not covered: If the user clicks a link to /aktivitaeten, then immediately navigates away before the load resolves, navigating.to?.route?.id changes reactively and the layout switches skeletons. This should work correctly given Svelte 5 reactivity, but there is no test for it. A stuck skeleton on cancelled navigation would be a visible regression.

  • Unit test scope for Skeleton.svelte is well-specified: The Elicit comment lists exactly what to verify (default props, custom width/height/rounded, aria-hidden on shimmer, aria-busy on wrapper). This is implementable as-written with vitest-browser-svelte.

  • Phase 2 has no AC or test plan: /persons, /persons/[id], /briefwechsel are listed as deferred but have no acceptance criteria, no proof shot spec, and no test names. If they ship in this issue, they need AC. If they ship in a follow-up, track them there.

Recommendations

  • For CLS measurement: add a Playwright helper that collects Layout Instability entries after replacing the skeleton with real content. Assert totalCLS < 0.1. Run this on /documents at minimum.
  • For 50ms appearance: use page.route('**/api/**', route => setTimeout(() => route.continue(), 500)) to hold responses, then screenshot. Assert skeleton markup is present.
  • Specify proof shot viewports explicitly in the AC: 375px / 768px / 1280px, all three Phase 1 routes, light and dark mode.
  • Add one Playwright test for navigation cancellation: navigate to /aktivitaeten, immediately navigate to /documents, verify the DocumentListSkeleton (not ChronikSkeleton) is shown and no "stuck" skeleton persists after load.
  • Upload Playwright test-results/ as a CI artifact on failure — visual regressions are undebuggable without the diff images.
## 🧪 Sara Holt — QA Engineer ### Observations - **CLS ≤ 0.1 is an AC without a test plan**: The Elicit comment specifies CLS ≤ 0.1 (Core Web Vitals "Good") as a measurable threshold. This is testable via `page.evaluate(() => new Promise(r => new PerformanceObserver(l => r(l.getEntries())).observe({ type: 'layout-shift' })))` in Playwright, or via Lighthouse CI (`lhci autorun`). Without specifying HOW to measure it, this AC cannot be confirmed closed. - **50ms skeleton appearance threshold is not deterministically testable**: Playwright can't intercept SvelteKit's internal navigation timing reliably enough to assert "skeleton appeared within 50ms." The practical proxy: use a Playwright route intercept (`page.route()`) to delay the server response by 500ms, then take a screenshot and assert the skeleton is rendered. This is deterministic and achieves the intent. - **Proof shot viewports not specified**: The Elicit comment says "all 3 responsive breakpoints" but gives no widths. Given the project's dual-audience split (transcribers on laptop/tablet, readers on mobile), the minimum set is: **375px** (mobile reader), **768px** (tablet/transcriber), **1280px** (desktop). These three must be captured for each Phase 1 route. - **Navigation cancellation not covered**: If the user clicks a link to `/aktivitaeten`, then immediately navigates away before the load resolves, `navigating.to?.route?.id` changes reactively and the layout switches skeletons. This should work correctly given Svelte 5 reactivity, but there is no test for it. A stuck skeleton on cancelled navigation would be a visible regression. - **Unit test scope for `Skeleton.svelte` is well-specified**: The Elicit comment lists exactly what to verify (default props, custom width/height/rounded, `aria-hidden` on shimmer, `aria-busy` on wrapper). This is implementable as-written with `vitest-browser-svelte`. - **Phase 2 has no AC or test plan**: `/persons`, `/persons/[id]`, `/briefwechsel` are listed as deferred but have no acceptance criteria, no proof shot spec, and no test names. If they ship in this issue, they need AC. If they ship in a follow-up, track them there. ### Recommendations - **For CLS measurement**: add a Playwright helper that collects Layout Instability entries after replacing the skeleton with real content. Assert `totalCLS < 0.1`. Run this on `/documents` at minimum. - **For 50ms appearance**: use `page.route('**/api/**', route => setTimeout(() => route.continue(), 500))` to hold responses, then screenshot. Assert skeleton markup is present. - **Specify proof shot viewports explicitly** in the AC: 375px / 768px / 1280px, all three Phase 1 routes, light and dark mode. - **Add one Playwright test for navigation cancellation**: navigate to `/aktivitaeten`, immediately navigate to `/documents`, verify the `DocumentListSkeleton` (not `ChronikSkeleton`) is shown and no "stuck" skeleton persists after load. - Upload Playwright `test-results/` as a CI artifact on failure — visual regressions are undebuggable without the diff images.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

  • prefers-reduced-motion is missing from the spec — this is Critical: The existing EnrichmentBlock.svelte applies motion-reduce:animate-none to stop shimmer animation for users who have opted out of motion effects. This is a WCAG 2.1 AAA criterion and essential for the 60+ audience who are more likely to have vestibular disorders. The new Skeleton.svelte plan describes @keyframes slide animation but makes no mention of stopping it under prefers-reduced-motion. This must be in the primitive before any routes use it.

  • Shimmer sweep may be imperceptible in dark mode: The plan uses var(--c-accent)/15 — brand-mint at 15% opacity — over a var(--c-muted) base. In dark mode, --c-muted is #011a30 (very dark navy) and --c-accent is #00c7b1 (teal). A 15% teal shimmer on near-black will be extremely subtle — potentially invisible on real screens. The purpose of a shimmer is to communicate active loading; if it's invisible, the skeleton looks frozen. Test this at actual dark-mode values before committing.

  • aria-label="Wird geladen…" must use Paraglide: This is a code-quality issue with UX consequence — if a Spanish-speaking family member uses the ES locale, their screen reader announces "Wird geladen" (German). Every user-visible string goes through Paraglide. No exceptions.

  • Skeleton shapes must be responsive to match real layouts: The DocumentDetailSkeleton plan shows an md:w-[400px] transcription panel — correct for tablet/desktop. On mobile, the document detail likely collapses to single-column (transcription panel below PDF). If the real layout goes single-column at mobile and the skeleton shows a side-panel layout, layout shift will be worse than having no skeleton, not better. Each skeleton component's responsive behavior must mirror the real component.

  • h-[75px] for the TopBar skeleton is an assumed value: The implementation plan states TopBar h-[75px] for DocumentDetailSkeleton. The actual rendered height of DocumentTopBar.svelte may differ depending on content (wrapping titles, multiple receivers). A hardcoded height that doesn't match the real TopBar height defeats the CLS goal. Use the actual rendered height measured in DevTools, or better, give the TopBar a min-h that both the skeleton and real component share via a CSS variable.

Recommendations

  • Add reduced motion support to Skeleton.svelte before anything else: Either wrap the @keyframes slide in @media (prefers-reduced-motion: no-preference), or add motion-reduce:opacity-0 to the shimmer element (if using the Tailwind class approach). This is a blocking prerequisite for the senior audience.
  • Test dark mode shimmer visibility before finalizing the opacity. If var(--c-accent)/15 is imperceptible, increase to /25 or /30. A shimmer that isn't visible is not communicating loading state.
  • Replace "Wird geladen…" with a Paraglide key in all skeleton wrappers. Create m.skeleton_loading() in all three locale files.
  • Verify each skeleton component's mobile layout: At 375px, does the real page go single-column? The skeleton must match. Document this explicitly in the implementation plan for each route.
  • Measure the actual DocumentTopBar height in DevTools and use that exact value. If the topbar height is dynamic (title wraps), use min-h for the skeleton or extract a shared CSS variable for the topbar height so both stay in sync.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations - **`prefers-reduced-motion` is missing from the spec — this is Critical**: The existing `EnrichmentBlock.svelte` applies `motion-reduce:animate-none` to stop shimmer animation for users who have opted out of motion effects. This is a WCAG 2.1 AAA criterion and essential for the 60+ audience who are more likely to have vestibular disorders. The new `Skeleton.svelte` plan describes `@keyframes slide` animation but makes no mention of stopping it under `prefers-reduced-motion`. This must be in the primitive before any routes use it. - **Shimmer sweep may be imperceptible in dark mode**: The plan uses `var(--c-accent)/15` — brand-mint at 15% opacity — over a `var(--c-muted)` base. In dark mode, `--c-muted` is `#011a30` (very dark navy) and `--c-accent` is `#00c7b1` (teal). A 15% teal shimmer on near-black will be extremely subtle — potentially invisible on real screens. The purpose of a shimmer is to communicate active loading; if it's invisible, the skeleton looks frozen. Test this at actual dark-mode values before committing. - **`aria-label="Wird geladen…"` must use Paraglide**: This is a code-quality issue with UX consequence — if a Spanish-speaking family member uses the ES locale, their screen reader announces "Wird geladen" (German). Every user-visible string goes through Paraglide. No exceptions. - **Skeleton shapes must be responsive to match real layouts**: The `DocumentDetailSkeleton` plan shows an `md:w-[400px]` transcription panel — correct for tablet/desktop. On mobile, the document detail likely collapses to single-column (transcription panel below PDF). If the real layout goes single-column at mobile and the skeleton shows a side-panel layout, layout shift will be worse than having no skeleton, not better. Each skeleton component's responsive behavior must mirror the real component. - **`h-[75px]` for the TopBar skeleton is an assumed value**: The implementation plan states `TopBar h-[75px]` for `DocumentDetailSkeleton`. The actual rendered height of `DocumentTopBar.svelte` may differ depending on content (wrapping titles, multiple receivers). A hardcoded height that doesn't match the real TopBar height defeats the CLS goal. Use the actual rendered height measured in DevTools, or better, give the TopBar a `min-h` that both the skeleton and real component share via a CSS variable. ### Recommendations - **Add reduced motion support to `Skeleton.svelte` before anything else**: Either wrap the `@keyframes slide` in `@media (prefers-reduced-motion: no-preference)`, or add `motion-reduce:opacity-0` to the shimmer element (if using the Tailwind class approach). This is a blocking prerequisite for the senior audience. - **Test dark mode shimmer visibility** before finalizing the opacity. If `var(--c-accent)/15` is imperceptible, increase to `/25` or `/30`. A shimmer that isn't visible is not communicating loading state. - **Replace `"Wird geladen…"` with a Paraglide key** in all skeleton wrappers. Create `m.skeleton_loading()` in all three locale files. - **Verify each skeleton component's mobile layout**: At 375px, does the real page go single-column? The skeleton must match. Document this explicitly in the implementation plan for each route. - **Measure the actual `DocumentTopBar` height** in DevTools and use that exact value. If the topbar height is dynamic (title wraps), use `min-h` for the skeleton or extract a shared CSS variable for the topbar height so both stay in sync.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Observations

  • This is a pure frontend feature. No Docker Compose changes, no backend changes, no new services, no infrastructure additions. The implementation has no operational cost or risk.
  • Playwright proof shots require baseline image storage: The AC requires visual regression screenshots per route. If using Playwright's toHaveScreenshot(), baseline PNGs must be committed to the repository. CI regenerates and diffs on each run. If the runner's rendering environment changes (OS update, font change, browser version bump), baselines will fail spuriously. The official Playwright Docker image (mcr.microsoft.com/playwright:v1.x) ensures consistent rendering across CI runs.
  • No new npm dependencies required: The skeleton shimmer uses the existing @keyframes slide from layout.css:382 (or Tailwind's animate-pulse, which is already in the project). CI build cache and node_modules are unaffected.
  • #289 prerequisite is a CI non-issue but an implementation one: The issue states this must land after #289 (progress bar). CI does not enforce this dependency — nothing will break in the pipeline if this PR is opened before #289 merges. But the visual baseline screenshots should be taken against a state that includes #289, otherwise the baselines will be wrong when both land.

Recommendations

  • Use the Playwright Docker image in the CI job (container: mcr.microsoft.com/playwright:v1.x-jammy) for all E2E tests that include screenshot comparison. Consistent rendering = no spurious baseline failures.
  • Add uses: actions/upload-artifact@v4 after the Playwright job to upload test-results/ on failure. Without this, a failing visual regression in CI is undebuggable without running locally.
  • Commit baseline screenshots only after #289 is merged — otherwise you'll regenerate baselines twice. A clean approach: open the PR without baselines, add a // TODO: commit baselines after #289 merges note, then update once both land.
  • No Compose file edits, no new service entries, no port changes. Clean delivery.
## 🛠️ Tobias Wendt — DevOps & Platform Engineer ### Observations - This is a pure frontend feature. No Docker Compose changes, no backend changes, no new services, no infrastructure additions. The implementation has no operational cost or risk. - **Playwright proof shots require baseline image storage**: The AC requires visual regression screenshots per route. If using Playwright's `toHaveScreenshot()`, baseline PNGs must be committed to the repository. CI regenerates and diffs on each run. If the runner's rendering environment changes (OS update, font change, browser version bump), baselines will fail spuriously. The official Playwright Docker image (`mcr.microsoft.com/playwright:v1.x`) ensures consistent rendering across CI runs. - **No new npm dependencies required**: The skeleton shimmer uses the existing `@keyframes slide` from `layout.css:382` (or Tailwind's `animate-pulse`, which is already in the project). CI build cache and `node_modules` are unaffected. - **#289 prerequisite is a CI non-issue but an implementation one**: The issue states this must land after #289 (progress bar). CI does not enforce this dependency — nothing will break in the pipeline if this PR is opened before #289 merges. But the visual baseline screenshots should be taken against a state that includes #289, otherwise the baselines will be wrong when both land. ### Recommendations - Use the Playwright Docker image in the CI job (`container: mcr.microsoft.com/playwright:v1.x-jammy`) for all E2E tests that include screenshot comparison. Consistent rendering = no spurious baseline failures. - Add `uses: actions/upload-artifact@v4` after the Playwright job to upload `test-results/` on failure. Without this, a failing visual regression in CI is undebuggable without running locally. - **Commit baseline screenshots only after #289 is merged** — otherwise you'll regenerate baselines twice. A clean approach: open the PR without baselines, add a `// TODO: commit baselines after #289 merges` note, then update once both land. - No Compose file edits, no new service entries, no port changes. Clean delivery.
Author
Owner

🗳️ Decision Queue — Action Required

1 decision needs your input before implementation starts.

UI / Loading Experience

  • Shimmer animation strategy for Skeleton.svelte — Two options exist:

    • animate-pulse (Tailwind): already used in EnrichmentBlock.svelte, motion-reduce support is built-in via motion-reduce:animate-none, no custom CSS needed. Visual result: gentle pulse fade.
    • @keyframes slide (custom, already in layout.css:382): produces a sweep shimmer (the "moving highlight" style), requires manually adding @media (prefers-reduced-motion: reduce) to stop it. Visual result: more dramatic left-to-right sweep.

    The codebase currently has both patterns. Shipping both for different loaders creates inconsistent loading UX. Pick one and use it for all skeleton/loading states going forward. Felix recommends animate-pulse (simpler, motion-reduce free). Use @keyframes slide if the sweep is the intentional design choice for skeleton loaders specifically. (Raised by: Felix, Leonie)

## 🗳️ Decision Queue — Action Required _1 decision needs your input before implementation starts._ ### UI / Loading Experience - **Shimmer animation strategy for `Skeleton.svelte`** — Two options exist: - **`animate-pulse`** (Tailwind): already used in `EnrichmentBlock.svelte`, motion-reduce support is built-in via `motion-reduce:animate-none`, no custom CSS needed. Visual result: gentle pulse fade. - **`@keyframes slide`** (custom, already in `layout.css:382`): produces a sweep shimmer (the "moving highlight" style), requires manually adding `@media (prefers-reduced-motion: reduce)` to stop it. Visual result: more dramatic left-to-right sweep. The codebase currently has both patterns. Shipping both for different loaders creates inconsistent loading UX. Pick one and use it for all skeleton/loading states going forward. Felix recommends `animate-pulse` (simpler, motion-reduce free). Use `@keyframes slide` if the sweep is the intentional design choice for skeleton loaders specifically. _(Raised by: Felix, Leonie)_
Author
Owner

we will use animate-pulse

we will use animate-pulse
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#292