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.

Forum Homepage Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the placeholder homepage with a live forum index that fetches and displays forum metadata, categories, and their boards from the AppView API.

Architecture: Server-side rendering in two parallel stages — first fetch forum + categories, then fan out to fetch boards for each category in parallel via Promise.all. Any fetch failure renders the full page as an error response. No HTMX partials needed.

Tech Stack: Hono JSX (server-rendered HTML), fetchApi() helper, existing Card/EmptyState/ErrorDisplay/PageHeader components, CSS custom properties (var(--token))


Reference#

  • Design doc: docs/plans/2026-02-18-homepage-design.md
  • fetchApi() helper: apps/web/src/lib/api.ts — throws "AppView network error: ..." or "AppView API error: N ..."
  • Existing components: apps/web/src/components/index.ts
  • CSS tokens: apps/web/src/styles/presets/neobrutal-light.ts
  • Theme stylesheet: apps/web/public/static/css/theme.css
  • Test pattern: see apps/web/src/routes/__tests__/login.test.tsx — mock fetch globally, stub APPVIEW_URL env var, dynamic-import the route module, test HTML content

API Response Shapes#

// GET /api/forum
{ id: string, did: string, name: string, description: string | null, indexedAt: string }

// GET /api/categories
{ categories: Array<{ id: string, did: string, name: string, description: string | null, slug: string | null, sortOrder: number | null, forumId: string, createdAt: string, indexedAt: string }> }

// GET /api/categories/:id/boards
{ boards: Array<{ id: string, did: string, name: string, description: string | null, slug: string | null, sortOrder: number | null, categoryId: string | null, categoryUri: string, createdAt: string, indexedAt: string }> }

Error Classification Helper#

fetchApi() throws with a message prefix. Use this check in the route handler:

function isNetworkError(error: unknown): boolean {
  return error instanceof Error &&
    error.message.startsWith("AppView network error:");
}

Task 1: Add CSS for homepage layout#

Files:

  • Modify: apps/web/public/static/css/theme.css (append at end)

Step 1: Add three new CSS rule blocks

Append to the end of theme.css:

/* ─── Homepage ──────────────────────────────────────────────────────────── */

.category-section {
  margin-bottom: var(--space-xl);
}

.category-header {
  margin: 0 0 var(--space-md) 0;
  font-size: var(--font-size-lg);
  padding-bottom: var(--space-sm);
  border-bottom: var(--border-width) solid var(--color-border);
}

