soul.demarkus.io/demarkus-library/plans/reader-mode.md/v4 draft reader meta

Reader mode — the focus overlay (plan)

Status: draft — recorded 2026-06-14 (Fritz). Next-session candidate. Fritz: "I really like the layout, it keeps the context nicely. One thing that would help: a quick reader mode that overlays in the middle the document you want to focus on" — since the trail has multiple columns, sometimes you want to sink into one document.

One-line

A reader overlay: the focused document rendered centered, at a comfortable measure, over the dimmed trail — context preserved behind it, one click (or Back) to return. A lens over the trail, NOT a new pane: the reasoning chain is untouched; you're just reading one link of it up close.

Why it fits the room

The trail's whole thesis is "context is spatial, never amputated." Reader mode honors that: the columns stay on screen, dimmed — you can see where you are, you just zoom your attention. It's the opposite of a modal that hides everything; the context is still there, deliberately. Complements focus (which pane gets the margin + live read) with a deeper "solo read" lens.

THE design decision: reader is URL state, not client state (ADR 0003)

Reader mode is a query param on the trail URL — /t/<trail>?reader=<paneIndex> (0-based; omitted ⇒ no overlay; out of range ⇒ ignored). Consequences, all the htmx-hard wins the trail already banks:

  • Shareable: a link can say "here's the chain, start by reading pane 2."
  • Browser Back closes it — temporal history over spatial state, free, no JS.
  • Zero client state — the whole overlay renders server-side from the URL.
  • Coexists with ?focus= (reader implies attention on that pane; keep both or fold focus into reader — settle when building).

Render

  • The full canvas renders as today, with a dimmed backdrop layer over it.
  • A centered reader panel renders the pane's already-fetched Document at the Tufte reading measure (≈60ch body + margin), so it's the same prose the column shows, just unconstrained by column width and centered.
  • No extra world read: reader shows the Document already fetched for that pane (focused-live or cached) — the overlay is pure presentation.
  • Margin stays (status, tags, provenance, backlinks, graph/edit/new/append affordances): reading mode is not a dead-end — you can still branch and act. Possibly collapsible; default shown (the trust layer is the point).
  • SSR + CSS only; ADR 0003's canvas island stays unspent. Degrades: no CSS ⇒ the doc just renders after the canvas; no JS ⇒ close via the link / Back.

Affordance + close

  • Each pane (header/spine) gets a "reader" control → link to trailURL?reader=<i>.
  • Close: an explicit AND the backdrop itself are <a> links back to the bare trail URL (no reader param). Optional Esc-to-close via a one-line _hyperscript snippet (an ADR 0003-sanctioned tiny concession) — or rely on Back. Recommend the snippet; it's the expected reader-mode gesture.

Load-bearing UX decision: does reader persist across link clicks?

  • RECOMMENDED — persist: a link clicked inside the reader continues the trail AND stays in reader on the newly-focused pane (read straight through the argument without surfacing). Implemented by the reader panel's links using trailAfterClick then setting reader=<new focus index>. This is the feature's whole point ("focus on the document you want," then the next).
  • Alternative (simpler v1): links exit reader to the canvas, focused on the new pane. Less magical; falls out for free. Decide with Fritz; recommend persist.

Architecture (web-only — no port/service/domain change)

  • trail.go: parse the reader query param into the trail struct (e.g. Reader int, -1 = none); trailURL optionally encodes it; a helper builds the reader/close links. The codec stays the single source of truth.
  • trail_handlers.go: when Reader is set and in range, render the canvas plus the reader-overlay view model (reuse the focused pane's Document; for persist-on-navigate, rewrite the reader panel's links to carry the new reader index).
  • Templates: a reader overlay block (backdrop + centered panel reusing the doc-body + doc-meta partials). CSS: backdrop, centered panel, dim.
  • Restrict to prose panes (doc/listing/tag); a floor/graph pane in reader is just a bigger SVG — allow later if wanted, not v1.

Tests

Codec round-trip with ?reader=; overlay renders the addressed pane; ✕/backdrop link back to the bare trail; persist-on-navigate link rewriting; out-of-range ignored; no-reader URL renders exactly today's canvas (no regression); auth-gated affordances still present in the reader margin.

