Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
+++ src/assets/js/mod/messages.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
@@ -0,0 +1,132 @@
+// Berichten: filteren, zoeken, en gesprekken in- en uitklappen (shaer-bqr).
+//
+// Dit stond inline in pages/messages.ejs. Dat werkte alleen na een volledige
+// laadbeurt: kwam je hier via een link BINNEN de site, dan arriveerde het script
+// via htmx met een nonce die het document niet kent, en weigerde de CSP het
+// (shaer-0i6). Chips, zoeken en het inklappen deden dan niets, en de reply-editor
+// laadde niet -- dat was Barts melding.
+//
+// TWEE DINGEN VERANDERD bij de verhuizing, en ze zijn allebei nodig omdat een
+// module ANDERS leeft dan een inline script:
+//
+//   NIETS VASTHOUDEN   het inline script pakte .msg-list, #msg-q en .msg-nomatch
+//                      een keer bij het inladen. Een module wordt per document
+//                      maar EEN keer geimporteerd, dus als je Berichten verlaat
+//                      en terugkomt wijzen die naar elementen die er niet meer
+//                      zijn. Nu wordt er per keer opgezocht.
+//   OPNIEUW INDEXEREN  bij binnenkomst op deze pagina, niet alleen bij "meer
+//                      laden". Anders is de lijst na een navigatie niet
+//                      geindexeerd en filtert het zoeken op niets.
+//
+// Alles gedelegeerd op document, met een slot: de module kan een tweede keer
+// geladen worden en mag dan geen tweede stel luisteraars neerzetten.
+
+(function () {
+  'use strict';
+  if (window.__msgWired) return;
+  window.__msgWired = true;
+
+  var kind = 'all';
+  var items = [];
+
+  // Per keer opzoeken. Zie de kop: vasthouden overleeft een navigatie niet.
+  function list() { return document.querySelector('.msg-list'); }
+  function noMatch() { return document.querySelector('.msg-nomatch'); }
+  function query() { return document.getElementById('msg-q'); }
+
+  function indexItem(li) {
+    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
+    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
+    if (li.classList.contains('msg-thread')) {
+      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
+      return;
+    }
+    var body = li.querySelector('.msg-content');
+    var post = li.querySelector('.msg-post');
+    var poll = li.querySelector('.msg-poll');
+    li._search = ((li.getAttribute('data-who') || '') + ' ' +
+      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
+      (poll ? poll.textContent : '')).toLowerCase();
+  }
+
+  function reindex() {
+    var l = list();
+    items = l ? Array.prototype.slice.call(l.querySelectorAll(':scope > .msg-item')) : [];
+    items.forEach(function (li) { if (!li._search) indexItem(li); });
+  }
+
+  function apply() {
+    if (!list()) return;
+    var qEl = query();
+    var term = (qEl && qEl.value || '').trim().toLowerCase();
+    var shown = 0;
+    items.forEach(function (li) {
+      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
+        (!term || li._search.indexOf(term) !== -1);
+      li.style.display = ok ? '' : 'none';
+      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
+      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
+      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
+      // stand die de lezer zelf koos.
+      if (li.classList.contains('msg-thread')) {
+        li.classList.toggle('is-search-open', !!term && ok);
+      }
+      if (ok) shown++;
+    });
+    var nm = noMatch();
+    if (nm) nm.hidden = shown !== 0;
+  }
+
+  // In- en uitklappen door op de tegenpartij te tikken. De stand leeft alleen in
+  // deze pagina: standaard uitgeklapt, en na een herlading weer. Dat is bewust --
+  // een gesprek dat je gisteren dichtklapte stilhouden is niet hetzelfde als het
+  // opruimen van je scherm van nu.
+  document.addEventListener('click', function (e) {
+    var who = e.target.closest && e.target.closest('.msg-thread-who'); if (!who) return;
+    var li = who.closest('.msg-thread'); if (!li) return;
+    var collapsed = li.classList.toggle('is-collapsed');
+    who.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+  });
+
+  document.addEventListener('click', function (e) {
+    var chip = e.target.closest && e.target.closest('.msg-chip'); if (!chip) return;
+    kind = chip.getAttribute('data-show');
+    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
+    apply();
+  });
+
+  // Gedelegeerd in plaats van op het invoerveld zelf: dat veld wordt bij een
+  // navigatie vervangen.
+  document.addEventListener('input', function (e) {
+    if (e.target && e.target.id === 'msg-q') apply();
+  });
+
+  document.body.addEventListener('htmx:afterSettle', function (e) {
+    // "Meer laden" voegt rijen toe aan de bestaande lijst; een navigatie brengt
+    // een hele nieuwe lijst. In beide gevallen opnieuw indexeren en de actieve
+    // chip toepassen.
+    if (e.target && (e.target.id === 'msg-list' || e.target.querySelector && e.target.querySelector('.msg-list'))) {
+      reindex();
+      apply();
+    }
+  });
+
+  // De bookmarklet-knop: bouwt zijn href pas in de browser, want hij heeft de
+  // origin nodig.
+  function wireBookmarklet() {
+    var a = document.getElementById('fedi-bm-btn');
+    if (!a || a.__wired) return;
+    a.__wired = true;
+    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
+    a.addEventListener('click', function (e) {
+      e.preventDefault();
+      a.classList.add('nudge');
+      setTimeout(function () { a.classList.remove('nudge'); }, 600);
+    });
+  }
+
+  function start() { reindex(); apply(); wireBookmarklet(); }
+  start();
+  // Kom je hier via een link binnen de site, dan is de lijst er pas na de swap.
+  document.body.addEventListener('pcmsNav', start);
+})();
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 20ae7b0b2f8b023f1dee3f6db669b42dedb5a2ce)
+++ src/middleware/render.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
@@ -249,4 +249,9 @@
     appVersion: APP_VERSION,
     bodyClass: data.bodyClass || 'on-home',
