feat: global navigation progress bar

Most data routes run their load with ssr = false, so SvelteKit holds the
previous page on screen while the next one's data resolves — with no
feedback. NavProgress, mounted once in the root layout and driven by the
$navigating store, shows a thin top bar that trickles while a navigation is
pending and fades out on completion. Degrades to a static visible bar under
prefers-reduced-motion. New --z-nav-progress token sits above the fixed
header but below modals/toasts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 19:38:14 +02:00
parent 34c1122e4e
commit 09f12c8959
8 changed files with 278 additions and 3 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.111.0" version = "0.112.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.111.0" version = "0.112.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -0,0 +1,147 @@
import { test, expect, type Page } from './fixtures';
// The global nav progress bar (rendered once in the root layout) is the only
// feedback most data routes have while a client-side navigation is pending:
// they run `load` with `ssr = false`, so SvelteKit holds the previous page on
// screen with no indication anything is happening. This drives a real
// home -> detail navigation, holds the detail's `getManga` open, and asserts
// the bar activates during the pending load and clears once it settles.
const mangaId = 'm1111111-1111-1111-1111-111111111111';
const listItem = {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: []
};
const mangaDetail = {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description: null,
cover_image_path: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [],
genres: [],
tags: [],
content_warnings: [],
chapter_storage_bytes: 0
};
async function mockCommon(page: Page) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'no' } })
})
);
await page.route('**/api/v1/genres*', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
);
// Catalog list (Pattern B). Registered before the detail routes so the
// more-specific handlers below win for the /mangas/:id URLs.
await page.route('**/api/v1/mangas*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [listItem], page: { limit: 50, offset: 0, total: 1 } })
})
);
// Detail load's non-manga calls.
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}/similar`, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) })
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
})
);
await page.route(`**/api/v1/me/reactions/${mangaId}`, (r) =>
r.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'not_found', message: 'no' } })
})
);
}
test('shows the nav progress bar during a pending navigation and clears it after', async ({
page
}) => {
await mockCommon(page);
// Hold the detail's getManga open so the navigation stays pending long
// enough to observe the bar. Registered last => wins for the exact
// /mangas/:id URL over the catalog glob.
let releaseDetail: () => void = () => {};
const gate = new Promise<void>((resolve) => {
releaseDetail = resolve;
});
await page.route(`**/api/v1/mangas/${mangaId}`, async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaDetail)
});
});
await page.goto('/');
await expect(page.getByTestId('manga-list')).toBeVisible();
const bar = page.getByTestId('nav-progress');
// Idle on a settled page.
await expect(bar).not.toHaveClass(/active/);
// Client-side navigate into the (gated) detail page.
await page.locator(`a[href="/manga/${mangaId}"]`).first().click();
// Bar activates while the detail load is in flight.
await expect(bar).toHaveClass(/active/);
// Let the load resolve; the detail renders and the bar goes idle again.
releaseDetail();
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
await expect(bar).not.toHaveClass(/active/);
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.111.0", "version": "0.112.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { navigating } from '$app/stores';
/**
* Thin top-of-viewport progress bar that shows during client-side
* navigation. Most data routes run their `load` with `ssr = false`, so
* SvelteKit keeps the previous page on screen while the next one's data
* resolves — with no feedback. This bar is that feedback: it trickles
* toward the right edge while a navigation is pending and fades out once
* it settles.
*
* Decorative (`aria-hidden`). Under the global prefers-reduced-motion
* override the trickle animation is disabled, so the `.active` rule keeps
* a static visible bar during navigation instead of an empty sliver.
*/
const active = $derived(!!$navigating);
</script>
<div class="nav-progress" class:active data-testid="nav-progress" aria-hidden="true"></div>
<style>
.nav-progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 0;
background: var(--primary);
z-index: var(--z-nav-progress);
opacity: 0;
/* Fade out on completion; the trickle (width) is handled by the
keyframe below while active. */
transition: opacity 200ms ease-out;
pointer-events: none;
will-change: width, opacity;
}
.nav-progress.active {
opacity: 1;
/* Base (non-animated) width — this is what reduced-motion users see,
and the keyframe's end state, so disabling the animation still
leaves a clearly visible bar. */
width: 90%;
animation: nav-progress-trickle 8s cubic-bezier(0.1, 0.7, 0.3, 1) forwards;
}
@keyframes nav-progress-trickle {
0% {
width: 0;
}
50% {
width: 65%;
}
100% {
width: 90%;
}
}
</style>

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
// A controllable stand-in for SvelteKit's `navigating` store: `null` when
// idle, a Navigation-like object while a client-side navigation is pending.
// Built via vi.hoisted (a minimal writable, so no import is needed before
// the hoisted vi.mock factory runs).
const { navigating } = vi.hoisted(() => {
let value: unknown = null;
const subs = new Set<(v: unknown) => void>();
return {
navigating: {
subscribe(fn: (v: unknown) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
set(v: unknown) {
value = v;
subs.forEach((fn) => fn(value));
}
}
};
});
vi.mock('$app/stores', () => ({ navigating }));
import NavProgress from './NavProgress.svelte';
afterEach(() => {
cleanup();
navigating.set(null);
});
describe('NavProgress', () => {
it('renders a decorative bar carrying the nav-progress testid', () => {
render(NavProgress);
const bar = screen.getByTestId('nav-progress');
expect(bar).toBeTruthy();
expect(bar.getAttribute('aria-hidden')).toBe('true');
});
it('is inactive when not navigating', () => {
render(NavProgress);
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(false);
});
it('becomes active while a navigation is pending', async () => {
render(NavProgress);
navigating.set({ from: null, to: { url: new URL('http://x/manga/1') } });
await Promise.resolve();
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(true);
});
it('returns to inactive once navigation settles', async () => {
render(NavProgress);
navigating.set({ from: null, to: { url: new URL('http://x/manga/1') } });
await Promise.resolve();
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(true);
navigating.set(null);
await Promise.resolve();
expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(false);
});
});

View File

@@ -95,6 +95,9 @@
--z-dropdown: 10; --z-dropdown: 10;
--z-sticky: 50; --z-sticky: 50;
/* Above the fixed header/app-bar (--z-sticky) so the nav progress bar
is never occluded, but below modals and toasts. */
--z-nav-progress: 60;
--z-modal: 100; --z-modal: 100;
--z-toast: 1000; --z-toast: 1000;
} }

View File

@@ -9,6 +9,7 @@
import { theme } from '$lib/theme.svelte'; import { theme } from '$lib/theme.svelte';
import AppBar from '$lib/components/AppBar.svelte'; import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte'; import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import NavProgress from '$lib/components/NavProgress.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle'; import UserCircle from '@lucide/svelte/icons/user-circle';
@@ -198,6 +199,8 @@
<title>{layoutTitle}</title> <title>{layoutTitle}</title>
</svelte:head> </svelte:head>
<NavProgress />
{#if showMobileChrome} {#if showMobileChrome}
<div class="mobile-app-bar-wrap" bind:this={appBarEl}> <div class="mobile-app-bar-wrap" bind:this={appBarEl}>
<AppBar title="Mangalord" testid="mobile-app-bar" /> <AppBar title="Mangalord" testid="mobile-app-bar" />