feat(frontend): mobile manga detail with hero + sticky CTA (0.58.0)

Phase 3 of the mobile redesign: the manga detail page gets a mobile-
native chrome — blurred-cover hero with a transparent app bar overlay,
secondary actions in an overflow Sheet, a 3-line description clamp
with Read more, and a sticky bottom CTA whose wording reflects read
progress. Desktop layout is untouched.

- /manga/[id] joins the layout's HIDE_MOBILE_CHROME route set so the
  global AppBar and BottomNav step aside for the page-specific hero
  chrome. A new `html[data-mobile-full-bleed]` opt-in zeroes main's
  mobile top padding without touching horizontal/bottom gutters.
- The hero block (mobile-only via CSS) renders a blurred cover
  backdrop, a transparent app bar (back / bookmark / overflow ⋯), and
  a sharp cover thumbnail beside the title, authors, and status. The
  bookmark icon swaps between Bookmark and BookmarkCheck based on the
  current bookmark state — reusing the existing toggleBookmark flow.
- Secondary actions (Add to collection / Edit / Upload chapter /
  Force resync) move into an overflow Sheet opened from the ⋯
  button. The desktop action-row stays.
- Description gets a `.clamped` modifier (-webkit-line-clamp: 3) and
  a Read more toggle on mobile, gated by a >200-char heuristic so
  short blurbs don't render a dangling button. Desktop shows the
  full description as before.
- Sticky bottom CTA bar reads "Continue {chapterLabel}" when
  data.readProgress has a chapter_id, "Read first chapter" when
  the manga has chapters but no progress, and hides otherwise. The
  bar honors env(safe-area-inset-bottom) so it sits above the iOS
  home indicator.
- New Playwright spec covers the CTA wording state machine
  (no-progress → Read first chapter, has-progress → Continue),
  Read more expand/collapse, overflow sheet contents, and a desktop
  regression for the unchanged action-row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 11:07:30 +02:00
parent be6b974150
commit 780632bee3
6 changed files with 741 additions and 5 deletions

View File

