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 };
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 9622f20fcfd256d8b1a7f89671b3f415d48cf12f)
+++ src/routes/posts.js	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -149,7 +149,7 @@
   'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
   'paid', 'push', 'guardian',
-  // De meeslepende tijdlijn (Barts idee, naar bartoverkamp.nl). Gereserveerd
+  // De meeslepende leesweergave. Gereserveerd
   // omdat een bericht met deze slug de route anders zou overschaduwen.
-  'lees',
+  'read',
 ]);
 
@@ -264,9 +264,9 @@
 //
 // ?partial=1 levert alleen het artikel, want dat is wat de schildwacht inruilt.
-router.get('/lees/:slug?', (req, res, next) => {
+router.get('/read/:slug?', (req, res, next) => {
   const site = res.locals.site;
   if (!site) return next();
 
-  // Zonder slug: het nieuwste bericht, zodat /lees een ingang is en niet een
+  // Zonder slug: het nieuwste bericht, zodat /read een ingang is en niet een
   // fout. Gepind eerst, net als op de voorpagina.
   const post = req.params.slug
@@ -291,12 +291,13 @@
     post, entry, newerPost, olderPost,
     pageTitle: post.title || site.title,
-    // on-lees zet de chrome weg; de mini-topnav blijft (chrome.ejs, _headerless).
-    bodyClass: 'on-lees on-special',
+    // on-read zet de chrome weg; de mini-topnav blijft (chrome.ejs, _headerless).
+    bodyClass: 'on-read on-special',
+    pageJs: 'read',
   };
   if (req.query.partial === '1' || req.headers['hx-request'] === 'true') {
-    return renderPage(req, res, 'partials/lees-article', model);
+    return renderPage(req, res, 'partials/read-article', model);
   }
   recordPageview(site.id, req);
-  return renderPage(req, res, 'pages/lees', model);
+  return renderPage(req, res, 'pages/read', model);
 });
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 9622f20fcfd256d8b1a7f89671b3f415d48cf12f)
+++ src/services/i18n.js	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -891,5 +891,5 @@
     'fgate.title': 'Alleen voor vrienden',
     'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',
-    'fgate.login': 'Inloggen / aanmelden', 'lees.exit': 'Terug naar de site', 'lees.exit_label': 'Uit het leesscherm',
+    'fgate.login': 'Inloggen / aanmelden', 'read.exit': 'Terug naar de site', 'read.exit_label': 'Uit het leesscherm',
     'lbio.empty': 'Nog geen links ingesteld.',
     'lbio.back_to_site': 'naar de site',
@@ -1897,5 +1897,5 @@
     'fgate.title': 'Friends only',
     'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',
-    'fgate.login': 'Log in / sign up', 'lees.exit': 'Back to the site', 'lees.exit_label': 'Leave reading view',
+    'fgate.login': 'Log in / sign up', 'read.exit': 'Back to the site', 'read.exit_label': 'Leave reading view',
     'lbio.empty': 'No links set up yet.',
     'lbio.back_to_site': 'back to the site',
@@ -2903,5 +2903,5 @@
     'fgate.title': 'Nur für Freunde',
     'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',
-    'fgate.login': 'Anmelden / Registrieren', 'lees.exit': 'Zurück zur Seite', 'lees.exit_label': 'Lesemodus verlassen',
+    'fgate.login': 'Anmelden / Registrieren', 'read.exit': 'Zurück zur Seite', 'read.exit_label': 'Lesemodus verlassen',
     'lbio.empty': 'Noch keine Links eingerichtet.',
     'lbio.back_to_site': 'zurück zur Seite',
