diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 332ac16..77e9e38 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.88.0"
+version = "0.89.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 88ade25..765d388 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.88.0"
+version = "0.89.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/package.json b/frontend/package.json
index 18e80b3..50639c3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.88.0",
+ "version": "0.89.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/components/HistoryList.svelte b/frontend/src/lib/components/HistoryList.svelte
new file mode 100644
index 0000000..e9b987e
--- /dev/null
+++ b/frontend/src/lib/components/HistoryList.svelte
@@ -0,0 +1,230 @@
+
+
+{#if clearError}
+
+ {clearError}
+
+{/if}
+
+{#if rows.length === 0}
+ {emptyText}
+{:else}
+
+ {#each rows as p (p.manga_id)}
+ -
+
+ {#if p.manga_cover_image_path}
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {#if onClear}
+
+ {/if}
+
+ {/each}
+
+{/if}
+
+
diff --git a/frontend/src/lib/components/HistoryList.svelte.test.ts b/frontend/src/lib/components/HistoryList.svelte.test.ts
new file mode 100644
index 0000000..7fcad8d
--- /dev/null
+++ b/frontend/src/lib/components/HistoryList.svelte.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/svelte';
+import HistoryList from './HistoryList.svelte';
+import type { ReadProgressSummary } from '$lib/api/read_progress';
+
+afterEach(() => cleanup());
+
+function entry(over: Partial = {}): ReadProgressSummary {
+ return {
+ manga_id: 'm1',
+ manga_title: 'Berserk',
+ manga_cover_image_path: null,
+ chapter_id: 'c1',
+ chapter_number: 5,
+ page: 1,
+ updated_at: '2026-01-02T00:00:00Z',
+ ...over
+ };
+}
+
+describe('HistoryList', () => {
+ it('renders one row per entry with a continue link to the chapter', () => {
+ render(HistoryList, {
+ props: { entries: [entry({ manga_id: 'm1', chapter_id: 'c9', chapter_number: 9 })] }
+ });
+ const cont = screen.getByRole('link', { name: /Continue Chapter 9/i });
+ expect(cont.getAttribute('href')).toBe('/manga/m1/chapter/c9');
+ });
+
+ it('appends the page number to the continue line only past page 1', () => {
+ // The page suffix is a separate text node from "Continue Chapter N",
+ // so assert against the link's normalized text rather than getByText.
+ render(HistoryList, { props: { entries: [entry({ page: 7, chapter_number: 3 })] } });
+ const link = screen.getByRole('link', { name: /Continue Chapter 3/i });
+ expect(link.textContent?.replace(/\s+/g, ' ').trim()).toBe('Continue Chapter 3 — page 7');
+ cleanup();
+ render(HistoryList, { props: { entries: [entry({ page: 1, chapter_number: 3 })] } });
+ expect(screen.queryByText(/page 1/i)).toBeNull();
+ });
+
+ it('shows a "chapter removed" fallback when the chapter id survives but its number is gone', () => {
+ render(HistoryList, {
+ props: { entries: [entry({ chapter_id: 'c1', chapter_number: null })] }
+ });
+ expect(screen.getByText(/chapter removed/i)).toBeTruthy();
+ expect(screen.queryByRole('link', { name: /Continue/i })).toBeNull();
+ });
+
+ it('shows a "whole manga" fallback when there is no chapter', () => {
+ render(HistoryList, {
+ props: { entries: [entry({ chapter_id: null, chapter_number: null, page: 4 })] }
+ });
+ expect(screen.getByText(/Whole manga, page 4/i)).toBeTruthy();
+ });
+
+ it('renders the empty state with the provided copy and testid when there are no entries', () => {
+ render(HistoryList, {
+ props: { entries: [], emptyText: 'Nothing here yet.', testid: 'library-history' }
+ });
+ const empty = screen.getByTestId('library-history-empty');
+ expect(empty.textContent).toContain('Nothing here yet.');
+ expect(screen.queryByTestId('library-history-list')).toBeNull();
+ });
+
+ it('omits clear buttons entirely when no onClear is supplied (read-only mode)', () => {
+ render(HistoryList, { props: { entries: [entry()] } });
+ expect(screen.queryByRole('button', { name: /Clear/i })).toBeNull();
+ });
+
+ it('optimistically removes a row and calls onClear when the clear button is pressed', async () => {
+ const onClear = vi.fn().mockResolvedValue(undefined);
+ render(HistoryList, {
+ props: {
+ entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
+ onClear,
+ testid: 'history-reading'
+ }
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
+ expect(onClear).toHaveBeenCalledWith(
+ expect.objectContaining({ manga_id: 'm1' })
+ );
+ await waitFor(() => expect(screen.queryByText('Berserk')).toBeNull());
+ });
+
+ it('restores the row and surfaces an inline error when onClear rejects', async () => {
+ const onClear = vi.fn().mockRejectedValue(new Error('network down'));
+ render(HistoryList, {
+ props: {
+ entries: [entry({ manga_id: 'm1', manga_title: 'Berserk' })],
+ onClear,
+ testid: 'history-reading'
+ }
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /Clear Berserk from history/i }));
+ await waitFor(() => {
+ const err = screen.getByTestId('history-reading-error');
+ expect(err.textContent).toMatch(/network down/i);
+ });
+ // Optimistic removal rolled back — the row is back.
+ expect(screen.getByText('Berserk')).toBeTruthy();
+ });
+});
diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte
index 89f8194..09af536 100644
--- a/frontend/src/routes/library/+page.svelte
+++ b/frontend/src/routes/library/+page.svelte
@@ -1,16 +1,19 @@