diff --git a/apps/web/app/page.spec.tsx b/apps/web/app/page.spec.tsx index 21151cc..6cf907f 100644 --- a/apps/web/app/page.spec.tsx +++ b/apps/web/app/page.spec.tsx @@ -80,7 +80,42 @@ function renderReaderShell( ); } +function countOccurrences(haystack: string, needle: string) { + return haystack.split(needle).length - 1; +} + describe("ArticleReaderShell", () => { + it("keeps the reader shell as a fixed overflow-hidden boundary", () => { + const html = renderReaderShell(secondArticleDetail, "a-2"); + + expect(html).toContain("h-dvh min-h-dvh overflow-hidden"); + expect(html).toContain("h-[calc(100dvh-1rem-2px)]"); + expect(html).toContain("sm:h-[calc(100dvh-1.5rem-2px)]"); + }); + + it("renders the shared scroll-area viewport and scrollbar structure", () => { + const html = renderReaderShell(secondArticleDetail, "a-2"); + + expect(html).toContain('data-slot="scroll-area"'); + expect(html).toContain('data-slot="scroll-area-viewport"'); + }); + + it("renders dedicated list and detail scroll roots with headers outside the scroll body", () => { + const html = renderReaderShell(secondArticleDetail, "a-2"); + + expect(html).toContain('data-testid="article-list-scroll-area"'); + expect(html).toContain('data-testid="article-detail-scroll-area"'); + expect(countOccurrences(html, 'data-slot="scroll-area"')).toBe(2); + expect(html).not.toContain("sm:h-full"); + + expect(html.indexOf(">Articles<")).toBeLessThan( + html.indexOf('data-testid="article-list-scroll-area"'), + ); + expect(html.indexOf(">Summary view<")).toBeLessThan( + html.indexOf('data-testid="article-detail-scroll-area"'), + ); + }); + it("renders the selected article detail", () => { const html = renderReaderShell(secondArticleDetail, "a-2"); @@ -131,13 +166,45 @@ describe("ArticleReaderShell", () => { ); expect(html).toContain("Summary pending"); + expect(html).toContain('data-testid="article-detail-scroll-area"'); }); +}); +describe("ArticleReaderShell placeholder states", () => { it("renders the empty state when no articles are available", () => { const html = renderReaderShell(null, null, []); + expect(html).toContain("Summary view"); expect(html).toContain("No article selected"); expect(html).toContain("When prepared items are available"); + expect(html).toContain('data-testid="article-detail-scroll-area"'); + expect(html).toContain("flex min-h-full flex-col"); + expect(html).toContain( + "mx-auto w-full max-w-5xl flex min-h-full flex-1 flex-col", + ); + }); + + it("keeps the detail scroll body for unavailable and failed-summary states", () => { + const unavailableHtml = renderReaderShell(null, "a-2"); + const failedHtml = renderReaderShell( + { + ...secondArticleDetail, + summary: "", + summaryErrorReason: "gateway_timeout", + }, + "a-2", + ); + + expect(unavailableHtml).toContain("Article unavailable"); + expect(unavailableHtml).toContain( + 'data-testid="article-detail-scroll-area"', + ); + expect(unavailableHtml).toContain("flex min-h-full flex-col"); + expect(unavailableHtml).toContain( + "mx-auto w-full max-w-5xl flex min-h-full flex-1 flex-col", + ); + expect(failedHtml).toContain("Summary generation failed"); + expect(failedHtml).toContain('data-testid="article-detail-scroll-area"'); }); }); diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index 6569813..d182582 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -1,4 +1,8 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +function scrollViewport(testId: string, page: Page) { + return page.getByTestId(testId).locator('[data-slot="scroll-area-viewport"]'); +} test("renders the first article summary by default on desktop", async ({ page, @@ -57,3 +61,106 @@ test("shows stale article fallback while keeping the list visible", async ({ }), ).toBeVisible(); }); + +test("keeps independent pane scroll roots on desktop overflow", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 240 }); + await page.goto("/"); + + const listViewport = scrollViewport("article-list-scroll-area", page); + const detailViewport = scrollViewport("article-detail-scroll-area", page); + const listScrollbar = page + .getByTestId("article-list-scroll-area") + .locator('[data-slot="scroll-area-scrollbar"]'); + const detailScrollbar = page + .getByTestId("article-detail-scroll-area") + .locator('[data-slot="scroll-area-scrollbar"]'); + const listThumb = page + .getByTestId("article-list-scroll-area") + .locator('[data-slot="scroll-area-thumb"]'); + const detailThumb = page + .getByTestId("article-detail-scroll-area") + .locator('[data-slot="scroll-area-thumb"]'); + const listHeader = page.getByText("Articles"); + const detailHeader = page.getByText("Summary view"); + + await expect(listViewport).toBeVisible(); + await expect(detailViewport).toBeVisible(); + await expect(listScrollbar).toHaveCount(1); + await expect(detailScrollbar).toHaveCount(1); + + const pageMetrics = await page.evaluate(() => ({ + clientHeight: document.scrollingElement?.clientHeight ?? 0, + scrollHeight: document.scrollingElement?.scrollHeight ?? 0, + })); + expect(pageMetrics.scrollHeight).toBeLessThanOrEqual( + pageMetrics.clientHeight + 1, + ); + + await expect + .poll(() => + listViewport.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + await expect + .poll(() => + detailViewport.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + + const listHeaderBefore = await listHeader.boundingBox(); + const detailHeaderBefore = await detailHeader.boundingBox(); + + const listScrollTop = await listViewport.evaluate((element) => { + element.scrollTop = 120; + return element.scrollTop; + }); + const detailScrollAfterList = await detailViewport.evaluate( + (element) => element.scrollTop, + ); + + expect(listScrollTop).toBeGreaterThan(0); + expect(detailScrollAfterList).toBe(0); + await expect(listThumb).toBeVisible(); + + const detailScrollTop = await detailViewport.evaluate((element) => { + element.scrollTop = 160; + return element.scrollTop; + }); + const listScrollAfterDetail = await listViewport.evaluate( + (element) => element.scrollTop, + ); + + expect(detailScrollTop).toBeGreaterThan(0); + expect(listScrollAfterDetail).toBe(listScrollTop); + await expect(detailThumb).toBeVisible(); + + const listHeaderAfter = await listHeader.boundingBox(); + const detailHeaderAfter = await detailHeader.boundingBox(); + + expect(listHeaderBefore?.y).toBe(listHeaderAfter?.y); + expect(detailHeaderBefore?.y).toBe(detailHeaderAfter?.y); +}); + +test("preserves the detail scroll root for pending and unavailable states", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 420 }); + + await page.goto("/?articleId=article-002"); + await expect(page.getByText("Summary pending")).toBeVisible(); + await expect( + scrollViewport("article-detail-scroll-area", page), + ).toBeVisible(); + + await page.goto("/?articleId=missing-id"); + await expect(page.getByText("Article unavailable")).toBeVisible(); + await expect( + scrollViewport("article-detail-scroll-area", page), + ).toBeVisible(); +}); diff --git a/apps/web/src/widgets/article-reader/ui/article-detail-frame.tsx b/apps/web/src/widgets/article-reader/ui/article-detail-frame.tsx new file mode 100644 index 0000000..0a90de1 --- /dev/null +++ b/apps/web/src/widgets/article-reader/ui/article-detail-frame.tsx @@ -0,0 +1,59 @@ +import Link from "next/link"; + +import { Button } from "@repo/ui/components/button"; + +import type { ArticleDetail } from "../api/articles-api"; + +type ArticleDetailHeaderProps = { + article: ArticleDetail | null; +}; + +type EmptyStateProps = { + title: string; + description: string; +}; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format( + new Date(value), + ); +} + +export function ArticleDetailHeader({ article }: ArticleDetailHeaderProps) { + return ( +
+
+
+

+ Summary view +

+ {article ? ( +

+ {article.sourceTitle} · {formatDate(article.publishedAt)} +

+ ) : null} +
+ {article ? ( + + ) : null} +
+
+ ); +} + +export function EmptyState({ title, description }: EmptyStateProps) { + return ( +
+
+

{title}

+

+ {description} +

+
+
+ ); +} diff --git a/apps/web/src/widgets/article-reader/ui/article-detail.tsx b/apps/web/src/widgets/article-reader/ui/article-detail.tsx index f1487fa..a13b0d4 100644 --- a/apps/web/src/widgets/article-reader/ui/article-detail.tsx +++ b/apps/web/src/widgets/article-reader/ui/article-detail.tsx @@ -1,28 +1,19 @@ -import Link from "next/link"; +import type { ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { Button } from "@repo/ui/components/button"; +import { ScrollArea } from "@repo/ui/components/scroll-area"; import { Separator } from "@repo/ui/components/separator"; +import { cn } from "@repo/ui/lib/utils"; import type { ArticleDetail } from "../api/articles-api"; +import { ArticleDetailHeader, EmptyState } from "./article-detail-frame"; type ArticleDetailProps = { article: ArticleDetail | null; isEmpty: boolean; }; -type EmptyStateProps = { - title: string; - description: string; -}; - -function formatDate(value: string) { - return new Intl.DateTimeFormat("en", { - dateStyle: "medium", - }).format(new Date(value)); -} - function getDisplayTitle(article: ArticleDetail) { return article.translatedTitle || article.title; } @@ -43,19 +34,6 @@ function stripSummaryTitleSection(summary: string) { return normalized.slice(match[0].length).trimStart(); } -function EmptyState({ title, description }: EmptyStateProps) { - return ( -
-
-

{title}

-

- {description} -

-
-
- ); -} - function SummaryMarkdownContent({ summary }: { summary: string }) { return ( ); - } - - if (!article) { - return ( + } else if (!article) { + content = ( ); + } else { + content = ( +
+

+ {getDisplayTitle(article)} +

+ +
+ {article.summary ? ( + + ) : ( + + )} +
+
+ ); } return ( -
-
-
-
-

- Summary view -

-

- {article.sourceTitle} · {formatDate(article.publishedAt)} -

-
- -
-
- -
-
-

- {getDisplayTitle(article)} -

- -
- {article.summary ? ( - - ) : ( - +
+ + +
+
+ > + {content} +
-
+
); } diff --git a/apps/web/src/widgets/article-reader/ui/article-list.tsx b/apps/web/src/widgets/article-reader/ui/article-list.tsx index e9291ff..42d87f0 100644 --- a/apps/web/src/widgets/article-reader/ui/article-list.tsx +++ b/apps/web/src/widgets/article-reader/ui/article-list.tsx @@ -23,7 +23,7 @@ function getDisplayTitle(article: ArticleListItem) { export function ArticleList({ articles, selectedArticleId }: ArticleListProps) { return ( -