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 ? (
-
- ) : (
-
+
+
);
}
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 (
-