diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 6415588..97f0614 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.114.1"
+version = "0.114.2"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 3e7d006..6f24e15 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.114.1"
+version = "0.114.2"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/nav-progress.spec.ts b/frontend/e2e/nav-progress.spec.ts
index ad8d347..93e0e31 100644
--- a/frontend/e2e/nav-progress.spec.ts
+++ b/frontend/e2e/nav-progress.spec.ts
@@ -132,16 +132,17 @@ test('shows the nav progress bar during a pending navigation and clears it after
const bar = page.getByTestId('nav-progress');
// Idle on a settled page.
- await expect(bar).not.toHaveClass(/active/);
+ await expect(bar).toHaveAttribute('data-phase', 'idle');
// 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/);
+ // Bar enters the loading phase while the detail load is in flight.
+ await expect(bar).toHaveAttribute('data-phase', 'loading');
- // Let the load resolve; the detail renders and the bar goes idle again.
+ // Let the load resolve; the detail renders and the bar settles back to
+ // idle (via a brief done phase).
releaseDetail();
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
- await expect(bar).not.toHaveClass(/active/);
+ await expect(bar).toHaveAttribute('data-phase', 'idle');
});
diff --git a/frontend/package.json b/frontend/package.json
index 1ed8283..1ac4f34 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.114.1",
+ "version": "0.114.2",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/components/MangaCard.svelte b/frontend/src/lib/components/MangaCard.svelte
index 0975e36..7c0bb06 100644
--- a/frontend/src/lib/components/MangaCard.svelte
+++ b/frontend/src/lib/components/MangaCard.svelte
@@ -90,17 +90,8 @@
diff --git a/frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts b/frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts
index acf3000..af4ac44 100644
--- a/frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts
+++ b/frontend/src/lib/components/MangaGridSkeleton.svelte.test.ts
@@ -28,4 +28,16 @@ describe('MangaGridSkeleton', () => {
render(MangaGridSkeleton);
expect(screen.getByTestId('manga-grid-skeleton').getAttribute('aria-hidden')).toBe('true');
});
+
+ it('reserves each cover as a 2/3 aspect box, not a collapsed zero-height one', () => {
+ render(MangaGridSkeleton, { props: { count: 1 } });
+ const card = screen
+ .getByTestId('manga-grid-skeleton')
+ .querySelector('.manga-card') as HTMLElement;
+ const cover = card.firstElementChild as HTMLElement;
+ expect(cover.style.aspectRatio).toBe('2 / 3');
+ // A definite height:0 would make aspect-ratio a no-op and collapse
+ // the cover — the exact layout-shift regression this guards.
+ expect(cover.style.height).toBe('');
+ });
});
diff --git a/frontend/src/lib/components/NavProgress.svelte b/frontend/src/lib/components/NavProgress.svelte
index 95017ae..6af8af6 100644
--- a/frontend/src/lib/components/NavProgress.svelte
+++ b/frontend/src/lib/components/NavProgress.svelte
@@ -2,57 +2,110 @@
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.
+ * Thin top-of-viewport progress bar shown during client-side navigation.
+ * 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. This bar is that feedback: it trickles toward the right edge
+ * while a navigation is pending, snaps to full 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.
+ * Decorative (`aria-hidden`). Animates `transform`/`opacity` only (both
+ * compositor-friendly), and drops `will-change` when idle. Under
+ * prefers-reduced-motion the trickle keyframe is neutralized by the global
+ * override, leaving the `.loading` base transform as a static visible bar.
*/
- const active = $derived(!!$navigating);
+
+ // idle: hidden. loading: trickling. done: snap to full, then fade to idle.
+ let phase = $state<'idle' | 'loading' | 'done'>('idle');
+ // Bumped whenever a *new* navigation begins. Keying the element on it
+ // remounts the bar so the trickle animation restarts even when one
+ // navigation supersedes another (the store stays truthy the whole time,
+ // just swapping Navigation objects).
+ let run = $state(0);
+ // Plain locals (not $state) so reading them in the effect doesn't make the
+ // effect depend on its own writes. `runCount` mirrors `run` so we can bump
+ // it without reactively reading the `run` state.
+ let runCount = 0;
+ let wasNavigating = false;
+ let doneTimer: ReturnType | undefined;
+
+ $effect(() => {
+ const isNavigating = !!$navigating;
+ if (isNavigating) {
+ clearTimeout(doneTimer);
+ run = ++runCount;
+ phase = 'loading';
+ } else if (wasNavigating) {
+ phase = 'done';
+ doneTimer = setTimeout(() => {
+ phase = 'idle';
+ }, 260);
+ }
+ wasNavigating = isNavigating;
+ return () => clearTimeout(doneTimer);
+ });
-
+{#key run}
+
+{/key}
diff --git a/frontend/src/lib/components/NavProgress.svelte.test.ts b/frontend/src/lib/components/NavProgress.svelte.test.ts
index 395b303..e65d591 100644
--- a/frontend/src/lib/components/NavProgress.svelte.test.ts
+++ b/frontend/src/lib/components/NavProgress.svelte.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
+import { flushSync } from 'svelte';
import { render, screen, cleanup } from '@testing-library/svelte';
// A controllable stand-in for SvelteKit's `navigating` store: `null` when
@@ -27,6 +28,8 @@ vi.mock('$app/stores', () => ({ navigating }));
import NavProgress from './NavProgress.svelte';
+const nav = (path: string) => ({ from: null, to: { url: new URL(`http://x${path}`) } });
+
afterEach(() => {
cleanup();
navigating.set(null);
@@ -40,25 +43,58 @@ describe('NavProgress', () => {
expect(bar.getAttribute('aria-hidden')).toBe('true');
});
- it('is inactive when not navigating', () => {
+ it('is idle when not navigating', () => {
render(NavProgress);
- expect(screen.getByTestId('nav-progress').classList.contains('active')).toBe(false);
+ const bar = screen.getByTestId('nav-progress');
+ expect(bar.dataset.phase).toBe('idle');
+ expect(bar.classList.contains('loading')).toBe(false);
});
- it('becomes active while a navigation is pending', async () => {
+ it('enters the loading phase while a navigation is pending', () => {
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(nav('/manga/1'));
+ flushSync();
+ const bar = screen.getByTestId('nav-progress');
+ expect(bar.dataset.phase).toBe('loading');
+ expect(bar.classList.contains('loading')).toBe(true);
});
- it('returns to inactive once navigation settles', async () => {
+ it('snaps to done then returns to idle once navigation settles', () => {
+ vi.useFakeTimers();
+ try {
+ render(NavProgress);
+ navigating.set(nav('/manga/1'));
+ flushSync();
+ expect(screen.getByTestId('nav-progress').dataset.phase).toBe('loading');
+
+ navigating.set(null);
+ flushSync();
+ // Completes to full and fades, rather than snapping away instantly.
+ expect(screen.getByTestId('nav-progress').dataset.phase).toBe('done');
+
+ vi.advanceTimersByTime(300);
+ flushSync();
+ expect(screen.getByTestId('nav-progress').dataset.phase).toBe('idle');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('restarts the bar when a new navigation supersedes a pending one', () => {
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);
+ navigating.set(nav('/manga/1'));
+ flushSync();
+ const first = screen.getByTestId('nav-progress');
+
+ // A second navigation begins before the first settles — the store
+ // stays truthy the whole time, just swapping objects.
+ navigating.set(nav('/manga/2'));
+ flushSync();
+ const second = screen.getByTestId('nav-progress');
+
+ expect(second.dataset.phase).toBe('loading');
+ // {#key run} remounts the element so the trickle animation replays
+ // rather than staying parked at its previous width.
+ expect(second).not.toBe(first);
});
});
diff --git a/frontend/src/lib/components/Skeleton.svelte b/frontend/src/lib/components/Skeleton.svelte
index 1bed695..b40ef5e 100644
--- a/frontend/src/lib/components/Skeleton.svelte
+++ b/frontend/src/lib/components/Skeleton.svelte
@@ -5,19 +5,25 @@
* out of the accessibility tree; the loading state should be announced by
* the container (e.g. `role="status"`).
*
- * The shimmer freezes to a solid `--surface-elevated` block under the
- * global `prefers-reduced-motion` override (see tokens.css), which is the
- * intended reduced-motion appearance.
+ * Under prefers-reduced-motion the shimmer gradient/animation is dropped
+ * entirely, leaving a flat `--surface-elevated` block.
*/
let {
width = '100%',
height,
+ aspectRatio,
radius = 'md',
variant = 'block',
testid
}: {
width?: string;
height?: string;
+ /**
+ * `aspect-ratio` for the box (e.g. `'2 / 3'`). Lets a fluid-width
+ * placeholder reserve its shape without a hard-coded height — the
+ * height stays `auto` so the ratio actually applies.
+ */
+ aspectRatio?: string;
/** Corner radius, mapped to the design-token scale. */
radius?: 'sm' | 'md' | 'lg' | 'pill';
/** `circle` forces a pill radius (for avatars / dots). */
@@ -27,38 +33,40 @@
const radiusClass = $derived(variant === 'circle' ? 'radius-pill' : `radius-${radius}`);
+ // A text line gets a sensible default height so callers don't have to; an
+ // explicit `height` still wins. Resolving it here (rather than in CSS)
+ // keeps a single source of truth for the box height and avoids an inline
+ // style silently shadowing a stylesheet rule.
+ const resolvedHeight = $derived(height ?? (variant === 'text' ? '0.9em' : undefined));
+
const style = $derived(
- [`width: ${width};`, height ? `height: ${height};` : null].filter(Boolean).join(' ')
+ `width: ${width};` +
+ (resolvedHeight ? ` height: ${resolvedHeight};` : '') +
+ (aspectRatio ? ` aspect-ratio: ${aspectRatio};` : '')
);
-
+