Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/assets/css/style.css	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -2353,6 +2353,5 @@
     padding: .35rem 0;
 }
-.view-switch-btn,
-.view-switch-link {
+.view-switch-btn {
     display: inline-flex;
     align-items: center;
@@ -2369,6 +2368,5 @@
     transition: all .15s;
 }
-.view-switch-btn:hover,
-.view-switch-link:hover {
+.view-switch-btn:hover {
     color: var(--ink);
     border-color: var(--ink-muted);
@@ -2400,4 +2398,71 @@
 body.on-home[data-feed-view="grid"]    .feed-grid,
 body.on-cirkel[data-feed-view="grid"]  .feed-grid { display: block; }
+
+/* -------- LEESWEERGAVE (data-feed-view="reader") --------
+   Dezelfde berichten als de tijdlijn, maar heel: één per scherm, grenzen door
+   scroll-snapping. GEEN eigen route en geen eigen shell -- de balken blijven
+   staan, want je bent nog op de feed en moet terug kunnen schakelen.
+   Pagina's zonder .feed-reader (cirkel, archief, post) negeren de keuze en
+   houden hun tijdlijn: er is hieronder simpelweg geen regel die die daar
+   verbergt. Zo is Lezen nooit een leeg scherm. */
+.feed-reader { display: none; }
+body.on-home[data-feed-view="reader"] .feed-reader { display: block; }
+/* :has(.feed-reader) is hier de LEGE STAAT, niet een truc. Zonder berichten
+   rendert de leessectie niet, en dan mag de tijdlijn niet verborgen worden --
+   want dáár staat "nog niets geschreven" met de knop om te beginnen. Zonder deze
+   voorwaarde is Lezen op een verse site een wit scherm. Hetzelfde geldt voor
+   feeds die (nog) geen leessectie hebben. */
+body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-timeline,
+body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-grid,
+body.on-home[data-feed-view="reader"]:has(.feed-reader) .pagination,
+body.on-home[data-feed-view="reader"]:has(.feed-reader) .related-posts,
+body.on-home[data-feed-view="reader"]:has(.feed-reader) .article-footer {
+    display: none !important;
+}
+
+/* Snappen doet de SCROLLCONTAINER, en dat is het document -- niet .read-stream,
+   die heeft overflow: visible en scrollt zelf niet. Proximity en geen mandatory:
+   in een bericht dat langer is dan het scherm moet je rustig door kunnen lezen. */
+html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: y proximity; }
+/* En de browser niet óók laten compenseren als Load more er berichten bij zet:
+   Chrome en Firefox schuiven bij invoegen zelf mee (scroll anchoring), en dat
+   vecht met het snappen. */
+html:has(body.on-home[data-feed-view="reader"]),
+.feed-reader .read-stream { overflow-anchor: none; }
+
+.feed-reader .read-post {
+    scroll-snap-align: start;
+    scroll-snap-stop: normal;
+    min-height: 100svh;
+    max-width: 46rem;
+    margin: 0 auto;
+    /* De onderrand loopt achter de vaste tabbalk door; deze padding houdt de
+       TEKST erboven. */
+    padding: 2rem 1.2rem 4rem;
+    box-sizing: border-box;
+}
+/* Een lijn tussen twee berichten: zonder grens is een stroom een lange brij. */
+.feed-reader .read-post + .read-post { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
+
+.feed-reader .read-head { padding-bottom: .5rem; }
+.feed-reader .read-title { font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .3rem; }
+/* De titel is een link, maar mag er niet als een link uitzien: dit is een
+   leesscherm, geen index. */
+.feed-reader .read-title a { color: inherit; text-decoration: none; }
+.feed-reader .read-title a:hover,
+.feed-reader .read-title a:focus-visible { text-decoration: underline; }
+.feed-reader .read-when { font-size: .85rem; opacity: .6; margin: 0; }
+
+.feed-reader .read-foot { margin-top: 2rem; }
+.feed-reader .read-open {
+    display: inline-block; font-size: .9rem; opacity: .75;
+    text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px;
+}
+.feed-reader .read-open:hover,
+.feed-reader .read-open:focus-visible { opacity: 1; }
+
+@media (prefers-reduced-motion: reduce) {
+    html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: none; }
+}
 
 /* -------- GRID VIEW TILES (Instagram-stijl) -------- */
Index: src/assets/js/mod/read.js
===================================================================
--- src/assets/js/mod/read.js	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/assets/js/mod/read.js	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -1,181 +1,21 @@
 /**
- * De leesweergave: een stroom van hele berichten, één per beeld.
+ * De leesweergave: tikken op een bericht opent dat bericht.
  *
- * 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.
+ * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
+ * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
+ * weg, en dat is winst: Lezen is nu een DERDE WEERGAVE van de feed
+ * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
+ * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
+ * doet CSS. Wat overblijft is dit ene gemak.
  *
- * 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.
+ * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
+ * voor toetsenbord en schermlezer; dit hier is er voor een duim.
  *
- * De namen hieronder zijn Engels, zoals overal in assets/js/mod. Het commentaar
- * blijft Nederlands, zoals overal in deze repo.
+ * Vier uitzonderingen, want een tik die je niet bedoelde is erger dan geen tik:
+ * iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen werking,
+ * een geselecteerde tekst is geen tik, een verschoven vinger is scrollen, en
+ * cmd/ctrl-klik hoort de browser zelf af te handelen.
  */
 
-// Hoeveel schermen vooruit we alvast halen. Eén is genoeg: je leest niet
-// sneller dan je scrolt, en meer betekent alleen meer verkeer.
-const ROOT_MARGIN = '1px 0px 100% 0px';
-
-// De teksten die dit script plaatst. Ze staan in data-i18n op de stroom, want
-// een zin die hier staat, staat in één taal -- en dat is nooit de taal van de
-// lezer. Zelfde afspraak als de topnav (mod/chrome.js).
-let strings = {};
-function str(key) { return strings[key] || ''; }
-
-const stream = () => document.getElementById('read-stream');
-const loaded = new Set();       // slugs die al in de stroom staan
-let busyBelow = false;
-let busyAbove = false;
-let observer = null;
-let lastY = 0;
-
-// Niet `chrome`: dat is in deze browser ook een global, en een module-scope die
-// window.chrome overschaduwt is een val voor de volgende lezer.
-function toggleChrome(hide) {
-  document.body.classList.toggle('read-chrome-hidden', !!hide);
-}
-
-/** Het artikel dat nu het meest in beeld is; die bepaalt titel en URL. */
-function inView() {
-  const posts = [...document.querySelectorAll('.read-post')];
-  const middle = window.innerHeight / 2;
-  return posts.find((p) => {
-    const r = p.getBoundingClientRect();
-    return r.top <= middle && r.bottom >= middle;
-  }) || posts[0];
-}
-
-/** Titel en adres volgen wat je leest. replaceState: je bladert, je stapelt niet. */
-function syncAddress() {
-  const p = inView();
-  if (!p) return;
-  const slug = p.dataset.slug;
-  const base = p.dataset.base || '';
-  if (location.pathname === `${base}/read/${slug}`) return;
-  history.replaceState(null, '', `${base}/read/${slug}`);
-  if (p.dataset.title) document.title = p.dataset.title;
-}
-
-async function fetchArticle(slug, base) {
-  const r = await fetch(`${base}/read/${encodeURIComponent(slug)}?fragment=1`, {
-    headers: { 'HX-Request': 'true' },
-    credentials: 'same-origin',
-  });
-  if (!r.ok) throw new Error('HTTP ' + r.status);
-  const box = document.createElement('div');
-  box.innerHTML = await r.text();
-  const art = box.querySelector('.read-post');
-  if (!art) throw new Error('no post in the response');
-  return art;
-}
-
-/** Onderaan aanvullen: gewoon erbij zetten, de scrollpositie verandert niet. */
-async function appendOlder() {
-  const s = stream();
-  if (!s || busyBelow) return;
-  const last = s.querySelector('.read-post:last-of-type');
-  const slug = last && last.dataset.older;
-  if (!slug) return markEnd(s);
-  if (loaded.has(slug)) return;
-  busyBelow = true;
-  const pending = notice(s, 'read-loading', '…');
-  try {
-    const art = await fetchArticle(slug, last.dataset.base || '');
-    loaded.add(slug);
-    pending.remove();
-    s.appendChild(art);
-    watch();
-  } catch (e) {
-    pending.textContent = str('load_error');
-    console.warn('[read] below:', e && e.message);
-  } finally { busyBelow = 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 prependNewer() {
-  const s = stream();
-  if (!s || busyAbove) return;
-  const first = s.querySelector('.read-post');
-  const slug = first && first.dataset.newer;
-  if (!slug || loaded.has(slug)) return;
-  busyAbove = true;
-  const root = document.documentElement;
-  try {
-    const art = await fetchArticle(slug, first.dataset.base || '');
-    loaded.add(slug);
-    // Snappen even UIT. De correctie hieronder zet de scrollpositie precies
-    // goed, maar een proximity-snap trekt hem daarna alsnog naar de grens van
-    // het bericht dat er net bij kwam -- gemeten: een sprong ter grootte van
-    // dat hele bericht, precies de ruk die deze opzet moet vermijden.
-    root.style.scrollSnapType = 'none';
-    const before = s.scrollHeight;
-    s.insertBefore(art, first);
-    const added = s.scrollHeight - before;
-    window.scrollBy(0, added);
-    watch();
-    // Twee frames: één om de correctie te laten landen, één om te snappen pas
-    // weer toe te staan als de browser klaar is met deze scroll.
-    requestAnimationFrame(() => requestAnimationFrame(() => { root.style.scrollSnapType = ''; }));
-  } catch (e) {
-    root.style.scrollSnapType = '';
-    console.warn('[read] above:', e && e.message);
-  } finally { busyAbove = false; }
-}
-
-function notice(s, className, text) {
-  const d = document.createElement('div');
-  d.className = className;
-  d.textContent = text;
-  s.appendChild(d);
-  return d;
-}
-
-function markEnd(s) {
-  if (s.querySelector('.read-end')) return;
-  notice(s, 'read-end', str('end'));
-}
-
-/** (Her)richt de waarnemer op de huidige eerste en laatste post. */
-function watch() {
-  if (observer) observer.disconnect();
-  observer = new IntersectionObserver((entries) => {
-    for (const e of entries) {
-      if (!e.isIntersecting) continue;
-      // GEEN else-if. Bij het laden staat er één artikel in de stroom, en dat is
-      // tegelijk het eerste én het laatste. Met een else-if wint de eerste tak
-      // altijd, en dan draait prependNewer() nooit: wie op het OUDSTE bericht
-      // binnenkomt, krijgt "einde archief" te zien terwijl er zes nieuwere
-      // boven hem staan die hij nooit te pakken krijgt.
-      if (e.target.matches('.read-post:last-of-type')) appendOlder();
-      if (e.target.matches('.read-post:first-of-type')) prependNewer();
-    }
-  }, { rootMargin: ROOT_MARGIN });
-  const first = stream() && stream().querySelector('.read-post');
-  const last = stream() && stream().querySelector('.read-post:last-of-type');
-  if (first) observer.observe(first);
-  if (last && last !== first) observer.observe(last);
-}
-
-/**
- * Tikken op een bericht opent dat bericht.
- *
- * De stroom toont de tekst; het gesprek -- reacties, waarderingen, boosts --
- * staat op de berichtpagina. De titel en de voetlink zijn echte <a>'s en doen
- * het werk voor toetsenbord en schermlezer; dit hier is het gemak voor een duim.
- *
- * Daarom vier uitzonderingen, want een tik die je niet bedoelde is erger dan
- * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
- * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
- * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
- */
 let tapX = 0, tapY = 0;
 function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
@@ -197,25 +37,14 @@
 }
 
-function onScroll() {
-  const y = window.scrollY || 0;
-  if (y < 8) toggleChrome(false);
-  else if (y > lastY + 4) toggleChrome(true);
-  else if (y < lastY - 24) toggleChrome(false);
-  lastY = y;
-  syncAddress();
-}
-
 export function init() {
-  const s = stream();
+  const s = document.getElementById('read-stream');
   if (!s) return;
-  try { strings = JSON.parse(s.getAttribute('data-i18n') || '{}'); } catch (e) { strings = {}; }
-  s.querySelectorAll('.read-post').forEach((p) => loaded.add(p.dataset.slug));
-  lastY = window.scrollY || 0;
-  window.addEventListener('scroll', onScroll, { passive: true });
-  window.addEventListener('resize', watch, { passive: true });
-  // Op de stroom, niet per artikel: aangevulde berichten doen zo vanzelf mee.
+  // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
+  // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
+  // dezelfde afhandelaar zich op en vuurt hij twee keer.
+  s.removeEventListener('pointerdown', onPointerDown);
+  s.removeEventListener('click', onTap);
   s.addEventListener('pointerdown', onPointerDown, { passive: true });
   s.addEventListener('click', onTap);
-  watch();
 }
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/routes/posts.js	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -232,5 +232,8 @@
 
   if (append) {
-    return renderPage(req, res, 'partials/home-append', { posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
+    return renderPage(req, res, 'partials/home-append', {
+      posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
+      readerItems: readerItems(site, posts, req),
+    });
   }
 
@@ -243,4 +246,5 @@
     pinnedPosts,
     posts,
+    readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
     hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     movedTo,
@@ -249,66 +253,7 @@
     socialDescr: site.description || site.tagline || '',
     bodyClass: 'on-home',
+    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
+    pageJs: 'read',
   });
-});
-
-// ==================== 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('/read/:slug?', (req, res, next) => {
-  const site = res.locals.site;
-  if (!site) return next();
-
-  // 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
-    ? 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-read zet de chrome weg; de mini-topnav blijft (chrome.ejs, _headerless).
-    bodyClass: 'on-read on-special',
-    pageJs: 'read',
-  };
-  // ALLEEN de leesmodule krijgt één artikel; ?fragment=1 is haar eigen teken.
-  //
-  // Hier stond `?partial=1 || hx-request`, en dat waren twee dingen door elkaar.
-  // Op deze site betekent ?partial=1 al iets anders -- "de pagina zonder de
-  // schil", voor htmx -- dus een gebooste link naar /read kreeg één kaal
-  // artikel in #pcms-main geschoven: geen stroom, geen on-read, geen script.
-  // Onzichtbaar zolang je /read alleen intikt; zichtbaar zodra er een link naar
-  // toe bestaat (de Lezen-knop in de view-switcher).
-  //
-  // Alles wat GEEN fragment vraagt gaat naar pages/read, en renderPage bepaalt
-  // daar zelf of de schil eromheen moet -- net als bij elke andere pagina.
-  if (req.query.fragment === '1') {
-    return renderPage(req, res, 'partials/read-article', model);
-  }
-  recordPageview(site.id, req);
-  return renderPage(req, res, 'pages/read', model);
 });
 
