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
4
fork

Configure Feed

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

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#

  1. Validate boardId query param (must be numeric). Missing or invalid → redirect to /.
  2. Fetch board data from AppView (GET /api/boards/:id) to obtain the AT URI (boardUri).
  3. Board not found → 404 error page.
  4. 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

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 div
  • hx-disabled-elt="[type=submit]" — disables submit during inflight to prevent double-submission
  • hx-indicator="#spinner" — shows spinner during request
  • Success: web server responds with HX-Redirect header; 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 boardUri field
  • 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=1 present
  • Reply form renders with correct rootPostId and parentPostId hidden 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.