ADR 0003 — SSR-first, htmx-hard, no JSON
promoted-via: knowledge promoted: mark://latebit/projects/demarkus-library/adr/0003-htmx-ssr-philosophy.md@v1
Status: accepted (2026-06-10). The load-bearing front-end principle for demarkus-library. Deliberately strict: we are pushing the hypermedia approach as far as it goes and treating every escape hatch as a tracked concession.
Decision
The application is hypermedia-driven (HATEOAS). The server renders HTML; htmx is the only general interaction layer; there is no JSON API and no client-side application state. This is not "SSR with some htmx" — it is htmx-hard: the default answer to any interaction is "return server-rendered HTML and swap it in."
The rules (hard)
- The server owns all rendering. Every response is HTML — a full page or a
fragment built by
html/template. The browser receives finished markup, never data to assemble. goldmark→bluemonday→template is the only render path. - No JSON tier, ever. Handlers return HTML via
c.Render/c.HTML, neverc.JSON. No REST/JSON projection, no GraphQL, no serialized view models crossing the wire. (Health check returns plain text; that's the lone non-HTML response and it's not application data.) - No client-side state. Application state lives on the server (session + the world). The DOM is the only client state, and the server is its source of truth. No stores, no client router, no reactivity layer.
- Interactivity is
hx-*attributes. Navigation (hx-boost), search (hx-getdebounced), version/panel swaps, pagination, forms (hx-post→ PUBLISH) — all are htmx returning server-rendered fragments into a target. Reach for an attribute before a line of JS. - Self-hosted assets. htmx is vendored in-repo and served from the Go binary
(
/static/htmx.min.js, pinned to the latest htmx — currently 4.0.0) — not a CDN. Single binary, no runtime dependency, no third-party origin, version pinned and reviewable. Keep it current: bump to the latest htmx release as they ship. (Embodied 2026-06-10.) - Fragments are first-class. Templates are factored so the inner content block can render standalone (for an htmx swap) or inside the page chrome (full load). A fragment request and a full navigation hit the same handler and the same template — no duplicate rendering paths.
- Degrade without JS. Because the server renders every route in full,
hx-boostnavigation and form posts work with JS disabled. htmx is enhancement, never the renderer. Keep it that way: no interaction may be only reachable through htmx.
How far we push it (the experiment)
We will attempt the entire app as hypermedia before admitting any island:
- Catalog search (LOOKUP): debounced
hx-get, results as an HTML<table>to click through. No JSON, no client filtering. - Editions (VERSIONS) + citations (backlinks): fragment swaps into a panel.
- The cataloging desk (writes): form
hx-post→ PUBLISH; live preview viahx-postto a server render endpoint that returns rendered HTML — the server previews, not a client markdown lib. - The AI librarian (Phase 4): streaming via SSE (htmx 4 first-party
hx-sse) — a token stream and a live tool-call trace, still server-driven HTML over the wire.
JS islands — last resort, tracked
A real island (custom JS beyond htmx + its official extensions) is allowed ONLY when hypermedia genuinely cannot do the job, and each one is recorded as a concession in this ADR with the reason. Pre-agreed candidates from the plan:
- CodeMirror — the editor textarea needs real code-editing affordances (Phase 3).
- Canvas graph — force-directed citation graph (post-v1).
Anything else must clear the bar: "this interaction is impossible or unacceptable
as server HTML + htmx." Prefer an htmx extension or a tiny _hyperscript snippet
over bespoke JS before conceding an island. No island is added silently — it
gets a line here and a log/comment at the site.
Conceded islands
- mermaid (2026-06-12) — diagram rendering is client-side by nature (the
server cannot rasterize mermaid without a headless browser, which is a far
larger concession). Vendored
mermaid.min.js11.15.0; loaded lazily byislands.jsonly when a page contains alanguage-mermaidblock; degrades to the readable source code block; render failure restores the source block. - KaTeX (2026-06-12) — math typesetting; same server-side rasterization
argument. Vendored 0.17.0 (js + auto-render + css + woff2 fonts); loaded
lazily on delimiter presence (
\(…\)inline,$$…$$/\[…\]display — single-$deliberately unsupported, dollar-amount false positives); degrades to readable TeX source.trust: false,throwOnError: false. - Both islands ride one tiny loader (
/static/islands.js) that re-scans onhtmx:after:settleso fragment swaps keep working. Server side, math is shielded from markdown by the goldmark passthrough extension; bluemonday still sanitizes everything (islands render from text content, never raw HTML).
Consequences
- No Node build, no bundler, no SPA framework — fits demarkus's single-Go-binary ethos and makes Phase 5's shareable, server-rendered links free.
- The win is conceptual surface area: one rendering path, one source of truth, no client/server state sync. The risk is hitting an interaction htmx strains on — the island rule is the pressure valve, kept deliberately narrow so we actually learn where the ceiling is.
.coderabbit.yamlenforces this on the web adapter: flag anyc.JSON, any client-side framework, any CDN asset reference, any island added without an ADR line.
Vendored asset versions
- htmx 2.0.10 (2026-06-10) — current latest; initial vendor. (Briefly 2.0.4 the same day before bumping to latest.)
- mermaid 11.15.0 (2026-06-12) — islands concession above.
- KaTeX 0.17.0 (2026-06-12) — islands concession above; woff2 fonts only (modern-browser format; the css's woff/ttf fallbacks 404 by design).
Conceded islands — Phase 4 (2026-07-03)
- htmx SSE extension (
sse.min.js2.2.4, vendored + pinned beside htmx) — the pre-agreed streaming-librarian spend named in this ADR's experiment list. Declarative attributes only (sse-connect/sse-swap/sse-close): the librarian pane's live answers stream trace/token/ rendered/done frames of server-rendered content into hx-swap targets — no hand-written EventSource code. Degrades: the ask form is a real POST that runs the ask server-side and PRG-redirects to the finished answer; SSE is enhancement, never the renderer. - Pane click-engagement toggle (2026-07-03, islands.js) — clicking into
a pane moves the visual attention marker (an
.engagedclass) without touching the URL focus (which owns layout + the live read; re-focusing collapses panes and reads as navigation). Presentational-only: no fetch, no URL change, no client state, reset by the next server render — the same register as the graph hover-highlight. htmx attributes cannot express click-scoped CSS toggling; without JS the server-truth focus marker stands.
Related non-island notes: the reader-overlay Esc handler, palette glue, and overlay summon/dismiss already ride islands.js under ADR 0005/0006 grants; the transcript's bottom-anchored chat scroll is pure CSS (column-reverse), deliberately NOT an island.
Vendored asset versions (continued)
- htmx-ext-sse 2.2.4 (2026-07-02) — SSE island above.
htmx 4 upgrade (2026-09-09)
- htmx 4.0.0 (released 2026-08-28; npm tag
next,lateststays 2.x until early 2027) replaces 2.0.10. Vendoreddist/htmx.min.js; the SSE extension is now first-party (dist/ext/hx-sse.min.js, vendored as/static/hx-sse.min.js, replaces htmx-ext-sse 2.2.4). Extensions load by script inclusion;hx-extis gone. - Inheritance is explicit:
<body hx-boost:inherited="true">; childhx-boost="false"still wins (own attribute checked first). - History has no DOM cache: back/forward re-fetch the URL with
HX-Request: true+HX-History-Restore-Request: true(noHX-Boosted) and swap the body.wantsFragmentin reading_handlers.go treats that as a full-page request; without it the back button swapped a bare fragment into the body. - SSE vocabulary rewired: htmx 4 swaps only unnamed SSE frames; named events become DOM events (
sse-swapremoved). trace/token/rendered now go out unnamed as<hx-partial hx-target="find .ask-…" hx-swap="…">(targets resolve relative to the connecting block, so no ids); answer/done stay named,hx-sse:close="done"closes. SeesseFramein librarian_stream.go. - Error responses swap by default in 4; kept the htmx 2 rule via
<meta name="htmx-config" content='{"noSwap":[204,304,"4xx","5xx"]}'>because Echo's default error body is JSON. Follow-up: render HTML error fragments, then drop the meta and let 4xx/5xx swap. - Events renamed: islands.js listens for
htmx:after:settle(on document);hx-on::after-requestbecamehx-on::after:request. - 60s default request timeout is new; hx-sse clears it once the stream responds, so long answers are unaffected.
htmx.ajax("GET", url, "#selector")still valid;next <selector>targets,delay:/once/changedtriggers,hx-headersJSON,HX-Request/HX-Boosted/HX-Current-URL/HX-Redirectall unchanged.- Official checker:
npx htmx.org@4.0.0 upgrade-check -- ./internal/adapter/inbound/web/templates. Its edit.htmlhx-headerswarning is a false positive (the header sits on the requesting textarea itself).
htmx 4 upgrade, live-test corrections (2026-09-09)
- Error handling moved off the
htmx-configmeta:noSwapshort-circuits beforehx-status, and a boosted 404 click still pushed the URL. Now every body carries{{template "hx-body"}}(page.html):hx-boost:inherited="true" hx-status:4xx:inherited="swap:none push:false" hx-status:5xx:inherited="swap:none push:false". Verified in Chrome: 404 link leaves URL and panes untouched, no JSON in body. - Verified end to end with Playwright against Google Chrome (
channel: 'chrome', no Chrome extension needed): boosted nav sendsHX-Boosted, back sendsHX-History-Restore-Requestand gets the full page without reload, preview cards, ⌃K palette, librarian SSE (hx-partial frames swap<p>pong</p>,hx-on::after:requestresets the form). - Pre-existing, NOT a regression: document-body links wrapped by the hover preview carry
hx-geton the<a>itself, so neither htmx 2 nor 4 boosts them (a click is a full page load). Fix candidate: move the preview attributes onto the.preview-hostspan (hx-trigger="mouseenter delay:300ms once",hx-target="find .preview-card") so the anchor stays boostable. - Drive scripts live in the session scratchpad (
pw/drive.js,compare.js,status.js); worth turning into a projectrunskill.