@@ -748,4 +693,20 @@
 // Renders a post's display HTML: baked content + the dynamic audio/embed layer.
 // Extracted so the paid unlock (slice 4) serves the exact same body as the page.
+// Dezelfde berichten, klaar voor de leesweergave.
+//
+// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
+// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
+// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
+//
+// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
+// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
+function readerItems(site, rows, req) {
+  const viewer = { user: req.session?.user || null, site, unlockedSlug: null };
+  return rows.map((post) => ({
+    post,
+    entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
+  }));
+}
+
 export function renderPostBodyHtml(site, post, req) {
   let html = (post.content_rendered != null && post.content_rendered !== '')
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/services/i18n.js	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -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', 'read.open': 'Reacties en waarderingen', 'read.end': 'Je bent bij het begin van het archief.', 'read.load_error': 'Kon het volgende bericht niet laden.',
+    'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
     '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', 'read.open': 'Replies and reactions', 'read.end': 'You have reached the beginning of the archive.', 'read.load_error': 'Could not load the next post.',
+    'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
     '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', 'read.open': 'Antworten und Reaktionen', 'read.end': 'Du bist am Anfang des Archivs angekommen.', 'read.load_error': 'Der nächste Beitrag konnte nicht geladen werden.',
+    'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
     'lbio.empty': 'Noch keine Links eingerichtet.',
     'lbio.back_to_site': 'zurück zur Seite',
Index: src/views/pages/home.ejs
===================================================================
--- src/views/pages/home.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/views/pages/home.ejs	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -59,4 +59,20 @@
 </div>
 
+<!-- ========== LEZEN (alleen zichtbaar bij body[data-feed-view="reader"]) ==========
+     Hetzelfde stel berichten, maar heel: één per scherm, met de grenzen door
+     CSS-snapping. Geen eigen route en geen eigen shell -- de balken blijven
+     staan, want je bent nog gewoon op de feed en moet terug kunnen schakelen.
+     Leeg? Dan valt de lege staat van de tijdlijn hierboven in beeld; deze
+     sectie rendert dan niet. -->
+<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %>
+<section class="feed-reader" aria-label="<%= t('switch.reader') %>">
+  <div class="read-stream" id="read-stream" data-base="<%= _base %>">
+    <% readerItems.forEach(function(it) { %>
+      <%- include('../partials/read-article', { post: it.post, entry: it.entry }) %>
+    <% }); %>
+  </div>
+</section>
+<% } %>
+
 <!-- ========== GRID (only visible when body[data-feed-view="grid"]) ========== -->
 <% if (allPosts.length > 0) { %>
Index: c/views/pages/read.ejs
===================================================================
--- src/views/pages/read.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ 	(revision )
@@ -1,111 +1,0 @@
-<%
-// 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 : '';
-%>
-
-<%# De teksten die het script zelf plaatst, komen van hier -- zoals de
-    zoekteksten van de topnav (zie mod/chrome.js). Een zin die in het
-    script staat, staat in één taal, en dat is nooit de taal van de lezer. %>
-<div class="read-stream" id="read-stream" data-base="<%= _b %>"
-     data-i18n="<%= JSON.stringify({ end: t('read.end'), load_error: t('read.load_error') }) %>">
-  <%- include('../partials/read-article', {
-        post: post, entry: entry, newerPost: newerPost, olderPost: olderPost,
-      }) %>
-</div>
-
-<%# Geen eigen uitgangsknop. De balken schuiven terug zodra je omhoog scrolt
-    (en staan er sowieso bovenaan), dus de weg terug is altijd één veeg weg. Een
-    zwevende knop erbij zou een tweede uitgang zijn die hetzelfde doet. %>
-
-<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-hidden #pcms-chrome { transform: translateY(-100%); opacity: 0; }
-.on-read.read-chrome-hidden .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.
-
-   OP <html>, en dat is geen smaakkwestie: snappen doet de SCROLLCONTAINER, en
-   dat is hier het document -- .read-stream heeft overflow: visible en scrollt
-   zelf niet. Op .read-stream stond de regel er wel, maar deed hij niets. */
-html:has(body.on-read) { scroll-snap-type: y proximity; }
-
-/* De browser NIET ook laten compenseren. Chrome en Firefox schuiven bij
-   invoegen bóven het beeld de scrollpositie zelf mee (scroll anchoring); samen
-   met onze eigen correctie in mod/read.js is dat twee keer, en dan springt de
-   tekst precies één bericht weg. Onze eigen correctie moet blijven, want Safari
-   (en dus iOS) kent scroll anchoring helemaal niet. */
-html:has(body.on-read), .on-read .read-stream, .on-read .read-post { overflow-anchor: none; }
-.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); }
-
-/* De titel is een link, maar mag er niet als een link uitzien: dit is een
-   leesscherm, geen index. Onderstrepen pas als je hem aanwijst. */
-.on-read .read-title a { color: inherit; text-decoration: none; }
-.on-read .read-title a:hover,
-.on-read .read-title a:focus-visible { text-decoration: underline; }
-
-/* De aankondiging onderaan: waar de tik je heen brengt. */
-.on-read .read-foot { margin-top: 2rem; }
-.on-read .read-open {
-  display: inline-block; font-size: .9rem; opacity: .75;
-  text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px;
-}
-.on-read .read-open:hover, .on-read .read-open:focus-visible { opacity: 1; }
-
-.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-loading { text-align: center; padding: 1.2rem; opacity: .6; font-size: .9rem; }
-.on-read .read-end  { text-align: center; padding: 2rem 1rem 3rem; opacity: .5; font-size: .9rem; }
-
-.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; }
-  html:has(body.on-read) { scroll-snap-type: none; }
-}
-</style>
Index: src/views/partials/home-append.ejs
===================================================================
--- src/views/partials/home-append.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/views/partials/home-append.ejs	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -4,3 +4,6 @@
     items instead of nesting under an extra box. %>
 <div hx-swap-oob="beforeend:#grid-tiles" style="display:contents"><% posts.forEach(function(post){ %><%- include('post-tile', { post: post }) %><% }); %></div>