+    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
+    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
+    // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
+    // pad.
+    pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
     socialDescr: data.socialDescr || '',
     socialImage: data.socialImage || '',
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 20ae7b0b2f8b023f1dee3f6db669b42dedb5a2ce)
+++ src/routes/posts.js	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
@@ -936,5 +936,5 @@
     : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
   renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
+    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
     hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
     success: req.query.success || null, error: req.query.error || null,
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision 20ae7b0b2f8b023f1dee3f6db669b42dedb5a2ce)
+++ src/views/pages/messages.ejs	(revision e64a49b380f38e666e08803dbc52f598a5d97bf2)
@@ -60,86 +60,8 @@
 </div>
 
-<script>
-(function () {
-  if (window.__msgWired) return; window.__msgWired = true;
-
-  // Filtering: kind chip AND free-text search, combined in JS (search over the
-  // sender and the rendered message text). Empty search + "all" = show all.
-  var list = document.querySelector('.msg-list');
-  var noMatch = document.querySelector('.msg-nomatch');
-  var q = document.getElementById('msg-q');
-  var kind = 'all';
-  var items = [];
-  function indexItem(li) {
-    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
-    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
-    if (li.classList.contains('msg-thread')) {
-      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
-      return;
-    }
-    // Index once: sender + message body + linked post title + poll text.
-    var body = li.querySelector('.msg-content');
-    var post = li.querySelector('.msg-post');
-    var poll = li.querySelector('.msg-poll');
-    li._search = ((li.getAttribute('data-who') || '') + ' ' +
-      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
-      (poll ? poll.textContent : '')).toLowerCase();
-  }
-  // Re-collect + index; called on load and after each "Load more" append so new
-  // items join the filter/search (and inherit the active chip via apply()).
-  function reindex() {
-    items = list ? Array.prototype.slice.call(list.querySelectorAll(':scope > .msg-item')) : [];
-    items.forEach(function (li) { if (!li._search) indexItem(li); });
-  }
-  reindex();
-  function apply() {
-    if (!list) return;
-    var term = (q && q.value || '').trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (li) {
-      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
-        (!term || li._search.indexOf(term) !== -1);
-      li.style.display = ok ? '' : 'none';
-      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
-      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
-      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
-      // stand die de lezer zelf koos.
-      if (li.classList.contains('msg-thread')) {
-        li.classList.toggle('is-search-open', !!term && ok);
-      }
-      if (ok) shown++;
-    });
-    if (noMatch) noMatch.hidden = shown !== 0;
-  }
-  // In- en uitklappen door op de tegenpartij te tikken (Barts aanwijzing).
-  // Gedelegeerd, zodat draden die via "meer laden" binnenkomen het vanzelf ook
-  // doen. De stand leeft alleen in deze pagina: standaard uitgeklapt, en na een
-  // herlading weer. Dat is bewust -- een gesprek dat je gisteren dichtklapte
-  // stilhouden is niet hetzelfde als het opruimen van je scherm van nu.
-  document.addEventListener('click', function (e) {
-    var who = e.target.closest('.msg-thread-who'); if (!who) return;
-    var li = who.closest('.msg-thread'); if (!li) return;
-    var dicht = li.classList.toggle('is-collapsed');
-    who.setAttribute('aria-expanded', dicht ? 'false' : 'true');
-  });
-  document.addEventListener('click', function (e) {
-    var chip = e.target.closest('.msg-chip'); if (!chip) return;
-    kind = chip.getAttribute('data-show');
-    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
-    apply();
-  });
-  if (q) q.addEventListener('input', apply);
-  // After a "Load more" append (htmx), index the new rows and re-apply the filter.
-  document.body.addEventListener('htmx:afterSettle', function (e) {
-    if (e.target && e.target.id === 'msg-list') { reindex(); apply(); }
-  });
-
-  var a = document.getElementById('fedi-bm-btn');
-  if (a) {
-    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-    a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
-  }
-})();
-</script>
+<%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js.
+    Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een
+    link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op
+    body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %>
 
 <style>
