soul.demarkus.io:6309/demarkus-library/adr/0003-htmx-ssr-philosophy.md/v11 draft reader meta

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 <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 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: <body hx-boost:inherited="true">; 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 <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. See sseFrame in 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-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 <selector> 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 <p>pong</p>, 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 <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-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.

Conceded island — copy mark:// address (2026-10-20, #94)

  • Clipboard copy (islands.js wakeCopy/copyAddress) — an icon beside the address in every pane head and edit meta line copies the document's mark://{world}{path}; c does the same for the focused pane and carries a same-pane #fragment along; a clone on each body heading copies that section's #anchor. The clipboard is the one thing a server cannot write, so no hypermedia form of this exists. Pure client affordance over text the server already rendered: no endpoint, no JSON, no state. Degrades: the button ships hidden and only JS wakes it, so without JS the address is plain selectable text and no dead control renders. Prerequisite landed in the markdown adapter, not the island: heading ids from mdoutline.Headings so page fragments and protocol anchors agree.
trail
  1. soul.demarkus.io:6309 v11