TOC rail + highlighter — data flow, boundaries, and the implicit org→html contract
Table of Contents
1. 1. What this note settles
The right-rail TOC ships (site/static/css/style.css, gated by
body:has(#table-of-contents) at ≥1024px). The next feature is the
highlighter — as the reader scrolls, mark the current section in
the rail. The reducer contract for it landed independently in
docs/toc-rail-reducer-contract.org and a JS mirror at
experiments/toc-nav-simulator/reducer.mjs; the head-less simulator
already grades that reducer against real org-publish HTML fixtures.
What is not settled and this note settles:
- Data flow between browser events, geometry reads, the pure reducer, and DOM mutations.
- Boundary responsibilities — what the reducer owns, what the browser adapter owns, what the CSS owns, what the publisher owns.
- The implicit contract the org→html exporter provides — the guarantees the highlighter relies on, and the guarantees the highlighter must not rely on.
- Bundle strategy — extend the existing tracker.js so the
highlighter ships with the ambient page load, without a new
<script>include touched into every template.
The reducer itself is done. This is the wiring diagram + boundary spec + bundle decision.
2. 2. Data flow
Six events, one pure reducer, one DOM effect. The reducer is a
function (state, event) → state; the adapter is what turns
browser noise into events and what turns state changes into class
toggles.
digraph DataFlow {
rankdir=LR;
bgcolor="#f8fafc";
labelloc="t"; label=<<b>TOC rail highlighter — data flow</b>>;
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11];
edge [fontname="Helvetica", fontsize=9, color="#475569"];
// Browser events
"DOMContentLoaded" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"scroll" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"resize" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"hashchange / click" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
// Adapter reads
"querySelectorAll #content h2,h3" [fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
"getBoundingClientRect() per heading" [fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
"window.scrollY / innerHeight / documentElement.scrollHeight" [fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
// Reducer boundary
"activeId(state)\\npure reducer" [fillcolor="#fef3c7", color="#a16207", fontcolor="#a16207", shape=box, style="rounded,filled,bold"];
// DOM mutation
"toggle .toc-active + aria-current" [fillcolor="#fecaca", color="#b91c1c", fontcolor="#b91c1c"];
"DOMContentLoaded" -> "querySelectorAll #content h2,h3" [label="init"];
"querySelectorAll #content h2,h3" -> "getBoundingClientRect() per heading" [label="build headings[]"];
"resize" -> "getBoundingClientRect() per heading" [label="recompute tops"];
"scroll" -> "window.scrollY / innerHeight / documentElement.scrollHeight" [label="rAF-throttle"];
"hashchange / click" -> "window.scrollY / innerHeight / documentElement.scrollHeight" [label="post-nav"];
"getBoundingClientRect() per heading" -> "activeId(state)\\npure reducer";
"window.scrollY / innerHeight / documentElement.scrollHeight" -> "activeId(state)\\npure reducer";
"activeId(state)\\npure reducer" -> "toggle .toc-active + aria-current" [label="diff cached id"];
}
The reducer inputs are five numbers and one array: scrollY,
headerH, viewportH, documentH, headings[]=. Output is one
string (activeId) or null. Nothing else crosses the boundary in
either direction.
3. 3. User flow — from click to highlight
Two entry points: passive scroll, and explicit navigation. Both go through the same reducer.
digraph UserFlow {
rankdir=TB;
bgcolor="#f8fafc";
labelloc="t"; label=<<b>TOC rail highlighter — user flow</b>>;
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11];
edge [fontname="Helvetica", fontsize=9, color="#475569"];
"PAGE LOAD" [shape=circle, fillcolor="#ecfccb", color="#3f6212", label="LOAD"];
"gate: #table-of-contents present\\n&& innerWidth >= 1024\\n&& headings.length >= 4"
[fillcolor="#e2e8f0", color="#334155", fontcolor="#334155"];
"build headings[]\\n{id, top} for h2/h3 in #content"
[fillcolor="#dbeafe", color="#1d4ed8", fontcolor="#1d4ed8"];
"READER READS" [shape=box, style="rounded,filled", fillcolor="#f3e8ff", color="#7e22ce", fontcolor="#7e22ce"];
"reader scrolls (mouse/kbd/touch)" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"reader clicks rail link" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"reader loads URL#hash" [fillcolor="#ecfccb", color="#3f6212", fontcolor="#3f6212"];
"rAF-throttle -> activeId()" [fillcolor="#fef3c7", color="#a16207", fontcolor="#a16207"];
"browser scroll-padding-top + smooth" [fillcolor="#fef3c7", color="#a16207", fontcolor="#a16207"];
"activeId != last?" [shape=diamond, fillcolor="#fff", color="#475569", fontcolor="#475569"];
"toggle .toc-active + aria-current" [fillcolor="#fecaca", color="#b91c1c", fontcolor="#b91c1c"];
"rail entry visible in overflow-y scroll" [fillcolor="#ccfbf1", color="#0f766e", fontcolor="#0f766e"];
"END" [shape=doublecircle, fillcolor="#fef3c7", color="#a16207", label="tab.blur\\n(existing tracker path)"];
"PAGE LOAD" -> "gate: #table-of-contents present\\n&& innerWidth >= 1024\\n&& headings.length >= 4";
"gate: #table-of-contents present\\n&& innerWidth >= 1024\\n&& headings.length >= 4" -> "build headings[]\\n{id, top} for h2/h3 in #content" [label="pass"];
"gate: #table-of-contents present\\n&& innerWidth >= 1024\\n&& headings.length >= 4" -> "END" [label="fail (no-op)", style=dashed];
"build headings[]\\n{id, top} for h2/h3 in #content" -> "READER READS";
"READER READS" -> "reader scrolls (mouse/kbd/touch)";
"READER READS" -> "reader clicks rail link";
"READER READS" -> "reader loads URL#hash";
"reader clicks rail link" -> "browser scroll-padding-top + smooth" [label="anchor nav"];
"reader loads URL#hash" -> "browser scroll-padding-top + smooth" [label="onhashchange"];
"browser scroll-padding-top + smooth" -> "reader scrolls (mouse/kbd/touch)" [style=dashed];
"reader scrolls (mouse/kbd/touch)" -> "rAF-throttle -> activeId()";
"rAF-throttle -> activeId()" -> "activeId != last?";
"activeId != last?" -> "toggle .toc-active + aria-current" [label="yes"];
"activeId != last?" -> "READER READS" [label="no (skip)", style=dashed];
"toggle .toc-active + aria-current" -> "rail entry visible in overflow-y scroll";
"rail entry visible in overflow-y scroll" -> "READER READS";
}
The "activeId != last" diamond matters. Without diff-caching, every rAF tick writes to the DOM, which is a 60Hz style recalc for zero visual change. With the cache the write happens exactly at section boundaries.
4. 4. Boundaries — who owns what
Five actors. Each owns a strictly non-overlapping surface. The whole feature works because none of them reach across into another's surface.
digraph Boundaries {
rankdir=LR;
bgcolor="#f8fafc";
labelloc="t"; label=<<b>Ownership boundaries</b>>;
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=10];
edge [fontname="Helvetica", fontsize=8, color="#475569", style=dashed];
// Publisher
subgraph cluster_pub {
label="publisher — publish.el + org-publish"; color="#3f6212"; fontcolor="#3f6212"; style=rounded; bgcolor="#ecfccb";
"emit <div id=\"table-of-contents\">";
"emit <a href=\"#id\">";
"emit h2/h3 with id attribute";
}
// CSS
subgraph cluster_css {
label="CSS — site/static/css/style.css"; color="#7e22ce"; fontcolor="#7e22ce"; style=rounded; bgcolor="#f3e8ff";
"body:has(#table-of-contents) gate";
"position: fixed + right rail";
"scroll-padding-top: 4.5rem";
".toc-active styling";
}
// Reducer
subgraph cluster_reducer {
label="pure reducer — wal-sh.site.toc-rail.core (.cljc)"; color="#a16207"; fontcolor="#a16207"; style=rounded; bgcolor="#fef3c7";
"activeId(state) -> id|null";
"atBottom clamp";
"reachableLimit math";
}
// Browser adapter
subgraph cluster_adapter {
label="browser adapter — wal-sh.site.toc-rail.browser (.cljs)"; color="#1d4ed8"; fontcolor="#1d4ed8"; style=rounded; bgcolor="#dbeafe";
"read geometry via getBoundingClientRect";
"rAF-throttle scroll handler";
"diff cache + DOM write";
}
// Browser (built-in)
subgraph cluster_browser {
label="browser built-ins"; color="#334155"; fontcolor="#334155"; style=rounded; bgcolor="#e2e8f0";
"scroll event dispatch";
"anchor-nav scroll positioning";
"smooth-scroll animation";
":has() selector matching";
}
"emit h2/h3 with id attribute" -> "read geometry via getBoundingClientRect" [label="ids stable within page-life"];
"emit <a href=\"#id\">" -> "diff cache + DOM write" [label="rail link addressability"];
"emit <div id=\"table-of-contents\">" -> "body:has(#table-of-contents) gate";
"scroll-padding-top: 4.5rem" -> "anchor-nav scroll positioning" [label="clearance for fixed header"];
".toc-active styling" -> "diff cache + DOM write" [label="class contract"];
"scroll event dispatch" -> "rAF-throttle scroll handler";
"read geometry via getBoundingClientRect" -> "activeId(state) -> id|null";
"activeId(state) -> id|null" -> "diff cache + DOM write";
}
Reading the arrows: every cross-boundary edge is a single-direction contract, and each is small enough to state in one sentence. If a future change forces an arrow to reverse, it's a spec-bump signal.
4.1. 4.1 Reducer ownership — what it must NOT touch
| Forbidden | Why |
|---|---|
document, window, Math.random |
Purity requirement — the reducer is graded against a JSON oracle on the JVM |
setTimeout, requestAnimationFrame |
Timing is the adapter's problem |
IntersectionObserver |
Alternative algorithm; the reducer decides from geometry, not from intersection events |
Any mutable state outside the state arg |
Idempotency — same input, same output, always |
4.2. 4.2 Adapter ownership — what it must NOT do
| Forbidden | Why |
|---|---|
| Decide which heading is active | That is the reducer's one job |
| Modify heading content or ids | Publisher-owned; adapter only READS |
Style the .toc-active class |
CSS-owned; adapter only toggles the class name |
| Fire tracker events | Separate concern — the tracker.js observer can pick up the class change via its own IntersectionObserver if wanted |
5. 5. The implicit org→html contract
Six guarantees the highlighter relies on, and two the highlighter
must NOT rely on. These aren't in the org manual as a "contract"
per se — they're what org-export-html observably does, and the
gap between "observable" and "guaranteed" is where the risk lives.
5.1. 5.1 Relied-on guarantees
| # | The exporter guarantees | Highlighter use |
|---|---|---|
| G1 | #+OPTIONS: toc:t emits <div id"table-of-contents"><div id="text-table-of-contents"><ul>= |
Gate: presence check |
| G2 | Every heading gets an id attribute |
headings array construction |
| G3 | The <ul> inside #text-table-of-contents has <a href"#<id>">= entries matching heading ids |
Class-toggle target |
| G4 | Heading order in the ToC matches DOM order | activeId scan invariant |
| G5 | :CUSTOM_ID: on a heading overrides the random id |
Stable-id path (see G7) |
| G6 | The publisher's canonical-URL filter rewrites .html paths but does NOT touch #hash fragments |
Rail anchors survive canonicalization |
5.2. 5.2 Must-not-rely-on guarantees
| # | The exporter DOES NOT guarantee | Consequence |
|---|---|---|
| G7 | Ids are stable across rebuilds — org-export-format-reference generates random 7-hex-digit ids per export (three exports of the same file produce three disjoint id sets, verified in experiments/toc-nav-simulator/) |
Highlighter reads DOM at load time; never caches ids across page-loads. Shared #hash URLs break on republish unless the source uses :CUSTOM_ID: on every heading. |
| G8 | Heading top coordinates are stable across viewport widths or font-loading |
Adapter recomputes on resize AND on load (after web-fonts settle) rather than trusting a single DOMContentLoaded read |
The G7 finding is why the reducer takes headings as an argument
rather than computing them from a URL or a manifest — the DOM is
the only source of truth for what ids exist right now.
6. 6. Bundle strategy
The site loads five sitewide JS bundles today (per
docs/views-projection.org §4):
tracker.js · page-tags.js · web-vitals-init.js · bot-signal.js
· global-pollution.js · event-pixel.js. Any of them could host
the highlighter. Options:
6.1. 6.1 Extend tracker.js (recommended)
- Add
wal-sh.site.toc-rail.core(.cljc, pure reducer, 77-line port ofexperiments/toc-nav-simulator/reducer.mjs) andwal-sh.site.toc-rail.browser(.cljs, ~50 lines) to the:site-trackershadow-cljs build. - The adapter reuses
wal-sh.site.tracker.browser' existingrAF-throttleutil (already handles scroll forscroll.depth). - Guard: gate the
toc-rail.browser/init!call on(and (.querySelector js/document "#table-of-contents") (>(.-innerWidth js/window) 1024))=, so pages without a TOC pay no cost. - Bundle-size cost: ~2 KB advanced-compiled (function-level) added
to the ~130 KB tracker.js. No new
<script>tag anywhere.
6.2. 6.2 New toc-rail.js bundle (rejected)
- Requires editing every template's postamble to add a new
<script src"/static/js/cljs/toc-rail.js">= include, OR touchingpublish.elto inject it into the exported HTML head. - Adds an HTTP round-trip on every page load.
- The only reason to prefer it: hard isolation from the tracker's
event vocabulary. But the two are ergonomically related — the
next feature after "highlight current section" is "fire a
section.dwellevent when a section is on the reading line ≥30s," which is already the tracker's shape.
6.3. 6.3 Piggyback on page-tags.js (rejected)
page-tags.jsrenders the keyword-chip footer; unrelated concern.- Would couple two independent UI behaviors in one bundle without a shared abstraction.
7. 7. Implementation sketch (follow-on, not this note)
Three files land, all in the :site-tracker shadow build:
src/wal_sh/site/toc_rail/core.cljc ; ~80 lines, port of reducer.mjs src/wal_sh/site/toc_rail/browser.cljs ; ~60 lines, adapter test/wal_sh/site/toc_rail/core_test.cljc ; grades against fixtures/geometry.json
core.cljc exposes (active-id state) and (reachable-limit state)
as pure functions. Test harness reads experiments/toc-nav-simulator/fixtures/geometry.json
and asserts the CLJC output byte-matches the JS oracle's output for
every step of every scenario.
browser.cljs wires:
init!— gate + buildheadingsarray + attach listeners.on-scroll— rAF-throttle →(active-id ...)→ diff-cache → DOM write.on-resize— recomputeheadings[].topanddocumentH.on-load— one delayed recompute after web-fonts settle, to catch G8.
Ledger block on land, with the reducer contract's own version as the pinned dependency.
8. 8. Refutation
The claim is that this reducer + adapter, added to tracker.js with
zero template touches, produces a working highlighter with no
regression on any current page. It fails if:
- The reducer disagrees with
reducer.mjson any fixture scenario (grading harness catches this in CI). - Rail links stop scrolling to the right position — implicates
scroll-padding-topinteracting with the class-toggle timing; test by clicking every link on a long note. - Pages without a TOC take a measurable perf hit — the gate at
init!should return in <1ms; measure withperformance.now()in dev. - The tracker's existing
scroll.depthevent count changes — the two handlers share the rAF-throttle util; a bug here shows up as event-count drift in the daily snapshot. - On a page with
:CUSTOM_ID:on every heading, the highlighter still misses one anchor — implicates G4 (heading order in ToC vs. DOM order). This is the one that would be a real spec bug.
9. Cross-references
- docs/toc-rail-reducer-contract.org — the ClojureScript reducer contract this note wraps
- experiments/toc-nav-simulator/README.org — the JS mirror + real org-publish fixtures + the G7 anchor-instability finding
- docs/views-projection.org — bundle-per-page map that §6.1 references
- tracker-rebuild-spec — the tracker whose rAF-throttle util the adapter reuses
site/static/css/style.css— the rail CSS (:has() gate + fixed positioning + scroll-padding-top)