.board-grid {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.board-card {
  display: block;
  text-decoration: none;
  color: inherit;
}

.board-card:hover .card {
  transform: translate(-2px, -2px);
  box-shadow: 8px 8px 0 var(--color-shadow);
}

.board-card__name {
  font-weight: var(--font-weight-bold);
  font-size: var(--font-size-base);
  margin: 0 0 var(--space-xs) 0;
}

.board-card__description {
  margin: 0;
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
}

Step 2: Verify by checking the file ends with those classes

No build step needed — CSS is served as a static file.

Step 3: Commit

git add apps/web/public/static/css/theme.css
git commit -m "style: add homepage category and board grid CSS"

Task 2: Write failing tests for the homepage#

Files:

  • Create: apps/web/src/routes/__tests__/home.test.tsx

Step 1: Create the test file with all failing tests

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const mockFetch = vi.fn();

describe("createHomeRoutes", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  // Helper: build a mock fetch response
  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  // Sets up the standard 3-call sequence:
  // 1. session check (unauthenticated by default)
  // 2. GET /api/forum
  // 3. GET /api/categories
  // 4+. GET /api/categories/:id/boards (one per category)
  function setupSuccessfulFetch(options: {
    forum?: { name: string; description: string | null };
    categories?: Array<{ id: string; name: string; description?: string | null }>;
    boardsPerCategory?: Record<string, Array<{ id: string; name: string; description?: string | null }>>;
  } = {}) {
    const forum = options.forum ?? { name: "Test Forum", description: "A test forum." };
    const categories = options.categories ?? [
      { id: "1", name: "General", description: "General chat" },
    ];
    const boardsPerCategory = options.boardsPerCategory ?? {
      "1": [{ id: "10", name: "Introductions", description: "Say hello!" }],
    };

    // Call 1: session (unauthenticated)
    mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
    // Call 2: GET /api/forum
    mockFetch.mockResolvedValueOnce(mockResponse({
      id: "1",
      did: "did:plc:forum",
      name: forum.name,
      description: forum.description,
      indexedAt: "2025-01-01T00:00:00.000Z",
    }));
    // Call 3: GET /api/categories
    mockFetch.mockResolvedValueOnce(mockResponse({
      categories: categories.map(c => ({
        id: c.id,
        did: "did:plc:forum",
        name: c.name,
        description: c.description ?? null,
        slug: null,
        sortOrder: 1,
        forumId: "1",
        createdAt: "2025-01-01T00:00:00.000Z",
        indexedAt: "2025-01-01T00:00:00.000Z",
      })),
    }));
    // Call 4+: boards for each category
    for (const cat of categories) {
      const boards = boardsPerCategory[cat.id] ?? [];
      mockFetch.mockResolvedValueOnce(mockResponse({
        boards: boards.map(b => ({
          id: b.id,
          did: "did:plc:forum",
          name: b.name,
          description: b.description ?? null,
          slug: null,
          sortOrder: 1,
          categoryId: cat.id,
          categoryUri: `at://did:plc:forum/space.atbb.forum.category/${cat.id}`,
          createdAt: "2025-01-01T00:00:00.000Z",
          indexedAt: "2025-01-01T00:00:00.000Z",
        })),
      }));
    }
  }

  async function loadHomeRoutes() {
    const { createHomeRoutes } = await import("../home.js");
    return createHomeRoutes("http://localhost:3000");
  }

  it("returns 200 with forum name in page title", async () => {
    setupSuccessfulFetch({ forum: { name: "My Forum", description: null } });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("My Forum — atBB Forum");
  });

  it("renders forum name in page header", async () => {
    setupSuccessfulFetch({ forum: { name: "My Forum", description: null } });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("My Forum");
  });

  it("renders forum description in page header", async () => {
    setupSuccessfulFetch({ forum: { name: "My Forum", description: "Welcome to my forum!" } });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("Welcome to my forum!");
  });

  it("renders category section header", async () => {
    setupSuccessfulFetch({
      categories: [{ id: "1", name: "General Discussion" }],
    });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("General Discussion");
    expect(html).toContain("category-header");
  });

  it("renders board card with name and link", async () => {
    setupSuccessfulFetch({
      categories: [{ id: "1", name: "General" }],
      boardsPerCategory: {
        "1": [{ id: "42", name: "Introductions" }],
      },
    });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("Introductions");
    expect(html).toContain('href="/boards/42"');
  });

  it("renders board description when present", async () => {
    setupSuccessfulFetch({
      categories: [{ id: "1", name: "General" }],
      boardsPerCategory: {
        "1": [{ id: "42", name: "Introductions", description: "Say hello to everyone!" }],
      },
    });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("Say hello to everyone!");
  });

  it("shows empty state when there are no categories", async () => {
    setupSuccessfulFetch({ categories: [] });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("No categories yet");
  });

  it("shows per-category empty state when category has no boards", async () => {
    setupSuccessfulFetch({
      categories: [{ id: "1", name: "Empty Category" }],
      boardsPerCategory: { "1": [] },
    });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("Empty Category");
    expect(html).toContain("No boards in this category yet");
  });

  it("returns 503 and error display on AppView network error", async () => {
    // session check
    mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
    // forum fetch throws network error
    mockFetch.mockRejectedValueOnce(new Error("AppView network error: fetch failed"));
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
    expect(html).toContain("unavailable");
  });

  it("returns 500 and error display on AppView API error", async () => {
    // session check
    mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
    // forum fetch returns API error
    mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("renders multiple categories with their boards", async () => {
    setupSuccessfulFetch({
      categories: [
        { id: "1", name: "Announcements" },
        { id: "2", name: "General" },
      ],
      boardsPerCategory: {
        "1": [{ id: "10", name: "News" }],
        "2": [{ id: "20", name: "Off Topic" }, { id: "21", name: "Introductions" }],
      },
    });
    const routes = await loadHomeRoutes();
    const res = await routes.request("/");
    const html = await res.text();
    expect(html).toContain("Announcements");
    expect(html).toContain("General");
    expect(html).toContain("News");
    expect(html).toContain("Off Topic");
    expect(html).toContain("Introductions");
    expect(html).toContain('href="/boards/10"');
    expect(html).toContain('href="/boards/20"');
  });
});

Step 2: Run tests to verify they all fail

cd /path/to/repo
PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx

Expected: All tests FAIL with "createHomeRoutes is not a function" or similar (route not implemented yet).

Step 3: Commit the failing tests

git add apps/web/src/routes/__tests__/home.test.tsx
git commit -m "test: add failing tests for homepage route (ATB-27)"

Task 3: Implement the homepage route#

Files:

  • Modify: apps/web/src/routes/home.tsx

Step 1: Replace the placeholder implementation

import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import {
  PageHeader,
  EmptyState,
  ErrorDisplay,
  Card,
} from "../components/index.js";
import { getSession } from "../lib/session.js";
import { fetchApi } from "../lib/api.js";

// API response type shapes
interface ForumResponse {
  id: string;
  did: string;
  name: string;
  description: string | null;
  indexedAt: string;
}

interface CategoryResponse {
  id: string;
  did: string;
  name: string;
  description: string | null;
  slug: string | null;
  sortOrder: number | null;
}

interface BoardResponse {
  id: string;
  did: string;
  name: string;
  description: string | null;
  slug: string | null;
  sortOrder: number | null;
}

interface CategoriesListResponse {
  categories: CategoryResponse[];
}

interface BoardsListResponse {
  boards: BoardResponse[];
}

function isNetworkError(error: unknown): boolean {
  return (
    error instanceof Error &&
    error.message.startsWith("AppView network error:")
  );
}

export function createHomeRoutes(appviewUrl: string) {
  return new Hono().get("/", async (c) => {
    const auth = await getSession(appviewUrl, c.req.header("cookie"));

    // Stage 1: fetch forum metadata and category list in parallel
    let forum: ForumResponse;
    let categories: CategoryResponse[];
    try {
      const [forumData, categoriesData] = await Promise.all([
        fetchApi<ForumResponse>("/forum"),
        fetchApi<CategoriesListResponse>("/categories"),
      ]);
      forum = forumData;
      categories = categoriesData.categories;
    } catch (error) {
      const status = isNetworkError(error) ? 503 : 500;
      const message =
        status === 503
          ? "The forum is temporarily unavailable. Please try again later."
          : "Something went wrong loading the forum. Please try again later.";
      return c.html(
        <BaseLayout title="Error — atBB Forum" auth={auth}>
          <ErrorDisplay message={message} />
        </BaseLayout>,
        status
      );
    }

    // Stage 2: fetch boards for each category in parallel
    let boardsByCategory: BoardResponse[][];
    try {
      boardsByCategory = await Promise.all(
        categories.map((cat) =>
          fetchApi<BoardsListResponse>(`/categories/${cat.id}/boards`).then(
            (data) => data.boards
          )
        )
      );
    } catch (error) {
      const status = isNetworkError(error) ? 503 : 500;
      const message =
        status === 503
          ? "The forum is temporarily unavailable. Please try again later."
          : "Something went wrong loading the forum. Please try again later.";
      return c.html(
        <BaseLayout title="Error — atBB Forum" auth={auth}>
          <ErrorDisplay message={message} />
        </BaseLayout>,
        status
      );
    }

    // Build category+boards pairs for rendering
    const categorySections = categories.map((cat, i) => ({
      category: cat,
      boards: boardsByCategory[i] ?? [],
    }));

    return c.html(
      <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}>
        <PageHeader title={forum.name} description={forum.description ?? undefined} />
        {categorySections.length === 0 ? (
          <EmptyState message="No categories yet." />
        ) : (
          categorySections.map(({ category, boards }) => (
            <section class="category-section" key={category.id}>
              <h2 class="category-header">{category.name}</h2>
              <div class="board-grid">
                {boards.length === 0 ? (
                  <EmptyState message="No boards in this category yet." />
                ) : (
                  boards.map((board) => (
                    <a href={`/boards/${board.id}`} class="board-card" key={board.id}>
                      <Card>
                        <p class="board-card__name">{board.name}</p>
                        {board.description && (
                          <p class="board-card__description">{board.description}</p>
                        )}
                      </Card>
                    </a>
                  ))
                )}
              </div>
            </section>
          ))
        )}
      </BaseLayout>
    );
  });
}

Step 2: Run the tests to verify they pass

PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/home.test.tsx

Expected: All 11 tests PASS.

Step 3: Commit the implementation

git add apps/web/src/routes/home.tsx
git commit -m "feat: implement forum homepage with live API data (ATB-27)"

Task 4: Update the stubs test for the new homepage#

The existing stubs.test.tsx has a GET / test that will now fail because the homepage makes 3+ API calls but the test only mocks 1 (the session check).

Files:

  • Modify: apps/web/src/routes/__tests__/stubs.test.tsx

Step 1: Run the stubs test first to see the current failure

PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test src/routes/__tests__/stubs.test.tsx

Step 2: Update the GET / tests in stubs.test.tsx

Find the two existing GET / tests ("GET / returns 200 with home title" and "GET / shows handle in header when authenticated") and update them to mock all needed fetch calls.

Replace both tests with:

  it("GET / returns 200 with home title", async () => {
    // session: unauthenticated
    mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
    // GET /api/forum
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }),
    });
    // GET /api/categories
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ categories: [] }),
    });
    const { createHomeRoutes } = await import("../home.js");
    const routes = createHomeRoutes("http://localhost:3000");
    const res = await routes.request("/");
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("Test Forum — atBB Forum");
  });

  it("GET / shows handle in header when authenticated", async () => {
    // session: authenticated
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
    });
    // GET /api/forum
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: null, indexedAt: "2025-01-01T00:00:00.000Z" }),
    });
    // GET /api/categories
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ categories: [] }),
    });
    const { createHomeRoutes } = await import("../home.js");
    const routes = createHomeRoutes("http://localhost:3000");
    const res = await routes.request("/", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("alice.bsky.social");
    expect(html).toContain("Log out");
    expect(html).not.toContain('href="/login"');
  });

Step 3: Run all web tests to verify everything passes

PATH=".devenv/profile/bin:$PATH" pnpm --filter @atbb/web test

Expected: All tests PASS.

Step 4: Commit

git add apps/web/src/routes/__tests__/stubs.test.tsx
git commit -m "test: update stubs test to mock homepage API calls"

Task 5: Run full test suite and verify#

Step 1: Run the full test suite

PATH=".devenv/profile/bin:$PATH" pnpm test

Expected: All tests PASS across all packages.

Step 2: Do a type check

PATH=".devenv/profile/bin:$PATH" pnpm turbo lint

Expected: No type errors.

Step 3: If all green, update Linear

Mark ATB-27 as Done in Linear. Add a comment noting:

  • Homepage now fetches live data from /api/forum, /api/categories, and /api/categories/:id/boards
  • Server-side rendered, no HTMX partials needed for MVP
  • Topic counts deferred (not available in API response)

Done#

The homepage now:

  • Fetches live forum name and description from AppView
  • Displays all categories as section headers
  • Nests boards under each category as clickable cards linking to /boards/:id
  • Shows empty states for no categories and no boards per category
  • Returns 503 on network errors, 500 on API errors