WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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):

  1. GET /api/topics/:id
  2. GET /api/boards/:boardId
  3. GET /api/categories/:categoryId

Authenticated (with atbb_session= cookie):

  1. GET /api/auth/session
  2. GET /api/topics/:id
  3. GET /api/boards/:boardId
  4. GET /api/categories/:categoryId

HTMX partial (HX-Request header):

  1. 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"