HTMX v4 foundation: vendored lib, fragment responses, user-facing error mapping
closedProblem
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
latestuntil 2027, so pin explicitly — never tracklatest). - Vendor
htmx.min.js(hash-verified, committed underinternal/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 inembed.go/StaticHandler<script>inlayout.templ. Check the 2.x→4.x migration notes (v4 is fetch-based); greenfield adoption so risk is low.
- Convention: handlers detect
HX-Request→ return422+ 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.serverErrorstays for true 500s (log full error, show generic message, never rawerr). - 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
HTMX v4 Foundation Implementation Plan
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.jsviaJustfile ensure-htmx(pinned vars, hash-verify, prereq for build) + existinggo:embed+StaticHandler+Layoutscript tag. Handlers branch onHX-Request:true: htmx gets 422 + error-DIV fragment, browser gets existing 303 +?msg=flash. Newhx.goholds detection + error catalog; newfragments.templholds 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;.gitignorehas no static rule)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).Also add
ensure-htmxas dep todev,test,integration(same one-line dep syntax).go build/test/runfails at compile time ifhtmx.min.jsis missing becauseembed.gowill//go:embedit. Recipe is a no-op when hash matches (one openssl call).Run:
just ensure-htmxExpected: downloads once, second run printshtmx OKwithout re-downloading.Run:
openssl dgst -sha384 -binary internal/appview/ui/static/htmx.min.js | openssl base64 -AExpected:BvJpBiO8Kh31EqtJe5DRIeWrHWnCGkwytKs9NKFi86Hhw96dEqdEMzZDeK9iEGTcRun:
rm internal/appview/ui/static/htmx.min.js && just genExpected: re-download + templ generate succeeds (proves prereq wiring).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)Add route inside
StaticHandler():Keep existing
tailwind.css/app.jshandlers unchanged.No
integrityattr needed self-hosted; no CDN URL. Bothdefer.Run:
go run github.com/a-h/templ/cmd/templ generate ./internal/appview/ui/Expected:layout_templ.goupdated, 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.goRun:
go test ./internal/appview/ui/ -run 'TestIsHXRequest|TestUserFacingError' -vExpected: FAILundefined: isHXRequest,undefined: userFacingError.serverErrorinhandler.go:250-252stays for true 500s (log full error server-side, show generic).Run:
go test ./internal/appview/ui/ -run 'TestIsHXRequest|TestUserFacingError' -vExpected: PASS.Task 4: Fragment components
Files: - Create:
internal/appview/ui/fragments.templ- Modify:internal/appview/ui/handler.go:239-244(render adds Vary)Run:
go run github.com/a-h/templ/cmd/templ generate ./internal/appview/ui/Expected:fragments_templ.gogenerated.Convention: forms get
id="form-error"target. Handlers returnformError(userFacingError(err))viawriteHXFragment(w, 422, ...). Full-page fallback keeps existing 303 +?msg=+@flash.In
handler.gorender()add beforecomp.Render: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: extendinternal/appview/ui/ui_test.goForms keep native
action+methodso no-JS still posts. Layer htmx on top. Do NOT use globalhx-boost— per-form only. v4 swaps 422 by default, no responseHandling config. Greenfield v4: skiphtmx-2-compatext, skipimplicitInheritance=true.Same pattern for mailbox-add (
hx-post="/domains/{id}/accounts") indomain.templ:58and app-password (hx-post=".../app-passwords") inaccount.templ:59.Apply identically to
createAccountandissueAppPassword. Success paths stay 303 (htmx follows via navigation; inline success swaps are follow-ups 4f7a69b, 6663bc8).Run:
SOVRN_INTEGRATION=0 go test ./internal/appview/ui/ -vExpected: PASS.Task 6: Verify + report
Run:
Expected: clean, all PASS.
Run:
Manual: with JS disabled POST forms still 303-redirect; with JS HX-Request → 422 fragment inline.
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.