Index: src/assets/js/mod/read.js
===================================================================
--- src/assets/js/mod/read.js	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
+++ src/assets/js/mod/read.js	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -0,0 +1,161 @@
+/**
+ * De leesweergave: een stroom van hele berichten, één per beeld.
+ *
+ * NIET de truc van bartoverkamp.nl. Daar springt de pagina bij de rand naar de
+ * andere kant en wordt het nieuwe artikel "vloeiend" in beeld gezet -- een naad
+ * die verborgen wordt. Hier groeit de stroom in de richting waarin je leest:
+ * bij de onderrand komt de oudere buur eronder te staan, bij de bovenrand de
+ * nieuwere erboven. De browser snapt naar de berichtgrenzen (CSS, proximity).
+ * Er is dan geen naad, dus ook niets te verbergen.
+ *
+ * Elk artikel draagt zijn eigen buren in data-attributen. Wat er nog te halen
+ * valt staat dus altijd op het stuk dat er al is, en deze module hoeft geen
+ * lijst bij te houden die uit de pas kan lopen.
+ */
+
+// Hoeveel schermen vooruit we alvast halen. Eén is genoeg: je leest niet
+// sneller dan je scrolt, en meer betekent alleen meer verkeer.
+const MARGE = '1px 0px 100% 0px';
+
+const stroom = () => document.getElementById('read-stream');
+const geladen = new Set();      // slugs die al in de stroom staan
+let bezigOnder = false;
+let bezigBoven = false;
+let waarnemer = null;
+let laatsteY = 0;
+
+function chrome(verbergen) {
+  document.body.classList.toggle('read-chrome-weg', !!verbergen);
+}
+
+/** Het artikel dat nu het meest in beeld is; die bepaalt titel en URL. */
+function inBeeld() {
+  const posts = [...document.querySelectorAll('.read-post')];
+  const midden = window.innerHeight / 2;
+  return posts.find((p) => {
+    const r = p.getBoundingClientRect();
+    return r.top <= midden && r.bottom >= midden;
+  }) || posts[0];
+}
+
+/** Titel en adres volgen wat je leest. replaceState: je bladert, je stapelt niet. */
+function volgAdres() {
+  const p = inBeeld();
+  if (!p) return;
+  const slug = p.dataset.slug;
+  const basis = p.dataset.base || '';
+  if (location.pathname === `${basis}/read/${slug}`) return;
+  history.replaceState(null, '', `${basis}/read/${slug}`);
+  if (p.dataset.title) document.title = p.dataset.title;
+}
+
+async function haal(slug, basis) {
+  const r = await fetch(`${basis}/read/${encodeURIComponent(slug)}?partial=1`, {
+    headers: { 'HX-Request': 'true' },
+    credentials: 'same-origin',
+  });
+  if (!r.ok) throw new Error('HTTP ' + r.status);
+  const doos = document.createElement('div');
+  doos.innerHTML = await r.text();
+  const art = doos.querySelector('.read-post');
+  if (!art) throw new Error('geen bericht in het antwoord');
+  return art;
+}
+
+/** Onderaan aanvullen: gewoon erbij zetten, de scrollpositie verandert niet. */
+async function vulAan() {
+  const s = stroom();
+  if (!s || bezigOnder) return;
+  const laatste = s.querySelector('.read-post:last-of-type');
+  const slug = laatste && laatste.dataset.older;
+  if (!slug) return klaar(s);
+  if (geladen.has(slug)) return;
+  bezigOnder = true;
+  const wacht = melding(s, 'read-laden', '…');
+  try {
+    const art = await haal(slug, laatste.dataset.base || '');
+    geladen.add(slug);
+    wacht.remove();
+    s.appendChild(art);
+    kijk();
+  } catch (e) {
+    wacht.textContent = 'Kon het volgende bericht niet laden.';
+    console.warn('[read] onderaan:', e && e.message);
+  } finally { bezigOnder = false; }
+}
+
+/**
+ * Bovenaan aanvullen. Dit is het enige plek waar we de scrollpositie WEL
+ * moeten bijstellen: er komt hoogte bij bóven het beeld, en zonder correctie
+ * schuift alles wat je las naar beneden weg. We meten de hoogte na het invoegen
+ * en tellen die bij de scrollpositie op -- geen animatie, geen sprong.
+ */
+async function vulAanBoven() {
+  const s = stroom();
+  if (!s || bezigBoven) return;
+  const eerste = s.querySelector('.read-post');
+  const slug = eerste && eerste.dataset.newer;
+  if (!slug || geladen.has(slug)) return;
+  bezigBoven = true;
+  try {
+    const art = await haal(slug, eerste.dataset.base || '');
+    geladen.add(slug);
+    const voor = s.scrollHeight;
+    s.insertBefore(art, eerste);
+    const erbij = s.scrollHeight - voor;
+    window.scrollBy(0, erbij);
+    kijk();
+  } catch (e) {
+    console.warn('[read] bovenaan:', e && e.message);
+  } finally { bezigBoven = false; }
+}
+
+function melding(s, klasse, tekst) {
+  const d = document.createElement('div');
+  d.className = klasse;
+  d.textContent = tekst;
+  s.appendChild(d);
+  return d;
+}
+
+function klaar(s) {
+  if (s.querySelector('.read-eind')) return;
+  melding(s, 'read-eind', 'Je bent bij het begin van het archief.');
+}
+
+/** (Her)richt de waarnemer op de huidige eerste en laatste post. */
+function kijk() {
+  if (waarnemer) waarnemer.disconnect();
+  waarnemer = new IntersectionObserver((entries) => {
+    for (const e of entries) {
+      if (!e.isIntersecting) continue;
+      if (e.target.matches('.read-post:last-of-type')) vulAan();
+      else if (e.target.matches('.read-post:first-of-type')) vulAanBoven();
+    }
+  }, { rootMargin: MARGE });
+  const eerste = stroom() && stroom().querySelector('.read-post');
+  const laatste = stroom() && stroom().querySelector('.read-post:last-of-type');
+  if (eerste) waarnemer.observe(eerste);
+  if (laatste && laatste !== eerste) waarnemer.observe(laatste);
+}
+
+function bijScroll() {
+  const y = window.scrollY || 0;
+  if (y < 8) chrome(false);
+  else if (y > laatsteY + 4) chrome(true);
+  else if (y < laatsteY - 24) chrome(false);
+  laatsteY = y;
+  volgAdres();
+}
+
+export function init() {
+  const s = stroom();
+  if (!s) return;
+  s.querySelectorAll('.read-post').forEach((p) => geladen.add(p.dataset.slug));
+  laatsteY = window.scrollY || 0;
+  window.addEventListener('scroll', bijScroll, { passive: true });
+  window.addEventListener('resize', kijk, { passive: true });
+  kijk();
+}
+
+export default { init };
