Index: src/server.js
===================================================================
--- src/server.js	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/server.js	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -109,10 +109,9 @@
         (req, res) => `'nonce-${res.locals.cspNonce}'`,
       ],
-      // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
-      // event handlers (onchange/onclick/onsubmit) — causing e.g. the avatar
-      // upload (<input onchange="this.form.submit()">) and the role dropdown to
-      // silently do nothing. We explicitly allow inline handlers, consistent with
-      // the already-allowed inline <script> above.
-      scriptSrcAttr: ["'unsafe-inline'"],
+      // No inline event handlers anywhere: every on* attribute was moved to a
+      // delegated data-* handler (the shared script in shell.ejs), so inline
+      // handlers are blocked entirely — this closes the last 'unsafe-inline' in
+      // the script directives.
+      scriptSrcAttr: ["'none'"],
       styleSrc: ["'self'", "'unsafe-inline'"],
       // blob: required for the image editor (Cropper) — it displays the chosen
@@ -144,4 +143,13 @@
   referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
 }));
+
+// Permissions-Policy: disable powerful features Klonkt never uses (camera, microphone,
+// geolocation) and opt out of the Topics API. Features that embeds legitimately need
+// (autoplay, fullscreen, encrypted-media, picture-in-picture) are left at their default
+// allowlist, so YouTube/Spotify/SoundCloud players keep working.
+app.use((req, res, next) => {
+  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
+  next();
+});
 
 app.set('view engine', 'ejs');
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-audio.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -121,5 +121,5 @@
                         style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
               <% } %>
-              <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('<%= _aaudDelConfirm %>')" class="ax-track-delete">
+              <form action="/admin/audio/<%= t.id %>/delete" method="post" data-confirm="<%= _aaudDelConfirm %>" class="ax-track-delete">
                 <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _aaudDelete %>" title="<%= _aaudDelete %>">🗑</button>
               </form>
@@ -135,5 +135,5 @@
       <h2 style="margin:0 0 .4rem">🔊 <%= t('aaud.embed_player') %></h2>
       <p style="opacity:.75;font-size:.9rem;margin:0 0 .6rem"><%= t('aaud.embed_hint') %></p>
-      <textarea readonly rows="3" onclick="this.select()" style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
+      <textarea readonly rows="3" data-selectall style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
       <p style="margin:.6rem 0 0"><a class="btn" href="<%= embedUrl %>" target="_blank">▶ <%= t('aaud.preview_player') %></a></p>
     </section>
Index: src/views/pages/admin-newsletter.ejs
===================================================================
--- src/views/pages/admin-newsletter.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-newsletter.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -24,5 +24,5 @@
 
   <% var _sendConfirm = t('anews.send_confirm', { n: c.confirmed }); %>
-  <form method="POST" action="<%= siteUrlBase %>/admin/newsletter/send" class="anl-form">
+  <form method="POST" action="<%= siteUrlBase %>/admin/newsletter/send" class="anl-form" data-confirm="<%= _sendConfirm %>">
     <label><%= t('anews.subject') %>
       <input type="text" name="subject" required maxlength="200" value="<%= (typeof nlSubject!=='undefined')?nlSubject:'' %>" placeholder="<%= t('anews.subject_ph') %>">
@@ -31,5 +31,5 @@
       <textarea name="body" rows="10" required placeholder="<%= t('anews.body_ph') %>"><%= (typeof nlBody!=='undefined')?nlBody:'' %></textarea>
     </label>
-    <button type="submit" class="anl-send" <%= smtp ? '' : 'disabled' %> onclick="return confirm('<%= _sendConfirm %>')"><%= t('anews.send_btn', { n: c.confirmed }) %></button>
+    <button type="submit" class="anl-send" <%= smtp ? '' : 'disabled' %>><%= t('anews.send_btn', { n: c.confirmed }) %></button>
   </form>
 
Index: src/views/pages/admin-shows.ejs
===================================================================
--- src/views/pages/admin-shows.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-shows.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -14,5 +14,5 @@
   <form method="POST" action="<%= siteUrlBase %>/admin/shows/toggle" style="margin:0 0 14px">
     <label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-weight:600">
-      <input type="checkbox" name="enabled" value="1" <%= agOn ? 'checked' : '' %> onchange="this.form.submit()" style="width:18px;height:18px;accent-color:var(--accent)">
+      <input type="checkbox" name="enabled" value="1" <%= agOn ? 'checked' : '' %> data-autosubmit style="width:18px;height:18px;accent-color:var(--accent)">
       <span><%= t('ashow.show_toggle') %> <small style="font-weight:400;opacity:.7"><%= t('ashow.show_toggle_hint') %></small></span>
     </label>
