Compose Forms Design — ATB-31#
Date: 2026-02-19 Issue: ATB-31 Status: Approved
Summary#
Add HTMX-powered compose forms for new topic creation and replies. The web server acts as a proxy between the browser and the AppView JSON API, forwarding session cookies and returning either redirect headers (success) or HTML error fragments (failure).
Architecture#
The web server (apps/web) proxies all write operations to the AppView, the same pattern established by the logout handler (auth.ts). HTMX forms post to web server endpoints; the web server calls the AppView JSON API with the forwarded session cookie and returns an HTMX-appropriate response.
HTMX form → web server POST handler → AppView JSON API (with Cookie)
↓ success ↓ 201
HX-Redirect header { uri, cid, rkey }
↓ error
error HTML fragment (swapped into #form-error)
The AppView remains a pure JSON API. The web server owns HTML rendering.
Files Changed#
| File | Change |
|---|---|
apps/web/src/routes/new-topic.tsx |
Replace stub: add form rendering (GET) + proxy handler (POST) |
apps/web/src/routes/topics.tsx |
Replace #reply-form-slot placeholder; add POST /topics/:id/reply handler |
apps/web/src/routes/boards.tsx |
Add flash banner when ?posted=1 query param present |
No new files. routes/index.ts needs no changes (both route files already registered).
New Topic Form#
GET /new-topic?boardId=X#
- Validate
boardIdquery param (must be numeric). Missing or invalid → redirect to/. - Fetch board data from AppView (
GET /api/boards/:id) to obtain the AT URI (boardUri). - Board not found → 404 error page.
- Render full-page form with:
- Textarea (name=
text, required, 1–300 graphemes) - Hidden input (name=
boardUri, value=board's AT URI) - Character counter (see below)
- Submit button with loading state
- Textarea (name=
POST /new-topic#
Reads text and boardUri from the URL-encoded form body. Forwards to AppView POST /api/topics as JSON with the session cookie.
| AppView response | Web server action |
|---|---|
| 201 | HX-Redirect: /boards/:boardId?posted=1 |
| 400 | Error HTML: validation message |
| 401 | Error HTML: "You must be logged in to post." |
| 403 | Error HTML: "You are banned from this forum." |
| 503 | Error HTML: "Forum temporarily unavailable. Please try again." |
| 500 / other | Error HTML: "Something went wrong. Please try again." |
The boardId for the success redirect is extracted from the boardUri (the numeric ID is a query param from the originating board page, passed through the form as a second hidden field).
Board Flash Banner#
GET /boards/:id detects ?posted=1 and renders a dismissible success notice above the topic list: "Your topic has been posted. It will appear shortly."
Reply Form#
Placement#
The existing #reply-form-slot div in topics.tsx receives the actual form. Conditional rendering:
- Topic locked → "This topic is locked. Replies are disabled."
- Unauthenticated → "Log in to reply." link
- Authenticated → reply form
Form Fields#
- Textarea (name=
text, required) - Hidden input (name=
rootPostId, value=topicId) - Hidden input (name=
parentPostId, value=topicId — flat replies default to root)
POST /topics/:id/reply#
Handler added to createTopicsRoutes. Reads text from form body; derives rootPostId and parentPostId from URL param :id. Forwards to AppView POST /api/posts as JSON with the session cookie.
| AppView response | Web server action |
|---|---|
| 201 | HX-Redirect: /topics/:id (page reloads; reply appears once indexed) |
| 400 | Error HTML: validation message |
| 401 | Error HTML: login prompt |
| 403 (banned) | Error HTML: "You are banned." |
| 403 (locked) | Error HTML: "This topic is locked." |
| 503 | Error HTML: "Forum temporarily unavailable. Please try again." |
| 500 / other | Error HTML: "Something went wrong. Please try again." |
HTMX Integration#
<form
hx-post="/new-topic"
hx-target="#form-error"
hx-swap="innerHTML"
hx-indicator="#spinner"
hx-disabled-elt="[type=submit]"
>
...
<div id="form-error"></div>
<button type="submit">Post Topic</button>
<span id="spinner" class="htmx-indicator">Posting…</span>
</form>
hx-target="#form-error"+hx-swap="innerHTML"— error HTML swaps into the error divhx-disabled-elt="[type=submit]"— disables submit during inflight to prevent double-submissionhx-indicator="#spinner"— shows spinner during request- Success: web server responds with
HX-Redirectheader; HTMX does full browser navigation
Character Counter#
Inlined <script> on each form page using Intl.Segmenter for grapheme-accurate counting (matches AppView server-side validation):
function updateCharCount(el) {
const seg = new Intl.Segmenter();
const n = [...seg.segment(el.value)].length;
const counter = document.getElementById("char-count");
counter.textContent = (300 - n) + " left";
counter.dataset.over = n > 300 ? "true" : "false";
}
Server-side validation is the authority. The counter is UX-only.
Testing#
- New topic form renders with correct hidden
boardUrifield - New topic form renders login prompt when unauthenticated
- POST /new-topic proxies correctly and returns redirect on success
- POST /new-topic returns error fragment on AppView 400/401/403/5xx
- POST /new-topic returns 400 for missing fields
- Board page shows flash banner when
?posted=1present - Reply form renders with correct
rootPostIdandparentPostIdhidden fields - Reply form hidden when topic locked
- Reply form shows login prompt when unauthenticated
- POST /topics/:id/reply proxies correctly and returns redirect on success
- POST /topics/:id/reply returns error fragment on AppView error responses
Open Questions / Deferred#
- boardId in flash redirect: The boardId must be passed through the form (as a second hidden field alongside boardUri) to construct the success redirect URL. This is a minor implementation detail.
- Fire-and-forget latency: New replies/topics may not appear immediately after redirect. No spinner or polling — the firehose is fast enough for MVP. A future improvement (ATB-33) could add real-time updates.