Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/web/app/page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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"');
});
});

Expand Down
109 changes: 108 additions & 1 deletion apps/web/e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
});
59 changes: 59 additions & 0 deletions apps/web/src/widgets/article-reader/ui/article-detail-frame.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="border-b border-border/80 px-5 py-4 lg:px-7 lg:py-5 xl:px-8">
<div className="mx-auto flex w-full max-w-5xl flex-wrap items-center justify-between gap-4">
<div>
<p className="text-reader-eyebrow text-foreground/42 uppercase">
Summary view
</p>
{article ? (
<p className="mt-1 text-reader-meta text-foreground/54">
{article.sourceTitle} · {formatDate(article.publishedAt)}
</p>
) : null}
</div>
{article ? (
<Button asChild className="shrink-0">
<Link href={article.originalUrl} target="_blank" rel="noreferrer">
Jump to original
</Link>
</Button>
) : null}
</div>
</header>
);
}

export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className="flex min-h-full items-center justify-center px-8 py-10 lg:px-12 lg:py-12">
<div className="max-w-sm text-center">
<p className="text-sm font-medium text-foreground/68">{title}</p>
<p className="mt-2 text-sm leading-6 text-foreground/54">
{description}
</p>
</div>
</div>
);
}
Loading