@@ -45,5 +45,5 @@
           <span class="ash-when"><%= s.date %><% if (s.time) { %> <%= s.time %><% } %></span>
           <span class="ash-where"><%= s.city %><% if (s.venue) { %> — <%= s.venue %><% } %></span>
-          <form method="POST" action="<%= siteUrlBase %>/admin/shows/<%= s.id %>/delete" onsubmit="return confirm('<%= _delConfirm %>')" style="margin:0">
+          <form method="POST" action="<%= siteUrlBase %>/admin/shows/<%= s.id %>/delete" data-confirm="<%= _delConfirm %>" style="margin:0">
             <button type="submit" class="ash-del">🗑</button>
           </form>
Index: src/views/pages/admin-sites.ejs
===================================================================
--- src/views/pages/admin-sites.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-sites.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -93,5 +93,5 @@
             <% if (!s.is_primary && (typeof canMutate === 'undefined' || canMutate)) { %>
               <form method="post" action="/admin/sites/<%= s.slug %>/make-primary" style="margin:0;display:inline"
-                    onsubmit="return confirm('<%= _s_makeprim_c %>')">
+                    data-confirm="<%= _s_makeprim_c %>">
                 <button type="submit" class="ax-icon-btn" aria-label="<%= _s_makeprim %>" title="<%= _s_makeprim_t %>">★</button>
               </form>
@@ -99,5 +99,5 @@
             <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="<%= _s_edit %>" title="<%= _s_edit %>">✎</a>
             <form method="post" action="/admin/sites/<%= s.slug %>/delete" class="ax-delete-form"
-                  onsubmit="return confirm('<%= _s_del_c %>')">
+                  data-confirm="<%= _s_del_c %>">
               <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _s_del %>" title="<%= _s_del %>">🗑</button>
             </form>
Index: src/views/pages/admin-updates.ejs
===================================================================
--- src/views/pages/admin-updates.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-updates.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -40,5 +40,5 @@
     <% if (canCheck && canSelfUpdate) { %>
     <form method="post" action="/admin/updates/run" class="set-form"
-          onsubmit="return confirm('<%= t('aupd.run_confirm') %>');">
+          data-confirm="<%= t('aupd.run_confirm') %>">
       <button type="submit" class="btn btn-primary">
         <%= upToDate ? '↻ ' + t('aupd.redeploy') : '⬆ ' + t('aupd.update_now') %>
Index: src/views/pages/admin-users.ejs
===================================================================
--- src/views/pages/admin-users.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/admin-users.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -80,5 +80,5 @@
               <label class="ax-role">
                 <span><%= _u_rol %></span>
-                <select name="role" onchange="this.form.submit()" <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
+                <select name="role" data-autosubmit <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
                   <option value="kijker" <%= u.role === 'kijker' ? 'selected' : '' %>><%= _u_r_kijker %></option>
                   <option value="member" <%= u.role === 'member' ? 'selected' : '' %>><%= _u_r_member %></option>
@@ -94,5 +94,5 @@
                    : ''; %>
               <form method="post" action="/admin/users/<%= u.id %>/delete" class="ax-delete-form"
-                    onsubmit="return confirm('<%= t('ausr.del_confirm', { name: u.username }) %> <%= _warn %><%= t('ausr.del_undo') %>')">
+                    data-confirm="<%= t('ausr.del_confirm', { name: u.username }) %> <%= _warn %><%= t('ausr.del_undo') %>">
                 <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _u_del %>" title="<%= _u_del %>">🗑</button>
               </form>
Index: src/views/pages/authorize-interaction.ejs
===================================================================
--- src/views/pages/authorize-interaction.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/authorize-interaction.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -37,5 +37,5 @@
               <a class="fedi-goto-btn" href="<%= m.in_reply_to %>" target="_blank" rel="nofollow noopener"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg> <%= t('fedi.goto_post') %></a>
               <% } %>
-              <form method="post" action="/fediverse/<%= m.id %>/delete" class="fedi-del-form" onsubmit="return confirm('<%= t('fedi.delete_confirm') %>')">
+              <form method="post" action="/fediverse/<%= m.id %>/delete" class="fedi-del-form" data-confirm="<%= t('fedi.delete_confirm') %>">
                 <button type="submit" class="fedi-del-btn"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <%= t('comments.delete') %></button>
               </form>
Index: src/views/pages/following.ejs
===================================================================
--- src/views/pages/following.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/following.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -28,5 +28,5 @@
           <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
             <input type="hidden" name="actor_uri" value="<%= f.actor_uri %>">
-            <label><input type="checkbox" name="auto_boost" value="1" <%= f.auto_boost ? 'checked' : '' %> onchange="this.form.submit()"> ✨ <%= t('tl.autoboost') %></label>
+            <label><input type="checkbox" name="auto_boost" value="1" <%= f.auto_boost ? 'checked' : '' %> data-autosubmit> ✨ <%= t('tl.autoboost') %></label>
           </form>
           <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= f.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/news.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -135,5 +135,5 @@
             <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
             <button type="button" class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
-            <form method="post" action="/blocking/add" class="tl-act-form" onsubmit="return confirm('<%= t('tl.block') %>?');"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
+            <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
           </div>
         </li>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/post.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -68,5 +68,5 @@
       <% } %>
       <% if (permissions.canDeletePost(user, post, site)) { %>
-        <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
+        <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" data-confirm="Delete this post?">
           <button type="submit" class="btn btn-danger">🗑 Delete</button>
         </form>
Index: src/views/pages/viewer-blocked.ejs
===================================================================
--- src/views/pages/viewer-blocked.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/pages/viewer-blocked.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -8,5 +8,5 @@
     </p>
     <div class="vb-actions">
-      <button type="button" class="vb-btn vb-btn-primary" onclick="history.back()">← <%= t('vblk.back') %></button>
+      <button type="button" class="vb-btn vb-btn-primary" data-back>← <%= t('vblk.back') %></button>
       <a class="vb-btn" href="/"><%= t('vblk.to_home') %></a>
     </div>
Index: src/views/partials/fedi-node.ejs
===================================================================
--- src/views/partials/fedi-node.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/partials/fedi-node.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -14,5 +14,5 @@
   <div class="comment-actions">
     <% if (n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.outboxId) { %>
-      <form method="post" action="<%= _base %>/fediverse/<%= n.outboxId %>/delete" onsubmit="return confirm('<%= t('fedi.delete_confirm') %>')">
+      <form method="post" action="<%= _base %>/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
         <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
       </form>
Index: src/views/partials/footer.ejs
===================================================================
--- src/views/partials/footer.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/partials/footer.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -22,5 +22,5 @@
       <% if (typeof langs !== 'undefined' && langs && langs.length > 1) { %>
         <select class="footer-lang" aria-label="<%= t('nav.language') %>"
-                onchange="location.href='/lang/'+this.value+'?r='+encodeURIComponent(location.pathname+location.search)">
+                data-lang-switch>
           <% langs.forEach(function(l){ %><option value="<%= l.code %>"<%= l.active ? ' selected' : '' %>><%= l.name %></option><% }); %>
         </select>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/partials/playlist-editor.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -442,5 +442,5 @@
       const u = coverEl.value.trim();
       coverThumb.innerHTML = u
-        ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
+        ? `<img src="${esc(u)}" alt="" data-fallback>`
         : `<span class="pl-cover-empty">🎨</span>`;
     });
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 127f87e414f1483c3a0f33525c00626cc91ba2fc)
+++ src/views/shell.ejs	(revision f7d142fd84f4cfd1fcf99b1ca2ac8620ef8159f2)
@@ -747,4 +747,40 @@
 </script>
 
+<script>
+// Delegated replacements for inline on* handlers, so the CSP needs no
+// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
+(function () {
+  if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
+  // Confirm before submitting a form that carries data-confirm.
+  document.addEventListener('submit', function (e) {
+    var f = e.target;
+    if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
+  });
+  // Auto-submit a form / switch language when a <select> changes.
+  document.addEventListener('change', function (e) {
+    var el = e.target;
+    if (!el || !el.dataset) return;
+    if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
+    else if (el.dataset.langSwitch !== undefined) {
+      location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
+    }
+  });
+  // Misc click helpers (select-all in a field, history-back button).
+  document.addEventListener('click', function (e) {
+    var el = e.target.closest && e.target.closest('[data-selectall],[data-back]');
+    if (!el) return;
+    if (el.dataset.selectall !== undefined && el.select) el.select();
+    if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
+  });
+  // Image fallback (the error event doesn't bubble → capture phase).
+  document.addEventListener('error', function (e) {
+    var el = e.target;
+    if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
+      el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
+    }
+  }, true);
+})();
+</script>
+
 <!-- Per-site custom footer HTML -->
 <% if (safeSite.custom_foot_html) { %>
