Index: src/assets/js/mod/lib.js
===================================================================
--- src/assets/js/mod/lib.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/assets/js/mod/lib.js	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -64,2 +64,27 @@
   });
 }
+
+/**
+ * De WebAuthn-bibliotheek erbij halen, vanuit JavaScript (shaer-0i6).
+ *
+ * Hij stond als `<script src>` in paid-gate.ejs en paid-passkey.ejs, en dat
+ * werkt alleen bij een volledige laadbeurt. Kom je op zo'n pagina via een link
+ * BINNEN de site, dan is het een htmx-fragment met de nonce van een ANDER
+ * verzoek, en weigert de CSP hem. Het gevolg was stil: de module laadde wel,
+ * vond `window.SimpleWebAuthnBrowser` niet, en de knop deed niets -- op het
+ * passkey- en betaalpad.
+ *
+ * Een dynamische import vanuit deze module mag wel: de shell startte hem met
+ * een geldige nonce, en `strict-dynamic` bestaat er juist voor dat vertrouwde
+ * scripts verder mogen laden. Het bestand is een UMD-bundel; zonder CommonJS
+ * of AMD zet die zichzelf op globalThis, dus na afloop staat de global er.
+ *
+ * Faalt het laden, dan geeft dit undefined terug en niet een fout: beide
+ * aanroepers hebben al een nette terugval voor "geen WebAuthn hier".
+ */
+export async function loadWebAuthn() {
+  if (window.SimpleWebAuthnBrowser) return window.SimpleWebAuthnBrowser;
+  try { await import('/assets/vendor/simplewebauthn-browser.umd.min.js'); }
+  catch (e) { console.warn('[paid] WebAuthn-bibliotheek laadt niet:', e && e.message); }
+  return window.SimpleWebAuthnBrowser;
+}
Index: src/assets/js/mod/paid-gate.js
===================================================================
--- src/assets/js/mod/paid-gate.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/assets/js/mod/paid-gate.js	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -5,9 +5,14 @@
 // interpolatie kan niet in een statisch bestand.
 
-import { pageData } from './lib.js';
+import { pageData, loadWebAuthn } from './lib.js';
 
 // Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
 // aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
+//
+// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
+// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
+// run() daar niet op, dan valt hij terug op "geen WebAuthn" en verbergt hij
+// de ontgrendelknop op een pagina waar ontgrendelen gewoon kan.
+export async function init() { await loadWebAuthn(); run(); }
 
 function run() {
Index: src/assets/js/mod/paid-passkey.js
===================================================================
--- src/assets/js/mod/paid-passkey.js	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/assets/js/mod/paid-passkey.js	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -5,9 +5,14 @@
 // interpolatie kan niet in een statisch bestand.
 
-import { pageData } from './lib.js';
+import { pageData, loadWebAuthn } from './lib.js';
 
 // Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
 // aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
+//
+// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
+// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
+// run() daar niet op, dan ziet hij de global nog niet staan en zet hij de
+// knop uit terwijl er niets aan de hand is.
+export async function init() { await loadWebAuthn(); run(); }
 
 function run() {
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/views/pages/paid-gate.ejs	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -38,6 +38,9 @@
 </article>
 
-<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr).
+    De WebAuthn-bibliotheek stond hier als `<script src>` en is weg (shaer-0i6):
+    deze pagina komt via een link binnen de site als htmx-fragment binnen, en
+    dan draagt zo'n tag de nonce van een ander verzoek en weigert de CSP hem.
+    De module haalt hem nu zelf op met loadWebAuthn() uit lib.js. %>
 
 <style>
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/views/pages/paid-passkey.ejs	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -11,6 +11,9 @@
 </section>
 
-<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr).
+    De WebAuthn-bibliotheek stond hier als `<script src>` en is weg (shaer-0i6):
+    deze pagina komt via een link binnen de site als htmx-fragment binnen, en
+    dan draagt zo'n tag de nonce van een ander verzoek en weigert de CSP hem.
+    De module haalt hem nu zelf op met loadWebAuthn() uit lib.js. %>
 
 <style>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision b5a0268c8f0e642ed6369335c5f06a29013f8d75)