@@ -0,0 +1,244 @@
import { test, expect, type Page } from '@playwright/test';
// Phase 3: the manga detail page gains a mobile hero (blurred backdrop
// + transparent app bar), a sticky bottom CTA whose wording reflects
// read progress, a 3-line description clamp with Read more, and an
// overflow Sheet that hosts secondary actions (Edit / Upload chapter /
// Add to collection / Force resync). Desktop layout is preserved.
const MOBILE = { width: 390, height: 844 } as const;
const DESKTOP = { width: 1280, height: 720 } as const;
const mangaId = 'a1111111-1111-1111-1111-111111111111';
const firstChapterId = 'c1111111-1111-1111-1111-111111111111';
const latestChapterId = 'c2222222-2222-2222-2222-222222222222';
// Chapters arrive newest-first from the API (source_index DESC) — the
// detail page's "Read first chapter" CTA targets the last element.
const chaptersFixture = [
{
id: latestChapterId,
manga_id: mangaId,
number: 2,
title: 'Second',
page_count: 10,
created_at: '2026-02-01T00:00:00Z'
},
{
id: firstChapterId,
manga_id: mangaId,
number: 1,
title: 'The Brand',
page_count: 8,
created_at: '2026-01-01T00:00:00Z'
}
];
const shortDescription = 'A brief synopsis.';
const longDescription =
'This is an extended description that runs over multiple lines so that ' +
'the clamp + Read more behavior on mobile has actual content to truncate. ' +
'Long-form descriptions are common for manga that have been crawled from ' +
'public sources with editorial blurbs, so the catalog has to handle them ' +
'gracefully without pushing the chapter list below the fold on phones.';
function mangaFixture(description: string = shortDescription) {
return {
id: mangaId,
title: 'Berserk',
status: 'ongoing',
alt_titles: [],
description,
cover_image_path: `mangas/${mangaId}/cover.png`,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
authors: [{ id: 'au1', name: 'Kentaro Miura' }],
genres: [],
tags: []
};
}
async function mockDetail(
page: Page,
opts: {
description?: string;
readProgress?: {
chapter_id: string;
chapter_number: number;
page: number;
} | null;
authed?: boolean;
} = {}
) {
const authed = opts.authed ?? false;
await page.route('**/api/v1/auth/config', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (route) =>
route.fulfill({
status: authed ? 200 : 401,
contentType: 'application/json',
body: authed
? JSON.stringify({
user: {
id: 'u1',
username: 'reader',
created_at: '2026-01-01T00:00:00Z',
is_admin: false
}
})
: JSON.stringify({
error: { code: 'unauthenticated', message: 'unauthenticated' }
})
})
);
await page.route('**/api/v1/auth/me/preferences', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
);
await page.route('**/api/v1/me/bookmarks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mangaFixture(opts.description))
})
);
await page.route(`**/api/v1/mangas/${mangaId}/chapters*`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: chaptersFixture,
page: { limit: 50, offset: 0, total: chaptersFixture.length }
})
})
);
await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) =>
route.fulfill({
status: opts.readProgress ? 200 : 404,
contentType: 'application/json',
body: opts.readProgress
? JSON.stringify(opts.readProgress)
: JSON.stringify({ error: { code: 'not_found', message: 'not found' } })
})
);
// 1x1 transparent PNG so cover image requests don't 404 noisily.
const png = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
'hex'
);
await page.route('**/api/v1/files/**', (route) =>
route.fulfill({ status: 200, contentType: 'image/png', body: png })
);
}
test.describe('mobile manga detail', () => {
test('phone viewport: with no progress, CTA reads "Read first chapter" and links to the oldest chapter', async ({
page
}) => {
await mockDetail(page);
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('mobile-hero')).toBeVisible();
const cta = page.getByTestId('continue-cta');
await expect(cta).toHaveText('Read first chapter');
await expect(cta).toHaveAttribute(
'href',
`/manga/${mangaId}/chapter/${firstChapterId}`
);
});
test('phone viewport: with progress, CTA reads "Continue {chapterLabel}" and links to the in-progress chapter', async ({
page
}) => {
await mockDetail(page, {
authed: true,
readProgress: { chapter_id: latestChapterId, chapter_number: 2, page: 5 }
});
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}`);
const cta = page.getByTestId('continue-cta');
// chapterLabel returns the title when present, falling back to
// "Chapter N" only when empty — the fixture has title "Second".
await expect(cta).toHaveText(/Continue\s+Second/);
await expect(cta).toHaveAttribute(
'href',
`/manga/${mangaId}/chapter/${latestChapterId}`
);
});
test('phone viewport: short description shows no Read more toggle', async ({ page }) => {
await mockDetail(page, { description: shortDescription });
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('manga-description')).toBeVisible();
await expect(page.getByTestId('read-more-toggle')).toHaveCount(0);
});
test('phone viewport: long description shows Read more, expands on tap, collapses on second tap', async ({
page
}) => {
await mockDetail(page, { description: longDescription });
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}`);
const toggle = page.getByTestId('read-more-toggle');
await expect(toggle).toHaveText('Read more');
await toggle.click();
await expect(toggle).toHaveText('Read less');
await toggle.click();
await expect(toggle).toHaveText('Read more');
});
test('phone viewport: overflow ⋯ opens the actions sheet with Edit / Upload / Add-to-collection rows', async ({
page
}) => {
await mockDetail(page, { authed: true });
await page.setViewportSize(MOBILE);
await page.goto(`/manga/${mangaId}`);
await expect(page.getByTestId('detail-overflow-sheet')).toBeHidden();
await page.getByTestId('detail-overflow').click();
const sheet = page.getByTestId('detail-overflow-sheet');
await expect(sheet).toBeVisible();
await expect(sheet.getByTestId('overflow-edit')).toBeVisible();
await expect(sheet.getByTestId('overflow-upload-chapter')).toBeVisible();
await expect(sheet.getByTestId('overflow-add-to-collection')).toBeVisible();
});
test('desktop viewport: mobile hero is hidden, existing layout + action-row stays', async ({
page
}) => {
await mockDetail(page, { authed: true });
await page.setViewportSize(DESKTOP);
await page.goto(`/manga/${mangaId}`);
// Hero block exists in DOM but is hidden by CSS at >640px.
await expect(page.getByTestId('mobile-hero')).toBeHidden();
// The desktop title/cover/action surfaces remain visible.
await expect(page.getByTestId('manga-title')).toBeVisible();
await expect(page.getByTestId('bookmark-toggle')).toBeVisible();
});
});