HTMX v4 foundation: vendored lib, fragment responses, user-facing error mapping

closed
#d656998 opened by agent Sep 11

Problem

Form errors render as plain-text on a separate page (serverError → http.Error), and user-facing errors leak Go-style wrapped internals (e.g. create account: account: host is required). All UI mutations are full-page POST+redirect.

Decisions (locked 2026-09-11)

  • Adopt HTMX v4 (4.0.0 exists; npm still marks 2.x latest until 2027, so pin explicitly — never track latest).
  • Vendor htmx.min.js (hash-verified, committed under internal/appview/ui/static/) rather than CDN: the login page must not depend on a third party, dev works offline.
  • Keep no-JS fallbacks wherever possible (progressive enhancement).

Proposal

  • static/htmx.min.js + go:embed + route in embed.go/StaticHandler
    • <script> in layout.templ. Check the 2.x→4.x migration notes (v4 is fetch-based); greenfield adoption so risk is low.
  • Convention: handlers detect HX-Request → return 422 + error-DIV fragment for htmx, full-page fallback otherwise.
  • Error catalog: typed errors (ErrDomainMismatch, store.ErrNotFound, validation failures) → plain-English strings telling the user how to fix it. serverError stays for true 500s (log full error, show generic message, never raw err).
  • Convert forms incrementally: domain create → mailbox add → app-password issue → recheck. Foundation for inline-mailbox and account-page issues.

Acceptance

  • htmx works offline from vendored asset; no CDN reference.
  • Every user-facing error reads as plain-English fix guidance, inline on the form page; no-JS posts still work via redirects.

2 Comments

agent d36e516 Sep 11

HTMX v4 Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Vendor HTMX 4.0.0 and establish HX-Request → 422 + fragment + plain-English error catalog convention, keeping no-JS fallbacks.

Architecture: Self-hosted htmx.min.js via Justfile ensure-htmx (pinned vars, hash-verify, prereq for build) + existing go:embed + StaticHandler + Layout script tag. Handlers branch on HX-Request:true: htmx gets 422 + error-DIV fragment, browser gets existing 303 + ?msg= flash. New hx.go holds detection + error catalog; new fragments.templ holds swappable error components.

Tech Stack: HTMX 4.0.0 pinned (jsDelivr), Go 1.26.5, a-h/templ v0.3.1020, just, openssl/curl for verify.


Task 1: Justfile-driven vendor + verify, prereq for build

Files: - Modify: Justfile:1-3 - Create (by recipe): internal/appview/ui/static/htmx.min.js (committed; .gitignore has no static rule)

  • [ ] Step 1: Add pinned vars at top of Justfile
HTMX_VERSION := "4.0.0"
HTMX_SRC := "https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js"
HTMX_SHA384 := "BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTc"
HTMX_DST := "internal/appview/ui/static/htmx.min.js"

Bump process: change VERSION + SRC + SHA384 together (hash from https://four.htmx.org/docs#installing-htmx). Never track latest (npm latest stays on 2.x until 2027).

  • [ ] Step 2: Add idempotent ensure-htmx recipe
# Download + hash-verify vendored htmx. Idempotent: skips when DST exists with matching hash.
ensure-htmx:
    #!/usr/bin/env bash
    set -euo pipefail
    DST="{{ HTMX_DST }}"
    if [ -f "$DST" ]; then
      GOT=$(openssl dgst -sha384 -binary "$DST" | openssl base64 -A)
      if [ "$GOT" = "{{ HTMX_SHA384 }}" ]; then echo "htmx OK: $DST @ {{ HTMX_VERSION }}"; exit 0; fi
      echo "htmx hash mismatch (got $GOT) — re-downloading $DST" >&2
    fi
    curl -fsSL "{{ HTMX_SRC }}" -o "$DST"
    GOT=$(openssl dgst -sha384 -binary "$DST" | openssl base64 -A)
    [ "$GOT" = "{{ HTMX_SHA384 }}" ] || { echo "htmx verify failed: got $GOT" >&2; rm -f "$DST"; exit 1; }
    echo "htmx vendored: $DST @ {{ HTMX_VERSION }}"
  • [ ] Step 3: Wire as prereq where go:embed needs the file
gen: ensure-htmx
    go generate ./api/sovrn ./internal/appview/ui

Also add ensure-htmx as dep to dev, test, integration (same one-line dep syntax). go build/test/run fails at compile time if htmx.min.js is missing because embed.go will //go:embed it. Recipe is a no-op when hash matches (one openssl call).

  • [ ] Step 4: Verify

Run: just ensure-htmx Expected: downloads once, second run prints htmx OK without re-downloading.

Run: openssl dgst -sha384 -binary internal/appview/ui/static/htmx.min.js | openssl base64 -A Expected: BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTc

Run: rm internal/appview/ui/static/htmx.min.js && just gen Expected: re-download + templ generate succeeds (proves prereq wiring).

  • [ ] Step 5: Confirm no CDN reference will exist

Run: rg -n "cdn.jsdelivr|unpkg.com|cdnjs.*htmx|htmx.org@" --glob '!static/htmx.min.js' || echo "no CDN refs" Expected: no CDN refs.

Task 2: Serve vendored asset + load in Layout

Files: - Modify: internal/appview/ui/embed.go:8-26 - Modify: internal/appview/ui/layout.templ:11 - Test: internal/appview/ui/ui_test.go (new test in Task 5)

  • [ ] Step 1: Extend embed.go
//go:embed static/htmx.min.js
var htmxJS []byte

Add route inside StaticHandler():

mux.HandleFunc("GET /static/htmx.min.js", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/javascript")
    _, _ = w.Write(htmxJS)
})

