As a user I want a navigation progress bar so I can see that the app is loading after I click a link #289
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
When clicking a navigation link (sidebar, header, in-page link), SvelteKit runs the server
loadfunction before the next page renders. On slower routes (e.g./chronik,/documents, document detail) this delay is long enough that users think nothing happened and click again.There is currently no visual feedback between click and render.
Proposed solution (quick win)
Add a global top progress bar in
frontend/src/routes/+layout.sveltedriven by SvelteKit'snavigatingstore.navigatingfrom$app/state(Svelte 5) to detect pending navigations.brand-mint(#A6DAD8) on a transparent track, matching the existing palette.navigating !== null, then fade out on completion.Acceptance criteria
brand-mintcolorgoto()programmatic navigationaria-hidden="true"sincenavigatingstate is already semanticnavigatingis null, visible when it is setOut of scope
Per-route skeleton loaders — tracked separately.
Notes
Keeps implementation tiny (single component + root layout change). Does not require refactoring any existing routes.
🏛️ Markus Keller — Application Architect
Observations
navigatingfrom$app/stateis already in use infrontend/src/routes/documents/+page.svelte:3(isLoading={navigating.to !== null}). The pattern is established; the new component just hoists it to the layout.frontend/src/routes/+layout.svelte:48already renders a decorative<div class="h-1 bg-accent">strip. Two thin horizontal bars within 2px of each other would be visually redundant.Recommendations
frontend/src/lib/components/NavigationProgress.svelte, composed once in+layout.svelteabove the header. Keep it under ~40 lines — one$derived, one$effectfor the 100ms delay, one CSS animation. No extraction for reuse — there is only one consumer.navigating+ CSS keyframes cover every requirement in the AC. Every dependency is a supply-chain and bundle-size liability for 30 lines of logic.h-1 bg-accentstrip rather than stacking a second 2–3px bar above it. In the idle state the strip stays as a staticbg-accent; on navigation it animates. One element, two states — avoids visual noise and layout shift.loadfunctions here. Route-level performance is #292's concern; this issue is purely perceived-performance feedback.Open Decisions
None — implementation path is clear.
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
frontend/src/routes/documents/+page.svelte:109(isLoading={navigating.to !== null}). The test mocking pattern is infrontend/src/routes/documents/page.svelte.spec.ts:6:vi.mock('$app/state', () => ({ navigating: { to: null } })).navigatingin Svelte 5 / SvelteKit 2 is a reactive object exposed from$app/state. It isnullwhen idle, aNavigationobject (with.to,.from,.type,.complete) while a navigation is pending.+layout.svelte.spec.ts— this component will be tested in isolation, not by mounting the layout.Recommendations
NavigationProgress.svelte.spec.ts— three unit tests:navigatingisnullrole="progressbar"OR a visible.is-activeclass) whennavigating.tois set AND 100ms have elapsed (usevi.useFakeTimers()+vi.advanceTimersByTime(100))navigatingreturns tonull+layout.svelte, rendered for both authenticated and auth pages (the bar is still useful during the login flow).$app/stateand trust SvelteKit drivesnavigatingcorrectly on real clicks. Integration confidence comes from Playwright, not from trying to simulate a real navigation in Vitest.NavigationProgress.svelte, notLoadingBar,Spinner,TopBar. One nameable region, named after what it is.Open Decisions
None — test plan and implementation shape are deterministic.
🛡️ Nora Steiner — Application Security Engineer
Observations
navigating.tocontains the destination URL object. The bar itself renders no text fromnavigating, so there is no XSS or information-disclosure vector.Recommendations
aria-hidden="true"as specified. The bar is a decorative progress affordance — not a status message. Anaria-liveregion tied tonavigatingwould announce every click to screen readers (noise + route enumeration read aloud, which is also a privacy smell on shared-screen setups).navigating.to.url.pathname(or any property of it) into the DOM. Keep the component stateless beyond a booleanvisible. If someone later wants a "Loading /chronik…" label, that is a separate UX decision with its own security review — not a silent addition.style={}bindings that interpolate any URL or user-controlled value. Prevents future CSS-injection smells from sneaking in during a "let me just add a dynamic color" refactor.navigating.to.urlcan contain query params (search terms, filters, IDs). Logging them to the browser console or a server-side analytics sink is a data-minimisation concern for a family archive with private documents.Open Decisions
None — security posture is trivial and already correctly scoped in the acceptance criteria.
🧪 Sara Holt — QA Engineer
Observations
navigatingis already in the codebase:frontend/src/routes/documents/page.svelte.spec.ts:6. New spec can copy-paste the mock setup exactly.navigatingis null, visible when it is set" — this is underspecified. A bar that appears and never disappears would pass those two assertions.use:enhancetriggersnavigating), orgoto(..., { invalidate: true }).data-hydratedon the layout root (frontend/src/routes/+layout.svelte:45) — Playwright tests can wait for hydration before asserting on the bar.Recommendations
vi.useFakeTimers()):navigating.toset, 50ms elapsed — bar still not visible (flicker-free guarantee).navigating.toset, 150ms elapsed — bar is visible.navigating.totransitions back tonull— bar hides again./chronikis the realistic target): navigate, assert the.nav-progress.is-activeelement is visible within 500ms, assert it disappears afterloadresolves. Single test, under 3 seconds. Add to the existing authenticated journey, no new spec file needed if it fits.aria-hiddendecorative element should be invisible to axe, but run the existingAxeBuildersweep on/chronikwith the bar rendered to confirm no regression.use:enhance(since those also drivenavigating) — OR explicitly scope the AC to link clicks only and document whyOpen Decisions
navigatingis set duringuse:enhanceform submits too. Should the bar show? Options: (a) yes, same treatment — free progress feedback for saves/deletes; (b) no, forms have their own save bars and the page-top bar is misleading. I lean (a), but it depends on whether the save bars already show a spinner/disabled state. (Raised by: Sara — needs Leonie's input.)🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
frontend/src/routes/+layout.svelte:48already renders<div class="h-1 bg-accent">— a 4px decorativebrand-mintstrip at the very top of the viewport. A new 2–3px progress bar "at the top" would sit directly on top of or next to this. Two bars, bothbrand-mint, 2–3px apart is visual noise.brand-minton a "transparent track". On auth pages thebg-canvasis light sand, on authenticated pages the header background isbrand-navy. The bar will traverse both contexts when routing between/loginand/(or similar).prefers-reduced-motionis respected via CSS@mediaqueries across several components already (AnnotationShape.svelte,ChronikFuerDichBox.svelte). There is a consistent pattern to follow.sticky top-0 z-50— the progress bar must be atz-51or sit outside the header stacking context to stay above it.Recommendations
h-1 bg-accentstrip — do not add a second bar. The same element becomes the progress bar. Idle state: static solidbg-accent(current behaviour). Active state: indeterminate animated gradient sliding left→right. This preserves the brand's existing "mint hairline" signature and adds no vertical pixels.var(--color-accent)(idle) andvar(--color-accent-bg)(trough) — both already exist inlayout.css. This automatically handles dark mode; a hardcoded#A6DAD8would break in the dark theme.top: 0,position: sticky, height4px,z-index: 60(above the stickyz-50header). Full-width100vw. On auth pages where the header is hidden, the bar stays visible — useful during login redirects.pointer-events: noneon the bar so it cannot intercept clicks on the header.aria-hidden="true"is correct for the decorative bar. Do NOT add anaria-liveregion for navigation — announcing every click would be hostile to screen-reader users. If the team later wants accessible navigation feedback, it belongs in a sharedrole="status"live region tied to a longer timeout (e.g. 2s), which is out of scope for this quick-win issue.brand-mint #A6DAD8onbrand-navy #002850header = 7.2:1 ✅ AAA for large decorative UI. Onbg-canvas(light) it's ~2.8:1 — below AA for text but acceptable for a 4px decorative strip, since the bar is not text and isaria-hidden.Open Decisions
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
/chronikslow enough for users to think nothing happened?" — is not answered by this issue.Recommendations
@sveltejs/kit-progress,nprogress, or similar). Supply-chain discipline matters even for small packages on a family-archive project. The implementation is 30 lines; own it./chronik,/documents, and document detail with browser DevTools + Spring Boot request logs. Slowloadfunctions likely have fixable N+1 queries or missing indexes that a progress bar only disguises. Track those as separate backend issues if found.performance.mark('sveltekit:navigation:end')hooks and shipping them to Grafana via the existing stack. Premature here.Open Decisions
None — infrastructure impact is negligible.
🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
UX
Replace the existing
h-1 bg-accentbrand strip, or stack a second progress bar above it?Show the bar on
use:enhanceform submissions?navigatingis set during form submits too, so the bar will show by default unless explicitly filtered.Replace the brand bar with the loading indicator.
Show also on form saving