+++ src/views/shell.ejs	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -505,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 = 62;
+  var MOD_V = 63;
 
   // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
Index: test/csp-no-swapped-scripts.test.js
===================================================================
--- test/csp-no-swapped-scripts.test.js	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
+++ test/csp-no-swapped-scripts.test.js	(revision 59f404ac3fc5229fc0676774f093cbf7eaa661c3)
@@ -0,0 +1,63 @@
+// Geen uitvoerbaar script in inhoud die htmx wisselt (shaer-0i6).
+//
+// De CSP-nonce rouleert per verzoek. Een pagina die je via een link BINNEN de
+// site opent komt als fragment binnen, met de nonce van dat verzoek -- en die
+// kent het al geladen document niet, dus wordt zo'n script geweigerd. Elk
+// paginascript hoort daarom uit de shell te komen, die op body[data-js] een
+// module importeert; de gegevens gaan via partials/page-data.ejs.
+//
+// De valkuil die dit bewaakt: de fout is ONZICHTBAAR na een herlading. Wie een
+// script terugzet in een view merkt daar bij het testen niets van, tenzij hij
+// er via de navigatie naartoe gaat. Deze toets merkt het wel.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const VIEWS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'views');
+
+// Losse DOCUMENTEN, geen fragmenten: deze gaan via res.render en niet via
+// renderPage, dus htmx wisselt ze nooit in en hun scripts komen altijd met de
+// nonce van hun eigen laadbeurt binnen. Wie hier iets aan toevoegt moet dus
+// eerst nagaan langs welke weg de pagina gerenderd wordt.
+const LOSSE_DOCUMENTEN = new Set(['pages/embed-player.ejs', 'pages/guardian.ejs']);
+
+function views() {
+  const uit = [];
+  for (const map of ['pages', 'partials']) {
+    for (const naam of fs.readdirSync(path.join(VIEWS, map))) {
+      if (naam.endsWith('.ejs')) uit.push(`${map}/${naam}`);
+    }
+  }
+  return uit.sort();
+}
+
+test('geen uitvoerbaar script in een view die als fragment binnenkomt', () => {
+  const overtreders = [];
+  for (const rel of views()) {
+    if (LOSSE_DOCUMENTEN.has(rel)) continue;
+    // EJS-commentaar eerst weg: dat komt nooit in de uitvoer, en de views
+    // leggen juist in zo'n commentaar uit welke scripttag daar wegging. Zonder
+    // dit sloeg deze toets aan op zijn eigen uitleg.
+    const bron = fs.readFileSync(path.join(VIEWS, rel), 'utf8').replace(/<%#[\s\S]*?%>/g, '');
+    for (const m of bron.matchAll(/<script\b[^>]*>/gi)) {
+      // Een datablok (application/json) wordt door de browser nooit
+      // uitgevoerd, dus daar gaat script-src niet over. Dat is de vorm
+      // waarin een pagina zijn gegevens wel mag meegeven.
+      if (/type\s*=\s*["']application\/json["']/i.test(m[0])) continue;
+      overtreders.push(`${rel}: ${m[0].slice(0, 80)}`);
+    }
+  }
+  assert.deepEqual(overtreders, [],
+    'deze scripts overleven een htmx-navigatie niet; laat de shell de module laden (zie shaer-0i6)');
+});
+
+test('de losse documenten bestaan nog, anders bewaakt de uitzondering niets', () => {
+  // Een uitzonderingslijst die naar verdwenen bestanden wijst maakt de toets
+  // stilletjes zwakker: hernoemt iemand zo'n pagina, dan valt hij ineens onder
+  // de regel zonder dat iemand dat besloot.
+  for (const rel of LOSSE_DOCUMENTEN) {
+    assert.ok(fs.existsSync(path.join(VIEWS, rel)), `${rel} staat op de uitzonderingslijst maar bestaat niet meer`);
+  }
+});
