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};` : '') ); - +