Index: c/views/pages/lees.ejs
===================================================================
--- src/views/pages/lees.ejs	(revision 9622f20fcfd256d8b1a7f89671b3f415d48cf12f)
+++ 	(revision )
@@ -1,35 +1,0 @@
-<%
-// De meeslepende tijdlijn: één bericht vult het scherm, de chrome gaat weg.
-//
-// Barts idee, naar bartoverkamp.nl: lezen zonder balken, en voorbij de rand
-// scrollen brengt je bij de buurman. De ⏫/⏬ verschijnen bij de rand en worden
-// 🔄 als je ver genoeg doortrekt; de 👋 blijft staan als weg terug, want een
-// scherm zonder uitgang is geen scherm maar een val.
-//
-// Alles wat beweegt zit in de client (pageJs 'lees'). Zonder JavaScript blijft
-// dit een leesbaar artikel met twee gewone links -- dat is met opzet: een
-// tijdlijn die alleen met script bestaat, bestaat niet voor wie hem uitzet.
-var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-%>
-
-<div class="lees-shell" id="lees-shell">
-  <%- include('../partials/lees-article', {
-        post: post, entry: entry, newerPost: newerPost, olderPost: olderPost,
-      }) %>
-
-  <%# De weg terug. Blijft zichtbaar als de chrome weg is -- op bartoverkamp.nl
-      is de 👋 de sessieknop in het zwevende menu, en hier doet hij hetzelfde
-      werk: hij brengt je uit het leesscherm terug naar de site. %>
-  <nav class="lees-exit" aria-label="<%= t('lees.exit_label') %>">
-    <a class="lees-hand" href="<%= _b %>/" title="<%= t('lees.exit') %>" aria-label="<%= t('lees.exit') %>">👋</a>
-  </nav>
-
-  <%# Zonder script geen overscroll: dan zijn dit gewoon twee links, en kom je
-      er nog steeds doorheen. %>
-  <noscript>
-    <nav class="lees-fallback">
-      <% if (newerPost) { %><a href="<%= _b %>/lees/<%= newerPost.slug %>">⏫ <%= newerPost.title %></a><% } %>
-      <% if (olderPost) { %><a href="<%= _b %>/lees/<%= olderPost.slug %>">⏬ <%= olderPost.title %></a><% } %>
-    </nav>
-  </noscript>
-</div>
Index: src/views/pages/read.ejs
===================================================================
--- src/views/pages/read.ejs	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
+++ src/views/pages/read.ejs	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -0,0 +1,90 @@
+<%
+// De leesweergave: berichten achter elkaar, één per beeld, en de balken gaan weg.
+//
+// Barts idee, naar bartoverkamp.nl -- maar NIET met de truc die daar staat.
+// Daar wordt bij de rand naar de andere kant gesprongen en het artikel
+// "vloeiend" in beeld gezet; dat is een naad die je verbergt. Hier groeit de
+// stroom gewoon: bij de onderrand hangt de client de oudere buur eronder, bij de
+// bovenrand de nieuwere erboven, en de browser snapt naar de berichtgrenzen.
+// Dan is er geen naad om te verbergen.
+//
+// Zonder JavaScript blijft dit één leesbaar bericht met twee gewone links. Een
+// leesweergave die alleen met script bestaat, bestaat niet voor wie het uitzet.
+var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
+%>
+
+<div class="read-stream" id="read-stream" data-base="<%= _b %>">
+  <%- include('../partials/read-article', {
+        post: post, entry: entry, newerPost: newerPost, olderPost: olderPost,
+      }) %>
+</div>
+
+<%# De weg terug. Blijft staan als de balken weg zijn -- een scherm zonder
+    uitgang is geen scherm maar een val. %>
+<nav class="read-exit" aria-label="<%= t('read.exit_label') %>">
+  <a class="read-hand" href="<%= _b %>/" title="<%= t('read.exit') %>" aria-label="<%= t('read.exit') %>">👋</a>
+</nav>
+
+<noscript>
+  <nav class="read-fallback">
+    <% if (newerPost) { %><a href="<%= _b %>/read/<%= newerPost.slug %>">← <%= newerPost.title %></a><% } %>
+    <% if (olderPost) { %><a href="<%= _b %>/read/<%= olderPost.slug %>"><%= olderPost.title %> →</a><% } %>
+  </nav>
+</noscript>
+
+<style>
+/* Alles onder .on-read: dit is een nieuw scherm op een LIVE site, dus niets
+   hiervan mag daarbuiten iets aanraken. */
+
+/* De balken schuiven weg zodra je leest. Transform en niet display: een
+   schuivende balk laat zien dat hij terugkomt, een verdwijnende niet. */
+.on-read #pcms-chrome,
+.on-read .bottom-tab {
+  transition: transform .28s ease, opacity .28s ease;
+  will-change: transform;
+}
+.on-read.read-chrome-weg #pcms-chrome { transform: translateY(-100%); opacity: 0; }
+.on-read.read-chrome-weg .bottom-tab  { transform: translateY(100%);  opacity: 0; }
+
+/* De vrije ruimte onder de laatste post moet weg: anders eindigt het document
+   ver onder het laatste bericht en klopt "bij de rand" niet meer. */
+.on-read #pcms-main { padding-bottom: 0; }
+
+/* PROXIMITY, geen mandatory. Een bericht dat langer is dan het scherm moet je
+   rustig kunnen doorlezen; mandatory zou je bij elke pauze terugtrekken naar
+   een grens. Zo snapt hij alleen als je in de buurt van een grens bent. */
+.on-read .read-stream { scroll-snap-type: y proximity; }
+.on-read .read-post {
+  scroll-snap-align: start;
+  scroll-snap-stop: normal;
+  min-height: 100svh;
+  max-width: 46rem;
+  margin: 0 auto;
+  padding: 2rem 1.2rem 4rem;
+  box-sizing: border-box;
+}
+/* Een lijn tussen twee berichten: zonder grens is een stroom een lange brij. */
+.on-read .read-post + .read-post { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
+
+.on-read .read-head { padding-bottom: .5rem; }
+.on-read .read-title { font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .3rem; }
+.on-read .read-when { font-size: .85rem; opacity: .6; margin: 0; }
+
+/* Bezig / mislukt: de lezer hoort te merken dat er iets gebeurt, en vooral dat
+   er NIETS gebeurde als het misging. */
+.on-read .read-laden { text-align: center; padding: 1.2rem; opacity: .6; font-size: .9rem; }
+.on-read .read-eind  { text-align: center; padding: 2rem 1rem 3rem; opacity: .5; font-size: .9rem; }
+
+.on-read .read-exit { position: fixed; right: 1rem; bottom: 1rem; z-index: 60; }
+.on-read .read-hand {
+  display: grid; place-items: center; width: 3rem; height: 3rem;
+  font-size: 1.5rem; text-decoration: none; border-radius: 50%;
+  background: var(--paper, #fff); box-shadow: 0 2px 10px rgba(0,0,0,.18);
+}
+.on-read .read-fallback { display: flex; gap: 1rem; padding: 1rem 1.2rem; }
+
+@media (prefers-reduced-motion: reduce) {
+  .on-read #pcms-chrome, .on-read .bottom-tab { transition: none; }
+  .on-read .read-stream { scroll-snap-type: none; }
+}
+</style>
Index: c/views/partials/lees-article.ejs
===================================================================
--- src/views/partials/lees-article.ejs	(revision 9622f20fcfd256d8b1a7f89671b3f415d48cf12f)
+++ 	(revision )
@@ -1,39 +1,0 @@
-<%
-// Eén artikel in de meeslepende tijdlijn, met zijn schildwachten.
-//
-// Dit is precies het stuk dat bij het overscrollen wordt ingeruild, dus alles
-// wat de navigatie nodig heeft staat erin: welke buren er zijn, en waar ze
-// wonen. De client leest die van data-attributen en hoeft niets te onthouden --
-// na een verwisseling klopt de staat vanzelf, want hij komt uit het nieuwe stuk.
-//
-// De schildwachten staan BINNEN dit blok en niet eromheen: zouden ze buiten
-// staan, dan blijven ze na een swap naar het vorige bericht wijzen.
-var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-var _newer = (typeof newerPost !== 'undefined' && newerPost) ? newerPost : null;
-var _older = (typeof olderPost !== 'undefined' && olderPost) ? olderPost : null;
-%>
-<article class="lees-article"
-         id="lees-article"
-         data-slug="<%= post.slug %>"
-         data-newer="<%= _newer ? _newer.slug : '' %>"
-         data-older="<%= _older ? _older.slug : '' %>"
-         data-base="<%= _b %>">
-
-  <%# Schildwacht BOVEN: verschijnt pas als je voorbij de bovenrand trekt.
-      Leeg als er niets nieuwers is -- dan hoort er ook niets te gebeuren. %>
-  <div class="lees-sentinel lees-sentinel--prev" data-dir="newer" aria-hidden="true"
-       <% if (!_newer) { %>hidden<% } %>><span class="lees-cue">⏫</span></div>
-
-  <header class="lees-head">
-    <h1 class="lees-title"><%= post.title || '' %></h1>
-    <% if (post.published_at) { %>
-      <p class="lees-when"><%= formatDateTime(post.published_at) %></p>
-    <% } %>
-  </header>
-
-  <%- include('post-body', { post: post, access: entry.access, teaser: entry.teaser, _base: _b }) %>
-
-  <%# Schildwacht ONDER. %>
-  <div class="lees-sentinel lees-sentinel--next" data-dir="older" aria-hidden="true"
-       <% if (!_older) { %>hidden<% } %>><span class="lees-cue">⏬</span></div>
-</article>
Index: src/views/partials/post-body.ejs
===================================================================
--- src/views/partials/post-body.ejs	(revision 9622f20fcfd256d8b1a7f89671b3f415d48cf12f)
+++ src/views/partials/post-body.ejs	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -19,8 +19,12 @@
 var _b = (typeof _base !== 'undefined' && _base) ? _base : '';
 var _href = _b + '/' + post.slug;
+// Het lijf komt als eigen waarde binnen en wordt NIET uit `post` gevist. De rij
+// draagt nog de ruwe `content`; door alleen te tonen wat de aanroeper bewust
+// meegeeft, kan een gesloten poort hier niets laten ontsnappen.
+var _html = (typeof content_html !== 'undefined' && content_html) ? content_html : '';
 %>
 
 <% if (_acc === 'full') { %>
-  <div class="post-content"><%- post.content_html %></div>
+  <div class="post-content"><%- _html %></div>
 
 <% } else if (_acc === 'paid') { %>
Index: src/views/partials/read-article.ejs
===================================================================
--- src/views/partials/read-article.ejs	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
+++ src/views/partials/read-article.ejs	(revision b67edbdfb84f7d125475d92c470edf68a7886ccf)
@@ -0,0 +1,31 @@
+<%
+// Eén bericht in de leesstroom.
+//
+// Dit is het stuk dat de client AANVULT: bij de onderrand komt de oudere buur
+// eronder, bij de bovenrand de nieuwere erboven. Geen sprongen, geen
+// scrollpositie die stiekem verzet wordt -- de stroom groeit gewoon in de
+// richting waarin je leest, en de browser snapt naar de grenzen.
+//
+// Elk artikel draagt zijn eigen buren, dus de client hoeft niets te onthouden:
+// wat er nog te halen valt staat op het stuk dat er al is.
+var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
+var _newer = (typeof newerPost !== 'undefined' && newerPost) ? newerPost : null;
+var _older = (typeof olderPost !== 'undefined' && olderPost) ? olderPost : null;
+%>
+<article class="read-post"
+         data-slug="<%= post.slug %>"
+         data-newer="<%= _newer ? _newer.slug : '' %>"
+         data-older="<%= _older ? _older.slug : '' %>"
+         data-base="<%= _b %>"
+         data-title="<%= post.title || '' %>"
+         aria-label="<%= post.title || '' %>">
+  <header class="read-head">
+    <h1 class="read-title"><%= post.title || '' %></h1>
+    <% if (post.published_at) { %><p class="read-when"><%= formatDateTime(post.published_at) %></p><% } %>
+  </header>
+
+  <%- include('post-body', {
+        post: post, access: entry.access, teaser: entry.teaser,
+        content_html: entry.content_html, _base: _b,
+      }) %>
+</article>
