From 09f12c8959cc22ce49fbe333920b89ae982e3108 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 6 Jul 2026 19:38:14 +0200 Subject: [PATCH] feat: global navigation progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/nav-progress.spec.ts | 147 ++++++++++++++++++ frontend/package.json | 2 +- .../src/lib/components/NavProgress.svelte | 58 +++++++ .../lib/components/NavProgress.svelte.test.ts | 64 ++++++++ frontend/src/lib/styles/tokens.css | 3 + frontend/src/routes/+layout.svelte | 3 + 8 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 frontend/e2e/nav-progress.spec.ts create mode 100644 frontend/src/lib/components/NavProgress.svelte create mode 100644 frontend/src/lib/components/NavProgress.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 906bb04..ac52fa1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.111.0" +version = "0.112.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1105a02..6b96dc3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.111.0" +version = "0.112.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/nav-progress.spec.ts b/frontend/e2e/nav-progress.spec.ts new file mode 100644 index 0000000..ad8d347 --- /dev/null +++ b/frontend/e2e/nav-progress.spec.ts @@ -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((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/); +}); diff --git a/frontend/package.json b/frontend/package.json index 48e1360..4437c43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.111.0", + "version": "0.112.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/NavProgress.svelte b/frontend/src/lib/components/NavProgress.svelte new file mode 100644 index 0000000..95017ae --- /dev/null +++ b/frontend/src/lib/components/NavProgress.svelte @@ -0,0 +1,58 @@ + + + + + diff --git a/frontend/src/lib/components/NavProgress.svelte.test.ts b/frontend/src/lib/components/NavProgress.svelte.test.ts new file mode 100644 index 0000000..395b303 --- /dev/null +++ b/frontend/src/lib/components/NavProgress.svelte.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css index bd336b7..5e4e468 100644 --- a/frontend/src/lib/styles/tokens.css +++ b/frontend/src/lib/styles/tokens.css @@ -95,6 +95,9 @@ --z-dropdown: 10; --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-toast: 1000; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 87c2119..62c6328 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { theme } from '$lib/theme.svelte'; import AppBar from '$lib/components/AppBar.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 Upload from '@lucide/svelte/icons/upload'; import UserCircle from '@lucide/svelte/icons/user-circle'; @@ -198,6 +199,8 @@ {layoutTitle} + + {#if showMobileChrome}