Topic View Design — ATB-29#
Date: 2026-02-19 Linear: ATB-29 Status: Approved
Overview#
Implement the topic view page (GET /topics/:id) in the web app. The page displays a thread: the original post (OP) followed by all replies in chronological order, with HTMX Load More pagination and bookmarkable URLs.
Architecture#
Route#
The existing stub in apps/web/src/routes/topics.tsx is replaced. The factory signature stays the same:
export function createTopicsRoutes(appviewUrl: string): Hono
The single handler GET /topics/:id operates in two modes:
- Full page mode — renders the complete HTML page (OP + breadcrumb + initial replies)
- HTMX partial mode — detected via
HX-Requestheader; returns only the next batch of reply cards + a new Load More button
Data Fetching (full page)#
Three stages, each with independent error handling:
| Stage | Requests | Fatal? |
|---|---|---|
| 1 (parallel) | GET /api/topics/:id + session check |
Yes — 404/503/500 on failure |
| 2 (sequential) | GET /api/boards/:boardId |
No — breadcrumb degrades |
| 3 (sequential) | GET /api/categories/:categoryId |
No — breadcrumb degrades |
Bookmark support#
When ?offset=N is present on initial load, replies 0→N are fetched and rendered inline. The Load More button then fetches from N onward.
Components#
All inline in topics.tsx (no new files).
PostCard({ post, postNumber, isOP })#
Renders a single post. OP gets CSS modifier post-card--op (thicker border/larger shadow). Each card shows:
- Bold post number badge (
#1,#2, …) - Full text with
white-space: pre-wrap - Author handle (fallback to DID if null)
- Relative time via
timeAgo()
LoadMoreButton({ topicId, nextOffset })#
HTMX button that appends the next reply batch:
hx-get="/topics/:id?offset=N"
hx-swap="outerHTML"
hx-target="this"
hx-push-url="/topics/:id?offset=N"
hx-push-url advances the browser URL so each Load More click produces a bookmarkable URL.
ReplyFragment({ topicId, replies, total, offset })#
Renders a batch of PostCard reply cards followed by a LoadMoreButton when nextOffset < total.
Locked topic banner#
Rendered inline on the full page above the reply list. High-contrast neobrutal warning style. Hides the reply form slot when present.
Reply form slot#
<div id="reply-form-slot">
<!-- auth-gated: login prompt or placeholder -->
</div>
The compose forms ticket will fill this slot. For now: show "Log in to reply" for guests, placeholder text for authenticated users.
Error Handling#
Follows the pattern established in boards.tsx:
| Condition | Response |
|---|---|
| Non-numeric ID | 400 HTML page |
| Topic not found (API 404) | 404 HTML page |
| Network error | 503 HTML page |
| Other API error | 500 HTML page |
Programming error (TypeError etc.) |
Re-thrown |
| Board/category fetch failure | Non-fatal — breadcrumb degrades |
| HTMX partial error | Empty fragment (page does not break) |
Testing#
File: apps/web/src/routes/__tests__/topics.test.tsx
| Test | Verifies |
|---|---|
| Non-numeric ID | 400 |
| Topic not found | 404 HTML |
| Network error | 503 |
| AppView 500 | 500 |
| Happy path — OP only | Topic text, author, #1 badge rendered |
| Happy path — with replies | Replies rendered with #2, #3 badges |
| Locked topic | Locked banner shown, reply form hidden |
| Unauthenticated | "Log in to reply" shown |
| Authenticated | Reply form slot shown |
| Load More shown | When nextOffset < total |
| Load More hidden | When all replies loaded |
| HTMX partial | Returns reply fragment with hx-push-url |
| HTMX partial error | Empty fragment returned |
| Board fetch fails | Page renders, breadcrumb degrades |
| Category fetch fails | Page renders, breadcrumb degrades |
Bookmark URL ?offset=N |
OP + all replies 0→N rendered inline |