Topic View Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace the stub GET /topics/:id route with a full thread display page showing the OP, replies, breadcrumb, locked-topic indicator, auth-gated reply slot, and HTMX Load More pagination with bookmarkable ?offset=N URLs.
Architecture: Single route handler in apps/web/src/routes/topics.tsx with two modes — HTMX partial (returns reply fragment) and full page (returns complete HTML). Data fetches in three sequential stages: topic (fatal), board (non-fatal for breadcrumb), category (non-fatal for breadcrumb). The AppView returns all replies in one call; the web layer slices them for pagination.
Tech Stack: Hono JSX, HTMX (hx-push-url), fetchApi from lib/api.ts, getSession from lib/session.ts, error helpers from lib/errors.ts, timeAgo from lib/time.ts.
Design doc: docs/plans/2026-02-19-topic-view-design.md
Run tests with:
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
# All web tests:
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
API Shape Reference#
GET /api/topics/:id returns:
{
"topicId": "1",
"locked": false,
"pinned": false,
"post": {
"id": "1", "did": "did:plc:author", "rkey": "tid123",
"text": "This is the original post",
"forumUri": "at://...", "boardUri": "at://...", "boardId": "42",
"parentPostId": null,
"createdAt": "2025-01-01T00:00:00.000Z",
"author": { "did": "did:plc:author", "handle": "alice.bsky.social" }
},
"replies": [/* same shape as post */]
}
GET /api/boards/:id returns { id, name, description, categoryId, ... }.
GET /api/categories/:id returns { id, name, ... }.
Mock Fetch Order Reference#
The route makes fetches in this order:
Unauthenticated (no atbb_session cookie):
GET /api/topics/:idGET /api/boards/:boardIdGET /api/categories/:categoryId
Authenticated (with atbb_session= cookie):
GET /api/auth/sessionGET /api/topics/:idGET /api/boards/:boardIdGET /api/categories/:categoryId
HTMX partial (HX-Request header):
GET /api/topics/:id
Task 1: TypeScript Interfaces + Route Skeleton#
Files:
- Modify:
apps/web/src/routes/topics.tsx
Replace the current stub completely with typed interfaces and an empty handler shell that just returns 200 for now. No tests for this task — it just establishes the TypeScript types so later tasks can compile.
Step 1: Replace topics.tsx with this skeleton
import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js";
import { fetchApi } from "../lib/api.js";
import { getSession } from "../lib/session.js";
import {
isProgrammingError,
isNetworkError,
isNotFoundError,
} from "../lib/errors.js";
import { timeAgo } from "../lib/time.js";
// ─── API response types ───────────────────────────────────────────────────────
interface AuthorResponse {
did: string;
handle: string | null;
}
interface PostResponse {
id: string;
did: string;
rkey: string;
text: string;
forumUri: string | null;
boardUri: string | null;
boardId: string | null;
parentPostId: string | null;
createdAt: string | null;
author: AuthorResponse | null;
}
interface TopicDetailResponse {
topicId: string;
locked: boolean;
pinned: boolean;
post: PostResponse;
replies: PostResponse[];
}
interface BoardResponse {
id: string;
did: string;
name: string;
description: string | null;
slug: string | null;
sortOrder: number | null;
categoryId: string;
categoryUri: string | null;
createdAt: string | null;
indexedAt: string | null;
}
interface CategoryResponse {
id: string;
did: string;
name: string;
description: string | null;
slug: string | null;
sortOrder: number | null;
forumId: string | null;
createdAt: string | null;
indexedAt: string | null;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const REPLIES_PER_PAGE = 25;
// ─── Inline components ────────────────────────────────────────────────────────
function PostCard({
post,
postNumber,
isOP = false,
}: {
post: PostResponse;
postNumber: number;
isOP?: boolean;
}) {
const handle = post.author?.handle ?? post.author?.did ?? post.did;
const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown";
const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply";
return (
<div class={cardClass} id={`post-${postNumber}`}>
<div class="post-card__header">
<span class="post-card__number">#{postNumber}</span>
<span class="post-card__author">{handle}</span>
<span class="post-card__date">{date}</span>
</div>
<div class="post-card__body" style="white-space: pre-wrap">
{post.text}
</div>
</div>
);
}
function LoadMoreButton({
topicId,
nextOffset,
}: {
topicId: string;
nextOffset: number;
}) {
const url = `/topics/${topicId}?offset=${nextOffset}`;
return (
<button
hx-get={url}
hx-swap="outerHTML"
hx-target="this"
hx-push-url={url}
hx-indicator="#loading-spinner"
>
Load More
</button>
);
}
function ReplyFragment({
topicId,
replies,
total,
offset,
}: {
topicId: string;
replies: PostResponse[];
total: number;
offset: number;
}) {
const nextOffset = offset + replies.length;
const hasMore = nextOffset < total;
return (
<>
{replies.map((reply, i) => (
<PostCard key={reply.id} post={reply} postNumber={offset + i + 2} />
))}
{hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />}
</>
);
}
// ─── Route factory ────────────────────────────────────────────────────────────
export function createTopicsRoutes(appviewUrl: string) {
return new Hono().get("/topics/:id", async (c) => {
// TODO: implement in subsequent tasks
return c.html(<div>stub</div>, 200);
});
}
Step 2: Verify TypeScript compiles
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit
Expected: no errors.
Step 3: Commit
git add apps/web/src/routes/topics.tsx
git commit -m "feat(web): topic view type interfaces and component stubs (ATB-29)"
Task 2: Error Cases — 400, 404, 503, 500#
Files:
- Create:
apps/web/src/routes/__tests__/topics.test.tsx - Modify:
apps/web/src/routes/topics.tsx
Step 1: Write the failing tests
Create apps/web/src/routes/__tests__/topics.test.tsx:
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockFetch = vi.fn();
describe("createTopicsRoutes", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
mockFetch.mockResolvedValue({ ok: false, status: 401 });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
// ─── Mock response helpers ────────────────────────────────────────────────
function mockResponse(body: unknown, ok = true, status = 200) {
return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) };
}
function makeTopicResponse(overrides: Record<string, unknown> = {}) {
return {
ok: true,
json: () =>
Promise.resolve({
topicId: "1",
locked: false,
pinned: false,
post: {
id: "1",
did: "did:plc:author",
rkey: "tid123",
text: "This is the original post",
forumUri: null,
boardUri: null,
boardId: "42",
parentPostId: null,
createdAt: "2025-01-01T00:00:00.000Z",
author: { did: "did:plc:author", handle: "alice.bsky.social" },
},
replies: [],
...overrides,
}),
};
}
function makeBoardResponse(overrides: Record<string, unknown> = {}) {
return {
ok: true,
json: () =>
Promise.resolve({
id: "42",
did: "did:plc:forum",
name: "General Discussion",
description: null,
slug: null,
sortOrder: 1,
categoryId: "7",
categoryUri: null,
createdAt: null,
indexedAt: null,
...overrides,
}),
};
}
function makeCategoryResponse(overrides: Record<string, unknown> = {}) {
return {
ok: true,
json: () =>
Promise.resolve({
id: "7",
did: "did:plc:forum",
name: "Main Category",
description: null,
slug: null,
sortOrder: 1,
forumId: "1",
createdAt: null,
indexedAt: null,
...overrides,
}),
};
}
/**
* Sets up the standard 3-fetch sequence for a successful unauthenticated request:
* 1. GET /api/topics/1
* 2. GET /api/boards/42
* 3. GET /api/categories/7
*/
function setupSuccessfulFetch(topicOverrides: Record<string, unknown> = {}) {
mockFetch.mockResolvedValueOnce(makeTopicResponse(topicOverrides));
mockFetch.mockResolvedValueOnce(makeBoardResponse());
mockFetch.mockResolvedValueOnce(makeCategoryResponse());
}
async function loadTopicsRoutes() {
const { createTopicsRoutes } = await import("../topics.js");
return createTopicsRoutes("http://localhost:3000");
}
// ─── Error cases ──────────────────────────────────────────────────────────
it("returns 400 for non-numeric topic ID", async () => {
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/not-a-number");
expect(res.status).toBe(400);
const html = await res.text();
expect(res.headers.get("content-type")).toContain("text/html");
expect(html).toContain("Invalid");
});
it("returns 404 when topic not found", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain("Not Found");
});
it("returns 503 on network error", async () => {
mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed"));
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(503);
const html = await res.text();
expect(html).toContain("unavailable");
});
it("returns 500 on AppView server error", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(500);
const html = await res.text();
expect(html).toContain("went wrong");
});
it("re-throws TypeError (programming error)", async () => {
mockFetch.mockResolvedValueOnce(mockResponse(null)); // null — accessing null.post throws TypeError
mockFetch.mockResolvedValueOnce(makeBoardResponse());
mockFetch.mockResolvedValueOnce(makeCategoryResponse());
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(500);
expect(res.headers.get("content-type")).not.toContain("text/html");
});
});
Step 2: Run tests — verify they fail
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all 5 tests FAIL (the stub returns 200 for everything).
Step 3: Implement error handling in createTopicsRoutes
Replace the // TODO: implement stub body with:
export function createTopicsRoutes(appviewUrl: string) {
return new Hono().get("/topics/:id", async (c) => {
const idParam = c.req.param("id");
const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw;
// ── Validate ID ──────────────────────────────────────────────────────────
if (!/^\d+$/.test(idParam)) {
if (c.req.header("HX-Request")) {
return c.html("", 200);
}
return c.html(
<BaseLayout title="Bad Request — atBB Forum">
<ErrorDisplay message="Invalid topic ID." />
</BaseLayout>,
400
);
}
const topicId = idParam;
// ── HTMX partial mode ────────────────────────────────────────────────────
if (c.req.header("HX-Request")) {
try {
const data = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`);
const pageReplies = data.replies.slice(offset, offset + REPLIES_PER_PAGE);
return c.html(
<ReplyFragment
topicId={topicId}
replies={pageReplies}
total={data.replies.length}
offset={offset}
/>,
200
);
} catch (error) {
if (isProgrammingError(error)) throw error;
console.error("Failed to load replies for HTMX partial request", {
operation: "GET /topics/:id (HTMX partial)",
topicId,
offset,
error: error instanceof Error ? error.message : String(error),
});
return c.html("", 200);
}
}
// ── Full page mode ────────────────────────────────────────────────────────
const auth = await getSession(appviewUrl, c.req.header("cookie"));
// Stage 1: fetch topic (fatal on failure)
let topicData: TopicDetailResponse;
try {
topicData = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`);
} catch (error) {
if (isProgrammingError(error)) throw error;
if (isNotFoundError(error)) {
return c.html(
<BaseLayout title="Not Found — atBB Forum" auth={auth}>
<ErrorDisplay message="This topic doesn't exist." />
</BaseLayout>,
404
);
}
console.error("Failed to load topic page (stage 1: topic)", {
operation: "GET /topics/:id",
topicId,
error: error instanceof Error ? error.message : String(error),
});
const status = isNetworkError(error) ? 503 : 500;
const message =
status === 503
? "The forum is temporarily unavailable. Please try again later."
: "Something went wrong loading this topic. Please try again later.";
return c.html(
<BaseLayout title="Error — atBB Forum" auth={auth}>
<ErrorDisplay message={message} />
</BaseLayout>,
status
);
}
// Stage 2: fetch board for breadcrumb (non-fatal)
let boardName: string | null = null;
let categoryId: string | null = null;
if (topicData.post.boardId) {
try {
const board = await fetchApi<BoardResponse>(`/boards/${topicData.post.boardId}`);
boardName = board.name;
categoryId = board.categoryId;
} catch (error) {
if (isProgrammingError(error)) throw error;
console.error("Failed to load topic page (stage 2: board)", {
operation: "GET /topics/:id",
topicId,
boardId: topicData.post.boardId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Stage 3: fetch category for breadcrumb (non-fatal)
let categoryName: string | null = null;
if (categoryId) {
try {
const category = await fetchApi<CategoryResponse>(`/categories/${categoryId}`);
categoryName = category.name;
} catch (error) {
if (isProgrammingError(error)) throw error;
console.error("Failed to load topic page (stage 3: category)", {
operation: "GET /topics/:id",
topicId,
categoryId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Pagination: show replies from 0 to offset+REPLIES_PER_PAGE (bookmark support)
const allReplies = topicData.replies;
const displayCount = offset + REPLIES_PER_PAGE;
const initialReplies = allReplies.slice(0, displayCount);
const total = allReplies.length;
const topicTitle = topicData.post.text.slice(0, 60);
return c.html(
<BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}>
<nav class="breadcrumb">
<a href="/">Home</a>
{categoryName && (
<>
{" / "}
<a href="/">{categoryName}</a>
</>
)}
{boardName && (
<>
{" / "}
<a href={`/boards/${topicData.post.boardId}`}>{boardName}</a>
</>
)}
{" / "}
<span>{topicTitle}</span>
</nav>
<PageHeader title={topicTitle} />
{topicData.locked && (
<div class="topic-locked-banner">
<span class="topic-locked-banner__badge">Locked</span>
This topic is locked.
</div>
)}
<PostCard post={topicData.post} postNumber={1} isOP={true} />
<div id="reply-list">
{allReplies.length === 0 ? (
<EmptyState message="No replies yet." />
) : (
<ReplyFragment
topicId={topicId}
replies={initialReplies}
total={total}
offset={0}
/>
)}
</div>
<div id="reply-form-slot">
{topicData.locked ? (
<p>This topic is locked. Replies are disabled.</p>
) : auth?.authenticated ? (
<p>Reply form coming soon.</p>
) : (
<p>
<a href="/login">Log in</a> to reply.
</p>
)}
</div>
</BaseLayout>
);
});
}
Step 4: Run tests — verify they pass
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all 5 tests PASS.
Step 5: Commit
git add apps/web/src/routes/topics.tsx apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "feat(web): topic view error handling with 400/404/503/500 (ATB-29)"
Task 3: Happy Path — OP Renders#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
Add tests in the same describe block for OP rendering. The implementation already exists from Task 2.
Step 1: Add tests
Add inside the describe("createTopicsRoutes") block:
// ─── Happy path — OP ──────────────────────────────────────────────────────
it("renders OP text content", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("This is the original post");
});
it("renders OP author handle", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("alice.bsky.social");
});
it("renders OP with post number #1", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("#1");
});
it("renders OP with post-card--op CSS class", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("post-card--op");
});
it("renders page title with truncated topic text", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("This is the original post — atBB Forum");
});
it("falls back to DID when author handle is null", async () => {
setupSuccessfulFetch({
post: {
id: "1", did: "did:plc:author", rkey: "tid123",
text: "No handle post", forumUri: null, boardUri: null,
boardId: "42", parentPostId: null,
createdAt: "2025-01-01T00:00:00.000Z",
author: { did: "did:plc:author", handle: null },
},
});
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("did:plc:author");
});
it("shows empty state when topic has no replies", async () => {
setupSuccessfulFetch({ replies: [] });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("No replies yet");
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS (implementation is already complete).
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view happy path — OP rendering (ATB-29)"
Task 4: Breadcrumb with Graceful Degradation#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
Step 1: Add breadcrumb tests
// ─── Breadcrumb ───────────────────────────────────────────────────────────
it("renders full breadcrumb: Home / Category / Board / Topic", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("breadcrumb");
expect(html).toContain("Main Category");
expect(html).toContain("General Discussion");
expect(html).toContain("This is the original post");
});
it("renders page without board/category in breadcrumb when board fetch fails", async () => {
mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // board fails
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("This is the original post"); // page renders
expect(html).not.toContain("General Discussion"); // board name absent
expect(html).not.toContain("Main Category"); // category absent
});
it("renders page without category in breadcrumb when category fetch fails", async () => {
mockFetch.mockResolvedValueOnce(makeTopicResponse()); // topic OK
mockFetch.mockResolvedValueOnce(makeBoardResponse()); // board OK
mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); // category fails
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("General Discussion"); // board present
expect(html).not.toContain("Main Category"); // category absent
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view breadcrumb and graceful degradation (ATB-29)"
Task 5: Replies Render Correctly#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
Step 1: Add reply rendering tests
// ─── Replies ─────────────────────────────────────────────────────────────
function makeReply(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: "2",
did: "did:plc:reply-author",
rkey: "tid456",
text: "This is a reply",
forumUri: null,
boardUri: null,
boardId: "42",
parentPostId: "1",
createdAt: "2025-01-02T00:00:00.000Z",
author: { did: "did:plc:reply-author", handle: "bob.bsky.social" },
...overrides,
};
}
it("renders reply text and author", async () => {
setupSuccessfulFetch({ replies: [makeReply()] });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("This is a reply");
expect(html).toContain("bob.bsky.social");
});
it("renders reply with post number #2", async () => {
setupSuccessfulFetch({ replies: [makeReply()] });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("#2");
});
it("renders multiple replies with sequential post numbers", async () => {
setupSuccessfulFetch({
replies: [
makeReply({ id: "2", text: "First reply" }),
makeReply({ id: "3", text: "Second reply" }),
makeReply({ id: "4", text: "Third reply" }),
],
});
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("#2");
expect(html).toContain("#3");
expect(html).toContain("#4");
expect(html).toContain("First reply");
expect(html).toContain("Second reply");
expect(html).toContain("Third reply");
});
it("renders reply with post-card--reply CSS class (not OP class)", async () => {
setupSuccessfulFetch({ replies: [makeReply()] });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("post-card--reply");
// OP class appears once (for the OP), not on replies
const opOccurrences = (html.match(/post-card--op/g) ?? []).length;
expect(opOccurrences).toBe(1);
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view reply rendering (ATB-29)"
Task 6: Locked Topic Indicator + Auth-Gated Reply Slot#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
Step 1: Add locked + auth tests
// ─── Locked topic ─────────────────────────────────────────────────────────
it("shows locked banner when topic is locked", async () => {
setupSuccessfulFetch({ locked: true });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("topic-locked-banner");
expect(html).toContain("Locked");
expect(html).toContain("This topic is locked");
});
it("does not show locked banner when topic is unlocked", async () => {
setupSuccessfulFetch({ locked: false });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).not.toContain("topic-locked-banner");
});
it("hides reply form and shows locked message when topic is locked", async () => {
setupSuccessfulFetch({ locked: true });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("locked");
expect(html).toContain("Replies are disabled");
expect(html).not.toContain("Log in to reply");
expect(html).not.toContain("Reply form coming soon");
});
// ─── Auth-gated reply slot ────────────────────────────────────────────────
it("shows 'Log in to reply' for unauthenticated users", async () => {
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1"); // no cookie
const html = await res.text();
expect(html).toContain("Log in");
expect(html).toContain("to reply");
expect(html).not.toContain("Reply form coming soon");
});
it("shows reply form slot for authenticated users", async () => {
// Auth fetch (session check) runs first since cookie is present
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
authenticated: true,
did: "did:plc:user",
handle: "user.bsky.social",
}),
});
setupSuccessfulFetch();
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Reply form coming soon");
expect(html).not.toContain("Log in");
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view locked indicator and auth-gated reply slot (ATB-29)"
Task 7: Load More Pagination#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
The Load More button appears when total > REPLIES_PER_PAGE (25). The first page shows replies 0–24.
Step 1: Add pagination tests
// ─── Load More pagination ─────────────────────────────────────────────────
function makeReplies(count: number): Record<string, unknown>[] {
return Array.from({ length: count }, (_, i) => makeReply({ id: String(i + 2), text: `Reply ${i + 1}` }));
}
it("shows Load More button when more replies remain", async () => {
setupSuccessfulFetch({ replies: makeReplies(30) }); // 30 replies, page size 25
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("Load More");
expect(html).toContain("hx-get");
});
it("hides Load More button when all replies fit on one page", async () => {
setupSuccessfulFetch({ replies: makeReplies(10) }); // 10 replies, fits in 25
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).not.toContain("Load More");
});
it("Load More button URL contains correct next offset", async () => {
setupSuccessfulFetch({ replies: makeReplies(30) });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
// After 25 replies are shown, next offset is 25
expect(html).toContain("offset=25");
});
it("Load More button has hx-push-url for bookmarkable URL", async () => {
setupSuccessfulFetch({ replies: makeReplies(30) });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1");
const html = await res.text();
expect(html).toContain("hx-push-url");
expect(html).toContain("/topics/1?offset=25");
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view Load More pagination with hx-push-url (ATB-29)"
Task 8: Bookmark Support (?offset=N)#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
When visiting /topics/1?offset=25, the page should render the OP plus replies 0–49 inline (all previously-seen content), with Load More pointing to offset=50.
Step 1: Add bookmark tests
// ─── Bookmark support ─────────────────────────────────────────────────────
it("renders OP + all replies up to offset+pageSize when offset is set (bookmark)", async () => {
setupSuccessfulFetch({ replies: makeReplies(60) }); // 60 replies total
const routes = await loadTopicsRoutes();
// Bookmark at offset=25 — should render replies 0..49 (50 replies) inline
const res = await routes.request("/topics/1?offset=25");
const html = await res.text();
// Reply 1 and Reply 50 should both be present (0-indexed: replies[0] and replies[49])
expect(html).toContain("Reply 1");
expect(html).toContain("Reply 50");
// Reply 51 should not be present (beyond the bookmark range)
expect(html).not.toContain("Reply 51");
});
it("Load More after bookmark points to correct next offset", async () => {
setupSuccessfulFetch({ replies: makeReplies(60) });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1?offset=25");
const html = await res.text();
// After showing 0..49, Load More should point to offset=50
expect(html).toContain("offset=50");
});
it("ignores negative offset values (treats as 0)", async () => {
setupSuccessfulFetch({ replies: makeReplies(30) });
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1?offset=-5");
const html = await res.text();
expect(res.status).toBe(200);
// Should behave like offset=0 (show first 25 replies)
expect(html).toContain("Reply 1");
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view bookmark support with ?offset=N (ATB-29)"
Task 9: HTMX Partial Mode#
Files:
- Modify:
apps/web/src/routes/__tests__/topics.test.tsx
HTMX partial requests use the HX-Request header. The server fetches the full topic and slices the requested page of replies.
Step 1: Add HTMX partial tests
// ─── HTMX partial mode ────────────────────────────────────────────────────
it("HTMX partial returns reply fragment at given offset", async () => {
// HTMX partial: only 1 fetch (GET /api/topics/:id)
mockFetch.mockResolvedValueOnce(
makeTopicResponse({
replies: [
makeReply({ id: "26", text: "HTMX loaded reply" }),
makeReply({ id: "27", text: "Another HTMX reply" }),
],
})
);
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1?offset=25", {
headers: { "HX-Request": "true" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("HTMX loaded reply");
expect(html).toContain("Another HTMX reply");
});
it("HTMX partial includes hx-push-url on Load More button", async () => {
// 30 replies total, requesting offset=0 → should show 0..24 and Load More at 25
mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: makeReplies(30) }));
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1?offset=0", {
headers: { "HX-Request": "true" },
});
const html = await res.text();
expect(html).toContain("hx-push-url");
expect(html).toContain("/topics/1?offset=25");
});
it("HTMX partial returns empty fragment on error", async () => {
mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed"));
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/1?offset=25", {
headers: { "HX-Request": "true" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html.trim()).toBe("");
});
it("HTMX partial returns empty fragment for invalid topic ID", async () => {
const routes = await loadTopicsRoutes();
const res = await routes.request("/topics/not-a-number", {
headers: { "HX-Request": "true" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html.trim()).toBe("");
});
Step 2: Run tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/routes/__tests__/topics.test.tsx
Expected: all new tests PASS.
Step 3: Commit
git add apps/web/src/routes/__tests__/topics.test.tsx
git commit -m "test(web): topic view HTMX partial mode (ATB-29)"
Task 10: Full Test Suite Pass + Update Linear#
Step 1: Run all web tests
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: all tests PASS (no skips, no failures).
Step 2: Run full monorepo tests
PATH=.devenv/profile/bin:$PATH pnpm test
Expected: all tests PASS.
Step 3: Typecheck
PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit
Expected: no errors.
Step 4: Update Linear issue ATB-29 to In Progress
Change status to "In Progress" in Linear.
Step 5: Final commit
git add .
git commit -m "feat(web): topic view thread display with replies (ATB-29)
- GET /topics/:id renders OP + replies with post number badges
- Breadcrumb: Home → Category → Board → Topic (degrades gracefully)
- Locked topic banner + disabled reply slot
- HTMX Load More with hx-push-url for bookmarkable URLs
- ?offset=N bookmark support renders all replies 0→N inline
- Auth-gated reply form slot placeholder
- Full error handling: 400/404/503/500
- 30 tests covering all acceptance criteria"