Keep existing tailwind.css / app.js handlers unchanged.

  • [ ] Step 2: Add script tag in layout.templ:11
<script src="/static/app.js" defer></script>
<script src="/static/htmx.min.js" defer></script>

No integrity attr needed self-hosted; no CDN URL. Both defer.

  • [ ] Step 3: Regenerate templ

Run: go run github.com/a-h/templ/cmd/templ generate ./internal/appview/ui/ Expected: layout_templ.go updated, no diff in other *_templ.go.

Task 3: HX helper + user-facing error catalog (both branches now)

Files: - Create: internal/appview/ui/hx.go - Test: internal/appview/ui/hx_test.go

  • [ ] Step 1: Write the failing test
package ui

import (
    "errors"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestIsHXRequest(t *testing.T) {
    r := httptest.NewRequest("POST", "/domains", nil)
    if isHXRequest(r) { t.Fatal("want false without header") }
    r.Header.Set("HX-Request", "true")
    if !isHXRequest(r) { t.Fatal("want true with HX-Request:true") }
}

func TestUserFacingErrorHidesInternals(t *testing.T) {
    msg := userFacingError(errors.New("create account: account: host is required"))
    if msg == "create account: account: host is required" {
        t.Fatal("leaked wrapped internals")
    }
    if msg == "" { t.Fatal("empty message") }
}

func TestUserFacingErrorDomainMismatchStub(t *testing.T) {
    msg := userFacingError(errors.New("create account: ErrDomainMismatch: [email protected]"))
    if strings.Contains(msg, "ErrDomainMismatch") || msg == "" {
        t.Fatalf("leaked internals or empty: %q", msg)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/appview/ui/ -run 'TestIsHXRequest|TestUserFacingError' -v Expected: FAIL undefined: isHXRequest, undefined: userFacingError.

  • [ ] Step 3: Write minimal implementation hx.go
package ui

import (
    "errors"
    "net/http"
    "strings"

    "git.kilimanjaro.io/sovrn/internal/store"
)

func isHXRequest(r *http.Request) bool {
    return r.Header.Get("HX-Request") == "true"
}

// userFacingError maps typed/internal errors to plain-English fix guidance.
// Never return raw err to the browser from form handlers.
func userFacingError(err error) string {
    if err == nil { return "" }
    if errors.Is(err, store.ErrNotFound) {
        return "That item was not found. It may have been deleted — go back and try again."
    }
    s := err.Error()
    switch {
    case strings.Contains(s, "host is required"):
        return "Enter the full address (for example, [email protected])."
    case strings.Contains(s, "domain is required"):
        return "Enter a domain name (for example, example.com)."
    case strings.Contains(s, "address is required"):
        return "Enter a mailbox address (for example, [email protected])."
    case strings.Contains(s, "domain mismatch") || strings.Contains(s, "ErrDomainMismatch"):
        // STUB for 6651065: no typed ErrDomainMismatch exists yet (bare-local-part
        // shorthand lands there). When it does, replace this branch with
        // errors.Is(err, ErrDomainMismatch). Keep user text identical.
        return "That mailbox doesn't belong to this domain. Use the full address (for example, [email protected])."
    default:
        return "Something went wrong saving this. Check your input and try again."
    }
}

// writeHXFragment writes an HTML fragment for htmx swaps (422 for user errors).
// Always sets Vary so caches key correctly.
func writeHXFragment(w http.ResponseWriter, status int, html string) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Header().Set("Vary", "HX-Request")
    w.WriteHeader(status)
    _, _ = w.Write([]byte(html))
}

serverError in handler.go:250-252 stays for true 500s (log full error server-side, show generic).

  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/appview/ui/ -run 'TestIsHXRequest|TestUserFacingError' -v Expected: PASS.

  • [ ] Step 5: Commit
jj commit -m "feat(ui): htmx request helper + user-facing error catalog" internal/appview/ui/hx.go internal/appview/ui/hx_test.go

Task 4: Fragment components

Files: - Create: internal/appview/ui/fragments.templ - Modify: internal/appview/ui/handler.go:239-244 (render adds Vary)

  • [ ] Step 1: Create fragments.templ
package ui

templ formError(msg string) {
    if msg != "" {
        <div id="form-error" class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{ msg }</div>
    }
}

Run: go run github.com/a-h/templ/cmd/templ generate ./internal/appview/ui/ Expected: fragments_templ.go generated.

Convention: forms get id="form-error" target. Handlers return formError(userFacingError(err)) via writeHXFragment(w, 422, ...). Full-page fallback keeps existing 303 + ?msg= + @flash.

  • [ ] Step 2: Add Vary to full-page render

In handler.go render() add before comp.Render:

w.Header().Set("Vary", "HX-Request")

Prevents cache poisoning between fragment/full responses.

Task 5: Convert forms incrementally (hx-post + native fallback)

Files: - Modify: internal/appview/ui/home.templ:35, internal/appview/ui/domain.templ:58, internal/appview/ui/account.templ:59 - Modify: internal/appview/ui/handler.go:82-108, 133-151, 172-201 - Test: extend internal/appview/ui/ui_test.go

Forms keep native action+method so no-JS still posts. Layer htmx on top. Do NOT use global hx-boost — per-form only. v4 swaps 422 by default, no responseHandling config. Greenfield v4: skip htmx-2-compat ext, skip implicitInheritance=true.

  • [ ] Step 1: Annotate home.templ domain create
<form action="/domains" method="post" hx-post="/domains" hx-target="#form-error" hx-swap="outerHTML" class="mt-3 flex gap-2">
    @formError("")
    <input name="domain" type="text" required placeholder="example.com" class="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"/>
    @primaryButton("Add")
</form>

Same pattern for mailbox-add (hx-post="/domains/{id}/accounts") in domain.templ:58 and app-password (hx-post=".../app-passwords") in account.templ:59.

  • [ ] Step 2: Branch handlers on isHXRequest (example createDomain)
if name == "" {
    if isHXRequest(r) {
        // render formError("Enter a domain...") with 422 via writeHXFragment
        return
    }
    http.Redirect(w, r, "/?msg="+q("domain is required"), http.StatusSeeOther)
    return
}
if err != nil {
    if isHXRequest(r) {
        // render formError(userFacingError(err)) with 422
        return
    }
    serverError(w, r, "create domain", err)
    return
}

Apply identically to createAccount and issueAppPassword. Success paths stay 303 (htmx follows via navigation; inline success swaps are follow-ups 4f7a69b, 6663bc8).

  • [ ] Step 3: Add handler tests (validation branches only, nil-safe for Stalwart/Prober)
func TestVendoredHTMXServed(t *testing.T) {
    srv := newTestServer(t)
    resp, err := http.Get(srv.URL + "/static/htmx.min.js")
    if err != nil { t.Fatalf("GET htmx: %v", err) }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != 200 || len(body) == 0 { t.Fatalf("htmx not served: %d len=%d", resp.StatusCode, len(body)) }
}

func TestCreateDomainHXReturns422Fragment(t *testing.T) {
    srv := newTestServer(t)
    req, _ := http.NewRequest("POST", srv.URL+"/domains", strings.NewReader("domain="))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    req.Header.Set("HX-Request", "true")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { t.Fatalf("POST: %v", err) }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != 422 { t.Fatalf("status = %d, want 422: %s", resp.StatusCode, body) }
    if !strings.Contains(string(body), `id="form-error"`) { t.Errorf("missing fragment: %s", body) }
}

func TestCreateDomainNoJSRedirects(t *testing.T) {
    srv := newTestServer(t)
    client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
    resp, err := client.Post(srv.URL+"/domains", "application/x-www-form-urlencoded", strings.NewReader("domain="))
    if err != nil { t.Fatalf("POST: %v", err) }
    defer resp.Body.Close()
    if resp.StatusCode != 303 { t.Fatalf("status = %d, want 303", resp.StatusCode) }
}

Run: SOVRN_INTEGRATION=0 go test ./internal/appview/ui/ -v Expected: PASS.

Task 6: Verify + report

  • [ ] Step 1: Format/vet/test

Run:

gofmt -l internal/appview/ui/
go vet ./...
SOVRN_INTEGRATION=0 go test ./...

Expected: clean, all PASS.

  • [ ] Step 2: Offline check

Run:

rg -n "cdn\.|unpkg|jsdelivr.*http|htmx\.org@" internal/appview/ui/*.templ internal/appview/ui/*.go --glob '!static/htmx.min.js' || echo "vendored-only OK"

Manual: with JS disabled POST forms still 303-redirect; with JS HX-Request → 422 fragment inline.

  • [ ] Step 3: Comment progress on d656998; do not close until reviewed (per f4092c9 per-session checklist).
agent df63506 Sep 11

HTMX foundation implemented, ready for review. Justfile ensure-htmx vendors 4.0.0 hash-verified (BvJpBiO8…); embed.go + Layout serve/load /static/htmx.min.js; hx.go has isHXRequest + userFacingError incl. ErrDomainMismatch stub + Vary; fragments.templ formError always renders #form-error target; home/domain/account forms use hx-post with native fallback; handlers return 422 fragment for HX else 303/serverError. Verify: gofmt clean, go vet clean, SOVRN_INTEGRATION=0 go test ./… all ok, no CDN refs. Not closing.