fix(web): load HTMX so task (+tree/dashboard/bulk/classify) forms work

ROOT CAUSE (head-diagnosed, confirmed): projax templates use hx-post/hx-get/
hx-target/hx-swap across the task, tree, dashboard, bulk and classify forms,
but HTMX was NEVER loaded — layout.tmpl had only inline <script> blocks, no
htmx <script src>. So every hx-post form silently no-op'd to a GET-to-self
(logs: GET /i/social.mama on each 'Add' click). m hit it as 'can't add Tasks
(projax), nothing happens'. Both the mBrian AND the older CalDAV task forms
were dead. The style.css even comments 'HTMX-driven' — the script was just
never wired.

FIX: vendor htmx 1.9.12 into web/static (Tailscale-only app → vendored over
CDN, matches the go:embed asset model) + one deferred <script> in layout
<head>. htmx only intercepts hx-* elements, so the existing plain
method=post forms are untouched. The task handlers already return section
fragments for hx-swap, so the flow just works once htmx is present. Added
htmx.min.js to the service-worker shell precache (CACHE_NAME v1→v2).

The server-side write path was already proven green (TestMBrianTaskRoundTrip
PASS with prod creds) — the bug was purely the client form never POSTing.
Loading htmx closes that gap.

Regression guard: TestLayoutLoadsHTMX asserts the script ships in the layout
so this can't silently recur.
This commit is contained in:
mAi
2026-06-01 18:19:53 +02:00
parent b72744b567
commit 728788225c
4 changed files with 26 additions and 1 deletions

View File

@@ -185,6 +185,21 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
}
}
// TestLayoutLoadsHTMX guards against the Phase 7c regression: the task / tree
// / dashboard / bulk / classify forms drive in-place swaps with hx-* attrs,
// which are inert unless htmx is actually loaded. It went unnoticed for many
// phases (every hx-post task form silently no-op'd to a GET-to-self). This
// test fails the moment the vendored htmx <script> drops out of the layout.
func TestLayoutLoadsHTMX(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/views/dashboard")
if !strings.Contains(body, `src="/static/htmx.min.js"`) {
t.Errorf("layout must load vendored htmx (hx-* forms are dead without it); body: %s", truncate(body, 400))
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -12,9 +12,12 @@
// real PWA + keep static assets warm. Mutations (CalDAV / Gitea writeback)
// still require connectivity.
const CACHE_NAME = 'projax-shell-v1';
// v2: htmx.min.js joins the shell so the HTMX-driven forms work offline too
// (the cache name bump purges the v1 asset set on activate).
const CACHE_NAME = 'projax-shell-v2';
const SHELL_ASSETS = [
'/static/style.css',
'/static/htmx.min.js',
'/static/manifest.webmanifest',
'/static/icon-192.png',
'/static/icon-512.png',

View File

@@ -13,6 +13,12 @@
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/style.css">
<!-- HTMX powers the in-place fragment swaps on the task / tree / dashboard /
bulk / classify forms (hx-post/hx-get/hx-target/hx-swap). Vendored (not
CDN) — projax is Tailscale-only and ships its assets via go:embed. Loaded
deferred so it executes after parse but before DOMContentLoaded, where
htmx wires every hx-* element. Plain method=post forms are untouched. -->
<script src="/static/htmx.min.js" defer></script>
<script>
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
// main-content margin doesn't flash from 220px→56px on every navigation.