+<%# Lezen: dezelfde berichten OOB achteraan de leesstroom, net als de tegels
+    hierboven. Zonder dit zou "meer laden" in de leesweergave niets doen. %>
+<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %><div hx-swap-oob="beforeend:#read-stream" style="display:contents"><% readerItems.forEach(function(it){ %><%- include('read-article', { post: it.post, entry: it.entry }) %><% }); %></div><% } %>
 <%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: (typeof morePath !== 'undefined' ? morePath : '/'), oob: true }) %>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/views/partials/shared-styles.ejs	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -27,6 +27,5 @@
   padding: 0.15rem;
 }
-.view-switch-btn,
-.view-switch-link {
+.view-switch-btn {
   display: inline-flex; align-items: center; gap: 0.35rem;
   padding: 0.35rem 0.85rem;
@@ -36,12 +35,9 @@
   transition: background 120ms, color 120ms;
 }
-.view-switch-btn:hover,
-.view-switch-link:hover { color: var(--ink); }
-.view-switch-link { text-decoration: none; }
+.view-switch-btn:hover { color: var(--ink); }
 /* Active = white (light background, dark text). Via aria-selected (element
    attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non-
    feed pages syncAria sets aria-selected=false → both grey. */
-.view-switch-btn[aria-selected="true"],
-.view-switch-link[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
+.view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
 
 /* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
Index: src/views/partials/view-switcher.ejs
===================================================================
--- src/views/partials/view-switcher.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/views/partials/view-switcher.ejs	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -8,8 +8,6 @@
   // Pagina's zonder actieve feed-weergave → niets in de pill is 'actief' (Solo/Cirkel
   // + Tijdlijn/Grid grijs). Alleen Agenda krijgt z'n eigen is-active op de agenda.
-  var _neutral = ['on-shows','on-downloads','on-post','on-epk','on-linkbio','on-newsletter','on-archive','on-read']
+  var _neutral = ['on-shows','on-downloads','on-post','on-epk','on-linkbio','on-newsletter','on-archive']
                    .some(function(c){ return _bc.indexOf(c) >= 0; });
-  var _onRead = _bc.indexOf('on-read') >= 0;
-  var _sbR = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 <div class="view-switcher-wrap">
@@ -47,16 +45,12 @@
         <span><%= t('switch.grid') %></span>
       </button>
-      <%# Lezen is GEEN in-place omschakeling maar een andere route (/read), dus
-          een <a> en niet een <button>. En bewust NIET de klasse view-switch-btn:
-          de gedelegeerde klik in shell.ejs pakt alles met die klasse op, zet
-          body[data-feed-view] erop en springt op niet-feedpagina's terug naar
-          home -- dat zou deze link opeten. Andere klasse, zelfde uiterlijk.
-          aria-selected staat hier vast: syncAria() raakt alleen .view-switch-btn
-          aan, dus de actieve staat op /read blijft staan na een htmx-navigatie. %>
-      <a class="view-switch-link" href="<%= _sbR %>/read" role="tab"
-         aria-selected="<%= _onRead ? 'true' : 'false' %>">
+      <%# Lezen is een gewone derde weergave: dezelfde berichten, andere vorm.
+          Dus een <button> met data-view, net als de andere twee -- dan doen de
+          gedelegeerde klik, localStorage en syncAria in shell.ejs hun werk al. %>
+      <button type="button" class="view-switch-btn" data-view="reader" role="tab"
+              aria-selected="false">
         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
         <span><%= t('switch.reader') %></span>
-      </a>
+      </button>
     </div>
     <% if (!_neutral) { %>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 2d2fd8c352950d0d5ff366c901fb7e2e0cf6d614)
+++ src/views/shell.ejs	(revision 7cf8144b1073d0f9d019acfcd4ee74b4e6a4dccd)
@@ -222,5 +222,5 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=68">
+<link rel="stylesheet" href="/assets/css/style.css?v=70">
 <script>
 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
@@ -435,12 +435,8 @@
   // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
   // ALLES wat de server als page-class kan sturen moet hier staan: wat er niet in
-  // staat wordt bij een htmx-navigatie stilletjes weggefilterd. 'on-read' ontbrak,
-  // en dan kwam je via de Lezen-knop op een leesweergave waarvan ELKE regel
-  // (.on-read ...) niet aansloeg: geen schermhoge berichten, geen snappen, geen
-  // wegschuivende balken. Bij intikken van /read ging het wel goed, want dan
-  // rendert de shell de class zelf -- vandaar dat het lang onzichtbaar bleef.
+  // staat wordt bij een htmx-navigatie stilletjes weggefilterd.
   var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
     'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
-    'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat','on-read'];
+    'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
 
   document.body.addEventListener('pcmsNav', function(ev) {
@@ -509,5 +505,5 @@
   // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
   // bumpen kost een bugfix die nooit aankomt.
-  var MOD_V = 3;
+  var MOD_V = 4;
 
   // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
@@ -554,5 +550,5 @@
   try {
     var v = localStorage.getItem('pcms-feed-view');
-    if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
+    if (v === 'timeline' || v === 'grid' || v === 'reader') body.dataset.feedView = v;
     var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
     if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