Scope / non-goals

  • A presentation lens, not a pane kind — the trail path never changes.
  • No client zoom/pan, no JS reader engine (htmx + CSS + at most a 1-line Esc hyperscript).
  • Doc/listing/tag panes only for v1.

Open before building (settle with Fritz)

  1. Persist-on-navigate vs exit-on-navigate (recommend persist).
  2. ?reader= as its own param vs folding focus into it.
  3. Esc-to-close hyperscript vs Back-only.
  4. Margin shown vs collapsible in reader.

This is a self-contained next-session feature: web-layer only, no new reads, no broker/deploy coupling. Slots into the Reading Room family as "R4 — reader mode."


Decisions SETTLED + BUILT (2026-06-15, branch feature/reader-mode)

Fritz settled all four open questions — each matched the plan's recommendation:

  1. Persist-on-navigate — a link inside the overlay continues the trail AND stays in reader on the newly focused pane.
  2. Standalone ?reader= — its own query param, orthogonal to ?focus=.
  3. ✕ + backdrop + Esc — all three close; Esc via a 4-line vanilla handler in islands.js (NOT _hyperscript — pulling a ~40KB lib for one keybinding is worse than 4 lines in the ADR-0005-sanctioned canvas JS file).
  4. Margin shown by default in the overlay (the trust layer is the point).

Load-bearing implementation decision: reader implies focus

A valid ?reader=<i> also takes Focus := i in parseTrail. This keeps the single-live-read invariant (ADR 0005 d9): the overlay reuses the focused pane's already-fetched Document — no second world read — and so always has the full margin. Honors "standalone" (reader is its own param, present/absent independently of focus) and the shareable "read pane 2" use case (?reader=2 focuses + overlays pane 2). Out-of-range / non-prose (floor, graph) / junk ⇒ no overlay (-1), never a 400 — a stale shared link degrades to the canvas.

Web-only build (no port/service/domain/broker/deploy change)

  • trail.go: trail.Reader int (-1 = none); parseTrail gains a 3rd arg (readerParam); new trailReaderURL(t, reader) — the ONLY builder that emits ?reader=; trailURL stays reader-free (canonical canvas URL, so every existing click closes the overlay). trailAfterClick/trailFocused set Reader: -1.
  • trail_links.go: trailizeLinks/trailizeNode gain reader bool — prose targets (doc/tag) persist the overlay, non-prose (graph/floor) exit it.
  • trail_handlers.go: paneView gains reader bool (mode "reader"; skips RecordLinks on the overlay copy; backlinks persist, graph/map exit; no self-referential reader affordance in the overlay). Trail handler captures the focused doc and builds vm.Reader *paneVM + vm.CloseURL (bare trail). ReaderURL added to paneVM AND the single-doc page VM (unused there — the shared doc-meta template references it).
  • Templates: reader overlay block (backdrop scrim + centered panel reusing doc-body + doc-meta) in canvas.html; "reader" affordance in the pane-head and the margin escape line (content.html).
  • islands.js: Esc-to-close (clicks the close link; hx-boost keeps it a swap).
  • page.html: reader CSS (fixed backdrop, scrim-link behind panel, centered Tufte-width panel, dark-mode aware).

Tests: codec (reader takes focus, reader>focus precedence, tag overlays, out-of-range/negative/junk/absent → -1, floor+graph rejected, trailReaderURL round-trip + close URL); handler (overlay renders + canvas behind + margin ×2, no-reader regression, out-of-range ignored, persist-on-navigate link rewrite, auth affordances present + no self-reader-link in overlay). Gate green (./pre-commit.sh, lint 0 issues). Live QUIC smoke passed: overlay renders only on valid prose ?reader=, body links persist ?reader=, scrim/✕ → bare trail, floor + out-of-range ignored.

Gotcha: a strings.Contains(body, "reader-panel") negative assertion false-matched the .reader-panel CSS rule (the canvas page inlines all CSS). Fix: assert the rendered element class="reader-backdrop", not the class name.

NOT committed (Fritz commits). Next: Fritz + CodeRabbit (@coderabbitai review) → merge (library 0.1.15) → deploy bump (reader-only, no broker/worlds/ agent change).

trail
  1. soul.demarkus.io v4