Index: src/assets/js/mod/reply-editor.js
===================================================================
--- src/assets/js/mod/reply-editor.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
+++ src/assets/js/mod/reply-editor.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
@@ -0,0 +1,225 @@
+// Rijke reply-editor -- MODULE sinds shaer-nh2.
+//
+// Stond als <script src> onderaan partials/reply-editor.ejs. Bij een volledige
+// laadbeurt ging dat goed (injectCspNonce zet er een nonce op), maar bij een
+// htmx-navigatie draagt de partial de nonce van DAT verzoek terwijl het
+// document die van zijn eigen laadbeurt heeft. De CSP is strict-dynamic, dus
+// alleen die laatste telt -- en het script draaide niet. Gevolg: geen toolbar,
+// geen .re-full, en op mobiel dus geen fullscreen-compose.
+//
+// Als module wordt hij door de bootstrap in de shell geladen, die WEL de juiste
+// nonce heeft. Dat is bovendien de voorwaarde om straks code te DELEN met de
+// posteditor: een los script kan niet importeren uit mod/lib.js.
+
+// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
+// from a plain textarea to a contenteditable with a small toolbar and, on
+// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
+// mobile). Progressive enhancement: without this file the textarea submits as
+// before. On submit: `content` = editor HTML (server sanitizes), `text` =
+// plain-text fallback.
+
+  var MOBILE = '(max-width: 700px)';
+
+function initForm(form) {
+    if (form.__re) return;
+    form.__re = true;
+
+    var ta = form.querySelector('textarea[name="text"]');
+    var ed = form.querySelector('.re-editor');
+    var bar = form.querySelector('.re-toolbar');
+    var head = form.querySelector('.re-head');
+    var foot = form.querySelector('.re-foot');
+    var lang = form.querySelector('.re-lang');
+    var hidden = form.querySelector('input[name="content"]');
+    if (!ta || !ed || !hidden) return;
+
+    // Upgrade: swap the textarea for the editor.
+    ta.hidden = true;
+    ta.required = false;
+    ed.hidden = false;
+    bar.hidden = false;
+    if (lang) lang.hidden = false;
+
+    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
+    var attWrap = form.querySelector('.re-attachments');
+    var attField = form.querySelector('input[name="attachments"]');
+    var fileInput = form.querySelector('.re-file');
+    var canAttach = !!(attWrap && attField && fileInput);   // edit mode renders without media
+    var attachments = [];
+
+    function syncAtt() {
+      if (!canAttach) return;
+      attField.value = attachments.length ? JSON.stringify(attachments) : '';
+      attWrap.hidden = attachments.length === 0;
+    }
+    function addChip(a) {
+      var chip = document.createElement('span');
+      chip.className = 're-att';
+      if (a.mediaType.indexOf('image/') === 0) {
+        var img = document.createElement('img');
+        img.src = a.url; img.alt = a.name || '';
+        chip.appendChild(img);
+      } else {
+        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
+      }
+      var del = document.createElement('button');
+      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
+      del.addEventListener('click', function () {
+        attachments = attachments.filter(function (x) { return x !== a; });
+        chip.remove(); syncAtt();
+      });
+      chip.appendChild(del);
+      attWrap.appendChild(chip);
+    }
+    function uploadFiles(files) {
+      if (!canAttach) return;
+      Array.prototype.forEach.call(files, function (file) {
+        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
+        var chip = document.createElement('span');
+        chip.className = 're-att re-att-busy';
+        chip.textContent = '⏳ ' + file.name;
+        attWrap.hidden = false;
+        attWrap.appendChild(chip);
+        var fd = new FormData();
+        fd.append('media', file);
+        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
+          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
+          .then(function (j) {
+            chip.remove();
+            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
+            attachments.push(a); addChip(a); syncAtt();
+          })
+          .catch(function (err) {
+            chip.className = 're-att re-att-err';
+            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
+            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
+          });
+      });
+    }
+
+    // Toolbar commands (execCommand is deprecated-but-universal; same approach
+    // as the post editor).
+    bar.addEventListener('click', function (e) {
+      var btn = e.target.closest('button[data-cmd]');
+      if (!btn) return;
+      e.preventDefault();
+      var cmd = btn.getAttribute('data-cmd');
+      if (cmd === 'attach') { if (fileInput) fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
+      ed.focus();
+      if (cmd === 'bold') document.execCommand('bold');
+      else if (cmd === 'italic') document.execCommand('italic');
+      else if (cmd === 'list') document.execCommand('insertUnorderedList');
+      else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
+      else if (cmd === 'link') {
+        var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
+        if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
+      }
+    });
+
+    // Reflect the current formatting on the toolbar (bold/italic/list active),
+    // like the post editor, while the caret is inside this editor.
+    function syncToolbar() {
+      if (document.activeElement !== ed) return;
+      var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' };
+      bar.querySelectorAll('button[data-cmd]').forEach(function (b) {
+        var c = map[b.getAttribute('data-cmd')];
+        if (!c) return;
+        var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; }
+        b.classList.toggle('is-active', on);
+      });
+    }
+    document.addEventListener('selectionchange', syncToolbar);
+    ed.addEventListener('keyup', syncToolbar);
+    ed.addEventListener('mouseup', syncToolbar);
+
+    if (fileInput) fileInput.addEventListener('change', function () {
+      uploadFiles(fileInput.files);
+      fileInput.value = '';
+    });
+
+    // Paste: files become attachments; text pastes as plain text (rich paste
+    // becomes messy HTML; formatting is what the toolbar is for).
+    ed.addEventListener('paste', function (e) {
+      var cd = e.clipboardData || window.clipboardData;
+      if (cd.files && cd.files.length) {
+        e.preventDefault();            // never let the browser inline-paste a file as base64
+        uploadFiles(cd.files);         // no-op without the attach UI (edit mode)
+        return;
+      }
+      var txt = cd.getData('text/plain');
+      if (!txt) return;
+      e.preventDefault();
+      document.execCommand('insertText', false, txt);
+    });
+
+    // Drag/drop media onto the editor.
+    if (canAttach) {
+    ed.addEventListener('dragover', function (e) {
+      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
+        e.preventDefault();
+        ed.classList.add('re-drop');
+      }
+    });
+    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
+    ed.addEventListener('drop', function (e) {
+      ed.classList.remove('re-drop');
+      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
+        e.preventDefault();
+        uploadFiles(e.dataTransfer.files);
+      }
+    });
+    }
+
+    // Mentions bar (u02): removing a chip stops addressing that partner. The
+    // hidden field always mirrors the remaining chips.
+    var mField = form.querySelector('input[name="mentions"]');
+    if (mField) {
+      var parts = [];
+      try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
+      form.querySelectorAll('.re-mention-del').forEach(function (btn) {
+        btn.addEventListener('click', function () {
+          var chip = btn.closest('.re-mention');
+          var uri = chip.getAttribute('data-uri');
+          parts = parts.filter(function (p) { return p.uri !== uri; });
+          mField.value = JSON.stringify(parts);
+          chip.remove();
+        });
+      });
+    }
+
+    // Full-screen compose on mobile: enter on focus, leave via ×.
+    function setFull(on) {
+      form.classList.toggle('re-full', on);
+      head.hidden = !on;
+      document.documentElement.classList.toggle('re-lock', on);
+      if (on) ed.focus();
+    }
+    ed.addEventListener('focus', function () {
+      if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
+    });
+    var cancel = form.querySelector('.re-cancel');
+    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
+
+    // Serialize on submit; block truly empty replies (media-only is fine).
+    form.addEventListener('submit', function (e) {
+      var html = ed.innerHTML.trim();
+      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
+      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
+      hidden.value = plain ? html : '';
+      ta.value = plain;
+      document.documentElement.classList.remove('re-lock');
+    });
+
+    // Open <details> parents (fedi-node) keep working: nothing special needed.
+    void foot;
+  }
+
+/**
+ * De bootstrap roept dit aan bij elke paginawissel waarop deze module actief
+ * is. Opnieuw scannen is veilig: init(form) heeft een eigen `__re`-vlag per
+ * FORMULIER, en na een htmx-wissel zijn de formulieren nieuw -- dus geen vlag,
+ * dus opnieuw opgewaardeerd. Precies wat je wilt.
+ */
+export function init() {
+  document.querySelectorAll('form[data-re]').forEach(initForm);
+}
Index: src/assets/js/reply-editor.js
===================================================================
--- src/assets/js/reply-editor.js	(revision f3c7a05cc8e2dd91104646edb638bceaf72e9486)
+++ 	(revision )
@@ -1,213 +1,0 @@
-// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
-// from a plain textarea to a contenteditable with a small toolbar and, on
-// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
-// mobile). Progressive enhancement: without this file the textarea submits as
-// before. On submit: `content` = editor HTML (server sanitizes), `text` =
-// plain-text fallback.
-(function () {
-  'use strict';
-  if (window.__replyEditorInit) return;
-  window.__replyEditorInit = true;
-
-  var MOBILE = '(max-width: 700px)';
-
-  function init(form) {
-    if (form.__re) return;
-    form.__re = true;
-
-    var ta = form.querySelector('textarea[name="text"]');
-    var ed = form.querySelector('.re-editor');
-    var bar = form.querySelector('.re-toolbar');
-    var head = form.querySelector('.re-head');
-    var foot = form.querySelector('.re-foot');
-    var lang = form.querySelector('.re-lang');
-    var hidden = form.querySelector('input[name="content"]');
-    if (!ta || !ed || !hidden) return;
-
-    // Upgrade: swap the textarea for the editor.
-    ta.hidden = true;
-    ta.required = false;
-    ed.hidden = false;
-    bar.hidden = false;
-    if (lang) lang.hidden = false;
-
-    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
-    var attWrap = form.querySelector('.re-attachments');
-    var attField = form.querySelector('input[name="attachments"]');
-    var fileInput = form.querySelector('.re-file');
-    var canAttach = !!(attWrap && attField && fileInput);   // edit mode renders without media
-    var attachments = [];
-
-    function syncAtt() {
-      if (!canAttach) return;
-      attField.value = attachments.length ? JSON.stringify(attachments) : '';
-      attWrap.hidden = attachments.length === 0;
-    }
-    function addChip(a) {
-      var chip = document.createElement('span');
-      chip.className = 're-att';
-      if (a.mediaType.indexOf('image/') === 0) {
-        var img = document.createElement('img');
-        img.src = a.url; img.alt = a.name || '';
-        chip.appendChild(img);
-      } else {
-        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
-      }
-      var del = document.createElement('button');
-      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
-      del.addEventListener('click', function () {
-        attachments = attachments.filter(function (x) { return x !== a; });
-        chip.remove(); syncAtt();
-      });
-      chip.appendChild(del);
-      attWrap.appendChild(chip);
-    }
-    function uploadFiles(files) {
-      if (!canAttach) return;
-      Array.prototype.forEach.call(files, function (file) {
-        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
-        var chip = document.createElement('span');
-        chip.className = 're-att re-att-busy';
-        chip.textContent = '⏳ ' + file.name;
-        attWrap.hidden = false;
-        attWrap.appendChild(chip);
-        var fd = new FormData();
-        fd.append('media', file);
-        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
-          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
-          .then(function (j) {
-            chip.remove();
-            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
-            attachments.push(a); addChip(a); syncAtt();
-          })
-          .catch(function (err) {
-            chip.className = 're-att re-att-err';
-            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
-            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
-          });
-      });
-    }
-
-    // Toolbar commands (execCommand is deprecated-but-universal; same approach
-    // as the post editor).
-    bar.addEventListener('click', function (e) {
-      var btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      var cmd = btn.getAttribute('data-cmd');
-      if (cmd === 'attach') { if (fileInput) fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
-      ed.focus();
-      if (cmd === 'bold') document.execCommand('bold');
-      else if (cmd === 'italic') document.execCommand('italic');
-      else if (cmd === 'list') document.execCommand('insertUnorderedList');
-      else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
-      else if (cmd === 'link') {
-        var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
-        if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
-      }
-    });
-
-    // Reflect the current formatting on the toolbar (bold/italic/list active),
-    // like the post editor, while the caret is inside this editor.
-    function syncToolbar() {
-      if (document.activeElement !== ed) return;
-      var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' };
-      bar.querySelectorAll('button[data-cmd]').forEach(function (b) {
-        var c = map[b.getAttribute('data-cmd')];
-        if (!c) return;
-        var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; }
-        b.classList.toggle('is-active', on);
-      });
-    }
-    document.addEventListener('selectionchange', syncToolbar);
-    ed.addEventListener('keyup', syncToolbar);
-    ed.addEventListener('mouseup', syncToolbar);
-
-    if (fileInput) fileInput.addEventListener('change', function () {
-      uploadFiles(fileInput.files);
-      fileInput.value = '';
-    });
-
-    // Paste: files become attachments; text pastes as plain text (rich paste
-    // becomes messy HTML; formatting is what the toolbar is for).
-    ed.addEventListener('paste', function (e) {
-      var cd = e.clipboardData || window.clipboardData;
-      if (cd.files && cd.files.length) {
-        e.preventDefault();            // never let the browser inline-paste a file as base64
-        uploadFiles(cd.files);         // no-op without the attach UI (edit mode)
-        return;
-      }
-      var txt = cd.getData('text/plain');
-      if (!txt) return;
-      e.preventDefault();
-      document.execCommand('insertText', false, txt);
-    });
-
-    // Drag/drop media onto the editor.
-    if (canAttach) {
-    ed.addEventListener('dragover', function (e) {
-      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
-        e.preventDefault();
-        ed.classList.add('re-drop');
-      }
-    });
-    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
-    ed.addEventListener('drop', function (e) {
-      ed.classList.remove('re-drop');
-      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
-        e.preventDefault();
-        uploadFiles(e.dataTransfer.files);
-      }
-    });
-    }
-
-    // Mentions bar (u02): removing a chip stops addressing that partner. The
-    // hidden field always mirrors the remaining chips.
-    var mField = form.querySelector('input[name="mentions"]');
-    if (mField) {
-      var parts = [];
-      try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
-      form.querySelectorAll('.re-mention-del').forEach(function (btn) {
-        btn.addEventListener('click', function () {
-          var chip = btn.closest('.re-mention');
-          var uri = chip.getAttribute('data-uri');
-          parts = parts.filter(function (p) { return p.uri !== uri; });
-          mField.value = JSON.stringify(parts);
-          chip.remove();
-        });
-      });
-    }
-
-    // Full-screen compose on mobile: enter on focus, leave via ×.
-    function setFull(on) {
-      form.classList.toggle('re-full', on);
-      head.hidden = !on;
-      document.documentElement.classList.toggle('re-lock', on);
-      if (on) ed.focus();
-    }
-    ed.addEventListener('focus', function () {
-      if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
-    });
-    var cancel = form.querySelector('.re-cancel');
-    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
-
-    // Serialize on submit; block truly empty replies (media-only is fine).
-    form.addEventListener('submit', function (e) {
-      var html = ed.innerHTML.trim();
-      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
-      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
-      hidden.value = plain ? html : '';
-      ta.value = plain;
-      document.documentElement.classList.remove('re-lock');
-    });
-
-    // Open <details> parents (fedi-node) keep working: nothing special needed.
-    void foot;
-  }
-
-  function initAll() {
-    document.querySelectorAll('form[data-re]').forEach(init);
-  }
-  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
-  else initAll();
-})();
