As a user I want route-specific skeleton loaders so I see the page layout while data is still being fetched #292
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The global navigation progress bar (#289) tells users something is happening, but on data-heavy routes the page remains blank until the server
loadfunction 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)
/chronik— unified activity feed, often slow/documents— search + list/documents/[id]— file preview + metadata/persons— list with stats/persons/[id]— detail + related documents/conversations— timelineApproach options to evaluate
Promisein load: SvelteKit supports returning promises fromloadthat stream in. Pair with{#await}blocks + skeleton fallback. Pro: shell renders immediately. Con: requires splitting slow queries in each+page.server.ts.navigatingstore + per-route skeleton components: show skeleton whilenavigating.to.route.id === '/chronik'. Pro: no backend changes. Con: duplicates page shell.Shared building blocks
Skeleton.svelteprimitive — animated shimmer block (width/height/rounded props)brand-sandbase with a subtlebrand-mintsweepAcceptance criteria
navigatingstore) — see brainstorm phaseSkeleton.svelteprimitive exists with a unit test/chronik,/documents,/documents/[id](others as follow-ups)aria-busy="true", decorative shimmer isaria-hiddenDependencies
Out of scope
📋 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 —
navigatingstore + per-route skeleton components (not SvelteKit streaming). Rationale: all user-triggered loading events (first load, search param changes, post-save redirects) are navigation events thatnavigatingcatches. No+page.server.tschanges 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 timelineAdmin 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; customwidth,height,roundedprops apply correctly; shimmer element carriesaria-hidden="true"; wrapper carriesaria-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.
Implementation Plan — Skeleton Loaders
Approach Decision:
navigatingstore (Option B)Use
navigating.to?.route?.idin+layout.svelteto swap the page slot for the destination skeleton while data loads. No backend changes required. Thenavigatingstore is already used indocuments/+page.svelte:230as a loading signal, so the pattern is established.Why not streaming: requires splitting every slow
+page.server.tsquery — significant backend churn for a UI-only concern.Route name mapping
/chronik/aktivitaeten/conversations/briefwechsel1.
Skeleton.svelteprimitiveFile:
src/lib/components/Skeleton.svelteSingle animated shimmer block. All sizing and shape come from the caller via
class.var(--c-muted)— brand-sand in light, dark surface in dark mode (automatic)var(--c-accent)/15— brand-mint shimmer at low opacity@keyframes slidealready defined inlayout.css:382aria-hidden="true"— purely decorative, callers wrap inaria-busy="true"Unit test:
src/lib/components/Skeleton.svelte.spec.ts2. Skeleton shapes (Phase 1 — AC minimum)
ChronikSkeleton —
/aktivitaetenFile:
src/routes/aktivitaeten/ChronikSkeleton.svelteDocumentListSkeleton —
/documentsFile:
src/routes/documents/DocumentListSkeleton.svelteDocumentDetailSkeleton —
/documents/[id]File:
src/routes/documents/[id]/DocumentDetailSkeleton.svelteLayout integration
src/routes/+layout.svelte— wrap{@render children()}:Phase 2 (follow-up skeletons)
/persons/persons/[id]/briefwechselFiles to create/modify (Phase 1)
src/lib/components/Skeleton.sveltesrc/lib/components/Skeleton.svelte.spec.tssrc/routes/aktivitaeten/ChronikSkeleton.sveltesrc/routes/documents/DocumentListSkeleton.sveltesrc/routes/documents/[id]/DocumentDetailSkeleton.sveltesrc/routes/+layout.sveltenavigatingbranch + importsAccessibility
aria-busy="true" aria-label="Wird geladen…"<div>insideSkeleton.svelte:aria-hidden="true"👨💻 Felix Brandt — Senior Fullstack Developer
Observations
Import API drift — use
$app/state, not$app/stores:documents/+page.svelte:3andaktivitaeten/+page.sveltealready importnavigatingfrom$app/state(Svelte 5 runes).EnrichmentBlock.sveltestill uses the old$app/storesAPI — that's existing debt. The implementation plan must use$app/stateexclusively. Using stores in new code is a regression that will cause runtime warnings in Svelte 5.motion-reduceis absent from the Skeleton.svelte spec:EnrichmentBlock.svelteappliesmotion-reduce:animate-noneto stop shimmer animation for users with vestibular motion disorders. The newSkeleton.svelteplan describes the shimmer using@keyframes slidebut makes no mention ofprefers-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 asvelte-checkpass if the Paraglide lint rules are enforced.Two shimmer strategies in one codebase:
EnrichmentBlock.svelteuses Tailwindanimate-pulse. The plan proposes@keyframes slidefromlayout.css:382. Both are valid, but shipping both means inconsistent loading UX. Pick one.animate-pulsehas built-inmotion-reducesupport;@keyframes sliderequires manualprefers-reduced-motionhandling.Phase 1 skeleton components have no unit tests listed: The plan specifies a unit test for
Skeleton.svelte(the primitive) but nothing forChronikSkeleton,DocumentListSkeleton, orDocumentDetailSkeleton. Per TDD: each composed skeleton needs at minimum a render test assertingaria-busy="true"exists on the wrapper.Recommendations
import { navigating } from '$app/state'everywhere. AddEnrichmentBlock.sveltestore migration to a separate cleanup issue.animate-pulseas the shimmer strategy. It's established in the codebase, respectsmotion-reduceviamotion-reduce:animate-nonefor free, and avoids adding a custom animation dependency. If the visual sweep of@keyframes slideis specifically desired, keep it — but addmotion-reduce:opacity-0on the shimmer element.aria-label="Wird geladen…"with a Paraglide key on all three skeleton wrapper components. Createm.skeleton_loading()inmessages/de.json,en.json,es.json.render(ChronikSkeleton)→ assertaria-busy="true". Red → green → refactor.navigating.to?.route?.idpattern is correct for Svelte 5 — confirmed againstdocuments/+page.sveltewhich usesnavigating.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.🏗️ Markus Keller — Application Architect
Observations
+layout.sveltebecomes 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.svelteinsrc/routes/aktivitaeten/andDocumentListSkeleton.svelteinsrc/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?.idreturns 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 loggingnavigating.to?.route?.idin 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: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.
🔒 Nora Steiner — Application Security Engineer
Observations
aria-busy="true"andaria-hidden="true"usage is correct and does not create accessibility-as-security concerns.Recommendations
admin@familyarchive.local) against a fixture-only test database, not production data.🧪 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?.idchanges 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.svelteis well-specified: The Elicit comment lists exactly what to verify (default props, custom width/height/rounded,aria-hiddenon shimmer,aria-busyon wrapper). This is implementable as-written withvitest-browser-svelte.Phase 2 has no AC or test plan:
/persons,/persons/[id],/briefwechselare 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
totalCLS < 0.1. Run this on/documentsat minimum.page.route('**/api/**', route => setTimeout(() => route.continue(), 500))to hold responses, then screenshot. Assert skeleton markup is present./aktivitaeten, immediately navigate to/documents, verify theDocumentListSkeleton(notChronikSkeleton) is shown and no "stuck" skeleton persists after load.test-results/as a CI artifact on failure — visual regressions are undebuggable without the diff images.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
prefers-reduced-motionis missing from the spec — this is Critical: The existingEnrichmentBlock.svelteappliesmotion-reduce:animate-noneto 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 newSkeleton.svelteplan describes@keyframes slideanimation but makes no mention of stopping it underprefers-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 avar(--c-muted)base. In dark mode,--c-mutedis#011a30(very dark navy) and--c-accentis#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
DocumentDetailSkeletonplan shows anmd: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 statesTopBar h-[75px]forDocumentDetailSkeleton. The actual rendered height ofDocumentTopBar.sveltemay 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 amin-hthat both the skeleton and real component share via a CSS variable.Recommendations
Skeleton.sveltebefore anything else: Either wrap the@keyframes slidein@media (prefers-reduced-motion: no-preference), or addmotion-reduce:opacity-0to the shimmer element (if using the Tailwind class approach). This is a blocking prerequisite for the senior audience.var(--c-accent)/15is imperceptible, increase to/25or/30. A shimmer that isn't visible is not communicating loading state."Wird geladen…"with a Paraglide key in all skeleton wrappers. Createm.skeleton_loading()in all three locale files.DocumentTopBarheight in DevTools and use that exact value. If the topbar height is dynamic (title wraps), usemin-hfor the skeleton or extract a shared CSS variable for the topbar height so both stay in sync.🛠️ Tobias Wendt — DevOps & Platform Engineer
Observations
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.@keyframes slidefromlayout.css:382(or Tailwind'sanimate-pulse, which is already in the project). CI build cache andnode_modulesare unaffected.Recommendations
container: mcr.microsoft.com/playwright:v1.x-jammy) for all E2E tests that include screenshot comparison. Consistent rendering = no spurious baseline failures.uses: actions/upload-artifact@v4after the Playwright job to uploadtest-results/on failure. Without this, a failing visual regression in CI is undebuggable without running locally.// TODO: commit baselines after #289 mergesnote, then update once both land.🗳️ 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 inEnrichmentBlock.svelte, motion-reduce support is built-in viamotion-reduce:animate-none, no custom CSS needed. Visual result: gentle pulse fade.@keyframes slide(custom, already inlayout.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 slideif the sweep is the intentional design choice for skeleton loaders specifically. (Raised by: Felix, Leonie)we will use animate-pulse