Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
+++ src/routes/posts.js	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
@@ -24,4 +24,5 @@
 import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
 import { verifyBlob } from '../services/CryptoBox.js';
+import { postEntry } from '../services/PostAccessService.js';
 import MusicMeta from '../services/MusicMeta.js';
 import { mediaDir } from '../config/paths.js';
@@ -148,4 +149,7 @@
   'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
   'paid', 'push', 'guardian',
+  // De meeslepende tijdlijn (Barts idee, naar bartoverkamp.nl). Gereserveerd
+  // omdat een bericht met deze slug de route anders zou overschaduwen.
+  'lees',
 ]);
 
@@ -246,4 +250,53 @@
     bodyClass: 'on-home',
   });
+});
+
+// ==================== LEES (meeslepende tijdlijn) ====================
+//
+// Eén bericht vult het scherm, de chrome schuift weg, en je scrollt voorbij de
+// rand naar het vorige of volgende. Naar bartoverkamp.nl, waar prev/next
+// schildwachten boven en onder het artikel staan en het overscrollen zelf de
+// navigatie is.
+//
+// De buren komen uit postNeighbors(), dezelfde die de gewone postpagina al
+// gebruikt -- dus "vorige" en "volgende" betekenen hier precies hetzelfde als
+// daar, ook voor gepinde berichten.
+//
+// ?partial=1 levert alleen het artikel, want dat is wat de schildwacht inruilt.
+router.get('/lees/: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
+  // fout. Gepind eerst, net als op de voorpagina.
+  const post = req.params.slug
+    ? db.prepare(`SELECT p.*, u.username as author_username FROM posts p JOIN users u ON p.author_id = u.id
+                  WHERE p.site_id = ? AND p.slug = ?`).get(site.id, req.params.slug)
+    : db.prepare(`SELECT p.*, u.username as author_username FROM posts p JOIN users u ON p.author_id = u.id
+                  WHERE p.site_id = ? AND p.status = 'published'
+                  ORDER BY (p.pinned = 0) ASC, p.pinned ASC, p.published_at DESC LIMIT 1`).get(site.id);
+  if (!post) return next();
+
+  // Het besluit en het lijf komen uit één plek (PostAccessService). Een dichte
+  // poort levert hier GEEN tekst op: die wordt niet eens gerenderd.
+  const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
+  const unlockedSlug = (_u && _u.purpose === 'unlocked' && _u.siteId === site.id) ? String(_u.post) : null;
+  const entry = postEntry(post, { user: req.session?.user || null, site, unlockedSlug },
+    { renderBody: (p) => renderPostBodyHtml(site, p, req) });
+
+  if (entry.access === 'forbidden') return next();
+
+  const { newerPost, olderPost } = postNeighbors(site, post);
+  const model = {
+    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',
+  };
+  if (req.query.partial === '1' || req.headers['hx-request'] === 'true') {
+    return renderPage(req, res, 'partials/lees-article', model);
+  }
+  recordPageview(site.id, req);
+  return renderPage(req, res, 'pages/lees', model);
 });
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 8b65e67e700ea809fa6f1d5e556db13ed2a0696c)
+++ src/services/i18n.js	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
@@ -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',
+    'fgate.login': 'Inloggen / aanmelden', 'lees.exit': 'Terug naar de site', 'lees.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',
+    'fgate.login': 'Log in / sign up', 'lees.exit': 'Back to the site', 'lees.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',
+    'fgate.login': 'Anmelden / Registrieren', 'lees.exit': 'Zurück zur Seite', 'lees.exit_label': 'Lesemodus verlassen',
     'lbio.empty': 'Noch keine Links eingerichtet.',
     'lbio.back_to_site': 'zurück zur Seite',
Index: src/views/pages/lees.ejs
===================================================================
--- src/views/pages/lees.ejs	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
+++ src/views/pages/lees.ejs	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
@@ -0,0 +1,35 @@
+<%
+// 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/partials/lees-article.ejs
===================================================================
--- src/views/partials/lees-article.ejs	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
+++ src/views/partials/lees-article.ejs	(revision f5cf299d117e37bf9b4a410dcf3f49ad46836e62)
@@ -0,0 +1,39 @@
+<%
+// 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>
