# 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)
1. **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.
2. **No JSON tier, ever.** Handlers return HTML via `c.Render`/`c.HTML`, never
`c.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.)
3. **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.
4. **Interactivity is `hx-*` attributes.** Navigation (`hx-boost`), search
(`hx-get` debounced), 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.
5. **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.)
6. **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.
7. **Degrade without JS.** Because the server renders every route in full,
`hx-boost` navigation 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 `
` 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 via
`hx-post` to 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.js` **11.15.0**; loaded lazily by
`islands.js` only when a page contains a `language-mermaid` block; 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 on
`htmx:after:settle` so 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.yaml` enforces this on the web adapter: flag any `c.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.js` **2.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 `.engaged` class) 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`, `latest` stays 2.x until early 2027) replaces 2.0.10. Vendored `dist/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-ext` is gone.
- Inheritance is explicit: ``; child `hx-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` (no `HX-Boosted`) and swap the body. `wantsFragment` in 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-swap` removed). trace/token/rendered now go out unnamed as `` (targets resolve relative to the connecting block, so no ids); answer/done stay named, `hx-sse:close="done"` closes. See `sseFrame` in librarian_stream.go.
- Error responses swap by default in 4; kept the htmx 2 rule via `` 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-request` became `hx-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 ` targets, `delay:`/`once`/`changed` triggers, `hx-headers` JSON, `HX-Request`/`HX-Boosted`/`HX-Current-URL`/`HX-Redirect` all unchanged.
- Official checker: `npx htmx.org@4.0.0 upgrade-check -- ./internal/adapter/inbound/web/templates`. Its edit.html `hx-headers` warning 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-config` meta: `noSwap` short-circuits before `hx-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 sends `HX-Boosted`, back sends `HX-History-Restore-Request` and gets the full page without reload, preview cards, ⌃K palette, librarian SSE (hx-partial frames swap `pong
`, `hx-on::after:request` resets the form).
- Pre-existing, NOT a regression: document-body links wrapped by the hover preview carry `hx-get` on the `` 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-host` span (`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 project `run` skill.