Index: src/views/pages/404.ejs
===================================================================
--- src/views/pages/404.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,22 +1,0 @@
-<section class="nf">
-  <div class="nf-code">404</div>
-  <h1 class="nf-title"><%= t('e404.title') %></h1>
-  <p class="nf-sub"><%= t('e404.sub') %></p>
-  <div class="nf-actions">
-    <a class="nf-btn nf-btn-primary" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/">← <%= t('e404.home') %></a>
-    <% if (typeof site !== 'undefined' && site && site.show_archive_link !== 0) { %>
-      <a class="nf-btn" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/archive"><%= t('e404.archive') %></a>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .nf { max-width: 540px; margin: 0 auto; padding: 12vh 20px 64px; text-align: center; }
-  .nf-code { font-size: clamp(72px, 22vw, 140px); font-weight: 800; line-height: .9; color: var(--accent, #6b8f71); opacity: .9; letter-spacing: -.03em; }
-  .nf-title { font-size: clamp(22px, 6vw, 32px); margin: .4rem 0 .6rem; }
-  .nf-sub { opacity: .8; line-height: 1.6; margin: 0 auto 1.6rem; max-width: 36ch; }
-  .nf-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
-  .nf-btn { display: inline-block; padding: 12px 20px; border-radius: 999px; text-decoration: none; font-weight: 600; font-size: 15px; border: 1px solid rgba(128,128,128,.35); color: inherit; }
-  .nf-btn-primary { background: var(--accent, #6b8f71); border-color: var(--accent, #6b8f71); color: #fff; }
-  @media (max-width: 420px) { .nf-actions { flex-direction: column; } .nf-btn { width: 100%; } }
-</style>
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/account.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -2,8 +2,8 @@
 
   <header class="ax-header">
-    <a href="/" class="ax-back" aria-label="<%= t('acct.back_home') %>">←</a>
+    <a href="/" class="ax-back" aria-label="Terug naar home">←</a>
     <div>
-      <h1><%= t('acct.title') %></h1>
-      <p class="ax-tagline"><%= t('acct.subtitle') %></p>
+      <h1>Account</h1>
+      <p class="ax-tagline">Profiel en wachtwoord.</p>
     </div>
   </header>
@@ -12,165 +12,80 @@
   <% if (error)   { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
 
-  <% if (isViewer) { %>
-    <div class="ax-viewer-note" role="note">
-      <span class="ax-viewer-ico" aria-hidden="true">👁️</span>
-      <div>
-        <strong><%= t('acct.viewer_mode') %></strong>
-        <span><%= t('acct.viewer_note') %></span>
-      </div>
-    </div>
-  <% } %>
-
   <%# ── PROFILE ────────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.profile') %></div>
+    <div class="ax-card-title">Profiel</div>
 
     <div class="ax-profile-id">
-      <span class="ax-profile-avatar ax-profile-avatar-static">
-        <% var _av = (typeof siteAvatar !== 'undefined' ? siteAvatar : null); %>
-        <% if (_av) { %>
-          <img src="<%= avatar(_av, 128) %>" alt="">
+      <div class="ax-profile-avatar">
+        <% if (account.avatar_url) { %>
+          <img src="<%= account.avatar_url %>" alt="">
         <% } else { %>
           <span><%= account.username.charAt(0).toUpperCase() %></span>
         <% } %>
-      </span>
+      </div>
       <div class="ax-profile-meta">
         <div class="ax-profile-name"><%= account.username %></div>
         <div class="ax-profile-role"><%= account.role %> · <%= account.email %></div>
         <% if (account.created_at) { %>
-          <div class="ax-profile-joined"><%= t('acct.member_since') %> <%= formatDate(account.created_at) %></div>
+          <div class="ax-profile-joined">Lid sinds <%= formatDate(account.created_at) %></div>
         <% } %>
-        <% if (canMutate) { %>
-          <div class="ax-profile-joined"><%- t('acct.photo_site_hint') %></div>
-        <% } %>
       </div>
     </div>
 
-    <% if (canMutate) { %>
-      <form action="/account/profile" method="post" class="ax-form ax-form-bio">
-        <label class="ax-field">
-          <span><%= t('acct.username') %></span>
-          <input type="text" name="username" value="<%= account.username %>" minlength="2" maxlength="30" pattern="[A-Za-z0-9_\-]{2,30}" autocomplete="username">
-        </label>
-        <label class="ax-field">
-          <span><%= t('acct.email') %></span>
-          <input type="email" name="email" value="<%= account.email || '' %>" maxlength="254" autocomplete="email" placeholder="<%= t('acct.email_ph') %>">
-        </label>
-        <label class="ax-field">
-          <span><%= t('acct.bio') %></span>
-          <textarea name="bio" rows="3" maxlength="500" placeholder="<%= t('acct.bio_ph') %>"><%= account.bio || '' %></textarea>
-        </label>
-        <div class="ax-form-actions">
-          <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
-        </div>
-      </form>
-    <% } else { %>
-      <div class="ax-form ax-form-bio">
-        <label class="ax-field">
-          <span><%= t('acct.bio') %></span>
-          <textarea rows="3" disabled placeholder="<%= t('acct.bio_empty') %>"><%= account.bio || '' %></textarea>
-        </label>
-      </div>
-    <% } %>
-  </section>
-
-  <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %>
-  <% if (canMutate) { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.lang_label') %></div>
-    <form action="/account/lang" method="post" class="ax-form">
-      <label class="ax-field">
-        <span><%= t('acct.lang_label') %> <small><%= t('acct.lang_hint') %></small></span>
-        <select name="lang">
-          <% langs.forEach(function(l){ var sel = (account && account.lang) ? (account.lang === l.code) : l.active; %>
-            <option value="<%= l.code %>"<%= sel ? ' selected' : '' %>><%= l.name %></option>
-          <% }); %>
-        </select>
+    <form action="/account/avatar" method="post" enctype="multipart/form-data" class="ax-form">
+      <label class="ax-field ax-file">
+        <span>Avatar uploaden <small>(jpg/png/webp/gif, max 5 MB)</small></span>
+        <span class="ax-file-control">
+          <span class="ax-btn ax-btn-secondary ax-file-btn">📷 Kies bestand</span>
+          <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
+          <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" required>
+        </span>
       </label>
       <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
+        <button type="submit" class="ax-btn ax-btn-primary">⬆ Uploaden</button>
+        <% if (account.avatar_url) { %>
+          <%# Separate <form> block kept inline since it submits to a different endpoint. %>
+          <button type="button" class="ax-btn" onclick="document.getElementById('avatar-remove-form').submit()">Avatar verwijderen</button>
+        <% } %>
+      </div>
+    </form>
+    <% if (account.avatar_url) { %>
+      <form action="/account/avatar/remove" method="post" id="avatar-remove-form" hidden></form>
+    <% } %>
+
+    <form action="/account/profile" method="post" class="ax-form ax-form-bio">
+      <label class="ax-field">
+        <span>Bio</span>
+        <textarea name="bio" rows="3" maxlength="500" placeholder="Een korte regel over jezelf"><%= account.bio || '' %></textarea>
+      </label>
+      <div class="ax-form-actions">
+        <button type="submit" class="ax-btn ax-btn-primary">💾 Bio opslaan</button>
       </div>
     </form>
   </section>
-  <% } %>
-
-  <%# ── SITE (owner may update their site name) ─────────── %>
-  <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
+
+  <%# ── PASSWORD ──────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.site') %></div>
-    <form action="/account/site" method="post" class="ax-form">
-      <label class="ax-field">
-        <span><%= t('acct.site_name') %> <small><%= t('acct.site_name_hint') %></small></span>
-        <input type="text" name="site_title" value="<%= editableSite.title || '' %>" maxlength="200" required>
-      </label>
-      <label class="ax-field">
-        <span><%= t('acct.tagline') %> <small><%= t('acct.tagline_hint') %></small></span>
-        <input type="text" name="site_tagline" value="<%= editableSite.tagline || '' %>" maxlength="200">
-      </label>
-      <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.site_save') %></button>
-      </div>
-    </form>
-  </section>
-  <% } %>
-
-  <%# ── PASSWORD ──────────────────────────────────────────── %>
-  <%# In viewer mode there is no password section (nothing to change). %>
-  <%# ── CONNECTED APPS (OAuth C2S) ─────────────────────────── %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.oauth_apps') %></div>
-    <p class="ax-tagline" style="margin:0 0 .6rem"><%= t('acct.oauth_hint') %></p>
-    <% if (!authorizations || !authorizations.length) { %>
-      <p class="ax-oauth-empty"><%= t('acct.oauth_none') %></p>
-    <% } else { %>
-      <ul class="ax-oauth-list">
-        <% authorizations.forEach(function(a){ %>
-          <li class="ax-oauth-item">
-            <div class="ax-oauth-info">
-              <strong><%= a.client_name || t('acct.oauth_unknown_app') %></strong>
-              <span class="ax-oauth-meta">@<%= a.site_slug %> · <%= a.scope || 'c2s' %> · <%= t('acct.oauth_last_used') %> <%= a.last_used_at ? formatDate(a.last_used_at) : t('acct.oauth_never') %></span>
-            </div>
-            <form action="/account/oauth/revoke" method="post">
-              <input type="hidden" name="token_hash" value="<%= a.token_hash %>">
-              <button type="submit" class="ax-btn ax-btn-danger"><%= t('acct.oauth_revoke') %></button>
-            </form>
-          </li>
-        <% }); %>
-      </ul>
-    <% } %>
-  </section>
-
-  <% if (canMutate) { %>
-  <% if (hasPassword) { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.password_change') %></div>
+    <div class="ax-card-title">Wachtwoord wijzigen</div>
     <form action="/account/password" method="post" class="ax-form">
       <label class="ax-field">
-        <span><%= t('acct.password_current') %></span>
+        <span>Huidig wachtwoord</span>
         <input type="password" name="current" required autocomplete="current-password">
       </label>
       <div class="ax-row ax-row-2">
         <label class="ax-field">
-          <span><%= t('acct.password_new') %> <small><%= t('acct.password_min') %></small></span>
+          <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
           <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
         </label>
         <label class="ax-field">
-          <span><%= t('acct.password_confirm') %></span>
+          <span>Bevestig nieuw wachtwoord</span>
           <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
         </label>
       </div>
       <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.password_change') %></button>
+        <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
       </div>
     </form>
   </section>
-
-  <% } else { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.login') %></div>
-    <p class="ax-tagline" style="margin:0"><%= t('acct.login_google_only', { email: account.email }) %></p>
-  </section>
-  <% } %>
-  <% } %>
 
 </div>
@@ -193,19 +108,4 @@
 .ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
 .ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
-
-/* ─── Kijker-modus notice ──────────────────────────────────────── */
-.ax-viewer-note {
-  display: flex; align-items: flex-start; gap: 0.75rem;
-  padding: 0.85rem 1rem; margin-bottom: 1rem;
-  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
-  border-left: 3px solid var(--accent);
-  border-radius: 10px;
-  background: color-mix(in srgb, var(--accent) 8%, var(--paper));
-}
-.ax-viewer-ico { font-size: 1.1rem; line-height: 1.4; }
-.ax-viewer-note strong { display: block; font-size: 0.92rem; }
-.ax-viewer-note span { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
-.ax-profile-avatar-static { flex-shrink: 0; }
-.ax-form-bio textarea:disabled { opacity: 0.7; cursor: default; }
 
 /* ─── Cards ────────────────────────────────────────────────────── */
@@ -299,6 +199,6 @@
 .ax-btn:hover { border-color: var(--accent); }
 .ax-btn-secondary { background: var(--paper-2); }
-.ax-btn-primary, a.ax-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
-.ax-btn-primary:hover, a.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); color: #fff; }
+.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
+.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
 
 /* ─── Profile ID row ──────────────────────────────────────────── */
@@ -309,11 +209,6 @@
   margin-bottom: 1rem;
 }
-.ax-avatar-form { margin: 0; flex-shrink: 0; }
-.ax-avatar-wrap {
-  position: relative; display: block;
-  width: 84px; height: 84px; cursor: pointer;
-}
 .ax-profile-avatar {
-  width: 84px; height: 84px;
+  width: 64px; height: 64px;
   border-radius: 50%;
   overflow: hidden;
@@ -322,26 +217,8 @@
   display: inline-flex; align-items: center; justify-content: center;
   font-family: var(--font-display, serif);
-  font-size: 1.7rem; font-weight: 600;
-  border: 1px solid var(--rule);
-  transition: border-color 120ms;
-}
-.ax-avatar-wrap:hover .ax-profile-avatar { border-color: var(--accent); }
+  font-size: 1.6rem; font-weight: 600;
+  flex-shrink: 0;
+}
 .ax-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
-.ax-avatar-edit {
-  position: absolute; right: -2px; bottom: -2px;
-  width: 28px; height: 28px; border-radius: 50%;
-  background: var(--accent); color: #fff;
-  display: flex; align-items: center; justify-content: center;
-  border: 3px solid var(--paper);
-  transition: transform 120ms;
-}
-.ax-avatar-wrap:hover .ax-avatar-edit { transform: scale(1.08); }
-.ax-avatar-remove { margin-top: 0.45rem; }
-.ax-linkbtn {
-  background: none; border: 0; padding: 0; cursor: pointer;
-  color: var(--ink-muted, var(--ink-soft)); font-size: 0.8rem; text-decoration: underline;
-  font-family: var(--font-ui, system-ui), sans-serif;
-}
-.ax-linkbtn:hover { color: #c33; }
 .ax-profile-meta { min-width: 0; }
 .ax-profile-name { font-weight: 600; font-size: 1.1rem; }
@@ -355,13 +232,3 @@
   .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
 }
-
-.ax-oauth-empty { margin: 0; color: var(--ink-soft, #888); }
-.ax-oauth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
-.ax-oauth-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem .85rem; border-radius: 12px; background: var(--paper-2, rgba(0,0,0,.04)); }
-.ax-oauth-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
-.ax-oauth-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ax-oauth-meta { font-size: .8rem; color: var(--ink-soft, #888); }
-@media (max-width: 480px) {
-  .ax-oauth-item { flex-direction: column; align-items: stretch; }
-}
 </style>
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-audio.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,9 @@
-<%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze
-    pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus
-    later te komen, anders wint de partial ervan. %>
-<%- include('../partials/admin-styles') %>
 <div class="container ax-page">
 
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'audio', audioOn: true }) %>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('aaud.title') %></h1>
-      <p class="ax-tagline"><%= t('aaud.tagline_pre') %> <code>[[track:&lt;id&gt;]]</code> <%= t('aaud.tagline_post') %></p>
+      <h1>Audio tracks</h1>
+      <p class="ax-tagline">Site-level MP3s. Gebruik <code>[[track:&lt;id&gt;]]</code> in een post om een play-knop in te voegen.</p>
     </div>
   </header>
@@ -19,5 +14,5 @@
   <%# ── UPLOAD ──────────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('aaud.upload') %></div>
+    <div class="ax-card-title">Upload</div>
 
     <%# Bulk drag-drop zone. Multiple files at once, transcoded sequentially.
@@ -30,17 +25,17 @@
       <div class="ax-row ax-row-2">
         <label class="ax-field">
-          <span><%= t('aaud.artist') %> <small><%= t('aaud.applied_all') %></small></span>
-          <input type="text" id="batch-artist" placeholder="<%= t('aaud.optional') %>">
+          <span>Artiest <small>(toegepast op alle bestanden)</small></span>
+          <input type="text" id="batch-artist" placeholder="Optioneel">
         </label>
         <label class="ax-field">
-          <span><%= t('aaud.album') %> <small><%= t('aaud.applied_all') %></small></span>
-          <input type="text" id="batch-album" placeholder="<%= t('aaud.optional') %>">
+          <span>Album <small>(toegepast op alle bestanden)</small></span>
+          <input type="text" id="batch-album" placeholder="Optioneel">
         </label>
       </div>
       <label class="ax-field ax-file">
-        <span><%= t('aaud.cover') %> <small><%= t('aaud.cover_hint') %></small></span>
+        <span>Cover <small>(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)</small></span>
         <span class="ax-file-control">
-          <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 <%= t('aaud.choose_cover') %></span>
-          <span class="ax-file-name" data-empty><%= t('aaud.no_file') %></span>
+          <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 Kies cover</span>
+          <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
           <input type="file" id="batch-cover" accept="image/jpeg,image/png,image/webp,image/gif">
         </span>
@@ -50,6 +45,6 @@
       <label class="ax-dropzone" id="audio-dropzone" tabindex="0">
         <span class="ax-dropzone-icon">🎵</span>
-        <strong class="ax-dropzone-title"><%= t('aaud.drag_here') %></strong>
-        <span class="ax-dropzone-sub"><%= t('aaud.or_click') %><br><small>mp3, m4a, ogg, opus, flac · max <%= maxBytesMb %> MB · wav max <%= maxWavMb %> MB</small></span>
+        <strong class="ax-dropzone-title">Sleep audio hierheen</strong>
+        <span class="ax-dropzone-sub">of klik om bestanden te kiezen<br><small>mp3, m4a, ogg, opus, flac, wav · max <%= maxBytesMb %> MB per bestand</small></span>
         <input type="file" id="audio-files" accept="audio/*" multiple>
       </label>
@@ -59,6 +54,6 @@
 
       <div class="ax-form-actions" id="upload-actions" hidden>
-        <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ <%= t('aaud.start_upload') %></button>
-        <button type="button" class="ax-btn" id="clear-queue-btn"><%= t('aaud.clear_list') %></button>
+        <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ Start upload</button>
+        <button type="button" class="ax-btn" id="clear-queue-btn">Wis lijst</button>
       </div>
     </div>
@@ -67,10 +62,6 @@
   <%# ── TRACK LIST ─────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title" style="display:flex;align-items:center;gap:.5rem">
-      <%= t('aaud.tracks') %> <span class="ax-count"><%= tracks.length %></span>
-      <button type="button" class="ax-btn ax-btn-secondary" id="add-link-track-btn"
-              style="margin-left:auto;font-size:.85rem" title="<%= t('aaud.add_link_track_title') %>">
-        + <%= t('aaud.add_link_track') %>
-      </button>
+    <div class="ax-card-title">
+      Tracks <span class="ax-count"><%= tracks.length %></span>
     </div>
 
@@ -78,18 +69,7 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">♫</div>
-        <p><%= t('aaud.no_tracks') %></p>
+        <p>Nog geen tracks. Upload er een hierboven.</p>
       </div>
     <% } else { %>
-      <%
-        // Pre-fetch loop labels — `t` is shadowed by the loop var below.
-        var _aaudUntitled = t('aaud.untitled');
-        var _aaudPlay     = t('aaud.play');
-        var _aaudEdit     = t('aaud.edit');
-        var _aaudDlOn     = t('aaud.dl_on');
-        var _aaudDlOff    = t('aaud.dl_off');
-        var _aaudDelete   = t('aaud.delete');
-        var _aaudCopy     = t('aaud.copy_click');
-        var _aaudDelConfirm = t('aaud.delete_confirm');
-      %>
       <ul class="ax-list">
         <% tracks.forEach(function(t) { %>
@@ -102,5 +82,5 @@
 
             <div class="ax-track-meta">
-              <div class="ax-track-title" data-cell="title"><%= t.title || _aaudUntitled %></div>
+              <div class="ax-track-title" data-cell="title"><%= t.title || '(zonder titel)' %></div>
               <div class="ax-track-sub">
                 <span data-cell="artist"><%= t.artist || '—' %></span>
@@ -108,5 +88,5 @@
                 <span class="ax-track-sep">·</span><span><%= t.size ? Math.round(t.size/1024) + ' KB' : '—' %></span>
               </div>
-              <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="<%= _aaudCopy %>">[[track:<%= t.id %>]]</code>
+              <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="Klik om te kopiëren">[[track:<%= t.id %>]]</code>
             </div>
 
@@ -116,16 +96,11 @@
                         data-stream-url="<%= t.stream_url %>"
                         data-track-id="<%= t.id %>"
-                        aria-label="<%= _aaudPlay %>" title="<%= _aaudPlay %>">
+                        aria-label="Afspelen" title="Afspelen">
                   <span class="ax-track-play-icon" aria-hidden="true">▶</span>
                 </button>
               <% } %>
-              <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="<%= _aaudEdit %>" title="<%= _aaudEdit %>">✎</button>
-              <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-                <button type="button" class="ax-icon-btn" data-track-dl data-id="<%= t.id %>" data-on="<%= t.downloadable ? '1' : '0' %>"
-                        aria-label="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>" title="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>"
-                        style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
-              <% } %>
-              <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>
+              <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
+              <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('Track verwijderen?')" class="ax-track-delete">
+                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
               </form>
             </div>
@@ -136,13 +111,4 @@
   </section>
 
-  <% if ((typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof embedUrl !== 'undefined') { %>
-    <section class="ax-section" style="margin-top:1.5rem">
-      <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" 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>
-  <% } %>
-
 </div>
 
@@ -153,4 +119,57 @@
   margin: 1.5rem auto 4rem;
   padding: 0 1rem;
+}
+.ax-header {
+  display: flex; align-items: flex-start; gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+.ax-header h1 {
+  font-family: var(--font-display, serif);
+  font-size: 1.75rem;
+  margin: 0 0 0.25rem;
+}
+.ax-tagline {
+  color: var(--ink-muted, var(--ink-soft));
+  margin: 0;
+  font-size: 0.9rem;
+  line-height: 1.5;
+}
+.ax-tagline code {
+  background: var(--paper-2);
+  padding: 0.1em 0.4em;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+.ax-back {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  background: var(--paper);
+  color: var(--ink);
+  text-decoration: none;
+  font-size: 1.1rem;
+  flex-shrink: 0;
+  transition: border-color 120ms;
+}
+.ax-back:hover { border-color: var(--accent); }
+
+/* ─── Flash messages ───────────────────────────────────────────── */
+.ax-flash {
+  padding: 0.75rem 1rem;
+  border-radius: 8px;
+  margin-bottom: 1rem;
+  font-size: 0.9rem;
+}
+.ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
+.ax-flash-err { background: rgba(200,60,60,.15); color: #c33;    border: 1px solid rgba(200,60,60,.3); }
+
+/* ─── Card sections ────────────────────────────────────────────── */
+.ax-card {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  padding: 1.25rem;
+  margin-bottom: 1rem;
 }
 .ax-card-title {
@@ -254,4 +273,43 @@
 .ax-form-actions[hidden] { display: none; }
 
+/* ─── Buttons ──────────────────────────────────────────────────── */
+.ax-btn {
+  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
+  padding: 0.55rem 1rem;
+  border: 1px solid var(--rule);
+  background: var(--paper-2);
+  color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.9rem; font-weight: 500;
+  text-decoration: none;
+  border-radius: 6px;
+  cursor: pointer;
+  min-height: 40px;
+  transition: background 120ms, border-color 120ms;
+  -webkit-tap-highlight-color: transparent;
+}
+.ax-btn:hover { border-color: var(--accent); }
+.ax-btn-secondary { background: var(--paper-2); }
+.ax-btn-primary {
+  background: var(--accent); color: white;
+  border-color: var(--accent);
+}
+.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
+
+/* Tiny icon-only button for row actions */
+.ax-icon-btn {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule);
+  background: var(--paper-2);
+  color: var(--ink);
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 1rem;
+  transition: background 120ms, border-color 120ms, color 120ms;
+}
+.ax-icon-btn:hover { border-color: var(--accent); }
+.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
+
 .ax-track-play.is-playing {
   border-color: var(--accent);
@@ -260,9 +318,108 @@
 }
 .ax-track-play-icon { font-size: 0.85rem; line-height: 1; }
+
+/* ─── Empty state ──────────────────────────────────────────────── */
+.ax-empty {
+  text-align: center;
+  padding: 2rem 1rem;
+  color: var(--ink-muted, var(--ink-soft));
+}
+.ax-empty-icon {
+  font-size: 2.5rem;
+  font-family: var(--font-display, serif);
+  color: var(--ink-soft);
+  opacity: 0.4;
+  margin-bottom: 0.5rem;
+}
+.ax-empty p { margin: 0; }
+
+/* ─── Track list (card per row) ────────────────────────────────── */
+.ax-list {
+  list-style: none; padding: 0; margin: 0;
+  display: flex; flex-direction: column;
+  gap: 0.6rem;
+}
+.ax-track {
+  display: grid;
+  grid-template-columns: 56px 1fr auto;
+  gap: 0.85rem;
+  align-items: center;
+  padding: 0.75rem;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 10px;
+  transition: border-color 120ms;
+}
+.ax-track:hover { border-color: var(--accent); }
+.ax-track-cover {
+  width: 56px; height: 56px;
+  border-radius: 6px;
+  object-fit: cover;
+  background: var(--paper);
+  display: flex; align-items: center; justify-content: center;
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem;
+  color: var(--accent);
+  flex-shrink: 0;
+}
 .ax-track-cover-empty { /* span variant */ }
+.ax-track-meta {
+  min-width: 0;  /* allow text-overflow on long titles */
+  display: flex; flex-direction: column; gap: 0.2rem;
+}
+.ax-track-title {
+  font-weight: 600;
+  font-size: 0.95rem;
+  color: var(--ink);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.ax-track-sub {
+  font-size: 0.8rem;
+  color: var(--ink-muted, var(--ink-soft));
+  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
+}
+.ax-track-sep { opacity: 0.5; }
+.ax-embed {
+  display: inline-block;
+  background: var(--paper);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  font-family: var(--font-mono, ui-monospace, monospace);
+  font-size: 0.75rem;
+  color: var(--ink-soft);
+  cursor: pointer;
+  user-select: all;
+  margin-top: 0.15rem;
+  word-break: break-all;
+  align-self: flex-start;
+  transition: background 120ms;
+}
+.ax-embed:hover { background: var(--rule); }
+.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
+
+.ax-track-actions {
+  display: flex; gap: 0.4rem;
+  flex-shrink: 0;
+}
 .ax-track-delete { display: inline; }
 
 /* ─── Mobile tweaks ───────────────────────────────────────────── */
 @media (max-width: 480px) {
+  .ax-track {
+    grid-template-columns: 48px 1fr;
+    grid-template-areas: "cover meta" "actions actions";
+    gap: 0.6rem;
+  }
+  .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }
+  .ax-track-meta  { grid-area: meta; }
+  .ax-track-actions {
+    grid-area: actions;
+    justify-content: flex-end;
+    border-top: 1px solid var(--rule);
+    padding-top: 0.5rem;
+    margin-top: 0.25rem;
+  }
 }
 
@@ -386,4 +543,382 @@
 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 
-<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
+<script>
+(function() {
+
+  // ── Live filename display for custom file inputs ──────────────
+  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
+    const nameEl = input.parentElement.querySelector('.ax-file-name');
+    if (!nameEl) return;
+    input.addEventListener('change', () => {
+      if (input.files && input.files[0]) {
+        nameEl.textContent = input.files[0].name;
+        nameEl.removeAttribute('data-empty');
+      } else {
+        nameEl.textContent = 'Geen bestand gekozen';
+        nameEl.setAttribute('data-empty', '');
+      }
+    });
+  });
+
+  // ── Inline track preview (routed through the global mini-player) ──
+  // Each .ax-track-play button is a thin wrapper around the global
+  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
+  // .is-playing) is synced from the player's own audio element so it
+  // stays accurate even when the user uses the mini-player's controls.
+  (function setupTrackPreview() {
+    const buttons = document.querySelectorAll('.ax-track-play');
+    if (!buttons.length) return;
+
+    function setIcon(btn, playing) {
+      const icon = btn.querySelector('.ax-track-play-icon');
+      if (icon) icon.textContent = playing ? '⏸' : '▶';
+      btn.classList.toggle('is-playing', playing);
+      btn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
+    }
+
+    // Resync ALL preview buttons against the current audio element state.
+    // Called on every play/pause/ended event so admins always see the right
+    // icon — including the case where they hit pause on the mini-player
+    // bar instead of the row's button.
+    function resyncAll() {
+      const audio = document.getElementById('audio-element');
+      const playing = audio && !audio.paused && !audio.ended;
+      const currentSrc = audio ? audio.src : '';
+      buttons.forEach(b => {
+        const isThisOne = playing && currentSrc.endsWith(b.dataset.streamUrl);
+        setIcon(b, isThisOne);
+      });
+    }
+
+    buttons.forEach(btn => {
+      btn.addEventListener('click', e => {
+        e.preventDefault();
+        const player = window.pcmsAudioPlayer;
+        if (!player) {
+          console.warn('[admin-audio] miniplayer not available');
+          return;
+        }
+        const url = btn.dataset.streamUrl;
+        if (!url) return;
+
+        // Build track metadata from the row's DOM so the mini-player shows
+        // useful info (title/artist/album/cover) without an extra API call.
+        const row = btn.closest('.ax-track');
+        const titleEl  = row && row.querySelector('[data-cell="title"]');
+        const artistEl = row && row.querySelector('[data-cell="artist"]');
+        const albumEl  = row && row.querySelector('[data-cell="album"]');
+        const coverImg = row && row.querySelector('img[data-cover-thumb]');
+        const track = {
+          url,
+          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
+          artist: artistEl ? artistEl.textContent.trim() : '',
+          album:  albumEl  ? albumEl.textContent.trim()  : '',
+          cover:  coverImg ? coverImg.src                : '',
+        };
+
+        // If this exact track is already playing, toggle pause/play instead
+        // of restarting from zero.
+        const audio = document.getElementById('audio-element');
+        if (audio && audio.src.endsWith(url)) {
+          if (audio.paused) player.play();
+          else              player.pause();
+          return;
+        }
+
+        player.setQueue([track], 0);
+      });
+    });
+
+    // Hook the global audio element's events to keep the row buttons synced.
+    // We attach lazily after the player has built its DOM. The script in
+    // shell.ejs runs at page-load so #audio-element exists by the time
+    // this IIFE fires (script tag is below the body content).
+    const audio = document.getElementById('audio-element');
+    if (audio) {
+      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
+        audio.addEventListener(ev, resyncAll);
+      });
+      // Initial state on page load (e.g. user navigated back while a track
+      // was already playing — buttons should reflect that).
+      resyncAll();
+    }
+  })();
+
+
+  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
+  // Files dropped or picked are queued (not uploaded immediately) so the
+  // user can review the list, set shared metadata, then hit "Start upload".
+  // The loop POSTs one file at a time to /admin/audio/upload with
+  // Accept: application/json so the server returns structured per-file
+  // results instead of redirecting.
+  const dropzone   = document.getElementById('audio-dropzone');
+  const fileInput  = document.getElementById('audio-files');
+  const queueEl    = document.getElementById('upload-queue');
+  const actionsEl  = document.getElementById('upload-actions');
+  const startBtn   = document.getElementById('start-upload-btn');
+  const clearBtn   = document.getElementById('clear-queue-btn');
+  const artistInput= document.getElementById('batch-artist');
+  const albumInput = document.getElementById('batch-album');
+  const coverInput = document.getElementById('batch-cover');
+
+  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
+  const queue = [];
+
+  function fmtBytes(n) {
+    if (n < 1024) return n + ' B';
+    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
+    return (n / 1024 / 1024).toFixed(1) + ' MB';
+  }
+
+  function setItemStatus(item, status, label) {
+    const labels = {
+      queued:      'Wachten',
+      uploading:   'Uploaden…',
+      transcoding: 'Converteren…',
+      done:        '✓ Klaar',
+      error:       '✗ Fout',
+    };
+    item.status = status;
+    const badge = item.el.querySelector('.ax-queue-status');
+    badge.className = 'ax-queue-status ax-queue-status--' + status;
+    badge.textContent = label || labels[status] || status;
+    // Restyle the row
+    item.el.classList.remove(
+      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
+    );
+    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
+    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
+    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
+  }
+
+  function addFiles(files) {
+    let added = 0;
+    for (const file of files) {
+      if (!file.type.startsWith('audio/') &&
+          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
+        // Silently skip non-audio drops; keeps the UX uncluttered.
+        continue;
+      }
+      const li = document.createElement('li');
+      li.className = 'ax-queue-item';
+      li.innerHTML =
+        '<div class="ax-queue-name"></div>' +
+        '<span class="ax-queue-status ax-queue-status--queued">Wachten</span>';
+      // Use textContent to avoid HTML-injection if a filename contains markup.
+      li.querySelector('.ax-queue-name').textContent = file.name;
+      // Append size hint inline
+      const size = document.createElement('span');
+      size.className = 'ax-queue-size';
+      size.textContent = fmtBytes(file.size);
+      li.querySelector('.ax-queue-name').appendChild(size);
+      queueEl.appendChild(li);
+      queue.push({ file, el: li, status: 'queued' });
+      added++;
+    }
+    if (added) {
+      queueEl.hidden = false;
+      actionsEl.hidden = false;
+    }
+  }
+
+  // ── Drop-zone events ─────────────────────────────────────────
+  // Page-level guard: a dropped file outside the zone would otherwise
+  // make the browser navigate to it (e.g. opening the audio inline), which
+  // discards typed metadata. We swallow drops anywhere unless the dropzone
+  // explicitly handles them.
+  ['dragover', 'drop'].forEach(ev => {
+    window.addEventListener(ev, e => {
+      // Allow drops INSIDE the dropzone — its own listener handles those.
+      if (dropzone.contains(e.target)) return;
+      e.preventDefault();
+    });
+  });
+
+  ['dragenter', 'dragover'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.add('is-dragover');
+    });
+  });
+  ['dragleave', 'drop'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.remove('is-dragover');
+    });
+  });
+  dropzone.addEventListener('drop', e => {
+    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
+  });
+  fileInput.addEventListener('change', () => {
+    addFiles(fileInput.files);
+    // Reset so the same file can be picked again later if user wants
+    fileInput.value = '';
+  });
+
+  // ── Clear queue button ───────────────────────────────────────
+  clearBtn.addEventListener('click', () => {
+    // Only remove items that aren't currently uploading (anything queued
+    // or already finished). An in-progress upload finishes, then its row
+    // would also disappear once we re-render — but we keep it simple and
+    // just refuse to clear during an active run.
+    if (startBtn.disabled) return;
+    queue.length = 0;
+    queueEl.innerHTML = '';
+    queueEl.hidden = true;
+    actionsEl.hidden = true;
+  });
+
+  // ── Sequential upload loop ───────────────────────────────────
+  startBtn.addEventListener('click', async () => {
+    if (startBtn.disabled) return;
+    startBtn.disabled = true;
+    clearBtn.disabled = true;
+    dropzone.style.pointerEvents = 'none';
+    dropzone.style.opacity = '0.5';
+
+    const sharedArtist = artistInput.value.trim();
+    const sharedAlbum  = albumInput.value.trim();
+    const sharedCover  = coverInput.files && coverInput.files[0];
+
+    // Process queued items one at a time. We iterate via index so that
+    // if more files get dropped during the run they ALSO get processed
+    // (queue.push above mutates the same array we're iterating).
+    for (let i = 0; i < queue.length; i++) {
+      const item = queue[i];
+      if (item.status !== 'queued') continue;
+      try {
+        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
+      } catch (err) {
+        console.error('upload failed for', item.file.name, err);
+        setItemStatus(item, 'error', '✗ ' + (err.message || 'Mislukt'));
+      }
+    }
+
+    startBtn.disabled = false;
+    clearBtn.disabled = false;
+    dropzone.style.pointerEvents = '';
+    dropzone.style.opacity = '';
+
+    // Reload the page so the new tracks appear in the list below.
+    // Could also fetch them and inject, but a full reload is simpler and
+    // ensures position indexes / album-grouping are correct.
+    const anyDone = queue.some(q => q.status === 'done');
+    if (anyDone) {
+      setTimeout(() => location.reload(), 700);
+    }
+  });
+
+  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
+    setItemStatus(item, 'uploading');
+
+    const fd = new FormData();
+    fd.append('audio', item.file);
+    if (sharedArtist) fd.append('artist', sharedArtist);
+    if (sharedAlbum)  fd.append('album',  sharedAlbum);
+    if (sharedCover)  fd.append('cover',  sharedCover);
+    // Title is intentionally omitted — server uses filename fallback.
+
+    // We can't reliably distinguish "still uploading bytes" from
+    // "uploading done, ffmpeg running" without progress events, but the
+    // status flips to "Converteren…" once the request is past upload phase.
+    // We approximate this by waiting until the response arrives — by then
+    // both phases are complete on the server side. For a smoother feel we
+    // briefly show "transcoding" near the end of the request lifecycle.
+    const transcodeHint = setTimeout(() => {
+      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
+    }, 1500);
+
+    try {
+      const res = await fetch('/admin/audio/upload', {
+        method: 'POST',
+        headers: { 'Accept': 'application/json' },
+        body: fd,
+        credentials: 'same-origin',
+      });
+      clearTimeout(transcodeHint);
+
+      // Server responds with JSON for our Accept header. If it didn't
+      // (e.g. session expired and got an HTML login page), surface that.
+      let data;
+      try { data = await res.json(); }
+      catch (_) { throw new Error('Onverwacht serverantwoord (' + res.status + ')'); }
+
+      if (!res.ok || !data.ok) {
+        throw new Error(data.error || ('HTTP ' + res.status));
+      }
+
+      setItemStatus(item, 'done', '✓ ' + (data.title || 'Klaar'));
+    } catch (err) {
+      clearTimeout(transcodeHint);
+      throw err;
+    }
+  }
+
+  // ── Click-to-copy embed codes ─────────────────────────────────
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      const text = el.dataset.copy;
+      try {
+        await navigator.clipboard.writeText(text);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ gekopieerd';
+        setTimeout(() => {
+          el.classList.remove('is-copied');
+          el.textContent = original;
+        }, 1200);
+      } catch (_) { /* fall through — selection still works */ }
+    });
+  });
+
+  // ── Wire all "Edit" buttons to the track-editor modal ─────────
+  // After save we patch the row in-place rather than reloading,
+  // so the user keeps their scroll position on long lists.
+  document.querySelectorAll('[data-track-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openTrackEditor !== 'function') {
+        alert('Track editor niet geladen');
+        return;
+      }
+      const id = btn.dataset.id;
+      window.openTrackEditor({
+        id,
+        onSaved: (track) => {
+          const row = document.querySelector('li[data-track-id="' + id + '"]');
+          if (!row) return;
+          // Update visible cells
+          const titleEl  = row.querySelector('[data-cell="title"]');
+          const artistEl = row.querySelector('[data-cell="artist"]');
+          const albumEl  = row.querySelector('[data-cell="album"]');
+          if (titleEl)  titleEl.textContent  = track.title  || '(zonder titel)';
+          if (artistEl) artistEl.textContent = track.artist || '—';
+          if (albumEl)  {
+            albumEl.textContent = track.album || '';
+            if (track.album) albumEl.removeAttribute('hidden');
+            else albumEl.setAttribute('hidden', '');
+          }
+          // Update cover thumb (replace element if type changed)
+          const oldThumb = row.querySelector('[data-cover-thumb]');
+          if (oldThumb) {
+            const parent = oldThumb.parentElement;
+            if (track.cover_url) {
+              const img = document.createElement('img');
+              img.className = 'ax-track-cover';
+              img.src = track.cover_url;
+              img.alt = '';
+              img.dataset.coverThumb = '';
+              parent.replaceChild(img, oldThumb);
+            } else {
+              const sp = document.createElement('span');
+              sp.className = 'ax-track-cover ax-track-cover-empty';
+              sp.textContent = '♫';
+              sp.dataset.coverThumb = '';
+              parent.replaceChild(sp, oldThumb);
+            }
+          }
+        },
+      });
+    });
+  });
+})();
+</script>
Index: src/views/pages/admin-comments.ejs
===================================================================
--- src/views/pages/admin-comments.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/admin-comments.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,107 @@
+<div class="container admin-comments-page">
+  <p><a href="/admin" class="btn">&larr; Admin</a></p>
+  <h1>Comment moderation</h1>
+
+  <p class="muted">
+    Mode for this site: <strong><%= moderationMode %></strong>
+    <% if (moderationMode === 'trust') { %>
+      &mdash; comments are auto-approved. Switch to <em>moderate</em> in
+      <a href="/admin/sites/<%= site.slug %>/edit">site settings</a> to queue them.
+    <% } else { %>
+      &mdash; new comments require approval before showing up on posts.
+    <% } %>
+  </p>
+
+  <% if (success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
+  <% if (error)   { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
+
+  <h2>Pending (<%= pending.length %>)</h2>
+  <% if (!pending.length) { %>
+    <p class="muted">Nothing waiting.</p>
+  <% } else { %>
+    <ol class="moderation-list">
+      <% pending.forEach(function(c) { %>
+        <li class="moderation-item">
+          <div class="moderation-meta">
+            <strong><%= c.author_username %></strong>
+            <% if (c.parent_comment_id) { %><span class="muted">(reply)</span><% } %>
+            on <a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a>
+            <span class="muted">&middot; <%= formatDateTime(c.created_at) %></span>
+          </div>
+          <blockquote class="moderation-content"><%= c.content %></blockquote>
+          <div class="moderation-actions">
+            <form method="post" action="/admin/comments/<%= c.id %>/approve" style="display:inline">
+              <button type="submit" class="btn btn-primary">Approve</button>
+            </form>
+            <form method="post" action="/admin/comments/<%= c.id %>/reject" style="display:inline">
+              <button type="submit" class="btn btn-danger">Reject</button>
+            </form>
+          </div>
+        </li>
+      <% }); %>
+    </ol>
+  <% } %>
+
+  <h2>Recent decisions</h2>
+  <% if (!recent.length) { %>
+    <p class="muted">Nothing yet.</p>
+  <% } else { %>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Author</th><th>Post</th><th>Status</th><th>Date</th></tr>
+      </thead>
+      <tbody>
+        <% recent.forEach(function(c) { %>
+          <tr class="status-<%= c.status %>">
+            <td><%= c.author_username %></td>
+            <td><a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a></td>
+            <td>
+              <span class="status-pill status-pill--<%= c.status %>"><%= c.status %></span>
+            </td>
+            <td><%= formatDateTime(c.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+</div>
+
+<style>
+.admin-comments-page { max-width: 900px; margin: 3rem auto; padding: 0 1rem; }
+.admin-comments-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 1rem; }
+.admin-comments-page h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 1.5rem 0 0.75rem; }
+.muted { color: var(--ink-muted, var(--ink-soft)); }
+.muted a { color: var(--accent); }
+
+.moderation-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
+.moderation-item {
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  padding: 1rem;
+}
+.moderation-meta { font-size: 0.85rem; margin-bottom: 0.5rem; color: var(--ink-soft); }
+.moderation-meta a { color: var(--accent); }
+.moderation-content {
+  margin: 0 0 0.75rem;
+  padding: 0.5rem 0.75rem;
+  border-left: 3px solid var(--accent);
+  background: var(--paper);
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  color: var(--ink);
+}
+.moderation-actions { display: flex; gap: 0.5rem; }
+
+.status-pill {
+  display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px;
+  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
+}
+.status-pill--approved { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; }
+.status-pill--rejected { background: rgba(200, 60, 60, 0.15); color: #c33; }
+.status-pill--pending  { background: var(--paper); color: var(--ink-muted, var(--ink-soft)); }
+
+.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; font-size: 0.9rem; }
+.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
+.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
+</style>
Index: src/views/pages/admin-epk.ejs
===================================================================
--- src/views/pages/admin-epk.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,92 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aepk.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aepk.back_admin') %></a> <a href="/pers" class="btn" target="_blank" rel="noopener">📰 <%= t('aepk.view_epk') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-
-  <section class="set-card">
-    <h2><%= t('aepk.text_heading') %></h2>
-    <p class="set-help">
-      <%= t('aepk.text_help') %>
-    </p>
-
-    <form method="post" action="/admin/epk" class="set-form">
-      <label class="set-field">
-        <span><%= t('aepk.bio_label') %></span>
-        <textarea name="epk_bio" rows="5" maxlength="1000"
-                  placeholder="<%= t('aepk.bio_ph') %>"><%= epkBio %></textarea>
-      </label>
-      <label class="set-field">
-        <span><%= t('aepk.contact_label') %></span>
-        <input type="text" name="epk_contact" maxlength="300"
-               placeholder="<%= t('aepk.contact_ph') %>"
-               value="<%= epkContact %>">
-      </label>
-
-      <div class="set-field">
-        <span><%= t('aepk.tracks_label') %> <small><%= t('aepk.tracks_hint', { n: maxEpkTracks }) %></small></span>
-        <% if (!allTracks.length) { %>
-          <p class="set-help" style="margin:0"><%= t('aepk.no_tracks') %></p>
-        <% } else { %>
-          <% var _untitled = t('aepk.untitled'); %>
-          <div class="epk-track-pick" data-max="<%= maxEpkTracks %>">
-            <% allTracks.forEach(function(t){ %>
-              <label class="epk-pick-item">
-                <input type="checkbox" name="epk_tracks" value="<%= t.id %>" <%= chosenTracks.indexOf(t.id) >= 0 ? 'checked' : '' %>>
-                <span class="epk-box" aria-hidden="true">✓</span>
-                <span class="epk-pick-title"><%= t.title || _untitled %></span>
-                <% if (t.artist) { %><span class="epk-pick-artist"><%= t.artist %></span><% } %>
-              </label>
-            <% }); %>
-          </div>
-        <% } %>
-      </div>
-
-      <button type="submit" class="btn btn-primary"><%= t('aepk.save') %></button>
-    </form>
-  </section>
-</div>
-
-<style>
-  .admin-page .set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; margin-top: 1rem; }
-  .admin-page .set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
-  .admin-page .set-help { font-size: 0.85rem; color: var(--ink-soft, var(--ink-muted)); line-height: 1.5; margin: 0 0 1rem; max-width: 60ch; }
-  .admin-page .set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 520px; }
-  .admin-page .set-field { display: flex; flex-direction: column; gap: 0.3rem; }
-  .admin-page .set-field > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-  .admin-page .set-field input,
-  .admin-page .set-field textarea {
-    width: 100%; box-sizing: border-box;
-    padding: 0.6rem 0.75rem;
-    border: 1px solid var(--rule); border-radius: 6px;
-    background: var(--paper); color: var(--ink);
-    font-family: var(--font-ui, system-ui), sans-serif; font-size: 0.95rem;
-    -webkit-appearance: none; appearance: none;
-  }
-  .admin-page .set-field textarea { resize: vertical; line-height: 1.5; }
-  .admin-page .set-field input:focus,
-  .admin-page .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-  .admin-page .set-form .btn { align-self: flex-start; }
-  .admin-page .epk-track-pick { display: flex; flex-direction: column; gap: 2px; max-height: 300px; overflow-y: auto; border: 1px solid var(--rule); border-radius: 8px; padding: 4px; }
-  .admin-page .epk-pick-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; border: 1px solid transparent; }
-  .admin-page .epk-pick-item:hover { background: var(--paper); }
-  /* Native checkbox verbergen; we tekenen een eigen, duidelijk vakje (.epk-box). */
-  .admin-page .epk-pick-item input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
-  .admin-page .epk-box {
-    flex: 0 0 auto; width: 20px; height: 20px;
-    border: 2px solid var(--rule); border-radius: 5px;
-    display: grid; place-items: center;
-    color: transparent; font-size: 13px; font-weight: 900; line-height: 1;
-  }
-  .admin-page .epk-pick-item:has(input:checked) .epk-box { background: var(--accent); border-color: var(--accent); color: #fff; }
-  /* Aangevinkte rij ook getint. */
-  .admin-page .epk-pick-item:has(input:checked) {
-    background: color-mix(in srgb, var(--accent) 14%, transparent);
-    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
-  }
-  .admin-page .epk-pick-item:has(input:disabled) { opacity: .45; cursor: not-allowed; }
-  .admin-page .epk-pick-title { font-weight: 600; }
-  .admin-page .epk-pick-artist { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
-  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
Index: src/views/pages/admin-help.ejs
===================================================================
--- src/views/pages/admin-help.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,152 +1,0 @@
-<div class="container admin-page hb">
-  <p><a href="/admin" class="btn">&larr; <%= t('ahelp.back') %></a></p>
-  <h1><%= t('ahelp.title') %></h1>
-  <p class="hb-intro"><%= t('ahelp.intro') %></p>
-
-  <div class="hb-search">
-    <input type="search" id="hb-q" placeholder="<%= t('ahelp.search_placeholder') %>" autocomplete="off" aria-label="<%= t('ahelp.search_aria') %>">
-  </div>
-  <p class="hb-count" id="hb-count" aria-live="polite"></p>
-
-  <div class="hb-list">
-
-    <section class="hb-item">
-      <h2>✍️ <%= t('ahelp.s_newpost_h') %></h2>
-      <p><%- t('ahelp.s_newpost_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📝 <%= t('ahelp.s_excerpt_h') %></h2>
-      <p><%- t('ahelp.s_excerpt_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📌 <%= t('ahelp.s_pin_h') %></h2>
-      <p><%- t('ahelp.s_pin_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🗓️ <%= t('ahelp.s_schedule_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_schedule_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🖼️ <%= t('ahelp.s_images_h') %></h2>
-      <p><%- t('ahelp.s_images_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🎵 <%= t('ahelp.s_audio_h') %></h2>
-      <p><%- t('ahelp.s_audio_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>© <%= t('ahelp.s_credit_h') %></h2>
-      <p><%- t('ahelp.s_credit_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>⬇ <%= t('ahelp.s_downloads_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_downloads_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📃 <%= t('ahelp.s_albums_h') %></h2>
-      <p><%- t('ahelp.s_albums_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📅 <%= t('ahelp.s_agenda_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_agenda_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📰 <%= t('ahelp.s_presskit_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_presskit_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔗 <%= t('ahelp.s_circles_h') %></h2>
-      <p><%- t('ahelp.s_circles_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🌐 <%= t('ahelp.s_fedi_h') %></h2>
-      <p><%- t('ahelp.s_fedi_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📊 <%= t('ahelp.s_stats_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_stats_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>✉️ <%= t('ahelp.s_newsletter_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_newsletter_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔗 <%= t('ahelp.s_linkbio_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_linkbio_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>▶️ <%= t('ahelp.s_embed_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_embed_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🎨 <%= t('ahelp.s_appearance_h') %></h2>
-      <p><%- t('ahelp.s_appearance_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>👥 <%= t('ahelp.s_tenancy_h') %></h2>
-      <p><%- t('ahelp.s_tenancy_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>⭐ <%= t('ahelp.s_premium_h') %></h2>
-      <p><%- t('ahelp.s_premium_b') %></p>
-    </section>
-
-
-    <section class="hb-item">
-      <h2>🔑 <%= t('ahelp.s_password_h') %></h2>
-      <p><%- t('ahelp.s_password_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔄 <%= t('ahelp.s_updates_h') %></h2>
-      <p><%- t('ahelp.s_updates_b') %></p>
-    </section>
-
-  </div>
-
-  <p class="hb-empty" id="hb-empty" hidden><%= t('ahelp.empty') %></p>
-</div>
-
-<style>
-  .hb { max-width: 760px; }
-  .hb h1 { margin-bottom: .25rem; }
-  .hb-intro { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1rem; }
-  .hb-search input {
-    width: 100%; box-sizing: border-box;
-    padding: 0.7rem 0.9rem; font-size: 1rem;
-    border: 1px solid var(--rule); border-radius: 999px;
-    background: var(--paper); color: var(--ink);
-    -webkit-appearance: none; appearance: none;
-  }
-  .hb-search input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-  .hb-count { color: var(--ink-muted, var(--ink-soft)); font-size: .82rem; margin: .5rem 0 1rem; min-height: 1em; }
-  .hb-list { display: flex; flex-direction: column; gap: .75rem; }
-  .hb-item { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1rem 1.1rem; }
-  .hb-item h2 { font-family: var(--font-display, serif); font-size: 1.15rem; margin: 0 0 .4rem; }
-  .hb-item h2 small { font-weight: 400; font-size: .72rem; color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 999px; padding: .05rem .45rem; margin-left: .35rem; vertical-align: middle; }
-  .hb-item p { margin: 0; line-height: 1.55; color: var(--ink); }
-  .hb-item code { background: var(--paper); padding: .08rem .35rem; border-radius: 4px; font-size: .88em; }
-  .hb-item.hb-hidden { display: none; }
-  .hb-item mark { background: color-mix(in srgb, var(--accent) 35%, transparent); color: inherit; border-radius: 2px; }
-  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
Index: src/views/pages/admin-listeners.ejs
===================================================================
--- src/views/pages/admin-listeners.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,53 +1,0 @@
-<%# Beheer -> Media -> Luisteraars (shaer-0nh).
-
-    Deze pagina had zijn eigen <style>-blok met eigen klassen (.lis-*) en week
-    daarmee af van de vier tabs ernaast -- andere breedte, andere kop, andere
-    rij. Nu op dezelfde ax-*-romp; wat overblijft is een lijst zoals audio en
-    playlists er ook een hebben. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'listeners', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('alis.lis_title') %></h1>
-      <%# Waarom dit een eigen tab is en niet een lijstje bij de volgers: dit
-          zijn accounts die aan de BIBLIOTHEEK hangen. Ze krijgen de muziek en
-          met opzet niet de gewone posts, en dat verschil hoort zichtbaar te
-          zijn -- ook voor wie later moet begrijpen waarom ze niet in de
-          volgerslijst staan. %>
-      <p class="ax-tagline"><%= t('alis.lis_intro') %></p>
-    </div>
-  </header>
-
-  <% if (!luisteraars.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#127911;</div>
-      <p><%= t('alis.lis_empty') %></p>
-    </div>
-  <% } else { %>
-    <p class="ax-tagline" style="margin-bottom:.75rem;"><strong><%= luisteraars.length %></strong> <%= t('alis.lis_count') %></p>
-    <ul class="ax-list">
-      <% luisteraars.forEach(function (l) { %>
-        <li class="ax-track">
-          <div class="ax-track-cover">
-            <% if (l.icon) { %><img src="<%= l.icon %>" alt="" loading="lazy" decoding="async"><% } else { %>&#9835;<% } %>
-          </div>
-          <div class="ax-track-meta">
-            <div class="ax-track-title">
-              <%= l.name || l.handle || l.actor_uri %>
-              <% if (l.last_error_at) { %><span class="ax-pill"><%= t('alis.lis_error') %></span><% } %>
-            </div>
-            <div class="ax-track-sub">
-              <% if (l.handle) { %><span><%= l.handle %></span><span class="ax-track-sep">&middot;</span><% } %>
-              <span><%= t('alis.lis_since') %> <%= formatDate(l.created_at) %></span>
-              <span class="ax-track-sep">&middot;</span>
-              <span><%= t('alis.lis_last') %>: <%= l.last_delivery_at ? formatDate(l.last_delivery_at) : t('alis.lis_never') %></span>
-            </div>
-          </div>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
Index: src/views/pages/admin-media.ejs
===================================================================
--- src/views/pages/admin-media.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-<%# Beheer -> Media: de afbeeldingenbibliotheek met opruiming.
-    De audio-helft zit onder /admin/audio, als eigen tab.
-
-    Deze pagina stond volledig op inline styles en viel daarmee buiten het
-    ax-*-systeem waar audio en playlists al op zaten. Nu op dezelfde romp:
-    zelfde breedte, zelfde kop, tabs op dezelfde plek. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'images', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('admin.t_media') %></h1>
-      <p class="ax-tagline">
-        <%= items.length %> <%= t('admin.media_count') %><% if (unusedCount) { %> &middot; <strong><%= unusedCount %></strong> <%= t('admin.media_unused') %><% } %>
-      </p>
-    </div>
-  </header>
-
-  <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
-
-  <% if (unusedCount) { %>
-  <div class="ax-actions-bar">
-    <button type="button" id="media-cleanup" class="ax-btn ax-btn-danger">&#128465; <%= t('admin.media_cleanup') %> (<%= unusedCount %>)</button>
-  </div>
-  <% } %>
-
-  <% if (!items.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#128247;</div>
-      <p><%= t('admin.media_empty') %></p>
-    </div>
-  <% } else { %>
-  <div class="ax-grid">
-    <% items.forEach(function(it){ %>
-      <div class="ax-tile media-card" data-file="<%= it.file %>">
-        <div class="ax-tile-media" style="aspect-ratio:1;">
-          <img src="<%= thumb(it.url, 320) %>" alt="" loading="lazy" decoding="async" style="object-fit:cover;height:100%;">
-          <% if (it.hasVideo) { %><span class="ax-tile-badge ax-tile-badge-r" title="animated cover">&#127916;</span><% } %>
-          <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %>
-        </div>
-        <div class="ax-tile-body">
-          <div class="ax-tile-meta"><%= it.kb %> KB &middot; <%= it.usedCount %>&times;</div>
-          <div class="ax-tile-actions">
-            <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>
-            <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">&#128465;</button>
-          </div>
-        </div>
-      </div>
-    <% }) %>
-  </div>
-  <% } %>
-</div>
-
-<%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %>
Index: src/views/pages/admin-migrate.ejs
===================================================================
--- src/views/pages/admin-migrate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,329 +1,0 @@
-<div class="container mig-wrap">
-  <h1><%= t('mig.title') %></h1>
-  <p class="mig-lead"><%= t('mig.lead') %></p>
-
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# ── Het stappenplan ────────────────────────────────────────── %>
-  <%# Bestaat omdat de VOLGORDE niet vanzelfsprekend is en een fout erin je
-      halverwege vastzet. Twee dingen die niemand raadt:
-        - de ophaalknop werkt PAS nadat de verhuizing is aangekondigd; de bron
-          geeft niets af aan een instantie die niet in zijn movedTo staat;
-        - met een zip kan het juist andersom, want een zip vraagt niemand iets.
-      En stap 5 staat erbij omdat hij bestaat, niet omdat er een knop voor is. %>
-  <section class="mig-card mig-plan">
-    <h2><%= t('mig.plan_title') %></h2>
-    <p class="form-hint"><%= t('mig.plan_hint') %></p>
-    <%# "hier" hangt af van WELKE kant je bekijkt. Deze pagina draait op allebei
-        de instanties, en een vaste tekst is dus op de helft van de schermen
-        onzin. Heeft deze site een moved_to, dan is dit de vertrekkende kant. %>
-    <%# Stap 1, 3 en 4 horen op de NIEUWE kant; stap 2 en 5 op de oude. Vanaf
-        welke instantie je kijkt bepaalt dus welk van de twee "hier" is. %>
-    <% var _hier = stap.isOud ? t('mig.plan_new')  : t('mig.plan_here');
-       var _daar = stap.isOud ? t('mig.plan_here') : t('mig.plan_old'); %>
-    <% if (stap.isOud) { %>
-      <p class="form-hint mig-warn"><%= t('mig.plan_from_old') %></p>
-    <% } %>
-    <ol class="mig-steps">
-      <li class="<%= stap.geclaimd ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_1') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-        <em><%= t('mig.plan_1_why') %></em>
-      </li>
-      <li class="<%= stap.aangekondigd ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_2') %></strong>
-        <span class="mig-waar"><%= _daar %></span>
-        <em><%= t('mig.plan_2_why') %></em>
-      </li>
-      <li class="<%= stap.opgehaald ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_3') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-        <em><%= t('mig.plan_3_why') %></em>
-      </li>
-      <li>
-        <strong><%= t('mig.plan_4') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-      </li>
-      <li class="mig-step-later">
-        <strong><%= t('mig.plan_5') %></strong>
-        <span class="mig-waar"><%= _daar %></span>
-        <em><%= t('mig.plan_5_why') %></em>
-      </li>
-    </ol>
-  </section>
-
-  <%# ── Je oude account claimen (FEP-7628 alsoKnownAs) ─────────── %>
-  <%# Bewust als EERSTE: zonder deze claim weigert de oude instantie de Move
-      met no_backreference, en kan de ophaalknop hieronder ook niets. Dit stond
-      op de site-bewerkpagina tussen de kleuren; daar zoekt niemand het als hij
-      aan het verhuizen is. %>
-  <section class="mig-card">
-    <h2><%= t('mig.alias_title') %></h2>
-    <p class="form-hint"><%= t('mig.alias_hint') %></p>
-    <form method="post" action="/admin/migrate/aliases">
-      <label for="mig-alias"><%= t('mig.alias_label') %></label>
-      <textarea id="mig-alias" name="ap_aliases" rows="3" class="mig-input"
-        placeholder="@jij@mastodon.social&#10;@jij@oude-klonkt.nl"><%= aliassen || '' %></textarea>
-      <p class="form-hint"><%= t('mig.alias_note') %></p>
-      <button type="submit" class="btn"><%= t('mig.alias_btn') %></button>
-    </form>
-  </section>
-
-  <%# ── Exporteren ─────────────────────────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.export_title') %></h2>
-    <p class="form-hint"><%= t('mig.export_hint') %></p>
-
-    <% if (telling) { %>
-      <ul class="mig-counts">
-        <li><strong><%= telling.posts %></strong> <%= t('mig.c_posts') %></li>
-        <li><strong><%= telling.replies %></strong> <%= t('mig.c_replies') %></li>
-        <li><strong><%= telling.media %></strong> <%= t('mig.c_media') %></li>
-        <%# Muziek APART tellen, niet onder "media". Dit ontbrak, en juist daardoor
-            kon een export van 140 nummers er als 4,5 MB uitzien zonder dat iemand
-            het merkte: de teller zei "39 mediabestanden" en zweeg over de rest. %>
-        <% if (telling.tracks) { %>
-          <%# Wat er ECHT meegaat, niet hoeveel rijen er in de database staan.
-              "8 nummers" met daaronder "van 3 ontbreekt het bestand" telt niet
-              op; 5 plus die waarschuwing wel. %>
-          <li><strong><%= telling.tracks - (telling.audioMissing || 0) %></strong> <%= t('mig.c_tracks') %></li>
-        <% } %>
-        <% if (telling.playlists) { %>
-          <li><strong><%= telling.playlists %></strong> <%= t('mig.c_playlists') %></li>
-        <% } %>
-        <% if (typeof telling.following === 'number') { %>
-          <li><strong><%= telling.following %></strong> <%= t('mig.c_following') %></li>
-        <% } %>
-        <li><strong><%= mb(telling.bytes) %></strong> <%= t('mig.c_size') %></li>
-      </ul>
-
-      <% if (telling.ontbrekend) { %>
-        <%# Niets stils: media die op schijf ontbreekt wordt geteld en gemeld, want
-            een archief dat stilzwijgend minder bevat is erger dan een waarschuwing. %>
-        <p class="alert alert-error"><%= t('mig.missing', { n: telling.ontbrekend }) %></p>
-      <% } %>
-
-      <% if (telling.audioMissing) { %>
-        <%# Een eigen zin voor muziek. "N mediaverwijzingen" zei niets over de
-            vraag die er toe doet: gaat mijn bibliotheek mee. %>
-        <p class="alert alert-error"><%= t('mig.audio_missing', { n: telling.audioMissing }) %></p>
-      <% } %>
-
-
-      <% if (telling.groot) { %>
-        <p class="alert alert-error"><%= t('mig.too_big') %></p>
-      <% } else { %>
-        <p><a class="btn btn-primary" href="/admin/migrate/export"><%= t('mig.export_btn') %></a></p>
-      <% } %>
-    <% } else { %>
-      <p class="form-hint"><%= t('mig.export_none') %></p>
-    <% } %>
-  </section>
-
-  <%# ── Rechtstreeks ophalen (FEP-1580) ────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.pull_title') %></h2>
-    <p class="form-hint"><%= t('mig.pull_hint') %></p>
-
-    <% if (haalVerslag) { %>
-      <div class="mig-report<%= haalVerslag.error ? '' : ' is-done' %>">
-        <% if (haalVerslag.error) { %>
-          <h3><%= t('mig.pull_failed') %></h3>
-          <%# Elke weigering krijgt een eigen zin. "Er ging iets mis" laat je
-              raden welke van de twee kanten van de afspraak ontbreekt, en dat
-              is precies wat je moet weten om het op te lossen. %>
-          <p><%= t('mig.e_' + haalVerslag.error) !== 'mig.e_' + haalVerslag.error
-                   ? t('mig.e_' + haalVerslag.error) : (haalVerslag.melding || haalVerslag.error) %></p>
-          <% if (haalVerslag.error === 'not_moved_here' && haalVerslag.movedTo) { %>
-            <p class="form-hint"><%= t('mig.e_points_at') %> <code><%= haalVerslag.movedTo %></code></p>
-          <% } %>
-        <% } else { %>
-          <h3><%= t('mig.pull_done') %></h3>
-          <ul class="mig-counts">
-            <li><strong><%= haalVerslag.posts %></strong> <%= t('mig.r_imported') %></li>
-            <% if (haalVerslag.overgeslagen) { %><li><strong><%= haalVerslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li><% } %>
-            <% if (haalVerslag.media) { %><li><strong><%= haalVerslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
-            <% if (haalVerslag.blocks) { %><li><strong><%= haalVerslag.blocks %></strong> <%= t('mig.r_blocks') %></li><% } %>
-            <% if (haalVerslag.tracksBinnen) { %><li><strong><%= haalVerslag.tracksBinnen %></strong> <%= t('mig.c_tracks') %></li><% } %>
-            <%# Aangevuld telt apart van nieuw. Een tweede ronde die "0 erbij"
-                meldt terwijl hij drie nummers compleet maakte, leest als niets
-                gedaan. %>
-            <% if (haalVerslag.tracksBijgewerkt) { %><li><strong><%= haalVerslag.tracksBijgewerkt %></strong> <%= t('mig.r_tracks_updated') %></li><% } %>
-            <% if (haalVerslag.postsBijgewerkt) { %><li><strong><%= haalVerslag.postsBijgewerkt %></strong> <%= t('mig.r_posts_updated') %></li><% } %>
-            <% if (haalVerslag.linksBijgetrokken) { %><li><strong><%= haalVerslag.linksBijgetrokken %></strong> <%= t('mig.r_links_fixed') %></li><% } %>
-            <% if (haalVerslag.playlistsBinnen) { %><li><strong><%= haalVerslag.playlistsBinnen %></strong> <%= t('mig.c_playlists') %></li><% } %>
-          </ul>
-          <% if (haalVerslag.tracksMislukt) { %>
-            <p class="alert alert-error"><%= t('mig.r_tracks_missing', { n: haalVerslag.tracksMislukt }) %></p>
-          <% } %>
-        <% } %>
-        <% if (haalVerslag.waarschuwingen && haalVerslag.waarschuwingen.length) { %>
-          <ul class="mig-warnlist">
-            <% haalVerslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
-          </ul>
-        <% } %>
-      </div>
-    <% } %>
-
-    <form method="post" action="/admin/migrate/pull">
-      <label for="mig-bron"><%= t('mig.pull_source') %></label>
-      <input type="url" id="mig-bron" name="bron" value="<%= bron || '' %>"
-             placeholder="@jij@oude-klonkt.nl" class="mig-input">
-      <p class="form-hint"><%= t('mig.pull_source_hint') %></p>
-      <button type="submit" class="btn"><%= t('mig.pull_btn') %></button>
-    </form>
-
-    <% if (mig && (mig.total || mig.moves)) { %>
-      <p class="form-hint mig-state">
-        <%= t('mig.state', { n: mig.total }) %>
-        <%= mig.complete ? t('mig.state_done') : t('mig.state_busy') %>
-      </p>
-    <% } %>
-  </section>
-
-  <%# ── Importeren ─────────────────────────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.import_title') %></h2>
-    <p class="form-hint"><%= t('mig.import_hint') %></p>
-
-    <% if (verslag) { %>
-      <div class="mig-report<%= verslag.echt ? ' is-done' : '' %>">
-        <h3><%= verslag.echt ? t('mig.r_done') : t('mig.r_dry') %></h3>
-        <p class="form-hint"><%= verslag.bestand %> &middot; <%= mb(verslag.bytes) %></p>
-        <ul class="mig-counts">
-          <li><strong><%= verslag.posts %></strong> <%= verslag.echt ? t('mig.r_imported') : t('mig.r_would') %></li>
-          <li><strong><%= verslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li>
-          <% if (verslag.overschreven) { %><li><strong><%= verslag.overschreven %></strong> <%= t('mig.r_overwritten') %></li><% } %>
-          <% if (verslag.mediaMissing) { %><li><strong><%= verslag.mediaMissing %></strong> <%= t('mig.r_media_missing') %></li><% } %>
-          <% if (verslag.media) { %><li><strong><%= verslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
-          <%# Ook hier: muziek apart. Zonder deze twee regels zie je aan de
-              ontvangende kant net zo min of je bibliotheek is aangekomen. %>
-          <% if (verslag.tracks) { %><li><strong><%= verslag.tracks %></strong> <%= t('mig.c_tracks') %></li><% } %>
-          <% if (verslag.playlists) { %><li><strong><%= verslag.playlists %></strong> <%= t('mig.c_playlists') %></li><% } %>
-          <% if (verslag.linksBijgetrokken) { %><li><strong><%= verslag.linksBijgetrokken %></strong> <%= t('mig.r_links_fixed') %></li><% } %>
-        </ul>
-        <% if (verslag.tracksMissing) { %>
-          <p class="alert alert-error"><%= t('mig.r_tracks_missing', { n: verslag.tracksMissing }) %></p>
-        <% } %>
-        <% if (verslag.waarschuwingen && verslag.waarschuwingen.length) { %>
-          <ul class="mig-warnlist">
-            <% verslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
-          </ul>
-        <% } %>
-        <% if (verslag.idsBehouden === false) { %>
-          <%# Dit is de belangrijkste regel van het hele verslag: komt het archief
-              van een ANDER adres, dan krijgen de posts nieuwe id's en zijn het
-              nieuwe objecten voor de fediverse. %>
-          <p class="alert alert-error"><%= t('mig.r_new_ids') %></p>
-        <% } %>
-
-        <% if (!verslag.echt && verslag.posts > 0) { %>
-          <form method="post" action="/admin/migrate/import" enctype="multipart/form-data" class="mig-confirm">
-            <p class="form-hint"><%= t('mig.r_confirm_hint') %></p>
-            <label for="mig-file2"><%= t('mig.file_label') %></label>
-            <input type="file" id="mig-file2" name="archief" accept=".zip,application/zip" required>
-            <input type="hidden" name="echt" value="1">
-            <% if (verslag.overschrijf) { %><input type="hidden" name="overschrijf" value="1"><% } %>
-            <button type="submit" class="btn btn-primary"><%= t('mig.r_confirm_btn') %></button>
-          </form>
-        <% } %>
-      </div>
-    <% } %>
-
-    <form method="post" action="/admin/migrate/import" enctype="multipart/form-data">
-      <label for="mig-file"><%= t('mig.file_label') %></label>
-      <input type="file" id="mig-file" name="archief" accept=".zip,application/zip" required>
-
-      <label class="mig-check">
-        <input type="checkbox" name="overschrijf" value="1">
-        <%= t('mig.overwrite_label') %>
-      </label>
-      <p class="form-hint mig-warn"><%= t('mig.overwrite_hint') %></p>
-
-      <button type="submit" class="btn"><%= t('mig.check_btn') %></button>
-      <p class="form-hint"><%= t('mig.check_hint') %></p>
-    </form>
-  </section>
-
-  <%# ── De verhuizing aankondigen (FEP-7628 Move) ──────────────── %>
-  <%# ONDERAAN, en met een rode knop. Dit is de enige knop op deze pagina die
-      de fediverse in gaat en niet terug te draaien is: je volgers verhuizen
-      mee en dit account gaat op slot. Alles hierboven kun je overdoen. %>
-  <section class="mig-card mig-card-danger">
-    <h2><%= t('mig.move_title') %></h2>
-    <p class="form-hint"><%= t('mig.move_hint') %></p>
-    <% if (movedTo) { %>
-      <p class="alert alert-error"><%= t('mig.move_done') %> <code><%= movedTo %></code></p>
-    <% } else { %>
-      <form method="post" action="/admin/sites/<%= site.slug %>/move"
-            onsubmit="return confirm('<%= t('mig.move_confirm') %>');">
-        <input type="hidden" name="next" value="/admin/migrate">
-        <label for="mig-move"><%= t('mig.move_label') %></label>
-        <input type="text" id="mig-move" name="move_target" class="mig-input"
-               placeholder="@nieuw@andere-server.nl" required>
-        <p class="form-hint mig-warn"><%= t('mig.move_warn') %></p>
-        <button type="submit" class="btn btn-danger"><%= t('mig.move_btn') %></button>
-      </form>
-    <% } %>
-  </section>
-
-  <%# ── Wie je volgt ────────────────────────────────────────────── %>
-  <%# Stond op Connect, in een <details> zonder een greintje opmaak: labels als
-      losse tekst naast hun invoerveld, een textarea van vijf regels breed. Hier
-      krijgt hij de kaartopmaak van deze pagina, en hij hoort hier ook: je
-      volglijst meenemen is een migratie-optie. %>
-  <section class="mig-card">
-    <h2><%= t('mig.follows_title') %></h2>
-    <p class="form-hint"><%= t('tl.move_hint') %></p>
-    <p><a class="btn" href="/news/following.csv" download>&#11015; <%= t('tl.move_export') %></a></p>
-
-    <form method="post" action="/news/following/import" enctype="multipart/form-data">
-      <input type="hidden" name="next" value="/admin/migrate">
-      <label for="mig-volg-bestand"><%= t('tl.move_import_file') %></label>
-      <input type="file" id="mig-volg-bestand" name="csvfile" accept=".csv,text/csv,text/plain">
-
-      <label for="mig-volg-csv"><%= t('tl.move_import_lbl') %></label>
-      <textarea id="mig-volg-csv" name="csv" rows="5" spellcheck="false" class="mig-input"
-                placeholder="Account address,Show boosts,Notify on new posts,Languages,Featured"></textarea>
-      <p class="form-hint"><%= t('mig.follows_note') %></p>
-
-      <button type="submit" class="btn btn-primary"><%= t('tl.move_import') %></button>
-    </form>
-  </section>
-</div>
-
-<style>
-  .mig-wrap { max-width: 46rem; }
-  .mig-lead { color: var(--sub); margin-bottom: 1.5rem; }
-  .mig-card { border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; }
-  .mig-card h2 { margin: 0 0 .35rem; font-size: 1.1rem; }
-  .mig-counts { list-style: none; padding: 0; margin: .9rem 0; display: flex; flex-wrap: wrap; gap: .4rem 1.4rem; }
-  .mig-counts li { color: var(--sub); font-size: .94rem; }
-  .mig-counts strong { color: var(--ink); font-size: 1.05rem; }
-  .mig-card label { display: block; margin: .9rem 0 .35rem; font-weight: 600; font-size: .92rem; }
-  .mig-card input[type="file"] { display: block; margin-bottom: .6rem; }
-  .mig-check { display: flex; align-items: center; gap: .5rem; font-weight: 400 !important; }
-  .mig-input { width: 100%; max-width: 34rem; padding: .5rem .6rem; border: 1px solid var(--rule);
-               border-radius: 8px; background: transparent; color: var(--ink); font-size: .95rem; }
-  .mig-state { margin-top: .9rem; }
-  .mig-report code { word-break: break-all; font-size: .88rem; }
-  .mig-warn { color: var(--accent); }
-  .mig-plan { background: color-mix(in srgb, var(--ink, #000) 3%, transparent); }
-  .mig-steps { margin: 1rem 0 0; padding-left: 1.4rem; display: flex; flex-direction: column; gap: .7rem; }
-  .mig-steps li { color: var(--ink); }
-  .mig-steps li em { display: block; color: var(--sub); font-style: normal; font-size: .9rem; margin-top: .15rem; }
-  .mig-steps li.is-done > strong { text-decoration: line-through; opacity: .6; }
-  .mig-steps li.is-done::marker { content: '✓ '; }
-  .mig-steps li.mig-step-later { opacity: .8; }
-  .mig-waar { font-size: .8rem; padding: .05rem .4rem; border: 1px solid var(--rule); border-radius: 999px;
-              color: var(--sub); margin-left: .4rem; white-space: nowrap; }
-  .mig-card-danger { border-color: color-mix(in srgb, var(--accent, #c33) 45%, var(--rule)); }
-  textarea.mig-input { font-family: inherit; resize: vertical; }
-  .mig-report { border-left: 3px solid var(--accent); padding: .8rem 1rem; margin-bottom: 1.2rem;
-                background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border-radius: 0 8px 8px 0; }
-  .mig-report.is-done { border-left-color: #3fa843; }
-  .mig-report h3 { margin: 0 0 .3rem; font-size: 1rem; }
-  .mig-warnlist { margin: .5rem 0; padding-left: 1.2rem; color: var(--accent); font-size: .9rem; }
-  .mig-confirm { margin-top: .9rem; padding-top: .9rem; border-top: 1px solid var(--rule); }
-</style>
Index: src/views/pages/admin-newsletter.ejs
===================================================================
--- src/views/pages/admin-newsletter.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,68 +1,0 @@
-<%
-  var c = (typeof nlCounts !== 'undefined') ? nlCounts : { confirmed:0, pending:0, unsub:0 };
-  var msg = (typeof nlMsg !== 'undefined') ? nlMsg : '';
-  var msgKind = (typeof nlMsgKind !== 'undefined') ? nlMsgKind : '';
-  var hist = (typeof nlHistory !== 'undefined') ? nlHistory : [];
-  var smtp = (typeof nlSmtp !== 'undefined') ? nlSmtp : false;
-%>
-<section class="anl">
-  <h1><%= t('anews.title') %></h1>
-
-  <% if (msg) { %><div class="anl-msg anl-<%= msgKind %>"><%= msg %></div><% } %>
-
-  <div class="anl-stats">
-    <div class="anl-stat"><span class="anl-n"><%= c.confirmed %></span><span class="anl-l"><%= t('anews.confirmed') %></span></div>
-    <div class="anl-stat"><span class="anl-n"><%= c.pending %></span><span class="anl-l"><%= t('anews.pending') %></span></div>
-    <div class="anl-stat"><span class="anl-n"><%= c.unsub %></span><span class="anl-l"><%= t('anews.unsub') %></span></div>
-  </div>
-
-  <% if (!smtp) { %>
-    <div class="anl-msg anl-warn"><%= t('anews.smtp_warn_1') %> (<code>SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM</code> <%= t('anews.smtp_warn_2') %> <code>.env</code>).</div>
-  <% } %>
-
-  <p class="anl-share"><%= t('anews.share') %> <a href="<%= nlSubscribeUrl %>"><%= nlSubscribeUrl %></a></p>
-
-  <% var _sendConfirm = t('anews.send_confirm', { n: c.confirmed }); %>
-  <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') %>">
-    </label>
-    <label><%= t('anews.body') %>
-      <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' %>><%= t('anews.send_btn', { n: c.confirmed }) %></button>
-  </form>
-
-  <% if (hist.length) { %>
-    <h2 class="anl-h2"><%= t('anews.sent_heading') %></h2>
-    <% var _recipients = t('anews.recipients'); %>
-    <ul class="anl-hist">
-      <% hist.forEach(function(h){ %>
-        <li><span class="anl-hist-subj"><%= h.subject %></span><span class="anl-hist-meta"><%= h.recipient_count %> <%= _recipients %> · <%= formatDateTime ? formatDateTime(h.sent_at) : h.sent_at %></span></li>
-      <% }); %>
-    </ul>
-  <% } %>
-</section>
-
-<style>
-  .anl { max-width: 720px; margin: 0 auto; padding: 24px 18px 64px; }
-  .anl h1 { margin: 0 0 18px; }
-  .anl-msg { padding: 11px 14px; border-radius: 10px; margin-bottom: 16px; font-size: 14px; }
-  .anl-ok { background: rgba(60,160,90,.15); }
-  .anl-bad { background: rgba(200,60,60,.15); }
-  .anl-warn { background: rgba(210,160,40,.15); }
-  .anl-stats { display: flex; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
-  .anl-stat { flex: 1 1 100px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; padding: 14px; text-align: center; }
-  .anl-n { display: block; font-size: 28px; font-weight: 700; }
-  .anl-l { font-size: 12px; opacity: .65; text-transform: uppercase; letter-spacing: .06em; }
-  .anl-share { font-size: 13px; opacity: .8; margin: 0 0 20px; }
-  .anl-form { display: flex; flex-direction: column; gap: 14px; }
-  .anl-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 13.5px; }
-  .anl-form input, .anl-form textarea { padding: 11px 13px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font: inherit; font-weight: 400; }
-  .anl-send { align-self: flex-start; padding: 11px 20px; border-radius: 10px; border: none; background: var(--accent, #6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-  .anl-send:disabled { opacity: .5; cursor: not-allowed; }
-  .anl-h2 { margin: 30px 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
-  .anl-hist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .anl-hist li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.18); border-radius: 10px; flex-wrap: wrap; }
-  .anl-hist-meta { opacity: .6; font-size: 12.5px; }
-</style>
Index: src/views/pages/admin-paid.ejs
===================================================================
--- src/views/pages/admin-paid.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,74 +1,0 @@
-<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
-  <h1 style="margin:0 0 .3rem"><%= t('apaid.t') %></h1>
-  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
-    <%= t('apaid.intro') %>
-  </p>
-
-  <% if (saved) { %><p style="color:var(--accent);font-weight:600"><%= t('apaid.saved') %></p><% } %>
-  <% if (error) { %><p style="color:#c0392b"><%= error %></p><% } %>
-  <% if (!secretReady) { %>
-    <p style="color:#c0392b"><%= t('apaid.nokey') %></p>
-  <% } %>
-
-  <p style="margin:.2rem 0 1.2rem">
-    <%= t('apaid.status') %>
-    <% if (status.connected) { %>
-      <strong style="color:var(--accent)"><%= t('apaid.connected') %></strong> (<%= t('apaid.campaign') %> <%= status.campaignId %>)
-    <% } else if (status.configured) { %>
-      <strong><%= t('apaid.configured') %></strong>
-    <% } else { %>
-      <strong><%= t('apaid.notyet') %></strong>
-    <% } %>
-  </p>
-
-  <div style="border:1px solid var(--border,#333);border-radius:10px;padding:.9rem 1rem;margin:0 0 1.3rem;background:color-mix(in srgb,var(--accent,#6b8f71) 8%,transparent)">
-    <p style="margin:0 0 .4rem;font-weight:600"><%= t('apaid.redirect_h') %></p>
-    <p style="margin:0 0 .6rem;color:var(--ink-soft,#888);font-size:.9rem">
-      <%= t('apaid.redirect_p') %>
-    </p>
-    <div style="display:flex;gap:.5rem;align-items:center">
-      <input id="pd-redirect" type="text" readonly value="<%= redirectUri %>" style="flex:1;min-width:0;font-family:monospace;font-size:.9rem">
-      <button type="button" id="pd-copy" class="btn" data-copied="<%= t('apaid.copied') %>"><%= t('apaid.copy') %></button>
-    </div>
-  </div>
-
-  <form method="post" action="/admin/paid" style="display:flex;flex-direction:column;gap:.9rem">
-    <label><%= t('apaid.client_id') %>
-      <input type="text" name="client_id" value="<%= status.clientId || '' %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.client_secret') %>
-      <input type="password" name="client_secret" placeholder="<%= status.hasSecret ? t('apaid.unchanged') : '' %>" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.keep') %></small>
-    </label>
-    <label><%= t('apaid.campaign_id') %>
-      <input type="text" name="campaign_id" value="<%= status.campaignId || '' %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.public_page') %>
-      <input type="url" name="patreon_url" value="<%= status.patreonUrl || '' %>" placeholder="https://www.patreon.com/jouwnaam" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.public_help') %></small>
-    </label>
-    <label><%= t('apaid.access') %>
-      <input type="password" name="access_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.refresh') %>
-      <input type="password" name="refresh_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.token_help') %></small>
-    </label>
-    <label><%= t('apaid.min_eur') %>
-      <input type="text" name="default_min_eur" value="<%= status.defaultMinCents ? (status.defaultMinCents/100).toFixed(2) : '' %>" inputmode="decimal" style="width:120px">
-    </label>
-    <div style="display:flex;gap:.6rem;align-items:center">
-      <button type="submit" class="btn btn-primary"><%= t('apaid.save') %></button>
-    </div>
-  </form>
-
-  <% if (status.configured || status.connected) { %>
-    <form method="post" action="/admin/paid/disconnect" data-confirm="<%= t('apaid.disconnect_confirm') %>" style="margin-top:1rem">
-      <button type="submit" class="btn btn-danger"><%= t('apaid.disconnect') %></button>
-    </form>
-  <% } %>
-
-  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
-</div>
-
-<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
Index: src/views/pages/admin-playlists.ejs
===================================================================
--- src/views/pages/admin-playlists.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-playlists.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -5,17 +5,13 @@
 const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
 %>
-<%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze
-    pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus
-    later te komen, anders wint de partial ervan. %>
-<%- include('../partials/admin-styles') %>
 <div class="container ax-page">
 
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'playlists', audioOn: true }) %>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('apl.title') %></h1>
+      <h1>Playlists</h1>
       <p class="ax-tagline">
-        <%= t('apl.tagline_pre') %> <code>[[playlist:&lt;id&gt;]]</code><%= t('apl.tagline_post') %>
+        Canonieke playlists. Bewerk een playlist hier en de wijzigingen werken
+        door in álle posts die hem gebruiken via <code>[[playlist:&lt;id&gt;]]</code>.
       </p>
     </div>
@@ -25,5 +21,5 @@
   <div class="ax-actions-bar">
     <button type="button" class="ax-btn ax-btn-primary" id="pl-new-btn">
-      ＋ <%= t('apl.new_playlist') %>
+      ＋ Nieuwe playlist
     </button>
   </div>
@@ -33,29 +29,14 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">📃</div>
-        <p><%= t('apl.none') %></p>
-        <p class="ax-empty-sub"><%= t('apl.none_sub') %></p>
+        <p>Nog geen playlists.</p>
+        <p class="ax-empty-sub">Maak er een via de knop hierboven, of via de 📃 knop in de post-editor.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      // Pre-fetch loop labels (defensive — keep `t` calls out of the loop body).
-      var _aplPlaylist = t('apl.pill_playlist');
-      var _aplAlbum    = t('apl.pill_album');
-      var _aplMixtape  = t('apl.pill_mixtape');
-      // Een soort met een eigen teken en een eigen woord. De ternair die hier
-      // stond kende er maar twee, en toonde een mixtape dus als album.
-      var _aplTeken = { album: '💿', playlist: '📃', mixtape: '📼' };
-      var _aplWoord = { album: _aplAlbum, playlist: _aplPlaylist, mixtape: _aplMixtape };
-      var _aplTrack    = t('apl.track');
-      var _aplTracks   = t('apl.tracks');
-      var _aplCopy     = t('apl.copy_click');
-      var _aplEdit     = t('apl.edit');
-      var _aplDelete   = t('apl.delete');
-    %>
     <ul class="ax-list">
       <% playlists.forEach(function(p) { %>
         <li class="ax-track" data-id="<%= p.id %>">
           <div class="ax-track-cover" <%- p.cover ? `style="background-image:url('${p.cover}'); background-size:cover; background-position:center"` : '' %>>
-            <% if (!p.cover) { %><span><%= _aplTeken[p.kind] || _aplTeken.album %></span><% } %>
+            <% if (!p.cover) { %><span><%= p.kind === 'playlist' ? '📃' : '💿' %></span><% } %>
           </div>
 
@@ -63,17 +44,17 @@
             <div class="ax-track-title">
               <%= p.title %>
-              <span class="ax-pill"><%= (_aplTeken[p.kind] || _aplTeken.album) + ' ' + (_aplWoord[p.kind] || _aplAlbum) %></span>
+              <span class="ax-pill"><%= p.kind === 'playlist' ? '📃 playlist' : '💿 album' %></span>
             </div>
             <div class="ax-track-sub">
-              <span><%= p.track_count %> <%= p.track_count === 1 ? _aplTrack : _aplTracks %></span>
+              <span><%= p.track_count %> <%= p.track_count === 1 ? 'track' : 'tracks' %></span>
               <% if (p.artist) { %><span class="ax-track-sep">·</span><span><%= p.artist %></span><% } %>
               <% if (p.year) { %><span class="ax-track-sep">·</span><span><%= p.year %></span><% } %>
             </div>
-            <code class="ax-embed" data-copy="[[playlist:<%= p.id %>]]" title="<%= _aplCopy %>">[[playlist:<%= p.id %>]]</code>
+            <code class="ax-embed" data-copy="[[playlist:<%= p.id %>]]" title="Klik om te kopiëren">[[playlist:<%= p.id %>]]</code>
           </div>
 
           <div class="ax-track-actions">
-            <button type="button" class="ax-icon-btn" data-pl-edit data-id="<%= p.id %>" aria-label="<%= _aplEdit %>" title="<%= _aplEdit %>">✎</button>
-            <button type="button" class="ax-icon-btn ax-icon-btn-danger" data-pl-delete data-id="<%= p.id %>" data-title="<%= p.title %>" aria-label="<%= _aplDelete %>" title="<%= _aplDelete %>">🗑</button>
+            <button type="button" class="ax-icon-btn" data-pl-edit data-id="<%= p.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
+            <button type="button" class="ax-icon-btn ax-icon-btn-danger" data-pl-delete data-id="<%= p.id %>" data-title="<%= p.title %>" aria-label="Verwijderen" title="Verwijderen">🗑</button>
           </div>
         </li>
@@ -92,10 +73,210 @@
 
 .ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
+.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
+.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
+.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }
+.ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
+.ax-back {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule); border-radius: 8px;
+  background: var(--paper); color: var(--ink); text-decoration: none;
+  font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
+}
+.ax-back:hover { border-color: var(--accent); }
+
+.ax-actions-bar {
+  display: flex;
+  margin-bottom: 1rem;
+}
+
+.ax-card {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  padding: 1.25rem;
+  margin-bottom: 1rem;
+}
+
+/* ─── Buttons (same primitives as admin-audio) ─────────────────── */
+.ax-btn {
+  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
+  padding: 0.6rem 1.1rem;
+  border: 1px solid var(--rule); background: var(--paper-2);
+  color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.95rem; font-weight: 500;
+  text-decoration: none; border-radius: 8px; cursor: pointer;
+  min-height: 44px;
+  transition: background 120ms, border-color 120ms;
+  -webkit-tap-highlight-color: transparent;
+}
+.ax-btn:hover { border-color: var(--accent); }
+.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
+.ax-btn-primary:hover { opacity: 0.92; }
+
+.ax-icon-btn {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule); border-radius: 8px;
+  background: var(--paper-2); color: var(--ink);
+  cursor: pointer; font-size: 1rem;
+  transition: background 120ms, border-color 120ms, color 120ms;
+}
+.ax-icon-btn:hover { border-color: var(--accent); }
+.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
+
+/* ─── Empty state ──────────────────────────────────────────────── */
+.ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }
+.ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }
+.ax-empty p { margin: 0; }
+.ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }
+
+/* ─── List items (same as audio tracks) ───────────────────────── */
+.ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
+.ax-track {
+  display: grid;
+  grid-template-columns: 64px 1fr auto;
+  gap: 0.85rem; align-items: center;
+  padding: 0.85rem;
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  transition: border-color 120ms;
+}
+.ax-track:hover { border-color: var(--accent); }
+.ax-track-cover {
+  width: 64px; height: 64px; border-radius: 8px;
+  background: var(--paper-2);
+  display: flex; align-items: center; justify-content: center;
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem; color: var(--accent);
+  flex-shrink: 0;
+}
+.ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
+.ax-track-title {
+  font-family: var(--font-display, serif);
+  font-weight: 600; font-size: 1.05rem; color: var(--ink);
+  display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
+}
+.ax-pill {
+  font-family: var(--font-ui);
+  font-size: 0.7rem; font-weight: 500;
+  color: var(--ink-soft);
+  background: var(--paper-2);
+  padding: 0.15em 0.55em; border-radius: 999px;
+  letter-spacing: 0;
+}
+.ax-track-sub {
+  font-size: 0.85rem;
+  color: var(--ink-muted, var(--ink-soft));
+  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
+}
+.ax-track-sep { opacity: 0.5; }
+.ax-embed {
+  display: inline-block;
+  background: var(--paper-2);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  font-family: var(--font-mono, ui-monospace, monospace);
+  font-size: 0.75rem;
+  color: var(--ink-soft);
+  cursor: pointer; user-select: all;
+  margin-top: 0.15rem;
+  word-break: break-all;
+  align-self: flex-start;
+  transition: background 120ms;
+}
+.ax-embed:hover { background: var(--rule); }
+.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
+
+.ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
 
 /* ─── Mobile ──────────────────────────────────────────────────── */
 @media (max-width: 480px) {
+  .ax-track {
+    grid-template-columns: 56px 1fr;
+    grid-template-areas: "cover meta" "actions actions";
+    gap: 0.6rem;
+  }
+  .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }
+  .ax-track-meta  { grid-area: meta; }
+  .ax-track-actions {
+    grid-area: actions;
+    justify-content: flex-end;
+    border-top: 1px solid var(--rule);
+    padding-top: 0.5rem;
+    margin-top: 0.25rem;
+  }
 }
 </style>
 
-<%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %>
+<script>
+(function() {
+  const csrf = '<%= _csrf %>';
+
+  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
+    if (typeof window.openPlaylistEditor === 'function') {
+      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
+    }
+  });
+
+  // Click-to-copy on shortcodes
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      try {
+        await navigator.clipboard.writeText(el.dataset.copy);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ gekopieerd';
+        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
+      } catch (_) { /* fall back to selection */ }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openPlaylistEditor === 'function') {
+        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
+      }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id;
+      const title = btn.dataset.title || id;
+      if (!confirm(`Playlist "${title}" verwijderen? Posts die deze playlist embedden tonen vanaf nu een placeholder.`)) return;
+      try {
+        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
+          method: 'POST',
+          headers: { 'X-CSRF-Token': csrf },
+          credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (j.ok) location.reload();
+        else alert('Verwijderen mislukt: ' + (j.error || ''));
+      } catch (err) {
+        alert('Verwijderen mislukt: ' + err.message);
+      }
+    });
+  });
+
+  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
+  // openPlaylistEditor is defined synchronously by the included partial, so
+  // it's available by the time this IIFE runs.
+  (function deepLinkEdit() {
+    const params = new URLSearchParams(location.search);
+    const editId = params.get('edit');
+    if (!editId) return;
+    if (typeof window.openPlaylistEditor !== 'function') return;
+    // Strip the query param immediately so reload after save doesn't re-open.
+    history.replaceState({}, '', location.pathname);
+    window.openPlaylistEditor({
+      mode: 'edit',
+      id: editId,
+      onSaved: () => location.reload(),
+    });
+  })();
+})();
+</script>
Index: src/views/pages/admin-push.ejs
===================================================================
--- src/views/pages/admin-push.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,61 +1,0 @@
-<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
-  <h1 style="margin:0 0 .3rem"><%= t('push.t') %></h1>
-  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
-    <%= t('push.intro') %>
-  </p>
-
-  <% if (!vapidKey) { %>
-    <p style="color:#c0392b"><%= t('push.unavailable') %></p>
-  <% } else { %>
-
-  <div id="np-unsupported" hidden style="color:#c0392b;margin:0 0 1rem">
-    <%= t('push.unsupported') %>
-  </div>
-  <div id="np-ios-hint" hidden style="border:1px solid var(--border,#333);border-radius:10px;padding:.8rem 1rem;margin:0 0 1rem;font-size:.92rem;color:var(--ink-soft,#888)">
-    <%= t('push.ios_hint') %>
-  </div>
-
-  <section style="border:1px solid var(--border,#333);border-radius:12px;padding:1rem 1.1rem;margin:0 0 1.2rem">
-    <p style="margin:0 0 .6rem"><strong><%= t('push.this_device') %></strong> <span id="np-state"><%= t('push.checking') %></span></p>
-    <div style="display:flex;gap:.6rem;flex-wrap:wrap">
-      <button type="button" id="np-on" class="btn btn-primary" hidden><%= t('push.enable') %></button>
-      <button type="button" id="np-off" class="btn" hidden><%= t('push.disable') %></button>
-      <button type="button" id="np-test" class="btn" hidden><%= t('push.test') %></button>
-    </div>
-    <fieldset id="np-alerts" hidden style="border:none;padding:0;margin:1rem 0 0">
-      <legend style="font-weight:600;margin-bottom:.4rem"><%= t('push.what') %></legend>
-      <label style="display:block"><input type="checkbox" data-alert="follow"> <%= t('push.a_follow') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="reply"> <%= t('push.a_reply') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="like"> <%= t('push.a_like') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="boost"> <%= t('push.a_boost') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="dm"> <%= t('push.a_dm') %></label>
-      <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0"><%= t('push.saved') %></p>
-    </fieldset>
-  </section>
-
-  <% if (subscriptions.length) { %>
-    <h2 style="font-size:1.05rem;margin:0 0 .5rem"><%= t('push.devices') %></h2>
-    <ul style="list-style:none;padding:0;margin:0 0 1.2rem">
-      <% subscriptions.forEach(function (s) { %>
-        <li data-endpoint="<%= s.endpoint %>" style="display:flex;justify-content:space-between;gap:.8rem;align-items:center;padding:.45rem 0;border-bottom:1px solid var(--border,#2a2a2a)">
-          <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || t('push.device') %> <small style="color:var(--ink-soft,#888)"><%= t('push.since') %> <%= String(s.created_at || '').slice(0, 10) %></small></span>
-          <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>"><%= t('push.remove') %></button>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% } %>
-
-  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
-</div>
-
-<% if (vapidKey) { %>
-<script id="np-data" type="application/json"><%- JSON.stringify({
-  vapid: vapidKey,
-  alerts: defaultAlerts,
-  saved: subscriptions.reduce(function (m, s) { try { m[s.endpoint] = JSON.parse(s.alert_types || '{}'); } catch (e) { m[s.endpoint] = {}; } return m; }, {}),
-  i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
-}).replace(/</g, '\\u003c') %></script>
-<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
-<% } %>
Index: src/views/pages/admin-seo.ejs
===================================================================
--- src/views/pages/admin-seo.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,229 +1,0 @@
-<div class="container admin-site-edit-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('aseo.back') %></a></p>
-  <h1><%= t('aseo.title') %></h1>
-  <p class="admin-tagline"><%= t('aseo.tagline_pre') %> <strong><%= site.title %></strong> <%= t('aseo.tagline_post') %></p>
-
-  <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
-
-  <form method="post" action="/admin/seo" class="site-form">
-
-    <fieldset>
-      <legend><%= t('aseo.index_legend') %></legend>
-      <label class="cb">
-        <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
-        <span><%= t('aseo.index_label') %> <small class="form-hint-inline">— <%= t('aseo.index_hint_pre') %> <code>noindex</code> <%= t('aseo.index_hint_post') %></small></span>
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.title_legend') %></legend>
-      <label>
-        <span><%= t('aseo.title_template') %> <small class="form-hint-inline">— <%= t('aseo.title_template_hint_pre') %> <code>{title}</code> <%= t('aseo.title_template_hint_and') %> <code>{site}</code></small></span>
-        <input type="text" name="title_template" value="<%= site.title_template || '{title} — {site}' %>" maxlength="200" placeholder="{title} — {site}">
-      </label>
-      <label>
-        <span><%= t('aseo.default_desc') %> <small class="form-hint-inline">— <%= t('aseo.default_desc_hint') %></small></span>
-        <textarea name="default_description" rows="2" maxlength="500" placeholder="<%= t('aseo.default_desc_ph') %>"><%= site.default_description || '' %></textarea>
-      </label>
-      <label>
-        <span><%= t('aseo.canonical') %> <small class="form-hint-inline">— <%= t('aseo.canonical_hint') %></small></span>
-        <input type="text" name="canonical" value="<%= site.canonical || '' %>" placeholder="https://yourdomain.com" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.author') %> <small class="form-hint-inline">— <%= t('aseo.author_hint') %></small></span>
-        <input type="text" name="author" value="<%= site.author || '' %>" maxlength="120" placeholder="<%= t('aseo.author_ph') %>">
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.social_legend') %></legend>
-      <label>
-        <span><%= t('aseo.og_image') %> <small class="form-hint-inline">— <%= t('aseo.og_image_hint') %></small></span>
-        <input type="text" name="og_image_default" value="<%= site.og_image_default || '' %>" placeholder="https://yourdomain.com/share.jpg" maxlength="500">
-      </label>
-      <label>
-        <span><%= t('aseo.og_theme') %> <small class="form-hint-inline">— <%= t('aseo.og_theme_hint') %></small></span>
-        <select name="og_theme">
-          <option value="" <%= !site.og_theme ? 'selected' : '' %>><%= t('aseo.og_theme_auto') %></option>
-          <option value="light" <%= site.og_theme === 'light' ? 'selected' : '' %>><%= t('aseo.og_theme_light') %></option>
-          <option value="dark" <%= site.og_theme === 'dark' ? 'selected' : '' %>><%= t('aseo.og_theme_dark') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('aseo.og_locale') %> <small class="form-hint-inline">— <%= t('aseo.og_locale_hint_pre') %> <code>nl_NL</code> <%= t('aseo.og_locale_hint_or') %> <code>en_US</code></small></span>
-        <input type="text" name="og_locale" value="<%= site.og_locale || '' %>" placeholder="nl_NL" maxlength="32">
-      </label>
-      <label>
-        <span><%= t('aseo.twitter') %> <small class="form-hint-inline">— <%= t('aseo.twitter_hint') %></small></span>
-        <input type="text" name="twitter" value="<%= site.twitter || '' %>" placeholder="@jouwnaam" maxlength="64">
-      </label>
-      <label>
-        <span><%= t('aseo.fb_app') %> <small class="form-hint-inline">— <%= t('aseo.fb_app_hint') %></small></span>
-        <input type="text" name="facebook_app_id" value="<%= site.facebook_app_id || '' %>" maxlength="64">
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.publisher_legend') %></legend>
-      <label>
-        <span><%= t('aseo.type') %></span>
-        <select name="schema_type">
-          <option value="Person" <%= site.schema_type === 'Organization' ? '' : 'selected' %>><%= t('aseo.type_person') %></option>
-          <option value="Organization" <%= site.schema_type === 'Organization' ? 'selected' : '' %>><%= t('aseo.type_org') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_name') %> <small class="form-hint-inline">— <%= t('aseo.publisher_name_hint') %></small></span>
-        <input type="text" name="publisher_name" value="<%= site.publisher_name || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_url') %></span>
-        <input type="text" name="publisher_url" value="<%= site.publisher_url || '' %>" placeholder="https://yourdomain.com" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_logo') %></span>
-        <input type="text" name="publisher_logo" value="<%= site.publisher_logo || '' %>" placeholder="https://yourdomain.com/logo.png" maxlength="500">
-      </label>
-    </fieldset>
-
-    <%# ── MUSICBRAINZ ─────────────────────────────────────────────
-        Direct onder Uitgever, en dat is geen willekeur: sameAs IS een
-        schema.org-eigenschap, en dat blok hierboven is waar ons schema.org
-        vandaan komt. Een koppeling hier voedt twee uitgangen tegelijk -- de
-        JSON-LD van de pagina en het fediverse-profiel (shaer-mbz). %>
-    <fieldset id="mb-veld">
-      <legend><%= t('aseo.mb_legend') %></legend>
-      <p class="form-hint"><%= t('aseo.mb_hint') %></p>
-
-      <input type="hidden" name="mb_artist_id" id="mb-id" value="<%= site.mb_artist_id || '' %>">
-      <input type="hidden" name="mb_artist_name" id="mb-naam" value="<%= site.mb_artist_name || '' %>">
-
-      <p class="mb-huidig" id="mb-huidig" <%= site.mb_artist_id ? '' : 'hidden' %>>
-        <%= t('aseo.mb_linked') %>
-        <strong id="mb-huidig-naam"><%= site.mb_artist_name || site.mb_artist_id || '' %></strong>
-        <a id="mb-huidig-link" target="_blank" rel="noopener"
-           href="https://musicbrainz.org/artist/<%= site.mb_artist_id || '' %>"><%= t('aseo.mb_open') %></a>
-        <button type="button" class="btn" id="mb-wis"><%= t('aseo.mb_unlink') %></button>
-      </p>
-      <%# De terug-weg. Een koppeling van onze kant is een BEWERING -- iedereen
-          kan een id typen. Pas als de MusicBrainz-pagina terugwijst is het een
-          paar, en dat staat hier met zoveel woorden zodat "eenzijdig" niet als
-          "klaar" leest. %>
-      <p class="mb-terugweg" id="mb-terugweg" hidden></p>
-
-      <label>
-        <span><%= t('aseo.mb_search_label') %> <small class="form-hint-inline">— <%= t('aseo.mb_search_hint') %></small></span>
-        <div class="mb-zoek">
-          <input type="text" id="mb-q" value="<%= site.publisher_name || site.title || '' %>"
-                 autocomplete="off" placeholder="<%= t('aseo.mb_placeholder') %>">
-          <button type="button" class="btn" id="mb-zoek-btn"><%= t('aseo.mb_search') %></button>
-        </div>
-      </label>
-      <div id="mb-uit" class="mb-uit" hidden></div>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.verify_legend') %></legend>
-      <label>
-        <span><%= t('aseo.verify_google') %></span>
-        <input type="text" name="google_verification" value="<%= site.google_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_bing') %> <small class="form-hint-inline">— <%= t('aseo.verify_bing_hint') %></small></span>
-        <input type="text" name="bing_verification" value="<%= site.bing_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_pinterest') %> <small class="form-hint-inline">— <%= t('aseo.verify_pinterest_hint') %></small></span>
-        <input type="text" name="pinterest_verification" value="<%= site.pinterest_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_yandex') %></span>
-        <input type="text" name="yandex_verification" value="<%= site.yandex_verification || '' %>" maxlength="200">
-      </label>
-    </fieldset>
-
-    <button type="submit" class="btn btn-primary"><%= t('aseo.save') %></button>
-  </form>
-</div>
-
-<style>
-/* ── MusicBrainz-kandidaten: een kaartje per artiest ──────────────
-   Meerdere regels en geen opsomming op een rij: dit is een KEUZE, en de
-   toelichting die twee gelijknamige bands uit elkaar houdt hoort niet als
-   bijzin achter de naam weg te vallen. */
-.mb-uit { margin-top: 0.5rem; }
-.mb-lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.6rem; }
-.mb-kandidaat {
-  display: flex; flex-direction: column; gap: 0.2rem;
-  border: 1px solid var(--rule); border-radius: 8px;
-  padding: 0.7rem 0.85rem; background: var(--paper);
-  min-width: 0;
-}
-.mb-naam { font-weight: 600; font-size: 0.95rem; color: var(--ink); }
-.mb-toelichting { font-size: 0.85rem; color: var(--ink); }
-.mb-feiten { font-size: 0.8rem; color: var(--ink-muted); }
-.mb-voet { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.35rem; flex-wrap: wrap; }
-.mb-open { font-size: 0.8rem; }
-/* Een zwakke treffer als zwak tonen; bij een goede is een getal ruis. */
-.mb-zwak { font-size: 0.75rem; color: var(--ink-muted); }
-.mb-kies { margin-left: auto; }
-.mb-huidig { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; font-size: 0.9rem; }
-.mb-zoek { display: flex; gap: 0.5rem; min-width: 0; }
-.mb-zoek input { flex: 1 1 auto; min-width: 0; }
-/* De terug-weg: eenzijdig is geen fout, maar het mag ook niet als klaar lezen. */
-.mb-terugweg { font-size: 0.82rem; margin: 0.2rem 0 0; }
-.mb-terugweg.is-ok { color: var(--ok, #2e7d32); }
-.mb-terugweg.is-eenzijdig { color: var(--ink-muted); }
-
-.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
-.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 0.4rem; }
-.admin-site-edit-page .admin-tagline { color: var(--ink-muted); margin: 0 0 1.75rem; }
-
-.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* <fieldset> does not shrink because the UA sets min-inline-size:min-content → mobile overflow.
-   min-width:0 forces it to shrink. */
-.site-form fieldset { min-width: 0; }
-.site-form input, .site-form textarea, .site-form select { min-width: 0; }
-.site-form fieldset {
-  border: 1px solid var(--rule);
-  border-radius: 8px;
-  padding: 1rem 1.25rem;
-  background: var(--paper-2);
-  display: flex;
-  flex-direction: column;
-  gap: 0.85rem;
-}
-.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
-.site-form label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
-.site-form label.cb { flex-direction: row; align-items: flex-start; gap: 0.55rem; font-size: 0.9rem; color: var(--ink); }
-.site-form label.cb input { margin-top: 0.15rem; }
-.site-form label > span { line-height: 1.35; }
-.form-hint-inline { font-weight: 400; color: var(--ink-muted); font-size: 0.82em; }
-.site-form code { background: var(--paper); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.85em; }
-.site-form input[type="text"], .site-form textarea, .site-form select {
-  padding: 0.55rem 0.85rem;
-  border: 1px solid var(--rule);
-  border-radius: 5px;
-  background: var(--paper);
-  color: var(--ink);
-  font-family: inherit;
-  font-size: 0.95rem;
-  width: 100%;
-  box-sizing: border-box;
-}
-.site-form textarea { resize: vertical; min-height: 2.6rem; }
-.site-form select { max-width: 320px; }
-.site-form input:focus, .site-form textarea:focus, .site-form select:focus {
-  outline: none; border-color: var(--accent);
-}
-.seo-pointer p { margin: 0; font-size: 0.9rem; color: var(--ink-muted); }
-.seo-pointer a { color: var(--accent); font-weight: 600; }
-.site-form > button[type="submit"] { align-self: flex-start; }
-</style>
-
-<%# Servertekst naar mod/admin-seo.js. KORTE sleutels, zoals overal in dit
-    project -- de module leest T.mb_busy en niet T.aseo_mb_busy. Zonder dit
-    blok geeft pageData() een leeg object en valt alles terug op de
-    ingebakken Nederlandse tekst, ook voor een Engelse of Duitse lezer. %>
-<%- include('../partials/page-data', { pageData: { mb_busy: t('aseo.mb_busy'), mb_fail: t('aseo.mb_fail'), mb_none: t('aseo.mb_none'), mb_open: t('aseo.mb_open'), mb_pick: t('aseo.mb_pick'), mb_verified: t('aseo.mb_verified'), mb_unverified: t('aseo.mb_unverified'), mb_checking: t('aseo.mb_checking') } }) %>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,218 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aset.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aset.back_admin') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# ── Mode: Solo or Cirkels (ActivityPub) — the primary, top-of-page choice ── %>
-  <section class="set-card set-card--mode" style="margin-top:1rem">
-    <h2><%= t('aset.mode') %></h2>
-    <p class="set-help"><%= t('aset.mode_help') %></p>
-    <form method="post" action="/admin/settings/ap" class="set-form" id="mode-form"
-          data-was-cirkels="<%= (typeof apEnabledSetting === 'undefined' || apEnabledSetting) ? '1' : '0' %>"
-          data-confirm="<%= t('aset.mode_18plus_confirm') %>">
-      <label class="set-opt">
-        <input type="radio" name="mode" value="solo" <%= (typeof apEnabledSetting !== 'undefined' && !apEnabledSetting) ? 'checked' : '' %>>
-        <span><strong><%= t('aset.mode_solo') %></strong><small><%= t('aset.mode_solo_help') %></small></span>
-      </label>
-      <label class="set-opt">
-        <input type="radio" name="mode" value="cirkels" <%= (typeof apEnabledSetting === 'undefined' || apEnabledSetting) ? 'checked' : '' %>>
-        <span><strong><%= t('aset.mode_cirkels') %></strong><small><%= t('aset.mode_cirkels_help') %></small></span>
-      </label>
-      <div style="display:flex;gap:.6rem;align-items:flex-start;margin:.7rem 0 .2rem;padding:.7rem .85rem;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);background:color-mix(in srgb,var(--accent) 7%,transparent)">
-        <span style="font-size:1.05rem;line-height:1.4;flex:0 0 auto" aria-hidden="true">🔞</span>
-        <span style="font-size:.85rem;line-height:1.5;color:var(--ink)"><%= t('aset.mode_18plus') %></span>
-      </div>
-      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
-    </form>
-    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
-  </section>
-
-  <%# ── Default language for visitors ── %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.default_lang') %></h2>
-    <p class="set-help"><%= t('aset.default_lang_hint') %></p>
-    <form method="post" action="/admin/settings" class="set-form">
-      <label class="set-field">
-        <span><%= t('aset.default_lang') %></span>
-        <select name="default_lang">
-          <option value=""<%= !defaultLang ? ' selected' : '' %>><%= t('aset.default_lang_auto') %></option>
-          <% langs.forEach(function(l){ %>
-            <option value="<%= l.code %>"<%= defaultLang === l.code ? ' selected' : '' %>><%= l.name %></option>
-          <% }); %>
-        </select>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
-    </form>
-  </section>
-
-  <%# ── Timezone ── %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.timezone') %></h2>
-    <p class="set-help"><%= t('aset.timezone_hint') %></p>
-    <form method="post" action="/admin/settings" class="set-form">
-      <label class="set-field">
-        <span><%= t('aset.timezone') %></span>
-        <select name="timezone">
-          <option value=""<%= !timezone ? ' selected' : '' %>><%= t('aset.timezone_auto') %></option>
-          <%
-            var _tz = ['Europe/Amsterdam','Europe/Brussels','Europe/Berlin','Europe/Paris','Europe/London','Europe/Madrid','Europe/Rome','Europe/Lisbon','Europe/Warsaw','Europe/Athens','UTC','America/New_York','America/Chicago','America/Los_Angeles','America/Sao_Paulo','Asia/Tokyo','Asia/Dubai','Australia/Sydney'];
-            if (timezone && _tz.indexOf(timezone) < 0) _tz.unshift(timezone);
-          %>
-          <% _tz.forEach(function(z){ %><option value="<%= z %>"<%= timezone === z ? ' selected' : '' %>><%= z %></option><% }); %>
-        </select>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
-    </form>
-  </section>
-
-  <%# Mode-specific config — Circle settings below the mode card. (Hub removed.) %>
-
-  <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer
-      (env KLONKT_PREMIUM_ENABLED=on). Hidden by default; nothing gated. %>
-  <% if (premium && premium.enabled) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.premium') %></h2>
-    <p class="set-help"><%= t('aset.premium_help_1') %> <strong><%= t('aset.premium_lifetime') %></strong> <%= t('aset.premium_help_2') %></p>
-    <% if (premium.premium) { %>
-      <p class="set-help"><strong>✓ <%= t('aset.premium_active') %></strong>
-        <% if (premium.supportCents) { %><%= t('aset.lifetime_support') %> $<%= (premium.supportCents/100).toFixed(2) %>.<% } %></p>
-      <a href="/admin/patreon/disconnect" class="btn"><%= t('aset.patreon_disconnect') %></a>
-    <% } else if (premium.connected) { %>
-      <p class="set-help"><%= t('aset.patreon_no_lifetime') %><% if (premium.supportCents) { %>
-        (<%= t('aset.now') %> $<%= (premium.supportCents/100).toFixed(2) %>)<% } %>. <%= t('aset.patreon_support_again') %></p>
-      <a href="/admin/patreon/connect" class="btn btn-primary"><%= t('aset.patreon_reconnect') %></a>
-    <% } else { %>
-      <p class="set-help"><%= t('aset.patreon_not_connected') %></p>
-      <a href="/admin/patreon/connect" class="btn btn-primary"><%= t('aset.patreon_connect') %></a>
-    <% } %>
-  </section>
-  <% } %>
-
-  <%# Newsletter signup in the footer (premium). %>
-  <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.newsletter') %></h2>
-    <p class="set-help"><%= t('aset.newsletter_help_1') %> <strong><%= t('aset.newsletter_footer_field') %></strong> <%= t('aset.newsletter_help_2') %> <code><%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/nieuwsbrief</code>.)</p>
-    <form method="post" action="/admin/settings/footer" class="set-form">
-      <label class="set-toggle" style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
-        <input type="checkbox" name="footer_newsletter" value="1" <%= footerNewsletter ? 'checked' : '' %>>
-        <span><%= t('aset.newsletter_show_footer') %></span>
-      </label>
-      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
-    </form>
-  </section>
-  <% } %>
-
-  <%# E-mail / SMTP — required for sending newsletters, notify-mails and password resets. %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.email_smtp') %></h2>
-    <p class="set-help"><%= t('aset.smtp_help_1') %> <strong><%= t('aset.smtp_help_newsletter') %></strong><%= t('aset.smtp_help_2') %> <strong><%= t('aset.smtp_help_notify') %></strong><%= t('aset.smtp_help_3') %>
-      <% if (smtp && smtp.configured) { %><br><strong><%= t('aset.status_set') %>&nbsp;✓</strong><% if (smtp.fromEnv) { %> <small>(<%= t('aset.via_env') %>)</small><% } %><% } else { %><br><%= t('aset.smtp_not_set') %><% } %></p>
-
-    <form method="post" action="/admin/settings/smtp" class="set-form">
-      <label><span><%= t('aset.smtp_host') %></span>
-        <input type="text" name="smtp_host" value="<%= smtp ? smtp.host : '' %>" placeholder="smtp.jouwprovider.nl" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_port') %> <small style="font-weight:400">— <%= t('aset.smtp_port_hint') %></small></span>
-        <input type="text" name="smtp_port" value="<%= (smtp && smtp.port) ? smtp.port : '' %>" placeholder="587" autocomplete="off" inputmode="numeric">
-      </label>
-      <label><span><%= t('aset.smtp_user') %></span>
-        <input type="text" name="smtp_user" value="<%= smtp ? smtp.user : '' %>" placeholder="jij@jouwprovider.nl" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_pass') %> <% if (smtp && smtp.passSet) { %><small style="font-weight:400">— <%= t('aset.smtp_pass_set_hint') %></small><% } %></span>
-        <input type="password" name="smtp_pass" placeholder="<%= (smtp && smtp.passSet) ? t('aset.smtp_pass_ph_set') : t('aset.smtp_pass_ph') %>" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_from') %> <small style="font-weight:400">— <%= t('aset.smtp_from_hint') %></small></span>
-        <input type="text" name="smtp_from" value="<%= smtp ? smtp.from : '' %>" placeholder="<%= t('aset.smtp_from_ph') %>" autocomplete="off">
-      </label>
-      <div class="set-actions">
-        <button type="submit" class="btn btn-primary"><%= t('aset.smtp_save') %></button>
-        <% if (smtp && (smtp.host || smtp.passSet)) { %>
-          <button type="submit" name="clear" value="1" class="btn" formnovalidate><%= t('aset.clear') %></button>
-        <% } %>
-      </div>
-    </form>
-
-    <% if (smtp && smtp.configured) { %>
-      <form method="post" action="/admin/settings/smtp/test" class="set-form" style="margin-top:.8rem">
-        <label><span><%= t('aset.test_mail_to') %></span>
-          <input type="email" name="to" placeholder="jij@email.nl" autocomplete="off">
-        </label>
-        <div class="set-actions"><button type="submit" class="btn">✉ <%= t('aset.send_test_mail') %></button></div>
-      </form>
-    <% } %>
-  </section>
-
-</div>
-
-<style>
-.admin-page { max-width: 700px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.25rem; }
-.admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
-.admin-tagline a { color: var(--accent); }
-.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-/* Mode-specific card (Hub/Circle): accent border on the left so it visually
-   belongs to the selected mode, directly below the Mode card. */
-.set-card--mode { border-left: 3px solid var(--accent); }
-.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
-.set-help { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.25rem; }
-/* Google setup guide (collapsible) */
-.set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
-.set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; }
-.set-google-guide > summary::-webkit-details-marker { display: none; }
-.set-google-guide > summary::before { content: "▸ "; }
-.set-google-guide[open] > summary::before { content: "▾ "; }
-.set-google-steps { margin: 0; padding: 0 1rem 0.6rem 2rem; font-size: 0.88rem; color: var(--ink); line-height: 1.5; }
-.set-google-steps li { margin: 0.35rem 0; }
-.set-google-guide code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
-.set-google-guide .set-help { padding: 0 0.85rem; }
-.set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; }
-/* Bare <label>s inside a set-form inherit the same neat stacked style as .set-field:
-   label text on top, input full-width. (.set-toggle = checkbox row, excluded.) */
-.set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; }
-.set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-.set-form > label:not(.set-toggle):not(.set-opt) > span small { font-weight: 400; }
-.set-form > label:not(.set-toggle):not(.set-opt) input {
-  width: 100%; box-sizing: border-box; padding: 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem;
-}
-.set-form > label:not(.set-toggle):not(.set-opt) input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.set-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; }
-.set-opt { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; border: 1px solid var(--rule); border-radius: 8px; cursor: pointer; background: var(--paper); }
-.set-opt:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
-.set-opt input { margin-top: 0.25rem; flex-shrink: 0; }
-.set-opt span { display: flex; flex-direction: column; gap: 0.2rem; }
-.set-opt small { color: var(--ink-muted); font-size: 0.82rem; }
-.set-opt code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.85em; }
-.set-form .btn { align-self: flex-start; }
-.set-field { display: flex; flex-direction: column; gap: 0.3rem; }
-.set-field > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-.set-field input, .set-field textarea {
-  width: 100%; box-sizing: border-box; padding: 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem;
-}
-.set-field textarea { resize: vertical; }
-.set-field input:focus, .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-/* Selects styled the same as inputs instead of the bare native dropdown:
-   custom chevron, accent focus ring, full width. */
-.set-form select {
-  appearance: none; -webkit-appearance: none;
-  width: 100%; box-sizing: border-box;
-  padding: 0.55rem 2.2rem 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background-color: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem; cursor: pointer;
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
-  background-repeat: no-repeat; background-position: right 0.7rem center;
-  transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-.set-form select:hover { border-color: var(--ink-soft, #888); }
-.set-form select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.set-form select option { background: var(--paper); color: var(--ink); }
-.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
-.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
-</style>
Index: src/views/pages/admin-shows.ejs
===================================================================
--- src/views/pages/admin-shows.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-<%
-  var ss = (typeof shows !== 'undefined') ? shows : [];
-  var msg = (typeof msg !== 'undefined') ? msg : '';
-  var smtp = (typeof smtp !== 'undefined') ? smtp : false;
-  var nc = (typeof notifyCount !== 'undefined') ? notifyCount : 0;
-%>
-<section class="ash">
-  <p><a href="/admin" class="btn">&larr; <%= t('ashow.back_admin') %></a></p>
-  <h1><%= t('ashow.title') %></h1>
-
-  <% if (msg) { %><div class="ash-msg ash-<%= (typeof msgKind!=='undefined')?msgKind:'' %>"><%= msg %></div><% } %>
-
-  <% var agOn = (typeof agendaEnabled !== 'undefined') && agendaEnabled; %>
-  <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' : '' %> 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>
-    <noscript><button type="submit" class="btn"><%= t('ashow.save') %></button></noscript>
-  </form>
-  <% if (!agOn) { %><div class="ash-msg"><%= t('ashow.off_notice_1') %> <strong><%= t('ashow.off') %></strong> <%= t('ashow.off_notice_2') %></div><% } %>
-
-  <p class="ash-note"><strong><%= nc %></strong> <%= t('ashow.subscribers') %>
-    <% if (!smtp) { %><br><%= t('ashow.smtp_warn') %><% } %></p>
-
-  <form method="POST" action="<%= siteUrlBase %>/admin/shows" class="ash-form">
-    <div class="ash-grid">
-      <label><%= t('ashow.f_date') %><input type="date" name="date" required></label>
-      <label><%= t('ashow.f_time') %><input type="time" name="time"></label>
-      <label><%= t('ashow.f_city') %><input type="text" name="city" required placeholder="Amsterdam"></label>
-      <label><%= t('ashow.f_country') %><input type="text" name="country" placeholder="NL"></label>
-      <label class="ash-wide"><%= t('ashow.f_venue') %><input type="text" name="venue" placeholder="Paradiso"></label>
-      <label class="ash-wide"><%= t('ashow.f_ticket') %><input type="url" name="ticket_url" placeholder="https://..."></label>
-      <label class="ash-wide"><%= t('ashow.f_notes') %><input type="text" name="notes" placeholder="<%= t('ashow.f_notes_ph') %>"></label>
-    </div>
-    <label class="ash-check"><input type="checkbox" name="notify" value="1" <%= smtp ? '' : 'disabled' %>> <%= t('ashow.notify_label') %><%= smtp ? '' : ' ' + t('ashow.smtp_required') %></label>
-    <button type="submit" class="ash-add"><%= t('ashow.add_event') %></button>
-  </form>
-
-  <% if (ss.length) { %>
-    <% var _delConfirm = t('ashow.del_confirm'); %>
-    <ul class="ash-list">
-      <% ss.forEach(function(s){ %>
-        <li class="ash-item">
-          <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" data-confirm="<%= _delConfirm %>" style="margin:0">
-            <button type="submit" class="ash-del">🗑</button>
-          </form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="ash-empty"><%= t('ashow.empty') %></p>
-  <% } %>
-</section>
-
-<style>
-  .ash { max-width: 680px; margin: 0 auto; padding: 24px 18px 64px; }
-  .ash h1 { margin: .4rem 0 14px; }
-  .ash-msg { padding: 10px 14px; border-radius: 10px; margin-bottom: 14px; }
-  .ash-ok { background: rgba(60,160,90,.15); } .ash-bad { background: rgba(200,60,60,.15); }
-  .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; }
-  .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; }
-  /* Mobile-first: single column (two 1fr columns with non-shrinkable date/text inputs
-     combined to ~374px > phone width). Two columns only from 520px onwards. */
-  .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
-  @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } }
-  .ash-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; min-width: 0; }
-  .ash-grid .ash-wide { grid-column: 1 / -1; }
-  .ash-grid input { min-width: 0; padding: 9px 11px; border-radius: 8px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font: inherit; font-weight: 400; }
-  .ash-check { display: flex; align-items: center; gap: 8px; margin: 12px 0; font-size: 13.5px; }
-  .ash-add { padding: 10px 18px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-  .ash-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .ash-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.18); border-radius: 10px; }
-  .ash-when { font-variant-numeric: tabular-nums; font-weight: 600; flex: 0 0 auto; }
-  .ash-where { flex: 1 1 auto; opacity: .85; }
-  .ash-del { background: none; border: none; cursor: pointer; font-size: 16px; opacity: .6; }
-  .ash-empty { opacity: .7; }
-</style>
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-site-edit.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,5 +1,5 @@
 <div class="container admin-site-edit-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('asite.back_admin') %></a></p>
-  <h1><%= isNew ? t('asite.title_new') : t('asite.title_edit') + ': ' + site.title %></h1>
+  <p><a href="/admin/sites" class="btn">&larr; Sites</a></p>
+  <h1><%= isNew ? 'New site' : 'Edit: ' + site.title %></h1>
 
   <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
@@ -9,32 +9,28 @@
 
     <fieldset>
-      <legend><%= t('asite.identity') %></legend>
-      <label>
-        <span><%= t('asite.slug') %> <% if (!isNew) { %><em><%= t('asite.slug_fixed') %></em><% } %></span>
-        <span class="slug-url">
-          <span class="slug-url-host" id="slug-host"
-                data-prefix="/">website.com/</span><input
-                type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
-                pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
-                autocapitalize="none" autocorrect="off" spellcheck="false">
-        </span>
-      </label>
-      <label>
-        <span><%= t('asite.field_title') %> <small class="form-hint-inline"><%= t('asite.field_title_hint') %></small></span>
+      <legend>Identity</legend>
+      <label>
+        <span>Slug <% if (!isNew) { %><em>(immutable)</em><% } %></span>
+        <input type="text" name="slug" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
+               pattern="[a-z0-9_-]{2,40}"
+               placeholder="bedrijf1">
+      </label>
+      <label>
+        <span>Title <small class="form-hint-inline">— shown in the header and as display name</small></span>
         <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
       </label>
       <label>
-        <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
+        <span>Tagline <small class="form-hint-inline">— short one-liner</small></span>
         <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
       </label>
       <label>
-        <span><%= t('asite.bio') %> <small class="form-hint-inline"><%= t('asite.bio_hint') %></small></span>
+        <span>Bio / description <small class="form-hint-inline">— shown in the profile header and used for SEO</small></span>
         <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.profile_photo') %></span>
+        <span>Profile photo</span>
         <div class="photo-picker" id="photo-picker">
           <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
-            <img id="photo-preview" src="<%= avatar(site.profile_photo || '', 128) %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
+            <img id="photo-preview" src="<%= site.profile_photo || '' %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
             <span class="photo-empty" aria-hidden="true">
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@@ -42,8 +38,8 @@
           </div>
           <div class="photo-controls">
-            <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="<%= t('asite.photo_url_placeholder') %>">
+            <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="/media/avatars/foo.jpg of https://…">
             <div class="photo-actions">
-              <button type="button" class="btn" id="photo-upload-trigger"><%= t('asite.photo_upload') %></button>
-              <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>><%= t('asite.photo_clear') %></button>
+              <button type="button" class="btn" id="photo-upload-trigger">📷 Upload</button>
+              <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>>Verwijder</button>
               <small id="photo-status" class="form-hint photo-status"></small>
             </div>
@@ -53,15 +49,15 @@
       </label>
       <label>
-        <span><%= t('asite.language') %></span>
+        <span>Language (ISO code)</span>
         <input type="text" name="language" value="<%= site.language || 'nl' %>" maxlength="8">
       </label>
       <label class="cb">
         <input type="checkbox" name="profile_enabled" value="1" <%= site.profile_enabled ? 'checked' : '' %>>
-        <span><%= t('asite.profile_enabled') %></span>
+        <span>Show profile header below the nav</span>
       </label>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.appearance') %></legend>
+      <legend>Appearance</legend>
 
       <%# ── Curated accent picker ───────────────────────────────────────
@@ -70,5 +66,5 @@
           still what's stored in the DB — we just constrain WHICH hex. %>
       <fieldset class="accent-grid">
-        <legend><%= t('asite.accent_color') %></legend>
+        <legend>Accent color</legend>
         <% const _curAccent = (site.accent || '#c2410c').toLowerCase(); %>
         <% accents.forEach(function(a) { %>
@@ -87,25 +83,25 @@
           visitors with a localStorage override, still win over this default. %>
       <fieldset class="theme-default">
-        <legend><%= t('asite.theme_default') %></legend>
+        <legend>Default theme for new visitors</legend>
         <% const td = site.theme_override || ''; %>
         <label class="cb">
           <input type="radio" name="theme_override" value=""      <%= td === ''      ? 'checked' : '' %>>
-          <span><%= t('asite.theme_auto') %></span>
+          <span>Auto (volg device-voorkeur)</span>
         </label>
         <label class="cb">
           <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
-          <span><%= t('asite.theme_light') %></span>
+          <span>Light</span>
         </label>
         <label class="cb">
           <input type="radio" name="theme_override" value="dark"  <%= td === 'dark'  ? 'checked' : '' %>>
-          <span><%= t('asite.theme_dark') %></span>
+          <span>Dark</span>
         </label>
       </fieldset>
 
       <fieldset class="palette-grid">
-        <legend><%= t('asite.palette') %></legend>
+        <legend>Palette</legend>
         <% palettes.forEach(function(p) { %>
           <label class="palette-swatch">
-            <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
+            <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'sage') === p.key ? 'checked' : '' %>>
             <span class="palette-preview" aria-hidden="true">
               <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
@@ -124,33 +120,38 @@
     </fieldset>
 
-    <%# Live preview: selecting an accent / theme / palette re-themes this page
-        instantly (reverts on reload; persists on save). %>
-    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
-
     <fieldset>
-      <legend><%= t('asite.behavior') %></legend>
+      <legend>Behaviour</legend>
       <label class="cb">
         <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
-        <span><%= t('asite.is_public') %></span>
+        <span>Public site (uncheck for closed circle)</span>
       </label>
       <label class="cb">
         <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
-        <span><%= t('asite.robots_index') %></span>
-      </label>
-      <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
+        <span>Search engines may index (sitemap.xml is hidden if off)</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="require_login_to_comment" value="1" <%= site.require_login_to_comment ? 'checked' : '' %>>
+        <span>Require login to comment</span>
+      </label>
+      <label>
+        <span>Comment moderation</span>
+        <select name="comments_moderation_mode">
+          <option value="trust"    <%= (site.comments_moderation_mode || 'trust') === 'trust'    ? 'selected' : '' %>>Trust mode (auto-approve)</option>
+          <option value="moderate" <%= site.comments_moderation_mode === 'moderate' ? 'selected' : '' %>>Moderate (queue for review)</option>
+        </select>
+      </label>
       <label class="cb">
         <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
-        <span><%= t('asite.enable_audio') %></span>
-      </label>
-      <% } %>
-      <label class="cb">
-        <input type="checkbox" name="approve_followers" value="1" <%= site.approve_followers ? 'checked' : '' %>>
-        <span><%= t('asite.approve_followers') %></span>
+        <span>Enable audio player + embeds</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="enable_prutter" value="1" <%= site.enable_prutter ? 'checked' : '' %>>
+        <span>Enable Prutter (direct messaging)</span>
       </label>
     </fieldset>
 
     <fieldset class="profile-links-fieldset">
-      <legend><%= t('asite.links') %></legend>
-      <p class="form-hint"><%= t('asite.links_hint') %></p>
+      <legend>Social / streaming links</legend>
+      <p class="form-hint">Shown as branded icons on the profile header. Add as many as you like.</p>
 
       <div id="profile-links-rows">
@@ -180,84 +181,179 @@
       </template>
 
-      <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
-    </fieldset>
-
-    <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
-        exporteren, importeren en ophalen (Robin: alle migratie-opties op de
-        migrate-pagina). Ze hoorden hier ook niet thuis: dit scherm gaat over hoe
-        je site eruitziet, en een verhuizing is geen uiterlijk. Alleen de
-        wegwijzer blijft, want wie ze hier zocht moet ze kunnen vinden. %>
+      <button type="button" id="profile-link-add" class="btn">+ Add link</button>
+    </fieldset>
+
     <fieldset>
-      <legend><%= t('asite.aliases') %></legend>
-      <p class="form-hint"><%= t('asite.moved_to_migrate') %></p>
-      <p><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a></p>
+      <legend>Feed view</legend>
+      <label>
+        <span>Default view for the homepage</span>
+        <select name="feed_view_default">
+          <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>>Timeline (chronological list)</option>
+          <option value="grid"     <%= site.feed_view_default === 'grid' ? 'selected' : '' %>>Grid (cards)</option>
+        </select>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
+        <span>Show timeline ↔ grid switcher above the feed</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
+        <span>Show search button in nav</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
+        <span>Show archive link in nav</span>
+      </label>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.feed_view') %></legend>
-      <%# Twee vragen, en ze zijn niet hetzelfde: WELKE tweede weergave je site
-          aanbiedt naast Grid, en waarin hij opent. %>
-      <label>
-        <span><%= t('asite.feed_alt') %></span>
-        <select name="feed_alt_view">
-          <% var _a = ['timeline','auto'].indexOf(site.feed_alt_view) >= 0 ? site.feed_alt_view : 'reader'; %>
-          <option value="reader"   <%= _a === 'reader'   ? 'selected' : '' %>><%= t('asite.feed_alt_reader') %></option>
-          <option value="timeline" <%= _a === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_alt_timeline') %></option>
-          <option value="auto"     <%= _a === 'auto'     ? 'selected' : '' %>><%= t('asite.feed_alt_auto') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('asite.feed_default') %></span>
-        <select name="feed_view_default">
-          <option value="reader" <%= site.feed_view_default !== 'grid' ? 'selected' : '' %>><%= t('asite.feed_alt_' + _a) %></option>
-          <option value="grid"     <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
-        </select>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
-        <span><%= t('asite.feed_switch') %></span>
-      </label>
-      <%# Alleen van toepassing op Lezen: geeft elk bericht daar een eigen scherm. %>
-      <label class="cb">
-        <input type="checkbox" name="reader_full_page" value="1" <%= site.reader_full_page ? 'checked' : '' %>>
-        <span><%= t('asite.reader_full_page') %></span>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
-        <span><%= t('asite.show_search') %></span>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
-        <span><%= t('asite.show_archive') %></span>
-      </label>
-    </fieldset>
-
-    <fieldset class="seo-pointer">
-      <legend><%= t('asite.seo') %></legend>
-      <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
+      <legend>SEO &amp; social</legend>
+      <label>
+        <span>Title template (use {title} and {site})</span>
+        <input type="text" name="title_template" value="<%= site.title_template || '{title} — {site}' %>" maxlength="200">
+      </label>
+      <label>
+        <span>Canonical base URL (for production HTTPS)</span>
+        <input type="text" name="canonical" value="<%= site.canonical || '' %>" placeholder="https://your-domain.com">
+      </label>
+      <label>
+        <span>Twitter handle (with @)</span>
+        <input type="text" name="twitter" value="<%= site.twitter || '' %>" placeholder="@yourname" maxlength="64">
+      </label>
+      <details>
+        <summary>Search-engine verification</summary>
+        <label>
+          <span>Google site verification</span>
+          <input type="text" name="google_verification"    value="<%= site.google_verification    || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Bing (msvalidate.01)</span>
+          <input type="text" name="bing_verification"      value="<%= site.bing_verification      || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Pinterest (p:domain_verify)</span>
+          <input type="text" name="pinterest_verification" value="<%= site.pinterest_verification || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Yandex</span>
+          <input type="text" name="yandex_verification"    value="<%= site.yandex_verification    || '' %>" maxlength="200">
+        </label>
+      </details>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
-      <label>
-        <span><%= t('asite.custom_css') %></span>
+      <legend>Custom CSS &amp; HTML <em>(optional)</em></legend>
+      <label>
+        <span>Custom CSS (injected as &lt;style&gt; in &lt;head&gt;)</span>
         <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.custom_head') %></span>
+        <span>Custom &lt;head&gt; HTML (analytics, extra metas)</span>
         <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.custom_foot') %></span>
+        <span>Custom footer HTML</span>
         <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
       </label>
     </fieldset>
 
-    <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
+    <button type="submit" class="btn btn-primary"><%= isNew ? 'Create site' : 'Save changes' %></button>
   </form>
-
 </div>
 
-
+<script>
+(function() {
+  // Profile-links repeater: add row from <template>, remove on click.
+  var rows = document.getElementById('profile-links-rows');
+  var tpl  = document.getElementById('profile-link-template');
+  var add  = document.getElementById('profile-link-add');
+  if (!rows || !tpl || !add) return;
+
+  add.addEventListener('click', function() {
+    var clone = tpl.content.cloneNode(true);
+    rows.appendChild(clone);
+  });
+  rows.addEventListener('click', function(e) {
+    if (e.target && e.target.classList.contains('pl-remove')) {
+      var row = e.target.closest('.profile-link-row');
+      if (row) row.remove();
+    }
+  });
+})();
+
+// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
+// then write the returned URL into the visible input. Live thumb preview.
+(function() {
+  var picker  = document.getElementById('photo-picker');
+  if (!picker) return;
+  var thumb   = document.getElementById('photo-thumb');
+  var preview = document.getElementById('photo-preview');
+  var urlEl   = document.getElementById('photo-url');
+  var trigger = document.getElementById('photo-upload-trigger');
+  var clear   = document.getElementById('photo-clear');
+  var field   = document.getElementById('photo-upload-field');
+  var status  = document.getElementById('photo-status');
+
+  function showPreview(url) {
+    if (url) {
+      preview.src = url;
+      preview.hidden = false;
+      thumb.removeAttribute('data-empty');
+      clear.hidden = false;
+    } else {
+      preview.src = '';
+      preview.hidden = true;
+      thumb.setAttribute('data-empty', '');
+      clear.hidden = true;
+    }
+  }
+
+  if (urlEl) {
+    urlEl.addEventListener('input', function() {
+      showPreview(urlEl.value.trim());
+    });
+  }
+
+  if (trigger && field) {
+    trigger.addEventListener('click', function() { field.click(); });
+    field.addEventListener('change', async function() {
+      var file = field.files && field.files[0];
+      if (!file) return;
+      status.classList.remove('is-error');
+      status.textContent = 'Uploaden…';
+      try {
+        var fd = new FormData();
+        fd.append('photo', file);
+        var r = await fetch('/admin/sites/upload-photo', {
+          method: 'POST',
+          body: fd,
+          credentials: 'same-origin',
+        });
+        var j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
+        urlEl.value = j.url;
+        showPreview(j.url);
+        status.textContent = 'Geüpload ✓';
+        setTimeout(function() { status.textContent = ''; }, 2000);
+      } catch (e) {
+        status.classList.add('is-error');
+        status.textContent = 'Mislukt: ' + e.message;
+      } finally {
+        field.value = '';
+      }
+    });
+  }
+
+  if (clear) {
+    clear.addEventListener('click', function() {
+      urlEl.value = '';
+      showPreview('');
+      status.textContent = '';
+      // Note: doesn't delete the file from disk — saving the form with empty
+      // URL leaves the file orphaned on the server. Acceptable for now.
+    });
+  }
+})();
+</script>
 
 <style>
@@ -265,11 +361,5 @@
 .admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
 
-.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
-   therefore does NOT shrink → the entire form became wider than the screen. min-width:0
-   forces it to shrink; form fields get max-width:100% + border-box so they
-   never overflow their fieldset. */
-.site-form fieldset { min-width: 0; }
-.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
+.site-form { display: flex; flex-direction: column; gap: 1.5rem; }
 .site-form fieldset {
   border: 1px solid var(--rule);
@@ -310,58 +400,4 @@
 .site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
 
-/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
-.slug-url {
-  display: flex; align-items: stretch;
-  border: 1px solid var(--rule); border-radius: 5px;
-  background: var(--paper); overflow: hidden;
-  transition: border-color 120ms;
-}
-.slug-url:focus-within { border-color: var(--accent); }
-.slug-url-host {
-  display: inline-flex; align-items: center;
-  padding-left: 0.85rem;
-  color: var(--ink-muted, var(--ink-soft));
-  white-space: nowrap; user-select: none;
-  font-size: 0.95rem;
-}
-.site-form .slug-url-input {
-  flex: 1 1 auto; min-width: 0;
-  border: none !important; border-radius: 0 !important;
-  background: transparent !important;
-  color: var(--accent) !important; font-weight: 700;
-  padding: 0.55rem 0.85rem 0.55rem 0.1rem;
-}
-.site-form .slug-url-input:focus { outline: none; }
-.site-form .slug-url-input[readonly] {
-  opacity: 1; cursor: default; background: transparent !important;
-}
-
-/* Selects: same look as the inputs, with a custom chevron instead of the bare
-   native arrow. The open option list is still rendered by the browser. */
-.site-form select {
-  appearance: none;
-  -webkit-appearance: none;
-  padding: 0.55rem 2.2rem 0.55rem 0.85rem;
-  border: 1px solid var(--rule);
-  border-radius: 5px;
-  background-color: var(--paper);
-  color: var(--ink);
-  font-family: inherit;
-  font-size: 0.95rem;
-  cursor: pointer;
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
-  background-repeat: no-repeat;
-  background-position: right 0.7rem center;
-  transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-.site-form select:hover { border-color: var(--ink-soft, #888); }
-.site-form select:focus,
-.site-form input[type="text"]:focus,
-.site-form textarea:focus {
-  outline: none;
-  border-color: var(--accent, #c2410c);
-  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
-}
-
 /* Reuse the palette-swatch styles from account.ejs */
 .palette-swatch {
@@ -474,16 +510,8 @@
   .photo-controls { gap: .55rem; }
 }
-/* Mobile-first: platform selector on its own line (full width), URL input + remove
-   button below. Prevents horizontal overflow on phones (fixed 160px column +
-   non-shrinkable input together ~447px > screen width). */
 .profile-link-row {
-  display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
-}
-.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
-.profile-link-row input { min-width: 0; }
-@media (min-width: 520px) {
-  .profile-link-row { grid-template-columns: 160px 1fr auto; }
-  .profile-link-row select { grid-column: auto; }
-}
+  display: grid; grid-template-columns: 160px 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
+}
+.profile-link-row select { padding: 0.45rem 0.6rem; }
 .profile-links-fieldset details > label { margin-top: 0.4rem; }
 </style>
Index: src/views/pages/admin-sites.ejs
===================================================================
--- src/views/pages/admin-sites.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-sites.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,9 +1,9 @@
 <div class="container ax-page">
 
-  <p><a href="/admin" class="btn">&larr; <%= t('asit2.back') %></a></p>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('asit2.title') %></h1>
-      <p class="ax-tagline"><%= t('asit2.tagline') %></p>
+      <h1>Sites</h1>
+      <p class="ax-tagline">Beheer alle sites op deze installatie.</p>
     </div>
   </header>
@@ -13,5 +13,5 @@
 
   <div class="ax-actions-bar">
-    <a href="/admin/sites/new" class="ax-btn ax-btn-primary">＋ <%= t('asit2.new_site') %></a>
+    <a href="/admin/sites/new" class="ax-btn ax-btn-primary">＋ Nieuwe site</a>
   </div>
 
@@ -20,30 +20,9 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">🌐</div>
-        <p><%= t('asit2.empty') %></p>
-        <p class="ax-empty-sub"><%= t('asit2.empty_sub') %></p>
+        <p>Nog geen sites.</p>
+        <p class="ax-empty-sub">Maak er een via de knop hierboven.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      var _s_primary    = t('asit2.pill_primary');
-      var _s_primary_t  = t('asit2.pill_primary_title');
-      var _s_public     = t('asit2.pill_public');
-      var _s_public_t   = t('asit2.pill_public_title');
-      var _s_private    = t('asit2.pill_private');
-      var _s_private_t  = t('asit2.pill_private_title');
-      var _s_noindex    = t('asit2.pill_noindex');
-      var _s_noindex_t  = t('asit2.pill_noindex_title');
-      var _s_by         = t('asit2.by');
-      var _s_t_posts    = t('asit2.t_posts');
-      var _s_l_posts    = t('asit2.l_posts');
-      var _s_t_created  = t('asit2.t_created');
-      var _s_l_created  = t('asit2.l_created');
-      var _s_makeprim   = t('asit2.make_primary');
-      var _s_makeprim_t = t('asit2.make_primary_title');
-      var _s_makeprim_c = t('asit2.make_primary_confirm');
-      var _s_edit       = t('asit2.edit');
-      var _s_del        = t('asit2.delete');
-      var _s_del_c      = t('asit2.delete_confirm');
-    %>
     <ul class="ax-list">
       <% sites.forEach(function(s) { %>
@@ -56,15 +35,12 @@
             <div class="ax-site-meta">
               <div class="ax-site-name">
-                <a href="/user/<%= s.slug %>"><%= s.title %></a>
-                <% if (s.is_primary) { %>
-                  <span class="ax-pill ax-pill-primary" title="<%= _s_primary_t %>">★ <%= _s_primary %></span>
-                <% } %>
+                <a href="/sites/<%= s.slug %>"><%= s.title %></a>
                 <% if (s.is_public) { %>
-                  <span class="ax-pill ax-pill-ok" title="<%= _s_public_t %>">✓ <%= _s_public %></span>
+                  <span class="ax-pill ax-pill-ok" title="Publiek zichtbaar">✓ public</span>
                 <% } else { %>
-                  <span class="ax-pill" title="<%= _s_private_t %>">🔒 <%= _s_private %></span>
+                  <span class="ax-pill" title="Niet publiek">🔒 private</span>
                 <% } %>
                 <% if (!s.robots_index) { %>
-                  <span class="ax-pill" title="<%= _s_noindex_t %>">🚫🤖 <%= _s_noindex %></span>
+                  <span class="ax-pill" title="Niet geïndexeerd door zoekmachines">🚫🤖 noindex</span>
                 <% } %>
               </div>
@@ -73,5 +49,5 @@
                 <% if (s.owner_username) { %>
                   <span class="ax-track-sep">·</span>
-                  <span><%= _s_by %> <%= s.owner_username %></span>
+                  <span>door <%= s.owner_username %></span>
                 <% } %>
               </div>
@@ -80,25 +56,19 @@
 
           <div class="ax-site-stats">
-            <span class="ax-stat" title="<%= _s_t_posts %>">
+            <span class="ax-stat" title="Aantal posts">
               <span class="ax-stat-num"><%= s.post_count %></span>
-              <span class="ax-stat-label"><%= _s_l_posts %></span>
+              <span class="ax-stat-label">posts</span>
             </span>
-            <span class="ax-stat" title="<%= _s_t_created %>">
+            <span class="ax-stat" title="Aangemaakt op">
               <span class="ax-stat-num ax-stat-date"><%= formatDate(s.created_at) %></span>
-              <span class="ax-stat-label"><%= _s_l_created %></span>
+              <span class="ax-stat-label">created</span>
             </span>
           </div>
 
           <div class="ax-site-controls">
-            <% if (!s.is_primary && (typeof canMutate === 'undefined' || canMutate)) { %>
-              <form method="post" action="/admin/sites/<%= s.slug %>/make-primary" style="margin:0;display:inline"
-                    data-confirm="<%= _s_makeprim_c %>">
-                <button type="submit" class="ax-icon-btn" aria-label="<%= _s_makeprim %>" title="<%= _s_makeprim_t %>">★</button>
-              </form>
-            <% } %>
-            <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="<%= _s_edit %>" title="<%= _s_edit %>">✎</a>
+            <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="Bewerken" title="Bewerken">✎</a>
             <form method="post" action="/admin/sites/<%= s.slug %>/delete" class="ax-delete-form"
-                  data-confirm="<%= _s_del_c %>">
-              <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _s_del %>" title="<%= _s_del %>">🗑</button>
+                  onsubmit="return confirm('Site verwijderen? Lukt alleen als er geen posts zijn.')">
+              <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
             </form>
           </div>
@@ -228,9 +198,4 @@
   background: rgba(40,160,90,0.12);
 }
-.ax-pill-primary {
-  color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 14%, transparent);
-  font-weight: 600;
-}
 
 /* ─── Stats (2 mini-columns for sites) ────────────────────────── */
Index: src/views/pages/admin-stats.ejs
===================================================================
--- src/views/pages/admin-stats.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,177 +1,0 @@
-<div class="container stats-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('nav.admin') %></a></p>
-  <h1><%= t('astat.title') %></h1>
-  <p class="stats-note"><%= t('astat.intro') %></p>
-
-  <% if (typeof myIp !== 'undefined' && myIp) { %>
-  <form method="post" action="/admin/stats/exclude-ip" class="stats-note" style="margin:.2rem 0 1.2rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
-    <span><%= t('astat.your_ip') %> <code><%= myIp %></code> —
-      <% if (ipExcluded) { %><strong><%= t('astat.ip_not_counted') %></strong><% } else { %><%= t('astat.ip_counted') %><% } %></span>
-    <button type="submit" class="btn" style="padding:3px 10px;font-size:.85rem;"><%= ipExcluded ? t('astat.ip_count') : t('astat.ip_exclude') %></button>
-  </form>
-  <% } %>
-
-  <% var s = stats; var _max = Math.max(1, ...s.series.map(function(d){ return d.pageviews; })); %>
-
-  <div class="stats-cards">
-    <div class="stat-card"><div class="stat-num"><%= s.totals.visitors %></div><div class="stat-label"><%= t('astat.visitor_days', { n: s.days }) %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.pageviews %></div><div class="stat-label"><%= t('astat.pageviews_days', { n: s.days }) %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.plays %></div><div class="stat-label"><%= t('astat.plays_total') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.postViews %></div><div class="stat-label"><%= t('astat.postviews_total') %></div></div>
-  </div>
-
-  <% if (typeof s.allTime !== 'undefined') { %>
-    <p class="stats-note"><%= t('astat.alltime_pre') %> <strong><%= s.allTime.pageviews %></strong> <%= t('astat.alltime_mid') %> · <strong><%= s.allTime.visitorDays %></strong> <%= t('astat.alltime_post') %></p>
-  <% } %>
-
-  <details class="stats-help">
-    <summary><%= t('astat.help_summary') %></summary>
-    <div class="stats-help-body">
-      <dl class="stats-help-defs">
-        <div><dt><%= t('astat.help_vd_term') %></dt><dd><%= t('astat.help_vd_a') %> <em><%= t('astat.help_vd_em') %></em><%= t('astat.help_vd_b') %></dd></div>
-        <div><dt><%= t('astat.help_pv_term') %></dt><dd><%= t('astat.help_pv') %></dd></div>
-        <div><dt><%= t('astat.help_plays_term') %></dt><dd><%= t('astat.help_plays') %></dd></div>
-        <div><dt><%= t('astat.help_postviews_term') %></dt><dd><%= t('astat.help_postviews') %></dd></div>
-      </dl>
-      <p class="stats-help-note"><%= t('astat.help_footer') %></p>
-    </div>
-  </details>
-
-  <div class="stats-period">
-    <%= t('astat.period') %>
-    <% [7,14,30,90].forEach(function(n){ %>
-      <a href="/admin/stats?days=<%= n %>" class="<%= s.days === n ? 'is-on' : '' %>"><%= n %>d</a>
-    <% }); %>
-  </div>
-
-  <section class="stats-chart-wrap">
-    <div class="stats-chart-head">
-      <h2><%= t('astat.last_n_days', { n: s.days }) %></h2>
-      <div class="stats-legend">
-        <span class="lg lg-vis"><%= t('astat.lg_visitor_days') %></span>
-        <span class="lg lg-pv"><%= t('astat.lg_pageviews') %></span>
-      </div>
-    </div>
-    <div class="stats-chart">
-      <% var _step = s.days <= 14 ? 1 : (s.days <= 31 ? 4 : 10);
-         s.series.forEach(function(d, i){
-           var pvPct = Math.round(d.pageviews / _max * 100);
-           var visPct = Math.round(d.visitors / _max * 100);
-      %>
-        <div class="sc-col" title="<%= t('astat.bar_title', { day: d.day, pv: d.pageviews, vd: d.visitors }) %>">
-          <div class="sc-bar">
-            <span class="sc-pv" style="height: <%= pvPct %>%"></span>
-            <span class="sc-vis" style="height: <%= visPct %>%"></span>
-          </div>
-          <%# Unlabeled columns get an NBSP so every .sc-day keeps its text line: an empty
-              span collapses to 0 height and made the 30/90-day columns jump around. %>
-          <span class="sc-day"><%= (i % _step === 0) ? d.day.slice(8) : ' ' %></span>
-        </div>
-      <% }); %>
-    </div>
-  </section>
-
-  <div class="stats-tops">
-    <section class="stats-top">
-      <h2><%= t('astat.top_posts') %></h2>
-      <% if (!s.topPosts.length || s.topPosts[0].views === 0) { %>
-        <p class="stats-empty"><%= t('astat.no_views') %></p>
-      <% } else { %>
-        <ol>
-          <% s.topPosts.forEach(function(p){ %>
-            <li><a href="/<%= p.slug %>"><%= p.title %></a><span class="stats-count"><%= p.views %></span></li>
-          <% }); %>
-        </ol>
-      <% } %>
-    </section>
-    <section class="stats-top">
-      <h2><%= t('astat.most_played') %></h2>
-      <% if (!s.topTracks.length || s.topTracks[0].plays === 0) { %>
-        <p class="stats-empty"><%= t('astat.no_plays') %></p>
-      <% } else { %>
-        <ol>
-          <% s.topTracks.forEach(function(t){ %>
-            <li><span><%= t.title %></span><span class="stats-count"><%= t.plays %></span></li>
-          <% }); %>
-        </ol>
-      <% } %>
-    </section>
-  </div>
-
-  <% if (typeof s.referrers !== 'undefined' && s.referrers.length) { %>
-    <section class="stats-top" style="margin-top:1.2rem">
-      <h2>🌐 <%= t('astat.sources') %></h2>
-      <ol>
-        <% s.referrers.forEach(function(r){ %>
-          <li><span><%= r.host %></span><span class="stats-count"><%= r.n %></span></li>
-        <% }); %>
-      </ol>
-    </section>
-  <% } %>
-
-  <% if (typeof linkClicks !== 'undefined' && linkClicks.length) { %>
-    <section class="stats-top" style="margin-top:1.2rem">
-      <h2>🔗 <%= t('astat.linkbio_clicks') %></h2>
-      <ol>
-        <% linkClicks.forEach(function(l){ %>
-          <li><a href="<%= l.url %>" target="_blank" rel="noopener"><%= l.url.replace(/^https?:\/\//,'').slice(0,48) %></a><span class="stats-count"><%= l.clicks %></span></li>
-        <% }); %>
-      </ol>
-    </section>
-  <% } %>
-</div>
-
-<style>
-.stats-page { max-width: 820px; margin: 2.5rem auto; padding: 0 1rem; }
-.stats-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: .5rem 0 .25rem; }
-.stats-note { color: var(--ink-muted); font-size: .85rem; margin: 0 0 1.5rem; max-width: 60ch; }
-.stats-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin-bottom: 2rem; }
-.stat-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 10px; padding: 1rem 1.1rem; }
-.stat-num { font-family: var(--font-display, serif); font-size: 1.9rem; line-height: 1; color: var(--accent); }
-.stat-label { color: var(--ink-muted); font-size: .82rem; margin-top: .35rem; }
-
-.stats-chart-wrap { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.1rem 1.2rem 1rem; margin-bottom: 2rem; }
-.stats-chart-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: .5rem; }
-.stats-chart-head h2 { font-size: 1.05rem; margin: 0; }
-.stats-legend { display: flex; gap: .9rem; font-size: .78rem; color: var(--ink-muted); }
-.lg::before { content: ""; display: inline-block; width: .7rem; height: .7rem; border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }
-.lg-vis::before { background: var(--accent); }
-.lg-pv::before { background: color-mix(in srgb, var(--accent) 28%, transparent); }
-.stats-chart { display: flex; align-items: flex-end; gap: 2px; height: 160px; margin-top: 1rem; overflow: hidden; }
-.sc-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
-.sc-bar { position: relative; width: 70%; max-width: 26px; min-width: 2px; height: 140px; display: flex; align-items: flex-end; }
-.sc-day { white-space: nowrap; }
-.sc-pv, .sc-vis { position: absolute; bottom: 0; left: 0; width: 100%; border-radius: 3px 3px 0 0; min-height: 2px; }
-.sc-pv { background: color-mix(in srgb, var(--accent) 28%, transparent); }
-.sc-vis { background: var(--accent); }
-.sc-day { font-size: .68rem; color: var(--ink-muted); margin-top: .35rem; }
-
-.stats-tops { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; }
-@media (max-width: 560px) { .stats-tops { grid-template-columns: 1fr; } }
-.stats-top { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1rem 1.2rem; }
-.stats-top h2 { font-size: 1.05rem; margin: 0 0 .6rem; }
-.stats-top ol { margin: 0; padding-left: 1.2rem; }
-.stats-top li { padding: .35rem 0; display: flex; justify-content: space-between; gap: .5rem; align-items: baseline; }
-.stats-top a { color: var(--ink); text-decoration: none; }
-.stats-top a:hover { color: var(--accent); }
-.stats-count { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; }
-.stats-empty { color: var(--ink-muted); font-size: .88rem; margin: 0; }
-.stats-period { margin: .2rem 0 1.2rem; font-size: .9rem; color: var(--ink-muted); }
-.stats-period a { display: inline-block; margin-left: .4rem; padding: .2rem .6rem; border: 1px solid var(--rule); border-radius: 999px; text-decoration: none; color: var(--ink); }
-.stats-period a.is-on { background: var(--accent); color: #fff; border-color: var(--accent); }
-.stats-help { border: 1px solid var(--rule); border-radius: 12px; background: var(--paper-2); margin: 0 0 1.25rem; }
-.stats-help summary { cursor: pointer; padding: .7rem 1rem; font-weight: 600; font-size: .9rem; list-style: none; display: flex; align-items: center; gap: .5rem; }
-.stats-help summary::-webkit-details-marker { display: none; }
-.stats-help summary::before { content: '\203A'; font-size: 1.15rem; line-height: 1; color: var(--ink-soft); transition: transform .15s; }
-.stats-help[open] summary::before { transform: rotate(90deg); }
-.stats-help-body { padding: 0 1rem 1rem; }
-.stats-help-defs { margin: 0; }
-.stats-help-defs > div { display: grid; grid-template-columns: 130px 1fr; gap: .2rem 1.1rem; align-items: baseline;
-  padding: .55rem 0; border-top: 1px solid color-mix(in srgb, var(--rule) 55%, transparent); }
-.stats-help-defs dt { font-weight: 700; font-size: .85rem; color: var(--ink); }
-.stats-help-defs dd { margin: 0; font-size: .85rem; line-height: 1.5; color: var(--ink-soft); }
-.stats-help-defs dd em { font-style: italic; color: var(--ink); }
-.stats-help-note { margin: .85rem 0 0; padding-top: .7rem; border-top: 1px solid var(--rule);
-  font-size: .8rem; line-height: 1.55; color: var(--ink-muted); }
-@media (max-width: 480px) { .stats-help-defs > div { grid-template-columns: 1fr; gap: .1rem; } }
-</style>
Index: src/views/pages/admin-updates.ejs
===================================================================
--- src/views/pages/admin-updates.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,95 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aupd.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aupd.back_admin') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <section class="set-card">
-    <h2><%= t('aupd.version_heading') %></h2>
-
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.app_version') %></span>
-      <span class="upd-val"><%= appVersion || '—' %></span>
-    </div>
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.current') %></span>
-      <span class="upd-val">
-        <% if (currentSha) { %><code><%= currentSha %></code> <span class="upd-desc"><%= currentDesc %></span>
-        <% } else { %><em><%= t('aupd.current_unknown') %></em><% } %>
-      </span>
-    </div>
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.latest') %></span>
-      <span class="upd-val">
-        <% if (latestSha) { %><code><%= latestSha %></code> <span class="upd-desc"><%= latestDesc %></span>
-        <% } else { %><em><%= t('aupd.latest_failed') %></em><% } %>
-      </span>
-    </div>
-
-    <div class="upd-status">
-      <% if (!canCheck) { %>
-        <span class="upd-badge warn">&#9888; <%= t('aupd.no_source') %></span>
-      <% } else if (upToDate) { %>
-        <span class="upd-badge ok">&#10003; <%= t('aupd.uptodate') %></span>
-      <% } else { %>
-        <span class="upd-badge new">&#8635; <%= t('aupd.update_available') %><% if (behind) { %> (<%= behind === '1' ? t('aupd.behind_one', { n: behind }) : t('aupd.behind_many', { n: behind }) %>)<% } %></span>
-      <% } %>
-    </div>
-
-    <% if (canCheck && canSelfUpdate) { %>
-    <form method="post" action="/admin/updates/run" class="set-form"
-          data-confirm="<%= t('aupd.run_confirm') %>">
-      <button type="submit" class="btn btn-primary">
-        <%= upToDate ? '↻ ' + t('aupd.redeploy') : '⬆ ' + t('aupd.update_now') %>
-      </button>
-    </form>
-    <% } else if (typeof manualCommand !== 'undefined' && manualCommand && !upToDate) { %>
-    <p class="set-help" style="margin:.2rem 0 .45rem"><%= t('aupd.manual_hint') %></p>
-    <pre class="upd-cmd"><code><%= manualCommand %></code></pre>
-    <% } %>
-
-    <p class="set-help" style="margin-top:1rem">
-      <%= t('aupd.help') %>
-    </p>
-  </section>
-
-  <% if (changes && changes.length) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aupd.changes_heading') %></h2>
-    <ul class="upd-changes">
-      <% changes.forEach(function (c) { %>
-        <li><span class="upd-ch-date"><%= c.date %></span> <span class="upd-ch-msg"><%= c.msg %></span></li>
-      <% }) %>
-    </ul>
-  </section>
-  <% } %>
-</div>
-
-<style>
-.admin-page { max-width: 700px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 .25rem; }
-.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 1rem; }
-.set-help { color: var(--ink-muted); font-size: .9rem; }
-.upd-row { display: flex; gap: 1rem; padding: .55rem 0; border-bottom: 1px solid var(--rule); align-items: baseline; }
-.upd-label { width: 90px; flex-shrink: 0; font-size: .8rem; font-weight: 600; color: var(--ink-muted); }
-.upd-val { font-size: .9rem; }
-.upd-val code { background: var(--paper); padding: .1rem .4rem; border-radius: 4px; font-size: .85em; }
-.upd-desc { color: var(--ink-muted); }
-.upd-status { margin: 1.1rem 0; }
-.upd-cmd { background: var(--paper); border: 1px solid var(--rule); border-radius: 6px; padding: .6rem .8rem; margin: 0; overflow-x: auto; }
-.upd-cmd code { font-size: .9em; }
-.upd-badge { font-size: .8rem; font-weight: 700; padding: .25rem .7rem; border-radius: 20px; }
-.upd-badge.ok { background: #d1fae5; color: #065f46; }
-.upd-badge.new { background: #fef3c7; color: #92400e; }
-.upd-badge.warn { background: #fee2e2; color: #991b1b; }
-.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
-.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
-.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
-.upd-changes { list-style: none; padding: 0; margin: 0; }
-.upd-changes li { display: flex; gap: .7rem; padding: .45rem 0; border-bottom: 1px solid var(--rule); font-size: .9rem; align-items: baseline; }
-.upd-changes li:last-child { border-bottom: 0; }
-.upd-ch-date { color: var(--ink-muted); flex-shrink: 0; font-size: .8rem; font-variant-numeric: tabular-nums; }
-.upd-ch-msg { word-break: break-word; }
-</style>
Index: src/views/pages/admin-users.ejs
===================================================================
--- src/views/pages/admin-users.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-users.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,9 +1,9 @@
 <div class="container ax-page">
 
-  <p><a href="/admin" class="btn">&larr; <%= t('ausr.back') %></a></p>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('ausr.title') %></h1>
-      <p class="ax-tagline"><%= t('ausr.tagline_a') %> <strong><%= t('ausr.role_kijker') %></strong> <%= t('ausr.tagline_b') %></p>
+      <h1>Users</h1>
+      <p class="ax-tagline">Beheer gebruikers, rollen, en verwijderingen.</p>
     </div>
   </header>
@@ -16,24 +16,8 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">👤</div>
-        <p><%= t('ausr.empty') %></p>
+        <p>Geen gebruikers.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      var _u_self      = t('ausr.you');
-      var _u_t_sites   = t('ausr.t_sites');
-      var _u_t_posts   = t('ausr.t_posts');
-      var _u_t_joined  = t('ausr.t_joined');
-      var _u_l_sites   = t('ausr.l_sites');
-      var _u_l_posts   = t('ausr.l_posts');
-      var _u_l_joined  = t('ausr.l_joined');
-      var _u_newklonkt = t('ausr.new_klonkt');
-      var _u_rol       = t('ausr.role_label');
-      var _u_r_kijker  = t('ausr.role_kijker');
-      var _u_r_member  = t('ausr.role_member');
-      var _u_r_admin   = t('ausr.role_admin');
-      var _u_r_god     = t('ausr.role_god');
-      var _u_del       = t('ausr.delete');
-    %>
     <ul class="ax-list">
       <% users.forEach(function(u) { %>
@@ -42,5 +26,5 @@
           <div class="ax-user-id">
             <% if (u.avatar_url) { %>
-              <img class="ax-avatar" src="<%= avatar(u.avatar_url, 128) %>" alt="">
+              <img class="ax-avatar" src="<%= u.avatar_url %>" alt="">
             <% } else { %>
               <span class="ax-avatar ax-avatar-empty"><%= u.username.charAt(0).toUpperCase() %></span>
@@ -49,5 +33,5 @@
               <div class="ax-user-name">
                 <a href="/users/<%= encodeURIComponent(u.username) %>"><%= u.username %></a>
-                <% if (u.id === user.id) { %><span class="ax-pill"><%= _u_self %></span><% } %>
+                <% if (u.id === user.id) { %><span class="ax-pill">jij</span><% } %>
               </div>
               <div class="ax-user-email"><%= u.email %></div>
@@ -56,15 +40,15 @@
 
           <div class="ax-user-stats">
-            <span class="ax-stat" title="<%= _u_t_sites %>">
+            <span class="ax-stat" title="Sites">
               <span class="ax-stat-num"><%= u.site_count %></span>
-              <span class="ax-stat-label"><%= _u_l_sites %></span>
+              <span class="ax-stat-label">sites</span>
             </span>
-            <span class="ax-stat" title="<%= _u_t_posts %>">
+            <span class="ax-stat" title="Posts">
               <span class="ax-stat-num"><%= u.post_count %></span>
-              <span class="ax-stat-label"><%= _u_l_posts %></span>
+              <span class="ax-stat-label">posts</span>
             </span>
-            <span class="ax-stat" title="<%= _u_t_joined %>">
+            <span class="ax-stat" title="Geregistreerd op">
               <span class="ax-stat-num ax-stat-date"><%= formatDate(u.created_at) %></span>
-              <span class="ax-stat-label"><%= _u_l_joined %></span>
+              <span class="ax-stat-label">joined</span>
             </span>
           </div>
@@ -73,25 +57,18 @@
             <form method="post" action="/admin/users/<%= u.id %>/role" class="ax-role-form">
               <label class="ax-role">
-                <span><%= _u_rol %></span>
-                <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>
-                  <option value="admin"  <%= u.role === 'admin'  ? 'selected' : '' %>><%= _u_r_admin %></option>
-                  <option value="god"    <%= u.role === 'god'    ? 'selected' : '' %>><%= _u_r_god %></option>
+                <span>Rol</span>
+                <select name="role" onchange="this.form.submit()" <% if (u.id === user.id) { %>disabled<% } %>>
+                  <option value="member" <%= u.role === 'member' ? 'selected' : '' %>>member</option>
+                  <option value="admin"  <%= u.role === 'admin'  ? 'selected' : '' %>>admin</option>
+                  <option value="god"    <%= u.role === 'god'    ? 'selected' : '' %>>god</option>
                 </select>
               </label>
             </form>
 
-            <% if (u.id !== user.id && canMutate) { %>
-              <% var _warn = (u.post_count + u.site_count > 0)
-                   ? (t('ausr.del_warn', { n: u.post_count }) + ' ')
-                   : ''; %>
+            <% if (u.id !== user.id && (u.post_count + u.site_count === 0)) { %>
               <form method="post" action="/admin/users/<%= u.id %>/delete" class="ax-delete-form"
-                    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>
+                    onsubmit="return confirm('Verwijder gebruiker <%= u.username %>?')">
+                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
               </form>
-            <% } else { %>
-              <%# Empty placeholder so the role column aligns on every row. %>
-              <span class="ax-icon-btn-spacer" aria-hidden="true"></span>
             <% } %>
           </div>
@@ -215,5 +192,5 @@
 /* ─── Role select + delete ────────────────────────────────────── */
 .ax-user-controls {
-  display: flex; gap: 0.5rem; align-items: flex-end;
+  display: flex; gap: 0.5rem; align-items: center;
   flex-shrink: 0;
 }
@@ -231,7 +208,4 @@
 }
 .ax-role select {
-  width: 112px;
-  height: 40px;
-  box-sizing: border-box;
   padding: 0.4rem 1.75rem 0.4rem 0.6rem;
   border: 1px solid var(--rule);
@@ -248,7 +222,4 @@
 }
 .ax-role select:disabled { cursor: not-allowed; opacity: 0.5; }
-.ax-ro { align-items: center; }
-.ax-ro input { width: 18px; height: 18px; cursor: pointer; margin-top: 0.15rem; accent-color: var(--accent); }
-.ax-ro input:disabled { cursor: not-allowed; opacity: 0.5; }
 .ax-delete-form { margin: 0; }
 .ax-icon-btn {
@@ -262,5 +233,4 @@
 .ax-icon-btn:hover { border-color: var(--accent); }
 .ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
-.ax-icon-btn-spacer { display: inline-block; width: 40px; height: 40px; flex-shrink: 0; }
 
 /* ─── Mobile: stack everything vertically ─────────────────────── */
Index: src/views/pages/admin-videos.ejs
===================================================================
--- src/views/pages/admin-videos.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,53 +1,0 @@
-<%# Beheer -> Media -> Video's (Robins opdracht, 30-7): de video's die via C2S
-    binnenkwamen (Shaers composer, de reddingsboei), met gebruik en opruiming.
-
-    De voorvertoning is de video zelf met preload="metadata", zodat de browser
-    het eerste beeld als miniatuur toont tot echte poster-generatie landt
-    (bead: ffmpeg ontbreekt).
-
-    Zelfde romp als de andere mediapagina's; stond hiervoor op inline styles. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'videos', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('admin.t_media') %></h1>
-      <p class="ax-tagline"><%= items.length %> <%= t('admin.videos_count') %></p>
-    </div>
-  </header>
-
-  <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
-
-  <% if (!items.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#127916;</div>
-      <p><%= t('admin.videos_empty') %></p>
-    </div>
-  <% } else { %>
-  <div class="ax-grid" style="--ax-tile:220px;">
-    <% items.forEach(function(it){ %>
-      <div class="ax-tile media-card" data-file="<%= it.file %>">
-        <div class="ax-tile-media" style="aspect-ratio:16/10;background:#000;">
-          <video src="<%= it.url %>" preload="metadata" controls playsinline style="height:100%;object-fit:contain;"></video>
-          <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %>
-        </div>
-        <div class="ax-tile-body">
-          <div class="ax-tile-meta" style="word-break:break-all;"><%= it.file %></div>
-          <div class="ax-tile-meta"><%= it.kb %> KB &middot; <%= it.usedCount %>&times;</div>
-          <div class="ax-tile-actions">
-            <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>
-            <% if (!it.usedCount) { %>
-            <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">&#128465;</button>
-            <% } %>
-          </div>
-        </div>
-      </div>
-    <% }) %>
-  </div>
-  <% } %>
-</div>
-
-<%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %>
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,77 +1,74 @@
 <div class="container admin-page">
-  <h1><%= t('admin.title') %></h1>
-  <%
-    var _premOn = (typeof premiumEnabled !== 'undefined' && premiumEnabled);
-    var _premActive = _premOn && (typeof isPremium !== 'undefined' && isPremium);
-    var _premCls = _premActive ? 'on' : (_premOn ? 'off' : 'free');
-    var _premLbl = _premActive ? ('★ ' + t('adash.prem_active'))
-                 : (_premOn ? ('⚠ ' + t('adash.prem_unlinked'))
-                            : t('adash.prem_layeroff'));
-  %>
-  <a class="prem-badge <%= _premCls %>" href="/admin/settings" title="<%= t('admin.b_settings') %>"><%= _premLbl %></a>
-  <style>
-    .prem-badge{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;letter-spacing:.02em;padding:.3rem .85rem;border-radius:999px;text-decoration:none;border:1px solid transparent;margin:.1rem 0 .3rem}
-    .prem-badge.on{background:rgba(232,176,75,.16);color:var(--accent);border-color:rgba(232,176,75,.45)}
-    .prem-badge.off{background:rgba(239,68,68,.12);color:#ef9a9a;border-color:rgba(239,68,68,.35)}
-    .prem-badge.free{background:var(--paper-2);color:var(--ink-muted);border-color:var(--rule)}
-    .prem-badge:hover{filter:brightness(1.08)}
-  </style>
-  <% if (typeof circlesOn !== 'undefined' && circlesOn) { %>
-    <p class="admin-tagline"><%= t('admin.tagline_cirkels') %></p>
-  <% } else { %>
-    <p class="admin-tagline"><%= t('admin.tagline_solo') %></p>
-  <% } %>
+  <h1>Admin</h1>
+  <p class="admin-tagline">God-level overview. <em>(Stub — full dashboard later.)</em></p>
 
-  <nav class="admin-quick-links" aria-label="<%= t('nav.admin') %>">
-      <a href="/posts/new" class="btn"><%= t('admin.b_newpost') %></a>
-      <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a>
-      <% if (primarySite) { %>
-        <a href="/admin/sites/<%= primarySite.slug %>/edit" class="btn"><%= t('admin.b_look') %></a>
-      <% } else { %>
-        <a href="/admin/sites/new" class="btn"><%= t('admin.b_makesite') %></a>
-      <% } %>
-      <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
-      <% if (primarySite) { %><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a><% } %>
-      <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
-    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-      <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
-      <a href="/admin/paid" class="btn"><%= t('admin.b_paid') %></a>
-      <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
-      <% if (primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
-      <% if (primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %>
-      <% if (primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %>
-      <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a>
-    <% } %>
-    <%# Fediverse pages (News / Notifications / My replies / Blocking) are reached via the
-        "Fediverse" nav button + the bell + the tabbed section — not duplicated here. %>
-    <a href="/admin/push" class="btn"><%= t('admin.b_push') %></a>
-    <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a>
-    <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
+  <nav class="admin-quick-links" aria-label="Beheer-snelkoppelingen">
+    <a href="/admin/sites" class="btn">🌐 Sites</a>
+    <a href="/admin/users" class="btn">👥 Users</a>
+    <a href="/admin/audio" class="btn">🎵 Audio tracks</a>
+    <a href="/admin/playlists" class="btn">📃 Playlists</a>
+    <a href="/admin/comments" class="btn">💬 Comments</a>
   </nav>
 
   <div class="admin-stats">
-    <div class="stat-card"><div class="stat-num"><%= stats.posts %></div><div class="stat-label"><%= t('admin.st_posts') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= stats.published %></div><div class="stat-label"><%= t('admin.st_published') %></div></div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.users %></div>
+      <div class="stat-label">Users</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.sites %></div>
+      <div class="stat-label">Sites</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.posts %></div>
+      <div class="stat-label">Posts</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.published %></div>
+      <div class="stat-label">Published</div>
+    </div>
   </div>
 
-  <% if (typeof posts !== 'undefined' && posts && posts.length) { %>
-    <% var _drafts = posts.filter(function(p){ return p.isDraft; }).length; %>
-    <h2><%= t('admin.sec_posts') %><% if (_drafts) { %> <span class="pl-draftcount"><%= _drafts %> <%= t('admin.draft').toLowerCase() %><%= _drafts === 1 ? '' : 's' %></span><% } %></h2>
-    <ul class="post-admin-list">
-      <% posts.forEach(function(p) { %>
-        <li class="pal-row<%= p.isDraft ? ' is-draft' : '' %>">
-          <a class="pal-title" href="<%= p.editUrl %>">
-            <% if (p.isDraft) { %><span class="pal-badge"><%= t('admin.draft') %></span><% } %>
-            <span class="pal-name"><%= p.title || '(zonder titel)' %></span>
-          </a>
-          <span class="pal-actions">
-            <a class="pal-link" href="<%= p.editUrl %>"><%= t('admin.edit') %></a>
-            <% if (!p.isDraft) { %><a class="pal-link" href="<%= p.viewUrl %>"><%= t('admin.view') %></a><% } %>
-          </span>
-        </li>
-      <% }); %>
-    </ul>
+  <% if (sites && sites.length) { %>
+    <h2>Sites</h2>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Slug</th><th>Title</th><th>Owner</th><th>Created</th></tr>
+      </thead>
+      <tbody>
+        <% sites.forEach(function(s) { %>
+          <tr>
+            <td><code><%= s.slug %></code></td>
+            <td><%= s.title %></td>
+            <td><%= s.owner_username || '—' %></td>
+            <td><%= formatDate(s.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
   <% } %>
 
+  <% if (users && users.length) { %>
+    <h2>Users</h2>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Username</th><th>Email</th><th>Role</th><th>Joined</th></tr>
+      </thead>
+      <tbody>
+        <% users.forEach(function(u) { %>
+          <tr>
+            <td><%= u.username %></td>
+            <td><%= u.email %></td>
+            <td><span class="role-pill role-<%= u.role %>"><%= u.role %></span></td>
+            <td><%= formatDate(u.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+
+  <p class="admin-todo">
+    <strong>Coming later:</strong> create/delete sites, manage users, theme picker, audio toggles, Prutter settings.
+  </p>
 </div>
 
@@ -82,18 +79,4 @@
 .admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
 .admin-tagline em { font-style: normal; opacity: 0.7; }
-
-/* Posts/concepten-lijst */
-.pl-draftcount { font-family: var(--font-ui, system-ui); font-size: 0.8rem; font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.55rem; border-radius: 99px; vertical-align: middle; }
-.post-admin-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
-.pal-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.9rem; border: 1px solid var(--rule); border-radius: 10px; background: var(--paper-2); transition: border-color 120ms; }
-.pal-row:hover { border-color: var(--accent); }
-.pal-row.is-draft { border-left: 3px solid var(--accent); }
-.pal-title { display: inline-flex; align-items: center; gap: 0.6rem; min-width: 0; color: var(--ink); text-decoration: none; font-weight: 600; }
-.pal-title:hover { color: var(--accent); }
-.pal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pal-badge { flex-shrink: 0; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.5rem; border-radius: 99px; }
-.pal-actions { display: flex; gap: 0.75rem; flex-shrink: 0; }
-.pal-link { color: var(--ink-muted); font-size: 0.85rem; text-decoration: none; white-space: nowrap; }
-.pal-link:hover { color: var(--accent); text-decoration: underline; }
 
 /* Quick-links grid — mobile-first.
Index: src/views/pages/archive.ejs
===================================================================
--- src/views/pages/archive.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/archive.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,19 +1,32 @@
-<%# Archief — altijd de chronologische jaar/maand-lijst. Het archief negeert
-    bewust de timeline/grid-keuze (geen grid-weergave): de view-switcher is hier
-    grijs en springt bij klik terug naar de feed. Zie de CSS in style.css. %>
+<%
+// Archive — chronological feed of all posts.
+//
+// Renders both a timeline view (year/month grouped link list) AND a grid
+// view (flat tiles) — same toggle pattern as home.ejs. The view-switcher
+// at the top lets the user pick. Body class is `on-archive`; the inline
+// <style> below adds an `.on-archive` clause to the existing home-only
+// feed toggle in style.css (cleaner than editing the global CSS).
+//
+// Flatten posts for grid mode: skip the year/month grouping (it doesn't
+// translate well to a tile grid). Newest-first order is preserved by the
+// route's SQL ORDER BY.
+const _flatPosts = [];
+Object.keys(grouped).sort((a, b) => b - a).forEach(function(y) {
+  Object.keys(grouped[y]).forEach(function(m) {
+    grouped[y][m].forEach(function(p) { _flatPosts.push(p); });
+  });
+});
+%>
 
-<!-- ========== TIJDLIJN (jaar/maand gegroepeerd) ========== -->
+<%# View switcher used to be rendered here; now lives in shell.ejs above
+    #pcms-main, visible on every non-admin page. %>
+
+<!-- ========== TIMELINE (year/month grouped) ========== -->
 <div class="container archive-page feed-timeline">
-  <% var _archBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
-  <p class="archive-back-row">
-    <a href="<%= _archBase %>/" class="btn archive-back"
-       hx-get="<%= _archBase %>/?partial=1" hx-target="#pcms-main"
-       hx-push-url="<%= _archBase %>/" hx-indicator="#pcms-loading">&larr; <%= t('parch.back') %></a>
-  </p>
-  <h1><%= t('parch.title') %></h1>
-  <p class="archive-meta"><%= totalPosts === 1 ? t('parch.count_one', { n: totalPosts }) : t('parch.count_many', { n: totalPosts }) %></p>
+  <h1>Archive</h1>
+  <p class="archive-meta"><%= totalPosts %> post<%= totalPosts !== 1 ? 's' : '' %></p>
 
   <% if (totalPosts === 0) { %>
-    <p class="empty-state"><%= t('parch.empty') %></p>
+    <p class="empty-state">No posts yet.</p>
   <% } %>
 
@@ -42,7 +55,17 @@
 </div>
 
+<!-- ========== GRID (only visible when body[data-feed-view="grid"]) ========== -->
+<% if (_flatPosts.length > 0) { %>
+<section class="feed-grid container" aria-label="Grid-weergave">
+  <div class="grid-tiles">
+    <% _flatPosts.forEach(function(post) { %>
+      <%- include('../partials/post-tile', { post: post }) %>
+    <% }); %>
+  </div>
+</section>
+<% } %>
+
 <style>
 .archive-page { max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
-.archive-back-row { margin: 0 0 1rem; }
 .archive-page h1 { font-family: var(--font-display, serif); margin: 0 0 0.25rem; }
 .archive-meta { color: var(--ink-muted); margin-bottom: 2rem; }
Index: src/views/pages/auth-login.ejs
===================================================================
--- src/views/pages/auth-login.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-login.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,30 +1,23 @@
-<%
-  // The login page shows the admin username/password form. (Public listener
-  // login via Google was removed — interaction now runs via the fediverse.)
-  var _admin = (typeof adminLogin !== 'undefined' && adminLogin);
-%>
 <div class="container auth-page">
-  <h1><%= _admin ? t('auth.admin_login_title') : t('nav.login') %></h1>
+  <h1>Login</h1>
   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <% if (_admin) { %>
-    <%# Hidden admin login (password). %>
-    <form method="post" action="/auth/login" class="auth-form">
-      <% if (typeof next !== 'undefined' && next) { %>
-        <input type="hidden" name="next" value="<%= next %>">
-      <% } %>
-      <label>
-        <span><%= t('auth.username_or_email') %></span>
-        <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
-      </label>
-      <label>
-        <span><%= t('auth.password') %></span>
-        <input type="password" name="password" required autocomplete="current-password">
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('nav.login') %></button>
-      <p class="auth-link auth-link-muted"><a href="/auth/reset-request"><%= t('auth.forgot') %></a></p>
-    </form>
-  <% } %>
+  <form method="post" action="/auth/login" class="auth-form">
+    <% if (typeof next !== 'undefined' && next) { %>
+      <input type="hidden" name="next" value="<%= next %>">
+    <% } %>
+    <label>
+      <span>Username or email</span>
+      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
+    </label>
+    <label>
+      <span>Password</span>
+      <input type="password" name="password" required autocomplete="current-password">
+    </label>
+    <button type="submit" class="btn btn-primary">Login</button>
+    <p class="auth-link">No account?
+      <a href="/auth/register<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Register</a>
+    </p>
+    <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Forgot password?</a></p>
+  </form>
 </div>
-
Index: src/views/pages/auth-register.ejs
===================================================================
--- src/views/pages/auth-register.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-register.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,4 @@
 <div class="container auth-page">
-  <h1><%= t('setup.title') %></h1>
-  <p class="auth-intro"><%= t('setup.intro') %></p>
-
-  <div class="setup-lang" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 20px">
-    <span style="opacity:.7;font-size:.9rem"><%= t('setup.lang_label') %>:</span>
-    <% (typeof langs !== 'undefined' ? langs : []).forEach(function(L){ %>
-      <a href="/lang/<%= L.code %>?r=/auth/register"
-         style="padding:5px 13px;border-radius:8px;text-decoration:none;font-weight:600;font-size:.9rem;border:1px solid <%= L.active ? 'var(--accent)' : 'var(--rule, #2b3140)' %>;color:<%= L.active ? 'var(--accent)' : 'inherit' %>"><%= L.name %></a>
-    <% }) %>
-  </div>
-
+  <h1>Register</h1>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
   <form method="post" action="/auth/register" class="auth-form">
@@ -17,27 +7,19 @@
     <% } %>
     <label>
-      <span><%= t('setup.f_sitename') %></span>
-      <input type="text" name="siteName" value="<%= typeof siteName !== 'undefined' ? siteName : '' %>" required autofocus maxlength="80" placeholder="<%= t('setup.sitename_ph') %>">
+      <span>Username (3-32 chars, letters/numbers/_-)</span>
+      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
     </label>
     <label>
-      <span><%= t('auth.f_username') %></span>
-      <input type="text" id="setup-username" name="username" value="<%= username %>" required autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
-      <small class="setup-handle-hint"><%= t('setup.username_note') %> <code id="setup-handle">@username@…</code></small>
-    </label>
-    <label>
-      <span><%= t('auth.f_email') %></span>
+      <span>Email</span>
       <input type="email" name="email" value="<%= email %>" required autocomplete="email">
     </label>
     <label>
-      <span><%= t('auth.f_password') %></span>
+      <span>Password (min 8 chars)</span>
       <input type="password" name="password" required minlength="8" autocomplete="new-password">
     </label>
-    <button type="submit" class="btn btn-primary"><%= t('setup.submit') %></button>
+    <button type="submit" class="btn btn-primary">Create account</button>
+    <p class="auth-link">Already registered?
+      <a href="/auth/login<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Login</a>
+    </p>
   </form>
 </div>
-
-<style>
-  .setup-handle-hint { display: block; margin-top: .35rem; font-size: .82rem; line-height: 1.4; color: var(--ink-soft, #888); }
-  .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
Index: src/views/pages/auth-reset-request.ejs
===================================================================
--- src/views/pages/auth-reset-request.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-reset-request.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,12 +1,12 @@
 <div class="container auth-page">
-  <h1><%= t('arrq.title') %></h1>
+  <h1>Reset password</h1>
 
   <% if (sent) { %>
     <div class="alert alert-success">
-      <%= t('arrq.sent') %>
+      If an account exists for that email, a reset link has been sent.
     </div>
     <% if (devResetUrl) { %>
       <div class="alert alert-info">
-        <%- t('arrq.no_mailserver') %>
+        <strong>Dev mode:</strong> reset link below (no email server configured).
         <p style="margin: 0.5rem 0 0; word-break: break-all;">
           <a href="<%= devResetUrl %>"><%= devResetUrl %></a>
@@ -14,10 +14,7 @@
       </div>
     <% } %>
-    <% if (typeof mailer !== 'undefined' && !mailer) { %>
-      <p class="auth-link-muted"><%- t('arrq.no_mail_cli') %></p>
-    <% } %>
-    <p class="auth-link"><a href="/auth/login"><%= t('arrq.back_login') %></a></p>
+    <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
   <% } else { %>
-    <p class="auth-tagline"><%= t('arrq.tagline') %></p>
+    <p class="auth-tagline">Enter your email and we'll send a reset link.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -25,9 +22,9 @@
     <form method="post" action="/auth/reset-request" class="auth-form">
       <label>
-        <span><%= t('arrq.email') %></span>
+        <span>Email</span>
         <input type="email" name="email" required autofocus autocomplete="email">
       </label>
-      <button type="submit" class="btn btn-primary"><%= t('arrq.submit') %></button>
-      <p class="auth-link"><a href="/auth/login"><%= t('arrq.back_login') %></a></p>
+      <button type="submit" class="btn btn-primary">Send reset link</button>
+      <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
     </form>
   <% } %>
Index: src/views/pages/auth-reset.ejs
===================================================================
--- src/views/pages/auth-reset.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-reset.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,15 +1,14 @@
 <div class="container auth-page">
-  <h1><%= t('arst.title') %></h1>
+  <h1>Reset password</h1>
 
   <% if (!token) { %>
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
     <p class="auth-link">
-      <a href="/auth/reset-request"><%= t('arst.request_new') %></a>
+      <a href="/auth/reset-request">Request a new reset link</a>
       &middot;
-      <a href="/auth/login"><%= t('arst.back_login') %></a>
+      <a href="/auth/login">Back to login</a>
     </p>
   <% } else { %>
-    <p class="auth-tagline"><%- t('arst.set_for', { username: '<strong>' + username + '</strong>' }) %></p>
-    <%# arst.set_for contains {username}; rendered with raw output because username is wrapped in a strong tag %>
+    <p class="auth-tagline">Set a new password for <strong><%= username %></strong>.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -17,12 +16,12 @@
     <form method="post" action="/auth/reset/<%= token %>" class="auth-form">
       <label>
-        <span><%= t('arst.new_pw') %></span>
+        <span>New password (min 8 chars)</span>
         <input type="password" name="new_password" required minlength="8" autofocus autocomplete="new-password">
       </label>
       <label>
-        <span><%= t('arst.confirm_pw') %></span>
+        <span>Confirm new password</span>
         <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
       </label>
-      <button type="submit" class="btn btn-primary"><%= t('arst.submit') %></button>
+      <button type="submit" class="btn btn-primary">Set password</button>
     </form>
   <% } %>
Index: src/views/pages/authorize-interaction.ejs
===================================================================
--- src/views/pages/authorize-interaction.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,314 +1,0 @@
-<div class="auth-interact">
-  <% if (typeof manage !== 'undefined' && manage) { %>
-    <%- include('../partials/fedi-tabs', { active: 'reacties' }) %>
-    <h1 class="auth-interact-title"><%= t('fedi.manage_title') %></h1>
-
-    <%# Interact bookmarklet — drag to bookmarks bar, then click on any fediverse post. %>
-    <div class="fedi-bm">
-      <a class="fedi-bm-btn" id="fedi-bm-btn" href="#" draggable="true" title="<%= t('fedi.bm_help') %>">
-        <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
-        <%= t('fedi.bm_label') %>
-      </a>
-      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
-    </div>
-    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
-    <% if (!manage.length) { %>
-      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
-    <% } else { %>
-      <ul class="fedi-manage">
-        <% manage.forEach(function(m){ %>
-          <li class="fedi-manage-item">
-            <div class="fedi-manage-head">
-              <span class="fedi-manage-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></span>
-              <% if (m.to_handle) { %><span class="fedi-manage-to"><%= m.to_handle %></span><% } %>
-              <% if (m.created_at && typeof formatDateTime === 'function') { %><span class="fedi-manage-time"><%= formatDateTime(m.created_at) %></span><% } %>
-            </div>
-            <div class="fedi-manage-content"><%- m.content %></div>
-            <div class="fedi-manage-foot">
-              <button type="button" class="fedi-edit-btn" aria-expanded="false"><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="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg> <%= t('fedi.edit') %></button>
-              <% if (m.in_reply_to) { %>
-              <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" 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>
-            </div>
-            <div class="fedi-edit-form">
-              <%- include('../partials/reply-editor', {
-                action: '/fediverse/' + m.id + '/edit',
-                placeholder: t('fedi.reply_ph'),
-                submitLabel: t('fedi.save_edit'),
-                rows: 3,
-                initialHtml: m.content,
-                initialText: m.editable,
-                defaultLang: m.language,
-                noAttach: true,
-              }) %>
-            </div>
-          </li>
-        <% }); %>
-      </ul>
-      
-    <% } %>
-    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
-  <% } else if (typeof liked !== 'undefined' && liked) { %>
-    <h1 class="auth-interact-title">★ <%= t('fedi.liked_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.liked_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof boosted !== 'undefined' && boosted) { %>
-    <h1 class="auth-interact-title">🔁 <%= t('fedi.boosted_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.boosted_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof followed !== 'undefined' && followed) { %>
-    <h1 class="auth-interact-title"><%= t('fedi.followed_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.followed_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.view_profile') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof sent !== 'undefined' && sent) { %>
-    <h1 class="auth-interact-title"><%= t('fedi.remote_sent_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.remote_sent') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn btn-primary" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof reported !== 'undefined' && reported) { %>
-    <h1 class="auth-interact-title">🚩 <%= t('fedi.reported_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.reported_done') %></p>
-    <p class="auth-interact-actions">
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof voted !== 'undefined' && voted) { %>
-    <h1 class="auth-interact-title">📊 <%= t('poll.voted_title') %></h1>
-    <p class="auth-interact-note"><%= t('poll.voted_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn btn-primary" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof followTarget !== 'undefined' && followTarget) { %>
-  <h1 class="auth-interact-title"><%= t('fedi.follow_heading') %></h1>
-  <p class="auth-interact-meta">
-    <%= t('fedi.follow_intro') %>
-    <a href="<%= followTarget.actor_url %>" rel="nofollow noopener" target="_blank"><strong><%= followTarget.actor_name %></strong></a>
-    <span class="fedi-handle"><%= followTarget.actor_handle %></span>
-  </p>
-  <form method="post" action="/authorize_interaction/follow" class="auth-interact-form">
-    <input type="hidden" name="uri" value="<%= followTarget.actor_uri %>">
-    <div class="auth-interact-actions">
-      <button type="submit" class="btn btn-primary"><%= t('fedi.follow_btn') %></button>
-      <a href="/" class="btn"><%= t('comments.cancel') %></a>
-    </div>
-  </form>
-  <p class="auth-interact-note"><%= t('fedi.remote_as', { site: siteTitle }) %></p>
-  <% } else { %>
-  <h1 class="auth-interact-title"><%= t('fedi.remote_title') %></h1>
-
-  <% if (!target) { %>
-    <p class="auth-interact-note"><%= t('fedi.remote_notfound') %></p>
-    <form method="get" action="/authorize_interaction" class="auth-interact-form">
-      <input type="url" name="uri" placeholder="https://…/een-post" value="<%= uri || '' %>" required>
-      <button type="submit" class="btn btn-primary"><%= t('fedi.remote_load') %></button>
-    </form>
-  <% } else { %>
-    <p class="auth-interact-meta">
-      <%= t('fedi.remote_replying_to') %>
-      <a href="<%= target.actor_url %>" rel="nofollow noopener" target="_blank"><strong><%= target.actor_name %></strong></a>
-      <span class="fedi-handle"><%= target.actor_handle %></span>
-    </p>
-    <blockquote class="auth-interact-preview">
-      <% if (target.content) { %><div class="auth-interact-content"><%- target.content %></div>
-      <% } else if (target.preview) { %><%= target.preview %><% } %>
-      <% (target.images || []).forEach(function(im){ %>
-        <img class="auth-interact-img" src="<%= im %>" alt="" loading="lazy">
-      <% }); %>
-    </blockquote>
-    <% if (target.url || uri) { %>
-      <a class="auth-view-src" href="<%= target.url || uri %>" target="_blank" rel="nofollow noopener">
-        <svg viewBox="0 0 24 24" width="17" height="17" 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>
-        <span><%= t('fedi.remote_view_original') %></span>
-      </a>
-    <% } %>
-    <% if (target.poll) { var _pl = target.poll; var _tot = _pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _vn = (_pl.voters!=null?_pl.voters:_tot); %>
-      <% if (!_pl.closed) { %>
-        <form method="post" action="/authorize_interaction/vote" class="auth-poll">
-          <input type="hidden" name="uri" value="<%= uri %>">
-          <% _pl.options.forEach(function(o){ %>
-            <label class="auth-poll-choice"><input type="<%= _pl.multiple ? 'checkbox' : 'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label>
-          <% }); %>
-          <div class="auth-interact-actions">
-            <button type="submit" class="btn btn-primary"><%= t('poll.vote') %></button>
-          </div>
-          <p class="auth-poll-foot"><%= _vn %> <%= _vn === 1 ? t('poll.voter_one') : t('poll.voter_many') %> · <%= t('poll.open') %><% if (_pl.endTime) { %> · <%= t('poll.closes') %> <%= new Date(_pl.endTime).toLocaleString() %><% } %><% if (_pl.multiple) { %> · <%= t('poll.multiple') %><% } %></p>
-        </form>
-      <% } else { %>
-        <div class="auth-poll">
-          <% _pl.options.forEach(function(o){ var _pct = _tot ? Math.round((o.count||0)*100/_tot) : 0; %>
-            <div class="auth-poll-res"><span class="auth-poll-fill" style="width:<%= _pct %>%"></span><span class="auth-poll-name"><%= o.name %></span><span class="auth-poll-pct"><%= _pct %>%</span></div>
-          <% }); %>
-          <p class="auth-poll-foot"><%= _vn %> <%= _vn === 1 ? t('poll.voter_one') : t('poll.voter_many') %> · <%= t('poll.closed') %><% if (_pl.multiple) { %> · <%= t('poll.multiple') %><% } %></p>
-        </div>
-      <% } %>
-    <% } %>
-    <% var _liked = (typeof reacted !== 'undefined' && reacted.liked); var _boosted = (typeof reacted !== 'undefined' && reacted.boosted); %>
-    <div class="auth-interact-react">
-      <form method="post" action="/authorize_interaction/like" class="fedi-react-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <button type="submit" class="fedi-bigact fedi-bigact-like<%= _liked ? ' is-on' : '' %>" data-on="<%= t('fedi.unlike_short') %>" data-off="<%= t('fedi.like_short') %>">
-          <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-          <span class="fedi-bigact-label"><%= _liked ? t('fedi.unlike_short') : t('fedi.like_short') %></span>
-        </button>
-      </form>
-      <form method="post" action="/authorize_interaction/boost" class="fedi-react-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <button type="submit" class="fedi-bigact fedi-bigact-boost<%= _boosted ? ' is-on' : '' %>" data-on="<%= t('tl.unboost') %>" data-off="<%= t('fedi.boost_short') %>">
-          <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>
-          <span class="fedi-bigact-label"><%= _boosted ? t('tl.unboost') : t('fedi.boost_short') %></span>
-        </button>
-      </form>
-    </div>
-    <% if (locals.mayReply !== false) { %>
-    <p class="auth-interact-or"><%= t('fedi.or_reply') %></p>
-    <p class="auth-interact-where">💬 <%= t('fedi.reply_where') %></p>
-    <div class="auth-interact-form">
-      <%- include('../partials/reply-editor', {
-        action: '/authorize_interaction',
-        hiddenFields: [{ name: 'uri', value: uri }],
-        placeholder: t('fedi.reply_ph'),
-        submitLabel: t('fedi.send'),
-        rows: 4,
-        participants: (target && target.actor_uri) ? [{
-          uri: target.actor_uri,
-          url: target.actor_url || target.actor_uri,
-          handle: target.actor_handle || '',
-        }] : [],
-      }) %>
-      <div class="auth-interact-actions"><a href="<%= uri %>" class="btn"><%= t('comments.cancel') %></a></div>
-    </div>
-    <% } %>
-    <p class="auth-interact-note"><%= t('fedi.remote_as', { site: siteTitle }) %></p>
-    <details class="auth-report">
-      <summary>🚩 <%= t('fedi.report_open') %></summary>
-      <form method="post" action="/authorize_interaction/report" class="auth-interact-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <input type="hidden" name="actor_uri" value="<%= target.actor_uri %>">
-        <p class="auth-report-note"><%= t('fedi.report_where') %></p>
-        <textarea name="reason" rows="3" maxlength="3000" placeholder="<%= t('fedi.report_ph') %>"></textarea>
-        <div class="auth-interact-actions">
-          <button type="submit" class="btn btn-danger"><%= t('fedi.report_send') %></button>
-        </div>
-      </form>
-    </details>
-  <% } %>
-  <% } %>
-</div>
-
-<style>
-  .auth-interact { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .auth-interact-title { margin: 0 0 1rem; }
-  .auth-interact-meta { color: var(--ink-soft, #888); margin: 0 0 .75rem; }
-  .auth-interact-preview {
-    margin: 0 0 1rem; padding: .75rem 1rem; border-left: 3px solid var(--accent, #888);
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border-radius: 0 12px 12px 0;
-    color: var(--ink, inherit);
-  }
-  .auth-interact-form { display: flex; flex-direction: column; gap: .75rem; }
-  .auth-interact-form textarea, .auth-interact-form input[type=url] {
-    width: 100%; box-sizing: border-box; padding: .6rem .8rem; font: inherit; resize: vertical;
-    border-radius: 12px; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent);
-    background: var(--paper, #fff); color: var(--ink, #000);
-  }
-  .auth-interact-actions { display: flex; gap: .5rem; align-items: center; }
-  .auth-interact-note { color: var(--ink-soft, #999); font-size: .85rem; margin-top: 1rem; }
-  .auth-interact-where { margin: .25rem 0 .85rem; padding: .6rem .8rem; font-size: .85rem; line-height: 1.5; border-radius: 10px; background: color-mix(in srgb, var(--accent, #888) 12%, transparent); color: var(--ink, inherit); }
-  .auth-interact-content { line-height: 1.55; }
-  .auth-interact-content p { margin: .35rem 0; }
-  .auth-interact-content p:first-child { margin-top: 0; }
-  .auth-interact-img { max-width: 100%; height: auto; border-radius: 10px; margin: .5rem 0 0; display: block; }
-  .auth-interact-orig { margin: .75rem 0 0; font-size: .85rem; }
-  .auth-interact-orig a { color: var(--accent, #06c); }
-  .auth-view-src { display: inline-flex; align-items: center; gap: .5rem; margin: 0 0 1.1rem; padding: .6rem 1rem;
-    border: 1px solid color-mix(in srgb, var(--accent, #888) 45%, transparent); border-radius: 12px;
-    background: color-mix(in srgb, var(--accent, #888) 10%, transparent); color: var(--accent, #06c);
-    text-decoration: none; font-weight: 600; font-size: .92rem; transition: background .15s, border-color .15s; }
-  .auth-view-src:hover { background: color-mix(in srgb, var(--accent, #888) 18%, transparent); border-color: var(--accent, #888); }
-  .auth-interact-react { display: flex; gap: .6rem; flex-wrap: wrap; margin: 0 0 .25rem; }
-  .auth-interact-react form { margin: 0; }
-  .fedi-bigact { display: inline-flex; align-items: center; gap: .5rem; padding: .55rem 1.1rem; border-radius: 999px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent);
-    color: var(--ink, inherit); font: inherit; font-weight: 600; font-size: .92rem; cursor: pointer; white-space: nowrap;
-    transition: background .12s, border-color .12s; }
-  .fedi-bigact svg { width: 17px; height: 17px; flex: 0 0 17px; }
-  .fedi-bigact-like svg { color: #e8b04b; }
-  .fedi-bigact-like:hover, .fedi-bigact-like.is-on { background: color-mix(in srgb, #e8b04b 16%, transparent); border-color: color-mix(in srgb, #e8b04b 55%, transparent); }
-  .fedi-bigact-boost svg { color: #2fa85a; }
-  .fedi-bigact-boost:hover, .fedi-bigact-boost.is-on { background: color-mix(in srgb, #2fa85a 16%, transparent); border-color: color-mix(in srgb, #2fa85a 55%, transparent); }
-  .fedi-manage { list-style: none; padding: 0; margin: 1.25rem 0 1rem; display: flex; flex-direction: column; gap: .7rem; }
-  .fedi-manage-item { padding: .9rem 1rem; border-radius: 14px; background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent); transition: border-color .15s ease, box-shadow .15s ease; }
-  .fedi-manage-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 28%, transparent); box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .fedi-manage-head { display: flex; align-items: center; gap: .5rem; margin: 0 0 .5rem; }
-  .fedi-manage-icon { flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
-    background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); }
-  .fedi-manage-icon svg { width: 14px; height: 14px; }
-  .fedi-manage-to { font-weight: 600; color: var(--ink, inherit); font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .fedi-manage-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .fedi-manage-content { line-height: 1.55; overflow-wrap: anywhere; padding-left: calc(26px + .5rem); }
-  .fedi-manage-content p { margin: .15rem 0; }
-  .fedi-manage-content p:first-child { margin-top: 0; }
-  .fedi-manage-content p:last-child { margin-bottom: 0; }
-  .fedi-manage-foot { display: flex; align-items: center; justify-content: flex-start; flex-wrap: wrap; gap: .5rem; margin-top: .65rem; }
-  .fedi-goto-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, var(--accent, #888) 45%, transparent); background: color-mix(in srgb, var(--accent, #888) 12%, transparent); color: var(--accent, #06c); border-radius: 999px; padding: .3rem .8rem; font-size: .82rem; text-decoration: none; transition: background .15s ease; }
-  .fedi-goto-btn:hover { background: color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-  .fedi-del-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, #d9534f 45%, transparent); background: color-mix(in srgb, #d9534f 12%, transparent); color: #e06b66; border-radius: 999px; padding: .3rem .8rem; font: inherit; font-size: .82rem; cursor: pointer; transition: background .15s ease; }
-  .fedi-del-btn:hover { background: color-mix(in srgb, #d9534f 24%, transparent); }
-  .fedi-bm { margin: 0 0 1.5rem; padding: 1rem; border-radius: 14px;
-    border: 1px dashed color-mix(in srgb, var(--accent, #888) 45%, transparent);
-    background: color-mix(in srgb, var(--accent, #888) 7%, transparent); }
-  .fedi-bm-btn { display: inline-flex; align-items: center; gap: .45rem; padding: .5rem 1rem; border-radius: 999px;
-    background: var(--accent, #06c); color: #fff; font: inherit; font-weight: 600; font-size: .9rem; text-decoration: none;
-    cursor: grab; user-select: none; box-shadow: 0 2px 8px color-mix(in srgb, var(--accent, #000) 30%, transparent); }
-  .fedi-bm-btn:active { cursor: grabbing; }
-  .fedi-bm-btn.nudge { animation: fedi-bm-nudge .6s ease; }
-  @keyframes fedi-bm-nudge { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
-  .fedi-bm-help { color: var(--ink-soft, #888); font-size: .82rem; line-height: 1.5; margin: .6rem 0 0; }
-  .fedi-edit-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, var(--ink, #888) 28%, transparent);
-    background: color-mix(in srgb, var(--ink, #888) 8%, transparent); color: var(--ink-soft, #aaa); border-radius: 999px; padding: .3rem .8rem;
-    font: inherit; font-size: .82rem; cursor: pointer; transition: background .15s ease, color .15s ease; }
-  .fedi-edit-btn:hover { background: color-mix(in srgb, var(--ink, #888) 16%, transparent); }
-  .fedi-edit-btn.is-open { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
-  .fedi-del-form { margin-left: auto; }
-  .fedi-edit-form { display: none; margin: .55rem 0 0; }
-  .fedi-edit-form.is-open { display: block; }
-  .fedi-edit-ta { width: 100%; box-sizing: border-box; min-height: 70px; resize: vertical; padding: .55rem .7rem;
-    border: 1.5px solid var(--rule, #333); border-radius: 10px; background: var(--paper-2, #14141a); color: var(--ink, #eee); font: inherit; font-size: .9rem; }
-  .fedi-edit-ta:focus { outline: none; border-color: var(--accent); }
-  .fedi-edit-actions { display: flex; justify-content: flex-end; margin-top: .5rem; }
-  .fedi-edit-save { padding: .4rem 1rem; border: 0; border-radius: 999px; background: var(--accent); color: var(--paper, #fff);
-    font: inherit; font-weight: 600; font-size: .82rem; cursor: pointer; }
-  /* Poll ballot / results on the interact page */
-  .auth-poll { display: flex; flex-direction: column; gap: 8px; margin: 0 0 1rem; }
-  .auth-poll-choice { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; cursor: pointer;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-  .auth-poll-choice:hover { border-color: var(--accent); }
-  .auth-poll-choice input { accent-color: var(--accent); }
-  .auth-poll-res { position: relative; padding: 9px 12px; border-radius: 10px; overflow: hidden; display: flex; align-items: center; gap: 8px;
-    background: color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .auth-poll-fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 22%, transparent); z-index: 0; }
-  .auth-poll-name { position: relative; z-index: 1; flex: 1; font-size: .95rem; }
-  .auth-poll-pct { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 500; }
-  .auth-poll-foot { font-size: .82rem; color: var(--ink-soft, #888); margin: .1rem 0 0; }
-  .auth-report { margin: .6rem 0 0; }
-  .auth-report summary { cursor: pointer; font-size: .85rem; color: var(--ink-soft, #888); }
-  .auth-report summary:hover, .auth-report[open] summary { color: #c0392b; }
-  .auth-report[open] summary { margin-bottom: .5rem; }
-  .auth-report-note { font-size: .82rem; color: var(--ink-muted, #999); margin: 0 0 .5rem; }
-</style>
-
-
Index: src/views/pages/blocks.ejs
===================================================================
--- src/views/pages/blocks.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,42 +1,0 @@
-<div class="bl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'blokkeren' }) %>
-  <h1 class="bl-title"><%= t('blk.title') %></h1>
-  <p class="bl-lead"><%= t('blk.lead') %></p>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <form method="post" action="/blocking/add" class="bl-form">
-    <input type="text" name="target" placeholder="@naam@server.social  •  server.social" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('blk.block_btn') %></button>
-  </form>
-
-  <% if (!blocks || !blocks.length) { %>
-    <p class="bl-empty"><%= t('blk.empty') %></p>
-  <% } else { %>
-    <ul class="bl-list">
-      <% blocks.forEach(function(b){ %>
-        <li class="bl-item">
-          <span class="bl-kind"><%= b.kind === 'domain' ? '🌐' : '👤' %></span>
-          <span class="bl-label"><%= b.label || b.target %></span>
-          <form method="post" action="/blocking/remove"><input type="hidden" name="target" value="<%= b.target %>"><button type="submit" class="bl-unblock"><%= t('blk.unblock') %></button></form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-  .bl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .bl-title { margin: 0 0 .25rem; }
-  .bl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; }
-  .bl-form { display: flex; gap: .5rem; margin: 0 0 1.25rem; flex-wrap: wrap; }
-  .bl-form input { flex: 1; min-width: 220px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .bl-empty { color: var(--ink-soft, #888); }
-  .bl-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .5rem; }
-  .bl-item { display: flex; align-items: center; gap: .6rem; padding: .55rem .8rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent); }
-  .bl-kind { font-size: 1.05rem; }
-  .bl-label { flex: 1; min-width: 0; overflow-wrap: anywhere; }
-  .bl-unblock { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .bl-unblock:hover { color: var(--ink, #000); }
-</style>
Index: src/views/pages/changelog.ejs
===================================================================
--- src/views/pages/changelog.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,38 +1,0 @@
-<div class="container changelog-page">
-  <p><a href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/" class="btn">&larr; <%= t('chlog.back') %></a></p>
-  <h1><%= t('chlog.title') %></h1>
-  <p class="cl-meta">
-    <%= t('chlog.app_version') %> <strong>v<%= appVersion %></strong>
-    <% if (typeof user !== 'undefined' && user && user.role === 'god') { %>
-      <span class="cl-sep">·</span> <a href="/admin/updates"><%= t('chlog.manage_updates') %></a>
-    <% } %>
-  </p>
-  <div class="changelog-body"><%- changelogHtml %></div>
-</div>
-
-<style>
-.changelog-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
-.changelog-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.4rem; }
-.cl-meta { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.75rem; }
-.cl-meta a { color: var(--accent); }
-.cl-sep { opacity: 0.5; margin: 0 0.2rem; }
-.changelog-body { color: var(--ink); line-height: 1.65; }
-.changelog-body h2 {
-  font-family: var(--font-display, serif); font-size: 1.3rem;
-  margin: 2rem 0 0.5rem; padding-top: 1.25rem; border-top: 1px solid var(--rule);
-}
-.changelog-body h2:first-of-type { border-top: none; padding-top: 0; margin-top: 1rem; }
-.changelog-body h3 { font-size: 1rem; margin: 1.1rem 0 0.4rem; color: var(--ink-soft, var(--ink-muted)); }
-.changelog-body ul { margin: 0.3rem 0 0.9rem; padding-left: 1.3rem; }
-.changelog-body li { margin: 0.25rem 0; }
-.changelog-body blockquote {
-  margin: 1rem 0; padding: 0.6rem 0.9rem;
-  border-left: 3px solid var(--accent); background: var(--paper-2);
-  border-radius: 0 8px 8px 0; font-size: 0.9rem; color: var(--ink-muted);
-}
-.changelog-body code {
-  background: var(--paper-2); padding: 0.05rem 0.35rem;
-  border-radius: 4px; font-size: 0.88em;
-}
-.changelog-body a { color: var(--accent); }
-</style>
Index: src/views/pages/circle-feed.ejs
===================================================================
--- src/views/pages/circle-feed.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,135 +1,0 @@
-<div class="container cirkel-hero">
-  <span class="cirkel-badge" aria-hidden="true">
-    <svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
-      <defs>
-        <marker id="cf-a" viewBox="0 0 10 10" refX="6.5" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M1.5 1.5 L8.5 5 L1.5 8.5 Z" fill="currentColor" stroke="none"/></marker>
-      </defs>
-      <line x1="28.95" y1="43.5" x2="63.05" y2="26.5" stroke-width="2.6" marker-start="url(#cf-a)" marker-end="url(#cf-a)"/>
-      <line x1="72" y1="32" x2="72" y2="66" stroke-width="2.6" marker-end="url(#cf-a)"/>
-      <line x1="28.8" y1="52.7" x2="63.2" y2="71.3" stroke-width="2.6" marker-end="url(#cf-a)"/>
-      <circle cx="20" cy="48" r="7" stroke-width="2.4"/>
-      <circle cx="72" cy="22" r="7" stroke-width="2.4"/>
-      <circle cx="72" cy="76" r="7" stroke-width="2.4"/>
-    </svg>
-  </span>
-  <h1><%= t('cfeed.title') %></h1>
-  <p class="cirkel-sub"><%= t('cfeed.sub') %></p>
-  <% if (typeof sites !== 'undefined' && sites && sites.length) { %>
-    <% if (sites.length <= 5) { %>
-    <div class="cirkel-members">
-      <% sites.forEach(function(s){ %>
-        <% if (s.url) { %><a class="cirkel-member" href="<%= s.url %>" target="_blank" rel="noopener" title="<%= s.name %>"><% } else { %><span class="cirkel-member" title="<%= s.name %>"><% } %>
-          <span class="cirkel-avatar"<% if (s.avatar) { %> style="background-image:url('<%= s.avatar %>');background-color:transparent"<% } %>><% if (!s.avatar) { %><%= (s.name || '?').charAt(0).toUpperCase() %><% } %></span>
-          <span class="cirkel-member-name"><%= s.name %></span>
-        <% if (s.url) { %></a><% } else { %></span><% } %>
-      <% }); %>
-    </div>
-    <% } else { %>
-      <%# More than 5: the count pill becomes a button that opens a modal listing every
-          member (avatar + name + link). CSS-only via a hidden checkbox — no JS, so it's
-          CSP- (no inline handler) and htmx-safe. Checkbox must precede .cirkel-modal so
-          the `:checked ~` sibling selector matches. %>
-      <input type="checkbox" id="cirkel-more-cb" class="cirkel-more-cb" hidden>
-      <label for="cirkel-more-cb" class="cirkel-count cirkel-count-btn" role="button" tabindex="0"><%= t('cfeed.count', { n: sites.length }) %></label>
-      <div class="cirkel-modal" role="dialog" aria-modal="true" aria-label="<%= t('cfeed.title') %>">
-        <label for="cirkel-more-cb" class="cirkel-modal-backdrop" aria-label="<%= t('cfeed.close') %>"></label>
-        <div class="cirkel-modal-card">
-          <label for="cirkel-more-cb" class="cirkel-modal-x" aria-label="<%= t('cfeed.close') %>" role="button" tabindex="0">&times;</label>
-          <h2 class="cirkel-modal-h"><%= t('cfeed.count', { n: sites.length }) %></h2>
-          <ul class="cirkel-modal-list">
-            <% sites.forEach(function(s){ %>
-              <li>
-                <% if (s.url) { %><a class="cirkel-modal-link" href="<%= s.url %>" target="_blank" rel="noopener"><% } else { %><span class="cirkel-modal-link"><% } %>
-                  <span class="cirkel-avatar"<% if (s.avatar) { %> style="background-image:url('<%= s.avatar %>');background-color:transparent"<% } %>><% if (!s.avatar) { %><%= (s.name || '?').charAt(0).toUpperCase() %><% } %></span>
-                  <span class="cirkel-member-name"><%= s.name %></span>
-                  <% if (s.url) { %><span class="cirkel-modal-go" aria-hidden="true">↗</span><% } %>
-                <% if (s.url) { %></a><% } else { %></span><% } %>
-              </li>
-            <% }); %>
-          </ul>
-        </div>
-      </div>
-    <% } %>
-  <% } %>
-</div>
-
-<% if (!posts.length) { %>
-  <div class="container">
-    <p class="cirkel-empty"><%= t('cfeed.empty') %></p>
-  </div>
-<% } else { %>
-
-  <%# Same view as the home page: timeline (post-card) + grid (post-tile),
-      switched by the view-switcher via body[data-feed-view]. The remote posts
-      are shaped to the local post format in circle.js (with external_url,
-      so cards link to the source site). %>
-
-  <!-- ===== TIMELINE (standaard) ===== -->
-  <div class="container feed-timeline">
-    <ul class="post-list feed-timeline-list" id="post-list">
-      <% posts.forEach(function(post){ %>
-        <%- include('../partials/post-card', { post: post }) %>
-      <% }); %>
-    </ul>
-  </div>
-
-  <!-- ===== GRID (body[data-feed-view="grid"]) ===== -->
-  <section class="feed-grid container" aria-label="<%= t('cfeed.grid_view') %>">
-    <div class="grid-tiles" id="grid-tiles">
-      <% posts.forEach(function(post){ %>
-        <%- include('../partials/post-tile', { post: post }) %>
-      <% }); %>
-    </div>
-  </section>
-
-  <% if (typeof hasMore !== 'undefined') { %>
-  <div class="container feed-more-wrap">
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: '/cirkel' }) %>
-  </div>
-  <% } %>
-
-<% } %>
-
-<style>
-.cirkel-hero { text-align: center; max-width: 640px; margin: 2.5rem auto 2rem; }
-.cirkel-badge { display: block; width: 100px; height: 100px; margin: 0 auto .7rem; color: var(--accent);
-  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 65%, transparent)) drop-shadow(0 0 1px var(--accent)); }
-.cirkel-badge svg { width: 100%; height: 100%; }
-.cirkel-hero h1 { font-family: var(--font-display, serif); font-size: 2.4rem; margin: 0 0 .3rem; }
-.cirkel-sub { color: var(--ink-muted); margin: 0 0 1.25rem; font-size: 1.02rem; }
-.cirkel-members { display: flex; flex-wrap: wrap; gap: .55rem; justify-content: center; }
-.cirkel-member { display: inline-flex; align-items: center; gap: .45rem;
-  padding: .28rem .8rem .28rem .3rem; border: 1px solid var(--rule); border-radius: 999px;
-  text-decoration: none; color: var(--ink); background: var(--paper-2);
-  transition: border-color .15s, transform .15s; }
-.cirkel-member:hover { border-color: var(--accent); transform: translateY(-1px); }
-.cirkel-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
-  background-size: cover; background-position: center; background-color: var(--accent);
-  color: #fff; display: inline-flex; align-items: center; justify-content: center;
-  font-weight: 700; font-size: .82rem; }
-.cirkel-member-name { font-size: .9rem; font-weight: 600; }
-.cirkel-count { display: inline-block; color: var(--ink-muted); font-size: .92rem; font-weight: 600;
-  padding: .35rem 1rem; border: 1px solid var(--rule); border-radius: 999px; background: var(--paper-2); }
-.cirkel-count-btn { cursor: pointer; user-select: none; transition: border-color .15s, color .15s, transform .15s; }
-.cirkel-count-btn:hover { border-color: var(--accent); color: var(--ink); transform: translateY(-1px); }
-.cirkel-empty { color: var(--ink-muted); text-align: center; margin: 2rem auto; }
-.cirkel-empty a { color: var(--accent); }
-/* CSS-only "more members" modal (toggled by the hidden checkbox, no JS) */
-.cirkel-more-cb { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
-.cirkel-modal { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; padding: 1rem; }
-.cirkel-more-cb:checked ~ .cirkel-modal { display: flex; }
-.cirkel-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.45); cursor: pointer; }
-.cirkel-modal-card { position: relative; background: var(--paper); color: var(--ink); border-radius: 18px;
-  padding: 1.4rem 1.2rem; width: min(420px, 92vw); max-height: 78vh; overflow-y: auto; text-align: left;
-  box-shadow: 0 20px 60px rgba(0,0,0,.32); }
-.cirkel-modal-x { position: absolute; top: .55rem; right: .65rem; width: 34px; height: 34px; border-radius: 50%;
-  display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.45rem;
-  line-height: 1; color: var(--ink-muted); background: var(--paper-2); }
-.cirkel-modal-x:hover { color: var(--ink); }
-.cirkel-modal-h { font-family: var(--font-display, serif); font-size: 1.3rem; margin: .1rem 0 1rem; text-align: center; }
-.cirkel-modal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; }
-.cirkel-modal-link { display: flex; align-items: center; gap: .6rem; padding: .45rem .5rem; border-radius: 12px;
-  text-decoration: none; color: var(--ink); transition: background .15s; }
-.cirkel-modal-link:hover { background: var(--paper-2); }
-.cirkel-modal-go { margin-left: auto; color: var(--accent); font-weight: 700; }
-</style>
Index: src/views/pages/connect.ejs
===================================================================
--- src/views/pages/connect.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,198 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'connect' }) %>
-  <h1 class="tl-title"><%= t('tl.connect') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# FEP-633c §2 + §3.6: who guards this account, and how available each of
-      them is. Connect is the "who am I connected to" page, and a guardian is
-      the one connection a ward should never have to hunt for. Availability in
-      buddy-list language on the responsibility axis; the labels are UI, the
-      wire keeps the spec terms. Never public: this page is the owner's. %>
-  <% if (typeof myGuardians !== 'undefined' && myGuardians.length) { %>
-    <section class="conn-guardians">
-      <h2 class="conn-gtitle">&#128737; <%= t('msg.guardians_label') %></h2>
-      <ul class="conn-glist">
-        <% myGuardians.forEach(function(g){
-             var _st = g.availability === 'away' ? 'away' : (g.availability === 'dormant' ? 'dormant' : 'active');
-             var _lbl = _st === 'away' ? t('msg.g_away', { date: g.awayUntil ? formatDate(g.awayUntil) : '?' })
-                      : (_st === 'dormant' ? t('msg.g_offline') : t('msg.g_available')); %>
-          <li class="conn-guardian">
-            <span class="conn-gdot conn-gdot--<%= _st %>" aria-hidden="true"></span>
-            <span class="conn-ghandle"><%= g.handle %></span>
-            <span class="conn-gstate"><%= _lbl %></span>
-          </li>
-        <% }); %>
-      </ul>
-    </section>
-  <% } %>
-
-  <% var _moved = (typeof movedTo !== 'undefined' && movedTo) ? movedTo : null; %>
-  <% if (_moved) { %>
-    <%# De uitgaande kant staat op slot (FEP-7628). Dat hoort te blijken voordat
-        je op een knop drukt, niet daarna uit een foutmelding. Lezen blijft wel
-        gewoon werken, dus de lijst hieronder staat er nog. %>
-    <div class="alert alert-error tl-moved">
-      <strong><%= t('tl.moved_title') %></strong>
-      <p><%= t('tl.moved_lead') %> <a href="<%= _moved %>"><%= _moved %></a></p>
-      <p class="form-hint"><%= t('tl.moved_hint') %></p>
-    </div>
-  <% } else { %>
-  <form method="post" action="/news/follow" class="tl-follow">
-    <input type="text" name="handle" placeholder="@naam@server.social of site.com" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('tl.follow_btn') %></button>
-    <label class="tl-follow-ab" title="<%= t('tl.autoboost_hint') %>"><input type="checkbox" name="auto_boost" value="1"> &#10024; <%= t('tl.autoboost_follow') %></label>
-  </form>
-  <% } %>
-
-  <%# Je volglijst meenemen staat sinds 14-8 op /admin/migrate, bij de rest van
-      de verhuizing (Robin: alle migratie-opties op de migrate-pagina). Hier
-      stond hij bovendien zonder enige opmaak: labels als losse tekst naast hun
-      invoerveld. Op die pagina erft hij de kaartopmaak. %>
-
-  <%# De eigenaarspoort (Robins wens, 18-8): volgverzoeken die wachten op
-      een beslissing van de eigenaar. Alleen buiten voogdij — een ward ziet
-      dit nooit, want daar beslissen de guardians. De poort zelf zit hier
-      ook: aan/uit op de plek waar de verzoeken staan. %>
-  <% if (!(myGuardians && myGuardians.length)) { %>
-    <form method="post" action="/connect/approve-followers" class="tl-foll-ab" title="<%= t('tl.approve_toggle_hint') %>">
-      <label><input type="checkbox" name="on" value="1" <%= (typeof approveFollowers !== 'undefined' && approveFollowers) ? 'checked' : '' %> data-autosubmit> &#128737; <%= t('tl.approve_toggle') %></label>
-    </form>
-  <% } %>
-  <% var folreqs = (typeof followRequests !== 'undefined' && followRequests) || []; %>
-  <% if (folreqs.length) { %>
-    <h2 class="conn-h"><%= t('tl.folreq_title') %></h2>
-    <p class="conn-unreach-lead"><%= t('tl.folreq_sub') %></p>
-    <ul class="tl-foll-list">
-      <% folreqs.forEach(function (f) { var disp = f.follower_name || f.follower_handle || f.follower_uri; %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (f.follower_icon) { %><img src="<%= avatar(f.follower_icon, 96) %>" alt=""><% } else { %><%= String(disp || '?').replace(/^@/, '').charAt(0).toUpperCase() %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.follower_uri %>" target="_blank" rel="nofollow noopener"><%= disp %></a>
-            <% if (f.follower_handle && f.follower_name) { %><span class="conn-sub"><%= f.follower_handle %></span><% } %>
-          </span>
-          <form method="post" action="/follow-requests/approve"><input type="hidden" name="id" value="<%= f.id %>"><button type="submit" class="btn btn-primary"><%= t('tl.folreq_accept') %></button></form>
-          <form method="post" action="/follow-requests/deny"><input type="hidden" name="id" value="<%= f.id %>"><button type="submit" class="tl-unfollow"><%= t('tl.folreq_deny') %></button></form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <%
-    var conns = connections || [];
-    var reachable   = conns.filter(function (c) { return !c.unreachable; });
-    var unreachable = conns.filter(function (c) { return c.unreachable; });
-    // Display fields for one connection: name/handle (fallback: derive from actor_uri).
-    function connView(c) {
-      var host = '', user = '';
-      try { var u = new URL(c.actor_uri); host = u.hostname; user = (u.pathname.split('/').filter(Boolean).pop() || ''); } catch (e) {}
-      var handle = c.handle || (user ? ('@' + user + '@' + host) : c.actor_uri);
-      var display = c.name || handle;
-      return { handle: handle, display: display,
-               initial: String(display || '?').replace(/^@/, '').charAt(0).toUpperCase(),
-               profileUrl: c.url || c.actor_uri };
-    }
-    function arrow(dir) { return dir === 'mutual' ? '↔' : (dir === 'following' ? '→' : '←'); }
-  %>
-
-  <% if (reachable.length) { %>
-    <ul class="tl-foll-list">
-      <% reachable.forEach(function (c) { var v = connView(c); %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
-            <span class="conn-sub">
-              <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
-              <% if (c.follower && c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } %>
-            </span>
-          </span>
-          <% if (c.following) { %>
-            <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
-              <input type="hidden" name="actor_uri" value="<%= c.actor_uri %>">
-              <label><input type="checkbox" name="auto_boost" value="1" <%= c.auto_boost ? 'checked' : '' %> data-autosubmit> &#10024; <%= t('tl.autoboost') %></label>
-            </form>
-            <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= c.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
-          <% } %>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.connect_empty') %></p>
-  <% } %>
-
-  <% if (unreachable.length) { %>
-    <details class="conn-unreach">
-      <summary>&#9888; <%= t('tl.unreachable') %> (<%= unreachable.length %>)</summary>
-      <p class="conn-unreach-lead"><%= t('tl.unreachable_lead') %></p>
-      <ul class="tl-foll-list">
-        <% unreachable.forEach(function (c) { var v = connView(c); %>
-          <li class="tl-foll is-stale">
-            <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
-            <span class="tl-foll-meta">
-              <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
-              <span class="conn-sub">
-                <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
-                <% if (c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } else { %><span class="conn-never">&middot; <%= t('tl.never_delivered') %></span><% } %>
-                <% if (c.last_error_at) { %><span class="conn-err">&middot; <%= t('tl.delivery_failed') %>: <%= formatDateTime(c.last_error_at) %></span><% } %>
-              </span>
-            </span>
-            <% if (c.follower_id) { %>
-              <form method="post" action="/followers/<%= c.follower_id %>/remove" onsubmit="return confirm('<%= t('tl.remove_confirm') %>')">
-                <button type="submit" class="tl-unfollow"><%= t('tl.remove_follower') %></button>
-              </form>
-            <% } %>
-          </li>
-        <% }); %>
-      </ul>
-    </details>
-  <% } %>
-</div>
-
-<style>
-  /* Guardians (FEP-633c 3.6): the buddy-list dot on the responsibility axis.
-     Green available, yellow declared away with an end, grey observed dormant
-     (one answer restores). Set apart from the follow list on purpose: these
-     are not people you chose to follow, they are the ones looking after you. */
-  .conn-guardians { margin: 0 0 1.25rem; padding: .8rem 1rem; border-radius: 12px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
-    border-left: 3px solid #35a2ff; }
-  .conn-gtitle { margin: 0 0 .5rem; font-size: .95rem; }
-  .conn-glist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .35rem; }
-  .conn-guardian { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
-  .conn-gdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
-  .conn-gdot--active { background: #4caf7d; }
-  .conn-gdot--away { background: #e0a83a; }
-  .conn-gdot--dormant { background: #8a93a3; }
-  .conn-ghandle { font-weight: 600; }
-  .conn-gstate { color: var(--ink-soft, #888); font-size: .82rem; }
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .75rem; }
-  .tl-empty { color: var(--ink-soft, #888); }
-  .tl-follow { display: flex; gap: .5rem; margin: 0 0 1.25rem; flex-wrap: wrap; align-items: center; }
-  .tl-follow input[type="text"] { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .tl-follow-ab { font-size: .85rem; color: var(--ink-soft, #888); display: inline-flex; align-items: center; gap: .3rem; cursor: pointer; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll.is-stale { background: color-mix(in srgb, #c0392b 10%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-av img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .conn-sub { font-size: .78rem; color: var(--ink-soft, #888); display: inline-flex; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
-  .conn-dir { font-weight: 600; }
-  .conn-dir--mutual   { color: var(--accent, #06c); }
-  .conn-dir--following { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
-  .conn-dir--follower  { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
-  .conn-never, .conn-err { color: #c0392b; }
-  .tl-foll-ab { margin: 0; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-foll-ab label { display: inline-flex; align-items: center; gap: .25rem; cursor: pointer; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; white-space: nowrap; }
-  .tl-unfollow:hover { color: var(--ink, #000); }
-  .conn-unreach { margin: 1.25rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .75rem; }
-  .conn-unreach summary { cursor: pointer; font-weight: 600; color: #c0392b; }
-  .conn-unreach-lead { font-size: .8rem; color: var(--ink-soft, #888); margin: .5rem 0 .75rem; }
-</style>
Index: src/views/pages/download.ejs
===================================================================
--- src/views/pages/download.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,56 +1,0 @@
-<%
-  var _ready_h1     = t('dl.ready_title');
-  var _ready_sub    = t('dl.ready_sub');
-  var _manual       = t('dl.manual');
-  var _capture_sub  = t('dl.capture_sub');
-  var _email_ph     = t('dl.email_ph');
-  var _download_btn = t('dl.download_btn');
-%>
-<%
-  var tr = (typeof dlTrack !== 'undefined') ? dlTrack : {};
-  var st = (typeof dlState !== 'undefined') ? dlState : 'form';
-  var fileUrl = siteUrlBase + '/download/' + tr.id + '/bestand';
-%>
-<section class="dl">
-  <div class="dl-card">
-    <div class="dl-head">
-      <% if (tr.cover_url) { %><span class="dl-cover" style="background-image:url('<%= tr.cover_url %>')"></span><% } else { %><span class="dl-cover dl-cover-empty">♪</span><% } %>
-      <div>
-        <div class="dl-title"><%= tr.title %></div>
-        <% if (tr.artist) { %><div class="dl-artist"><%= tr.artist %></div><% } %>
-      </div>
-    </div>
-
-    <% if (st === 'ready') { %>
-      <h1 class="dl-h1"><%= _ready_h1 %></h1>
-      <p class="dl-sub"><%= _ready_sub %></p>
-      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
-      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
-    <% } else { %>
-      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
-      <p class="dl-sub"><%= _capture_sub %></p>
-      <% if (typeof dlError !== 'undefined' && dlError) { %><p class="dl-err"><%= dlError %></p><% } %>
-      <form method="POST" action="<%= siteUrlBase %>/download/<%= tr.id %>" class="dl-form">
-        <input type="email" name="email" required placeholder="<%= _email_ph %>" value="<%= (typeof dlPrefill!=='undefined')?dlPrefill:'' %>" autocomplete="email">
-        <button type="submit" class="dl-go">⬇ <%= _download_btn %></button>
-      </form>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .dl { max-width: 520px; margin: 0 auto; padding: 48px 18px; }
-  .dl-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 28px; }
-  .dl-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
-  .dl-cover { width: 56px; height: 56px; border-radius: 10px; background-size: cover; background-position: center; display: grid; place-items: center; flex: 0 0 auto; }
-  .dl-cover-empty { background: var(--accent,#6b8f71); color: #fff; opacity: .85; }
-  .dl-title { font-weight: 700; font-size: 17px; }
-  .dl-artist { opacity: .65; font-size: 13px; }
-  .dl-h1 { font-size: clamp(22px,4.5vw,30px); margin: 0 0 8px; }
-  .dl-sub { opacity: .85; line-height: 1.55; margin: 0 0 18px; }
-  .dl-err { color: #c43c3c; margin: 0 0 12px; }
-  .dl-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .dl-form input { flex: 1 1 200px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
-</style>
-<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
Index: src/views/pages/downloads.ejs
===================================================================
--- src/views/pages/downloads.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,44 +1,0 @@
-<%
-  var ts = (typeof dlTracks !== 'undefined') ? dlTracks : [];
-  // NB: de loop hieronder gebruikt 't' als track-variabele, die de vertaal-t()
-  // overschaduwt. Daarom het knoplabel hier al ophalen.
-  var _dlBtn = t('downloads.btn');
-%>
-<article class="container post-page">
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-  <section class="dls">
-  <h1 class="dls-h1"><%= t('downloads.title') %></h1>
-  <p class="dls-sub"><%= t('downloads.sub') %></p>
-
-  <% if (!ts.length) { %>
-    <p class="dls-empty"><%= t('downloads.empty') %></p>
-  <% } else { %>
-    <ul class="dls-list">
-      <% ts.forEach(function(t){ %>
-        <li class="dls-item">
-          <% if (t.cover_url) { %><span class="dls-cover" style="background-image:url('<%= t.cover_url %>')"></span><% } else { %><span class="dls-cover dls-cover-empty">♪</span><% } %>
-          <span class="dls-meta">
-            <span class="dls-title"><%= t.title %></span>
-            <% if (t.artist) { %><span class="dls-artist"><%= t.artist %></span><% } %>
-          </span>
-          <a class="dls-btn" href="<%= siteUrlBase %>/download/<%= t.id %>"><%= _dlBtn %></a>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-  </section>
-</article>
-
-<style>
-  .dls { padding: 0 0 64px; }
-  .dls-h1 { font-size: clamp(26px,5vw,38px); margin: 0 0 8px; }
-  .dls-sub { opacity: .8; margin: 0 0 24px; }
-  .dls-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
-  .dls-item { display: flex; align-items: center; gap: 14px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; }
-  .dls-cover { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 8px; background-size: cover; background-position: center; display: grid; place-items: center; }
-  .dls-cover-empty { background: var(--accent,#6b8f71); color: #fff; opacity: .85; }
-  .dls-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .dls-title { font-weight: 600; }
-  .dls-artist { font-size: 12.5px; opacity: .65; }
-  .dls-btn { flex: 0 0 auto; padding: 9px 15px; border-radius: 999px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; font-size: 13.5px; }
-</style>
Index: src/views/pages/embed-player.ejs
===================================================================
--- src/views/pages/embed-player.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,104 +1,0 @@
-<!DOCTYPE html>
-<html lang="nl">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title><%= site.title %> — speler</title>
-<style>
-  :root { --ac: <%= (site && site.accent) ? site.accent : '#6b8f71' %>; }
-  * { box-sizing: border-box; }
-  html, body { margin: 0; }
-  body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: #14161a; color: #f2f3f5; }
-  .ep { display: flex; flex-direction: column; height: 100vh; }
-  .ep-now { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.08); }
-  .ep-play { flex: 0 0 auto; width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--ac); color: #fff; font-size: 18px; cursor: pointer; display: grid; place-items: center; }
-  .ep-meta { flex: 1 1 auto; min-width: 0; }
-  .ep-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .ep-artist { font-size: 12px; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .ep-bar { height: 4px; background: rgba(255,255,255,.12); cursor: pointer; }
-  .ep-fill { height: 100%; width: 0; background: var(--ac); }
-  .ep-list { flex: 1 1 auto; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
-  .ep-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.05); }
-  .ep-item:hover { background: rgba(255,255,255,.05); }
-  .ep-item.on { color: var(--ac); }
-  .ep-num { width: 20px; text-align: right; opacity: .5; font-size: 13px; font-variant-numeric: tabular-nums; }
-  .ep-it-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
-  .ep-dur { opacity: .5; font-size: 12px; font-variant-numeric: tabular-nums; }
-  .ep-foot { padding: 8px 14px; font-size: 11px; opacity: .5; text-align: center; border-top: 1px solid rgba(255,255,255,.08); }
-  .ep-foot a { color: inherit; }
-  .ep-empty { padding: 24px; text-align: center; opacity: .6; }
-  /* Slim, themed scrollbar for the track list (instead of the chunky default). */
-  .ep-list { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.28) transparent; }
-  .ep-list::-webkit-scrollbar { width: 8px; }
-  .ep-list::-webkit-scrollbar-track { background: transparent; }
-  .ep-list::-webkit-scrollbar-thumb { background-color: rgba(255,255,255,.22); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
-  .ep-list::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--ac) 70%, #fff); }
-</style>
-</head>
-<body>
-<div class="ep">
-  <div class="ep-now">
-    <button class="ep-play" id="ep-play" aria-label="Afspelen">▶</button>
-    <div class="ep-meta">
-      <div class="ep-title" id="ep-title"><%= site.title %></div>
-      <div class="ep-artist" id="ep-artist">Selecteer een nummer</div>
-    </div>
-  </div>
-  <div class="ep-bar" id="ep-bar"><div class="ep-fill" id="ep-fill"></div></div>
-  <% if (!embedTracks.length) { %>
-    <div class="ep-empty">Geen nummers beschikbaar.</div>
-  <% } else { %>
-    <ul class="ep-list" id="ep-list">
-      <% embedTracks.forEach(function(t, i){ %>
-        <li class="ep-item" data-i="<%= i %>">
-          <span class="ep-num"><%= i+1 %></span>
-          <span class="ep-it-title"><%= t.title %><% if (t.artist) { %> — <span style="opacity:.6"><%= t.artist %></span><% } %></span>
-          <span class="ep-dur" data-dur="<%= t.duration || 0 %>"></span>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-  <div class="ep-foot">via <a href="<%= siteUrlBase %>/" target="_blank" rel="noopener"><%= site.title %></a> · Klonkt</div>
-</div>
-<audio id="ep-audio" preload="none" playsinline></audio>
-<script>
-(function(){
-  var TRACKS = <%- JSON.stringify(embedTracks) %>;
-  var audio = document.getElementById('ep-audio');
-  var playBtn = document.getElementById('ep-play');
-  var titleEl = document.getElementById('ep-title');
-  var artistEl = document.getElementById('ep-artist');
-  var fill = document.getElementById('ep-fill');
-  var bar = document.getElementById('ep-bar');
-  var list = document.getElementById('ep-list');
-  var cur = -1;
-  function fmt(s){ s = parseInt(s,10)||0; var m=Math.floor(s/60), x=s%60; return m+':'+String(x).padStart(2,'0'); }
-  // Vul de duur-labels.
-  document.querySelectorAll('.ep-dur').forEach(function(el){ var d=parseInt(el.getAttribute('data-dur'),10)||0; if(d) el.textContent=fmt(d); });
-  function mark(){ document.querySelectorAll('.ep-item').forEach(function(li){ li.classList.toggle('on', parseInt(li.getAttribute('data-i'),10)===cur); }); }
-  function load(i, play){
-    if(i<0||i>=TRACKS.length) return;
-    cur=i; var t=TRACKS[i];
-    titleEl.textContent=t.title||'Naamloos';
-    artistEl.textContent=t.artist||'';
-    audio.src=t.url;
-    mark();
-    if(play){ audio.play().catch(function(){}); }
-  }
-  playBtn.addEventListener('click', function(){
-    if(cur<0){ load(0, true); return; }
-    if(audio.paused) audio.play().catch(function(){}); else audio.pause();
-  });
-  if(list) list.addEventListener('click', function(e){
-    var li=e.target.closest('.ep-item'); if(!li) return;
-    load(parseInt(li.getAttribute('data-i'),10), true);
-  });
-  audio.addEventListener('play', function(){ playBtn.textContent='⏸'; });
-  audio.addEventListener('pause', function(){ playBtn.textContent='▶'; });
-  audio.addEventListener('timeupdate', function(){ if(audio.duration) fill.style.width=(audio.currentTime/audio.duration*100)+'%'; });
-  audio.addEventListener('ended', function(){ if(cur+1<TRACKS.length) load(cur+1, true); else { playBtn.textContent='▶'; fill.style.width='0'; } });
-  bar.addEventListener('click', function(e){ if(!audio.duration) return; var r=bar.getBoundingClientRect(); audio.currentTime=(e.clientX-r.left)/r.width*audio.duration; });
-})();
-</script>
-</body>
-</html>
Index: src/views/pages/epk.ejs
===================================================================
--- src/views/pages/epk.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,94 +1,0 @@
-<%
-  // Duur mm:ss
-  function fmtDur(s){ s = parseInt(s,10)||0; var m=Math.floor(s/60), x=s%60; return m+':'+String(x).padStart(2,'0'); }
-  var _accent = (site && site.accent) ? site.accent : '';
-  var _photo = (site && site.profile_photo) ? site.profile_photo : '';
-  var _isMail = epkContact && epkContact.indexOf('@') !== -1 && epkContact.indexOf(' ') === -1;
-  var _isUrl = epkContact && /^https?:\/\//i.test(epkContact);
-%>
-<section class="epk" <%= _accent ? ('style="--epk-accent:' + _accent + '"') : '' %>>
-  <header class="epk-hero">
-    <% if (_photo) { %><div class="epk-photo"><img src="<%= avatar(_photo, 320) %>" alt="<%= site.title %>"></div><% } %>
-    <div class="epk-hero-text">
-      <div class="epk-kicker"><%= t('epk.kicker') %><% if (typeof user !== 'undefined' && user && user.role === 'god') { %> <a class="epk-edit-link" href="/admin/epk">✎ <%= t('epk.edit') %></a><% } %></div>
-      <h1 class="epk-title"><%= site.title %></h1>
-      <% if (epkBio) { %><p class="epk-bio"><%= epkBio %></p><% } %>
-      <div class="epk-actions">
-        <% if (_isMail) { %>
-          <a class="epk-btn epk-btn-primary" href="mailto:<%= epkContact %>">✉ <%= t('epk.contact_booking') %></a>
-        <% } else if (_isUrl) { %>
-          <a class="epk-btn epk-btn-primary" href="<%= epkContact %>" target="_blank" rel="noopener"><%= t('epk.contact_booking') %></a>
-        <% } %>
-        <a class="epk-btn" href="<%= siteUrlBase %>/"><%= t('epk.view_site') %></a>
-        <% if (_photo) { %><a class="epk-btn" href="<%= _photo %>" download>⬇ <%= t('epk.press_photo') %></a><% } %>
-      </div>
-    </div>
-  </header>
-
-  <% if (epkTracks && epkTracks.length) { %>
-    <div class="epk-block">
-      <h2 class="epk-h2"><%= t('epk.most_played') %></h2>
-      <ul class="epk-tracks">
-        <% epkTracks.forEach(function(t, i){ %>
-          <li class="epk-track">
-            <span class="epk-rank"><%= i + 1 %></span>
-            <% if (t.cover_url) { %><span class="epk-cover" style="background-image:url('<%= t.cover_url %>')"></span><% } else { %><span class="epk-cover epk-cover-empty">♪</span><% } %>
-            <span class="epk-track-meta">
-              <span class="epk-track-title"><%= t.title %></span>
-              <% if (t.artist) { %><span class="epk-track-artist"><%= t.artist %></span><% } %>
-            </span>
-            <% if (t.duration) { %><span class="epk-track-dur"><%= fmtDur(t.duration) %></span><% } %>
-          </li>
-        <% }); %>
-      </ul>
-    </div>
-  <% } %>
-
-  <% if (epkPosts && epkPosts.length) { %>
-    <div class="epk-block">
-      <h2 class="epk-h2"><%= t('epk.recent') %></h2>
-      <ul class="epk-posts">
-        <% epkPosts.forEach(function(p){ %>
-          <li><a href="<%= siteUrlBase %>/<%= p.slug %>"><%= p.title %></a></li>
-        <% }); %>
-      </ul>
-    </div>
-  <% } %>
-
-  <footer class="epk-foot">
-    <% if (_isMail) { %><a href="mailto:<%= epkContact %>"><%= epkContact %></a> · <% } %>
-    <a href="<%= siteUrlBase %>/"><%= site.title %></a>
-  </footer>
-</section>
-
-<style>
-  .epk { max-width: 860px; margin: 0 auto; padding: 28px 18px 64px; --epk-accent: var(--accent, #6b8f71); }
-  .epk-hero { display: flex; gap: 26px; align-items: center; flex-wrap: wrap; margin-bottom: 40px; }
-  .epk-photo { flex: 0 0 auto; }
-  .epk-photo img { width: 190px; height: 190px; object-fit: cover; border-radius: 18px; box-shadow: 0 8px 30px rgba(0,0,0,.18); }
-  .epk-hero-text { flex: 1 1 280px; min-width: 240px; }
-  .epk-kicker { text-transform: uppercase; letter-spacing: .14em; font-size: 12px; font-weight: 700; color: var(--epk-accent); margin-bottom: 6px; }
-  .epk-edit-link { text-transform: none; letter-spacing: normal; font-weight: 600; font-size: 12px; margin-left: 8px; padding: 2px 8px; border: 1px solid rgba(128,128,128,.4); border-radius: 999px; color: inherit; opacity: .8; text-decoration: none; }
-  .epk-edit-link:hover { opacity: 1; border-color: var(--epk-accent); color: var(--epk-accent); }
-  .epk-title { font-size: clamp(30px, 6vw, 52px); line-height: 1.05; margin: 0 0 12px; }
-  .epk-bio { font-size: 16px; line-height: 1.6; opacity: .9; margin: 0 0 18px; max-width: 52ch; white-space: pre-line; }
-  .epk-actions { display: flex; gap: 10px; flex-wrap: wrap; }
-  .epk-btn { display: inline-block; padding: 9px 15px; border-radius: 999px; border: 1px solid rgba(128,128,128,.35); text-decoration: none; font-size: 13.5px; font-weight: 600; color: inherit; }
-  .epk-btn-primary { background: var(--epk-accent); border-color: var(--epk-accent); color: #fff; }
-  .epk-block { margin: 34px 0; }
-  .epk-h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .1em; opacity: .6; margin: 0 0 14px; }
-  .epk-tracks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .epk-track { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border: 1px solid rgba(128,128,128,.18); border-radius: 12px; }
-  .epk-rank { flex: 0 0 auto; width: 20px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; opacity: .45; }
-  .epk-cover { flex: 0 0 auto; width: 42px; height: 42px; border-radius: 8px; background-size: cover; background-position: center; display: grid; place-items: center; }
-  .epk-cover-empty { background: var(--epk-accent); color: #fff; opacity: .85; }
-  .epk-track-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .epk-track-title { font-weight: 600; }
-  .epk-track-artist { font-size: 12.5px; opacity: .65; }
-  .epk-track-dur { font-variant-numeric: tabular-nums; opacity: .6; font-size: 13px; }
-  .epk-posts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
-  .epk-posts a { text-decoration: none; color: inherit; border-bottom: 1px solid transparent; }
-  .epk-posts a:hover { border-bottom-color: var(--epk-accent); }
-  .epk-foot { margin-top: 44px; padding-top: 18px; border-top: 1px solid rgba(128,128,128,.2); font-size: 13px; opacity: .7; }
-  .epk-foot a { color: inherit; }
-</style>
Index: src/views/pages/fan-gate.ejs
===================================================================
--- src/views/pages/fan-gate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,60 +1,0 @@
-<div class="container fg-navwrap">
-  <%# Same post navigation (Newer/Older) as on a regular post, so a visitor
-      on the fan gate can still browse rather than getting stuck. Same
-      dimensions as .post-page (720px / padding 0 1rem) for pixel-perfect alignment. %>
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-</div>
-
-<section class="fg">
-  <div class="fg-card">
-    <div class="fg-lock">🔒</div>
-    <h1 class="fg-h1"><%= t('fgate.title') %></h1>
-    <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %>
-    <p class="fg-sub"><%= t('fgate.sub') %></p>
-
-    <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth,
-        FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze
-        site? -- in plaats van "heb je hier een account", wat juist de mensen
-        buitensloot voor wie de poort openstond. Geen account hier, geen
-        wachtwoord hier, geen cookie van een derde. %>
-    <form class="fg-owa" method="post" action="/owa/login">
-      <input type="hidden" name="next" value="<%= fgNext || '/' %>">
-      <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label>
-      <div class="fg-row">
-        <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email"
-               autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required>
-        <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button>
-      </div>
-      <p class="fg-hint"><%= t('fgate.owa_hint') %></p>
-      <% if (typeof owaError !== 'undefined' && owaError) { %>
-        <p class="fg-error"><%= t('fgate.owa_failed') %></p>
-      <% } %>
-    </form>
-
-    <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een
-        account heeft, en dat is de uitzondering. %>
-    <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
-  </div>
-</section>
-
-<style>
-  /* EXACT same class combination (.container + override) and values as
-     .post-page in post.ejs, so the post-nav aligns identically in the cascade. */
-  .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
-  .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .fg-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 36px 28px; }
-  .fg-lock { font-size: 40px; margin-bottom: 8px; }
-  .fg-h1 { font-size: 26px; margin: 0 0 6px; }
-  .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; }
-  .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; }
-  .fg-owa { margin: 0 0 18px; text-align: left; }
-  .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; }
-  .fg-row { display: flex; gap: 8px; flex-wrap: wrap; }
-  .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px;
-              border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
-              font-family: inherit; font-size: 1rem; }
-  .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; }
-  .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; }
-  .fg-alt { margin: 0; font-size: .85rem; opacity: .7; }
-</style>
Index: src/views/pages/fedi-notifications.ejs
===================================================================
--- src/views/pages/fedi-notifications.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,86 +1,0 @@
-<div class="nt-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'meldingen' }) %>
-  <h1 class="nt-title"><%= t('notif.title') %></h1>
-  <% if (!items || !items.length) { %>
-    <p class="nt-empty"><%= t('notif.empty') %></p>
-  <% } else { %>
-    <ul class="nt-list">
-      <% items.forEach(function(n){
-           var _ic = n.type === 'follow' ? 'follow' : (n.type === 'like' ? 'like' : (n.type === 'announce' ? 'boost' : (n.type === 'report' ? 'report' : (n.type === 'mention' ? 'mention' : 'reply'))));
-      %>
-        <li class="nt-item nt-<%= _ic %>">
-          <span class="nt-icon" aria-hidden="true">
-            <% if (_ic === 'like') { %><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-            <% } else if (_ic === 'boost') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
-            <% } else if (_ic === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
-            <% } else if (_ic === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
-            <% } else if (_ic === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>
-            <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %>
-          </span>
-          <div class="nt-body">
-            <div class="nt-line">
-              <a class="nt-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%= n.name || n.handle %></a>
-              <% if (n.handle && n.name) { %><span class="nt-handle"><%= n.handle %></span><% } %>
-              <% if (n.created_at) { %><span class="nt-time"><%= formatDateTime(n.created_at) %></span><% } %>
-            </div>
-            <div class="nt-what">
-              <% if (n.type === 'follow') { %><%= t('notif.followed') %>
-              <% } else if (n.type === 'like') { %><%= t('notif.liked') %>
-              <% } else if (n.type === 'announce') { %><%= t('notif.boosted') %>
-              <% } else if (n.type === 'report') { %><%= t('notif.reported') %>
-              <% } else if (n.type === 'mention') { %><%= t('notif.mentioned') %>
-              <% } else { %><%= t('notif.replied') %><% } %>
-              <% if (n.post_slug) { %><a class="nt-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
-              <% if (n.type === 'mention' && n.note_url) { %><a class="nt-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-            </div>
-            <% if (n.type === 'report') { %>
-              <% if (n.content) { %><div class="nt-content"><%= n.content %></div><% } else { %><div class="nt-content nt-noreason"><%= t('notif.report_noreason') %></div><% } %>
-              <% if (n.objects && n.objects.length) { %>
-                <div class="nt-report-about">
-                  <% n.objects.forEach(function (o) { %>
-                    <a class="nt-post" href="/<%= o.slug %>"><%= t('notif.report_about') %>: <%= o.title %></a>
-                  <% }); %>
-                </div>
-              <% } %>
-            <% } else if ((n.type === 'reply' || n.type === 'mention') && n.content) { %><div class="nt-content"><%- n.content %></div><% } %>
-          </div>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-  .nt-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .fedi-back { margin: 0 0 1.25rem; }
-  .nt-title { margin: 0 0 1.25rem; }
-  .nt-empty { color: var(--ink-soft, #888); }
-  .nt-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .6rem; }
-  .nt-item { display: flex; gap: .85rem; padding: .9rem 1rem; border-radius: 14px; align-items: flex-start;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .nt-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 30%, transparent);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .nt-icon { flex: 0 0 38px; width: 38px; height: 38px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center; }
-  .nt-icon svg { width: 18px; height: 18px; }
-  .nt-report-about { margin-top: .35rem; display: flex; flex-direction: column; gap: .15rem; }
-  .nt-noreason { font-style: italic; opacity: .7; }
-  .nt-like .nt-icon { background: color-mix(in srgb, #e8b04b 16%, transparent); color: #e8b04b; }
-  .nt-boost .nt-icon { background: color-mix(in srgb, #2fa85a 16%, transparent); color: #2fa85a; }
-  .nt-follow .nt-icon, .nt-reply .nt-icon { background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); }
-  .nt-report .nt-icon { background: color-mix(in srgb, #c0392b 16%, transparent); color: #c0392b; }
-  .nt-mention .nt-icon { background: color-mix(in srgb, #8e6cf0 16%, transparent); color: #8e6cf0; }
-  .nt-body { flex: 1; min-width: 0; }
-  .nt-line { display: flex; align-items: baseline; gap: .4rem; }
-  .nt-who { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .nt-who:hover { text-decoration: underline; }
-  .nt-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .nt-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .nt-what { color: var(--ink-soft, #aaa); margin-top: .12rem; line-height: 1.4; }
-  .nt-post { color: var(--accent, #06c); font-weight: 600; }
-  .nt-content { margin: .45rem 0 0; padding: .55rem .75rem; border-radius: 10px; line-height: 1.5;
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, inherit); overflow-wrap: anywhere; }
-  .nt-content p { margin: .2rem 0; } .nt-content p:first-child { margin-top: 0; } .nt-content p:last-child { margin-bottom: 0; }
-</style>
Index: src/views/pages/followers.ejs
===================================================================
--- src/views/pages/followers.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,58 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'followers' }) %>
-  <h1 class="tl-title"><%= t('tl.followers') %><% if (followers && followers.length) { %> (<%= followers.length %>)<% } %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <p class="tl-lead"><%= t('tl.followers_lead') %></p>
-
-  <% if (followers && followers.length) { %>
-    <ul class="tl-foll-list">
-      <% followers.forEach(function(f){
-           var host='', user='';
-           try { var u = new URL(f.actor_uri); host = u.hostname; user = (u.pathname.split('/').filter(Boolean).pop() || ''); } catch (e) {}
-           var handle = user ? ('@' + user + '@' + host) : f.actor_uri;
-           var stale = !f.last_delivery_at || (f.last_error_at && (!f.last_delivery_at || f.last_error_at > f.last_delivery_at));
-      %>
-        <li class="tl-foll<%= stale ? ' is-stale' : '' %>">
-          <span class="tl-foll-av"><%= (user || host || '?').charAt(0).toUpperCase() %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= handle %></a>
-            <span class="tl-foll-deliv">
-              <% if (f.last_delivery_at) { %>
-                <%= t('tl.last_delivery') %>: <%= formatDateTime(f.last_delivery_at) %>
-              <% } else { %>
-                <span class="tl-foll-never"><%= t('tl.never_delivered') %></span>
-              <% } %>
-              <% if (f.last_error_at) { %><span class="tl-foll-err">· <%= t('tl.delivery_failed') %>: <%= formatDateTime(f.last_error_at) %></span><% } %>
-            </span>
-          </span>
-          <form method="post" action="/followers/<%= f.id %>/remove" onsubmit="return confirm('<%= t('tl.remove_confirm') %>')">
-            <button type="submit" class="tl-unfollow"><%= t('tl.remove_follower') %></button>
-          </form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.empty_followers') %></p>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .25rem; }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; font-size: .9rem; line-height: 1.5; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll.is-stale { background: color-mix(in srgb, #c0392b 10%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .tl-foll-deliv { color: var(--ink-soft, #888); font-size: .78rem; }
-  .tl-foll-never { color: #c0392b; font-weight: 600; }
-  .tl-foll-err { color: #c0392b; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; white-space: nowrap; }
-  .tl-unfollow:hover { color: #c0392b; border-color: #c0392b; }
-  .tl-empty { color: var(--ink-soft, #888); }
-</style>
Index: src/views/pages/following.ejs
===================================================================
--- src/views/pages/following.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,81 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'following' }) %>
-  <h1 class="tl-title"><%= t('tl.tab_following') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <div class="tl-explain">
-    <p><span class="tl-explain-tag">✨ <%= t('tl.autoboost') %></span> — <%= t('tl.autoboost_hint') %></p>
-  </div>
-
-  <form method="post" action="/news/follow" class="tl-follow">
-    <input type="text" name="handle" placeholder="@naam@server.social of site.com" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('tl.follow_btn') %></button>
-    <label class="tl-follow-ab" title="<%= t('tl.autoboost_hint') %>"><input type="checkbox" name="auto_boost" value="1"> ✨ <%= t('tl.autoboost_follow') %></label>
-  </form>
-
-  <details class="tl-move">
-    <summary><%= t('tl.move_title') %></summary>
-    <p class="tl-move-hint"><%= t('tl.move_hint') %></p>
-    <p><a class="btn" href="/news/following.csv" download>⬇ <%= t('tl.move_export') %></a></p>
-    <form method="post" action="/news/following/import">
-      <label class="tl-move-lbl" for="tl-move-csv"><%= t('tl.move_import_lbl') %></label>
-      <textarea id="tl-move-csv" name="csv" rows="5" spellcheck="false"
-                placeholder="Account address,Show boosts,Notify on new posts,Languages,Featured"></textarea>
-      <button type="submit" class="btn btn-primary"><%= t('tl.move_import') %></button>
-    </form>
-  </details>
-
-  <% if (following && following.length) { %>
-    <h2 class="tl-sub"><%= t('tl.following') %> (<%= following.length %>)</h2>
-    <ul class="tl-foll-list">
-      <% following.forEach(function(f){ %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= avatar(f.icon, 96) %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.url || f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= f.name || f.handle %></a>
-            <span class="tl-foll-handle"><%= f.handle %></span>
-            <% if (f.status === 'pending') { %><span class="tl-pending"><%= t('tl.pending') %></span><% } %>
-          </span>
-          <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' : '' %> 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>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.empty_following') %></p>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .25rem; }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; }
-  .tl-explain { margin: 0 0 1.25rem; padding: .85rem 1rem; border-radius: 12px;
-    background: color-mix(in srgb, var(--accent, #888) 8%, transparent);
-    border: 1px solid color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-  .tl-explain p { margin: .3rem 0; font-size: .85rem; color: var(--ink-soft, #888); line-height: 1.5; }
-  .tl-explain-tag { font-weight: 600; color: var(--ink, inherit); white-space: nowrap; }
-  .tl-sub { font-size: 1.1rem; margin: 1.5rem 0 .75rem; }
-  .tl-follow { display: flex; gap: .5rem; margin: 0 0 1rem; flex-wrap: wrap; align-items: center; }
-  .tl-follow input[type="text"] { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .tl-follow-ab { font-size: .85rem; color: var(--ink-soft, #888); display: inline-flex; align-items: center; gap: .3rem; cursor: pointer; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-av img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .tl-foll-handle { color: var(--ink-soft, #888); font-size: .82rem; }
-  .tl-pending { font-size: .72rem; color: var(--ink-soft, #999); }
-  .tl-foll-ab { margin: 0; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-foll-ab label { display: inline-flex; align-items: center; gap: .25rem; cursor: pointer; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-unfollow:hover { color: var(--ink, #000); }
-  .tl-empty { color: var(--ink-soft, #888); }
-</style>
Index: src/views/pages/guardian.ejs
===================================================================
--- src/views/pages/guardian.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,107 +1,0 @@
-<!DOCTYPE html>
-<html lang="<%= lang %>">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title><%= t('guardian.app_name') %></title>
-  <link rel="manifest" href="/guardian/manifest.webmanifest">
-  <link rel="icon" href="/guardian/icon.svg" type="image/svg+xml">
-  <meta name="theme-color" content="#ff6b35">
-  <link rel="stylesheet" href="/guardian/app.css">
-</head>
-<body>
-  <header class="g-head">
-    <span class="g-buoy">🛟</span>
-    <div class="g-head-txt">
-      <h1><%= t('guardian.app_name') %></h1>
-      <p class="g-sub"><%= t('guardian.tagline') %></p>
-    </div>
-    <% if (sites.length > 1) { %>
-    <select id="site-picker" aria-label="<%= t('guardian.acting_as') %>">
-      <% for (const s of sites) { %>
-      <option value="<%= s.slug %>" <%= s.slug === state.site ? 'selected' : '' %>>@<%= s.slug %></option>
-      <% } %>
-    </select>
-    <% } else { %>
-    <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
-    <% } %>
-</header>
-
-  <main>
-    <!-- 1. Hulpverzoeken, uitgelicht over alle wards heen. Dat is waar dit voor
-            bestaat, en het moet opvallen zonder dat je eerst een kind opent.
-            De volledige geschiedenis per kind staat in dat kind zijn paneel. -->
-    <section id="help-section">
-      <div class="g-sec-head">
-        <h2><%= t('guardian.help_title') %></h2>
-        <span id="help-count" class="g-badge" hidden></span>
-      </div>
-      <p class="g-sec-sub"><%= t('guardian.help_sub') %></p>
-      <div id="help-list" class="g-list"></div>
-      <p id="help-empty" class="g-empty"><%= t('guardian.help_empty') %></p>
-    </section>
-
-    <!-- 2. Nieuwe ward adopteren. -->
-    <section id="adopt-section">
-      <div class="g-sec-head"><h2><%= t('guardian.adopt_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.adopt_sub') %></p>
-      <form id="adopt-form" autocomplete="off">
-        <input id="adopt-handle" type="text" inputmode="email" autocomplete="off"
-               placeholder="@kind@server.eu" aria-label="<%= t('guardian.adopt_label') %>">
-        <button type="submit" id="adopt-btn"><%= t('guardian.adopt_btn') %></button>
-      </form>
-      <p id="adopt-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 3. Verzonden aanvragen (nog niet beantwoord). -->
-    <section id="pending-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian.pending_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.pending_sub') %></p>
-      <div id="pending-list" class="g-list"></div>
-    </section>
-
-    <!-- 4. Mijn wards. Elk kind is een regel die opent naar een paneel met
-            alles over dat kind: instellingen, volgverzoeken, hulpvragen en
-            recente berichten. Een guardian denkt per kind, niet per functie. -->
-    <section id="wards-section">
-      <div class="g-sec-head"><h2><%= t('guardian.wards_title') %></h2></div>
-      <div id="wards-list" class="g-list"></div>
-      <p id="wards-empty" class="g-empty"><%= t('guardian.wards_empty') %></p>
-    </section>
-
-    <!-- 4b. Even afwezig (FEP-633c 3.6.1): de verantwoordelijke handeling.
-            Wie zich afmeldt telt niet mee in de besluiten, en een enkel
-            antwoord brengt je meteen terug. Alleen zichtbaar met wards. -->
-    <section id="away-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian.away_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.away_sub') %></p>
-      <div class="g-away-row">
-        <button id="away-week" class="quiet small"><%= t('guardian.away_week') %></button>
-        <button id="away-month" class="quiet small"><%= t('guardian.away_month') %></button>
-      </div>
-      <p id="away-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 5. Meldingen. -->
-    <section id="push-section">
-      <div class="g-sec-head"><h2><%= t('guardian.push_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.push_sub') %></p>
-      <button id="push-toggle" class="quiet" data-on-label="<%= t('guardian.push_off') %>"
-              data-off-label="<%= t('guardian.push_on') %>"><%= t('guardian.push_on') %></button>
-      <p id="push-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 6. Het logboek (FEP-633c §4.2). Onderaan en ingeklapt, want hier vraagt
-         niets om een antwoord: het staat er zodat een besluit -- en vooral de
-         REDEN ervan -- ergens na te lezen is. Zonder dit merkte een ward een
-         weigering alleen doordat er iets uit een lijst verdween. -->
-    <section id="log-section">
-      <div class="g-sec-head"><h2><%= t('guardian.panel_history') %></h2></div>
-      <div id="g-log"></div>
-    </section>
-  </main>
-
-  <script type="application/json" id="guardian-state"><%- JSON.stringify(state) %></script>
-  <script src="/guardian/app.js" defer></script>
-</body>
-</html>
Index: src/views/pages/home.ejs
===================================================================
--- src/views/pages/home.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/home.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,5 +11,4 @@
 
 const allPosts = [...pinnedPosts, ...posts];
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 
@@ -18,18 +17,4 @@
     non-admin page. %>
 
-<%# FEP-7628 slice 3: this account moved. The signpost comes FIRST, before
-    any post: a visitor should not scroll a dead feed to find out we left.
-    typeof-guard because an old route may render this template for one
-    request right after a deploy (view cache fills on first hit). %>
-<% if (typeof movedTo !== 'undefined' && movedTo) { %>
-  <div class="container">
-    <aside class="moved-banner">
-      <p class="moved-banner-lead"><%= t('phome.moved_lead') %></p>
-      <a class="moved-banner-link" href="<%= movedTo %>" rel="me noopener"><%= movedToLabel || movedTo %></a>
-      <p class="moved-banner-hint"><%= t('phome.moved_hint') %></p>
-    </aside>
-  </div>
-<% } %>
-
 <!-- ========== TIMELINE ========== -->
 <div class="container feed-timeline">
@@ -37,12 +22,12 @@
   <% if (allPosts.length === 0) { %>
     <div class="empty">
-      <h1><%= t('phome.empty_title') %></h1>
-      <p><%= t('phome.empty_sub') %></p>
-      <% if (user && canMutate && permissions.canCreatePost(user, site)) { %>
+      <h1>Hier is het nog stil.</h1>
+      <p>Nog geen posts. Spannend.</p>
+      <% if (user && permissions.canCreatePost(user, site)) { %>
         <p style="margin-top:2rem">
-          <a class="btn" href="<%= _base %>/posts/new"
-             hx-get="<%= _base %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-             hx-push-url="<%= _base %>/posts/new" hx-indicator="#pcms-loading">
-            <%= t('phome.write_first') %> →
+          <a class="btn" href="/posts/new"
+             hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+             hx-push-url="/posts/new" hx-indicator="#pcms-loading">
+            Schrijf je eerste post →
           </a>
         </p>
@@ -50,5 +35,5 @@
     </div>
   <% } else { %>
-    <ul class="post-list feed-timeline-list" id="post-list">
+    <ul class="post-list feed-timeline-list">
       <% allPosts.forEach(function(post) { %>
         <%- include('../partials/post-card', { post: post }) %>
@@ -59,41 +44,8 @@
 </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') %>">
-  <%# Terug naar boven. Staat IN de HTML en niet in JavaScript, zodat hij er ook
-      is als de module niet laadt -- dan doet hij niets, maar hij springt ook niet
-      opeens in beeld. Verschijnt pas als je ver genoeg bent (mod/read.js). %>
-  <%# Een bericht omhoog of omlaag. Alleen in paginamodus zichtbaar (CSS). %>
-  <%# Twee echte balken over de volle breedte. Tikzones zonder zichtbaar element
-      werkten niet: de onderste strook ligt onder de mini-speler en de tabbalk,
-      en die vangen de tik voordat de leesstroom hem ziet. Een balk kan daar wel
-      bovenop. %>
-  <button type="button" class="read-bar read-bar--top" id="read-prev" aria-label="<%= t('read.prev') %>">
-    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>
-  </button>
-  <button type="button" class="read-bar read-bar--bottom" id="read-next-nav" aria-label="<%= t('read.next') %>">
-    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-  </button>
-  <button type="button" id="read-top" class="read-top" aria-label="<%= t('read.to_top') %>" title="<%= t('read.to_top') %>">
-    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
-  </button>
-  <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) { %>
-<section class="feed-grid container" aria-label="<%= t('phome.grid_view') %>">
-  <div class="grid-tiles" id="grid-tiles">
+<section class="feed-grid container" aria-label="Grid-weergave">
+  <div class="grid-tiles">
     <% allPosts.forEach(function(post) { %>
       <%- include('../partials/post-tile', { post: post }) %>
@@ -102,7 +54,2 @@
 </section>
 <% } %>
-<% if (typeof hasMore !== 'undefined' && allPosts.length > 0) { %>
-<div class="container feed-more-wrap">
-  <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: '/' }) %>
-</div>
-<% } %>
Index: src/views/pages/linkbio.ejs
===================================================================
--- src/views/pages/linkbio.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,44 +1,0 @@
-<%
-  var links = (typeof lbLinks !== 'undefined') ? lbLinks : [];
-  var _photo = (site && site.profile_photo) ? site.profile_photo : (typeof siteOwnerAvatar !== 'undefined' ? siteOwnerAvatar : '');
-  var _name = (site && (site.profile_name || site.title)) || '';
-  var _bio = (site && (site.profile_bio || site.tagline)) || '';
-%>
-<section class="lb">
-  <div class="lb-head">
-    <% if (_photo) { %><span class="lb-photo" style="background-image:url('<%= _photo %>')"></span><% } else { %><span class="lb-photo lb-photo-empty">♪</span><% } %>
-    <h1 class="lb-name"><%= _name %></h1>
-    <% if (_bio) { %><p class="lb-bio"><%= _bio %></p><% } %>
-  </div>
-
-  <% if (!links.length) { %>
-    <p class="lb-empty"><%= t('lbio.empty') %></p>
-  <% } else { %>
-    <div class="lb-links">
-      <% links.forEach(function(l){ %>
-        <a class="lb-link" href="<%= siteUrlBase %>/links/go/<%= l.i %>" target="_blank" rel="noopener" <%= l.brand ? ('style="--lb-brand:' + l.brand + '"') : '' %>>
-          <% if (l.svg) { %><span class="lb-ico"><%- l.svg %></span><% } %>
-          <span class="lb-label"><%= l.label %></span>
-        </a>
-      <% }); %>
-    </div>
-  <% } %>
-
-  <p class="lb-foot"><a href="<%= siteUrlBase %>/">← <%= t('lbio.back_to_site') %></a></p>
-</section>
-
-<style>
-  .lb { max-width: 480px; margin: 0 auto; padding: 40px 18px 64px; text-align: center; }
-  .lb-photo { display: inline-block; width: 96px; height: 96px; border-radius: 50%; background-size: cover; background-position: center; margin-bottom: 14px; }
-  .lb-photo-empty { background: var(--accent,#6b8f71); color: #fff; display: grid; place-items: center; font-size: 36px; }
-  .lb-name { font-size: 24px; margin: 0 0 6px; }
-  .lb-bio { opacity: .8; margin: 0 0 26px; }
-  .lb-links { display: flex; flex-direction: column; gap: 12px; }
-  .lb-link { display: flex; align-items: center; gap: 12px; justify-content: center; padding: 14px 18px; border-radius: 14px; border: 1px solid rgba(128,128,128,.28); text-decoration: none; color: inherit; font-weight: 600; transition: transform .08s ease, border-color .15s ease; }
-  .lb-link:hover { transform: translateY(-1px); border-color: var(--lb-brand, var(--accent,#6b8f71)); }
-  .lb-ico { width: 22px; height: 22px; display: inline-flex; color: var(--lb-brand, currentColor); }
-  .lb-ico svg { width: 22px; height: 22px; }
-  .lb-empty { opacity: .7; }
-  .lb-foot { margin-top: 30px; font-size: 13px; opacity: .6; }
-  .lb-foot a { color: inherit; }
-</style>
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,244 +1,0 @@
-<div class="msg-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
-  <h1 class="msg-title"><%= t('msg.title') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
-
-  <%# FEP-633c: a pending guardianship offer is a special message: the kid
-      answers here (accept/reject travels the same C2S pipeline as the apps). %>
-  <% if (typeof guardianOffers !== 'undefined' && guardianOffers.length) { %>
-    <% guardianOffers.forEach(function(o){ %>
-    <div class="alert" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;border-left:3px solid #ff6b35;">
-      <span style="font-size:1.4em;">&#128735;</span>
-      <div style="flex:1;min-width:200px;">
-        <strong><%= o['shaer:candidateHandle'] || o['shaer:candidate'] %></strong><br>
-        <span><%= t('msg.guard_offer') %></span>
-      </div>
-      <form method="post" action="<%= (typeof moreBase !== 'undefined' ? moreBase : '') %>/messages/guardianship" style="display:flex;gap:8px;">
-        <input type="hidden" name="offer" value="<%= o.id %>">
-        <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button>
-        <button class="btn" type="submit" name="answer" value="reject" style="opacity:.7;"><%= t('msg.guard_reject') %></button>
-      </form>
-    </div>
-    <% }); %>
-  <% } %>
-
-  <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
-    <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
-        een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
-        draad staan. Activiteit en Moderatie blijven wat ze waren. %>
-    <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
-    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
-    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
-    <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
-  </div>
-  <div class="msg-search">
-    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
-    <input type="search" id="msg-q" placeholder="<%= t('msg.search_ph') %>" autocomplete="off" spellcheck="false" aria-label="<%= t('msg.search_ph') %>">
-  </div>
-
-  <% var _seen = (typeof seenAt !== 'undefined' && seenAt) ? seenAt : 0; %>
-  <% if (!items || !items.length) { %>
-    <p class="msg-empty"><%= t('msg.empty') %></p>
-  <% } else { %>
-    <ul class="msg-list" data-show="all" id="msg-list">
-      <% items.forEach(function(n){ %><%- include('../partials/msg-item', { n: n, seen: _seen }) %><% }); %>
-    </ul>
-    <p class="msg-nomatch" hidden><%= t('msg.no_match') %></p>
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages' }) %>
-  <% } %>
-
-  <%# Interact bookmarklet — moved here from the old Reacties page. %>
-  <details class="msg-bm">
-    <summary><%= t('fedi.bm_label') %></summary>
-    <p class="msg-bm-help"><%= t('fedi.bm_help') %></p>
-    <a class="fedi-bm-btn" id="fedi-bm-btn" href="#" draggable="true" title="<%= t('fedi.bm_help') %>">
-      <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
-      <%= t('fedi.bm_label') %>
-    </a>
-  </details>
-</div>
-
-<%# 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>
-  .msg-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .msg-title { margin: 0 0 .9rem; }
-  .msg-empty { color: var(--ink-soft, #888); }
-
-  .msg-filters { display: flex; gap: .4rem; margin: 0 0 1.1rem; flex-wrap: wrap; }
-  .msg-chip { border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: none;
-    color: var(--ink-soft, #888); font: inherit; font-size: .84rem; font-weight: 600;
-    padding: .3rem .85rem; border-radius: 999px; cursor: pointer; }
-  .msg-chip.is-on { background: var(--accent, #06c); border-color: var(--accent, #06c); color: #fff; }
-
-  .msg-search { display: flex; align-items: center; gap: .5rem; margin: 0 0 1.1rem;
-    padding: .45rem .7rem; border-radius: 10px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); }
-  .msg-search:focus-within { border-color: var(--accent, #06c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #06c) 18%, transparent); }
-  .msg-search svg { width: 16px; height: 16px; color: var(--ink-soft, #888); flex-shrink: 0; }
-  .msg-search input { flex: 1; border: none; background: none; color: inherit; font: inherit; outline: none; min-width: 0; }
-  .msg-nomatch { color: var(--ink-soft, #888); text-align: center; padding: 1.2rem 0; }
-
-  .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
-
-
-  .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .msg-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 30%, transparent);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .msg-item.is-new { border-color: color-mix(in srgb, var(--accent, #06c) 45%, transparent);
-    background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
-  .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
-
-  /* ── Gesprekken ──────────────────────────────────────────────────────
-     NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
-     gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
-     draadkop NAAST de bubbels te staan in plaats van erboven. */
-  .msg-thread { display: block; }
-  .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
-  /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
-     kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
-     Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
-  /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
-     geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
-     systeemblauw in 13px. */
-  .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
-    font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
-    padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
-    color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
-    box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    max-width: 100%; overflow: hidden; white-space: nowrap;
-    /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
-       niet te raken. De uitsparing blijft klein, het doel wordt groter. */
-    min-height: 32px; }
-  .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
-  .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
-    transition: transform .18s ease; }
-  /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
-     naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
-     waard. */
-  .msg-thread.is-collapsed .msg-thread-msgs,
-  .msg-thread.is-collapsed .msg-thread-actions { display: none; }
-  /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
-  .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
-  .msg-thread.is-collapsed.is-search-open .msg-thread-actions { display: flex; }
-  .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
-  .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
-  @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
-  /* De zwaai in de kop: klein, en met dezelfde rust als het aantal ernaast.
-     Hij staat er ook als het gesprek open is -- dan zegt de bubbel het al, maar
-     een kop die van stand verandert leest als iets dat gebeurde. */
-  .msg-thread-wave { flex: 0 0 auto; font-size: .95em; line-height: 1; }
-  .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
-    padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-  .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
-    margin-inline-start: auto; min-width: 0; max-width: 100%;
-    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
-  .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
-    border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
-  .msg-sub > .msg-body { min-width: 0; }
-  /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
-     .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
-     kaart zodra de regel smaller werd dan zijn inhoud. */
-  .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
-    background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
-  /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
-  .msg-sub .msg-line { flex-wrap: wrap; }
-
-  /* Antwoorden en zwaaien naast elkaar, ingeklapt, en sinds 25-8 BOVEN de
-     berichten -- daar staat het nieuwste, en daar antwoord je dus op. Heette
-     msg-thread-foot toen het onderaan stond; de marge verhuisde mee van top
-     naar bottom, anders plakt de rij tegen de eerste bubbel.
-     Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
-     wat deze weergave moest oplossen. */
-  .msg-thread-actions { display: flex; align-items: flex-start; gap: .5rem; margin-bottom: .5rem; flex-wrap: wrap; }
-  .msg-reply { flex: 1 1 12rem; min-width: 0; }
-  .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
-  .msg-reply[open] > summary { margin-bottom: .4rem; }
-  .msg-reply-form { min-width: 0; }
-  .msg-wave button { line-height: 1.2; }
-
-  /* Zwaaien gebeurt ter plekke (mod/messages.js). Twee toestanden na de klik:
-     gelukt, dan vervangt de bevestiging de knoppen -- en mislukt, dan komen de
-     knoppen terug met een schud, zodat een tweede poging voor de hand ligt.
-     De bestaande klasse `nudge` kon hier niet voor dienen: die heeft alleen
-     CSS op authorize-interaction, dus op deze pagina zou hij niets doen. */
-  .msg-wave-done { font-size: .82rem; color: var(--ink-soft, #888); white-space: nowrap; }
-  .msg-wave.is-failed, .msg-quickreply.is-failed { animation: msg-wave-shake .4s ease; }
-  @keyframes msg-wave-shake {
-    0%, 100% { transform: translateX(0); }
-    25% { transform: translateX(-4px); }
-    75% { transform: translateX(4px); }
-  }
-  @media (prefers-reduced-motion: reduce) {
-    .msg-wave.is-failed, .msg-quickreply.is-failed { animation: none; outline: 2px solid var(--accent); }
-  }
-
-  @media (max-width: 560px) {
-    /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
-       al naast, en de inhoud heeft de breedte harder nodig. */
-    .msg-sub > .msg-av { display: none; }
-    .msg-thread-msgs { padding-inline-start: .55rem; }
-    .msg-thread-post { margin-inline-start: 0; }
-    .msg-item { padding: .7rem .6rem; gap: .6rem; }
-  }
-
-  .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center; font-weight: 700;
-    background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); overflow: visible; }
-  .msg-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; background: #fff; }
-  .msg-av-sent svg { width: 17px; height: 17px; }
-  .msg-dot { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center;
-    border: 2px solid var(--paper, #fff); background: var(--accent, #06c); color: #fff; }
-  .msg-dot svg { width: 10px; height: 10px; }
-  .msg-dot-like { background: #e8b04b; } .msg-dot-boost { background: #2fa85a; }
-  .msg-dot-report { background: #c0392b; } .msg-dot-mention { background: #8e6cf0; }
-
-  .msg-body { flex: 1; min-width: 0; }
-  .msg-line { display: flex; align-items: baseline; gap: .4rem; }
-  .msg-who { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  a.msg-who:hover { text-decoration: underline; }
-  .msg-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .msg-new { width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #06c); flex: 0 0 auto; align-self: center; }
-  .msg-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .msg-what { color: var(--ink-soft, #aaa); margin-top: .12rem; line-height: 1.4; }
-  .msg-what .msg-post { color: var(--accent, #06c); font-weight: 600; margin-left: .25rem; }
-  .msg-priv { display: inline-block; margin-left: .3rem; padding: .05rem .5rem; border-radius: 999px; font-size: .72rem; font-weight: 700;
-    background: color-mix(in srgb, #8e6cf0 14%, transparent); color: #8e6cf0; }
-  .msg-content { margin: .45rem 0 0; padding: .55rem .75rem; border-radius: 10px; line-height: 1.5;
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, inherit); overflow-wrap: anywhere; }
-  .msg-content p { margin: .2rem 0; } .msg-content p:first-child { margin-top: 0; } .msg-content p:last-child { margin-bottom: 0; }
-
-  .msg-poll { margin: .5rem 0 0; display: flex; flex-direction: column; gap: .4rem; }
-  .msg-poll-opt { display: flex; flex-direction: column; gap: .2rem; }
-  .msg-poll-top { display: flex; justify-content: space-between; gap: .5rem; font-size: .85rem; }
-  .msg-poll-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-poll-pct { font-variant-numeric: tabular-nums; color: var(--ink-soft, #888); flex-shrink: 0; }
-  .msg-poll-bar { height: 7px; border-radius: 999px; overflow: hidden; background: color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .msg-poll-bar span { display: block; height: 100%; border-radius: 999px; background: var(--accent, #06c); }
-  .msg-poll-total { font-size: .78rem; color: var(--ink-soft, #888); margin-top: .1rem; }
-
-  .msg-actions { display: flex; gap: .7rem; align-items: center; margin-top: .45rem; }
-  .msg-edit summary { cursor: pointer; font-size: .8rem; color: var(--ink-soft, #888); font-weight: 600; }
-  .msg-edit-form { margin-top: .4rem; display: flex; flex-direction: column; gap: .4rem; }
-  .msg-edit-form textarea { width: 100%; font: inherit; padding: .5rem .65rem; border-radius: 8px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
-  .msg-del { background: none; border: none; padding: 0; font: inherit; font-size: .8rem; font-weight: 600;
-    color: var(--ink-soft, #888); cursor: pointer; }
-  .msg-del:hover { color: #c0392b; }
-
-  .msg-bm { margin: 1.4rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .8rem; }
-  .msg-bm summary { cursor: pointer; color: var(--ink-soft, #888); font-weight: 600; font-size: .88rem; }
-  .msg-bm-help { color: var(--ink-soft, #888); font-size: .82rem; line-height: 1.5; margin: .5rem 0 .6rem; }
-</style>
Index: src/views/pages/my-site.ejs
===================================================================
--- src/views/pages/my-site.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,73 +1,0 @@
-<div class="container admin-page">
-  <p><a href="/" class="btn">&larr; <%= t('myst.overview') %></a></p>
-  <h1><%= t('myst.title') %></h1>
-  <p class="admin-tagline"><%= mySite.title %></p>
-
-  <nav class="admin-quick-links" aria-label="<%= t('myst.quick_links_aria') %>">
-    <a href="/user/<%= mySite.slug %>/posts/new" class="btn">✍️ <%= t('myst.new_post') %></a>
-    <a href="/admin/sites/<%= mySite.slug %>/edit" class="btn">🎨 <%= t('myst.appearance') %></a>
-    <a href="/user/<%= mySite.slug %>" class="btn">👁️ <%= t('myst.view_site') %></a>
-    <a href="/account" class="btn">👤 <%= t('myst.account') %></a>
-  </nav>
-
-  <div class="admin-stats">
-    <div class="stat-card"><div class="stat-num"><%= mine.posts %></div><div class="stat-label"><%= t('myst.posts') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= mine.published %></div><div class="stat-label"><%= t('myst.published') %></div></div>
-  </div>
-
-  <% if (typeof posts !== 'undefined' && posts && posts.length) { %>
-    <% var _drafts = posts.filter(function(p){ return p.isDraft; }).length; %>
-    <%
-      var _lblBadge = t('myst.draft_badge');
-      var _lblUntitled = t('myst.untitled');
-      var _lblEdit = t('myst.edit');
-      var _lblView = t('myst.view');
-    %>
-    <h2 class="ms-posts-title"><%= t('myst.posts') %><% if (_drafts) { %> <span class="pl-draftcount"><%= _drafts === 1 ? t('myst.draft_count_one', { n: _drafts }) : t('myst.draft_count_many', { n: _drafts }) %></span><% } %></h2>
-    <ul class="post-admin-list">
-      <% posts.forEach(function(p) { %>
-        <li class="pal-row<%= p.isDraft ? ' is-draft' : '' %>">
-          <a class="pal-title" href="<%= p.editUrl %>">
-            <% if (p.isDraft) { %><span class="pal-badge"><%= _lblBadge %></span><% } %>
-            <span class="pal-name"><%= p.title || _lblUntitled %></span>
-          </a>
-          <span class="pal-actions">
-            <a class="pal-link" href="<%= p.editUrl %>"><%= _lblEdit %></a>
-            <% if (!p.isDraft) { %><a class="pal-link" href="<%= p.viewUrl %>"><%= _lblView %></a><% } %>
-          </span>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-.admin-page { max-width: 1000px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.25rem; }
-.admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
-.admin-quick-links {
-  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
-  gap: 0.5rem; margin: 0 0 1.5rem; padding: 0;
-}
-.admin-quick-links .btn { justify-content: center; padding: 0.7rem 0.9rem; font-weight: 600; text-align: center; }
-.admin-quick-links .btn:hover { background: color-mix(in srgb, var(--accent) 8%, var(--paper-2)); border-color: var(--accent); }
-.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }
-.stat-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 8px; padding: 1rem 1.25rem; text-align: center; }
-.stat-num { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; font-family: var(--font-display, serif); }
-.stat-label { color: var(--ink-muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.25rem; }
-
-/* Posts/concepten-lijst */
-.ms-posts-title { font-family: var(--font-display, serif); font-size: 1.4rem; margin: 2rem 0 0.75rem; }
-.pl-draftcount { font-family: var(--font-ui, system-ui); font-size: 0.8rem; font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.55rem; border-radius: 99px; vertical-align: middle; }
-.post-admin-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
-.pal-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.9rem; border: 1px solid var(--rule); border-radius: 10px; background: var(--paper-2); transition: border-color 120ms; }
-.pal-row:hover { border-color: var(--accent); }
-.pal-row.is-draft { border-left: 3px solid var(--accent); }
-.pal-title { display: inline-flex; align-items: center; gap: 0.6rem; min-width: 0; color: var(--ink); text-decoration: none; font-weight: 600; }
-.pal-title:hover { color: var(--accent); }
-.pal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pal-badge { flex-shrink: 0; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.5rem; border-radius: 99px; }
-.pal-actions { display: flex; gap: 0.75rem; flex-shrink: 0; }
-.pal-link { color: var(--ink-muted); font-size: 0.85rem; text-decoration: none; white-space: nowrap; }
-.pal-link:hover { color: var(--accent); text-decoration: underline; }
-</style>
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,163 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'feed' }) %>
-  <div class="tl-titlebar">
-    <h1 class="tl-title"><%= t('tl.title') %></h1>
-    <button type="button" id="tl-paste-btn" class="tl-paste-btn" title="<%= t('fedi.remote_interact') %>" aria-label="<%= t('fedi.remote_interact') %>">
-      <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
-    </button>
-  </div>
-  <% if (typeof movedTo !== 'undefined' && movedTo) { %>
-    <%# Eén uitleg bovenaan in plaats van een tooltip per knop: een tooltip op een
-        uitgeschakelde knop is toch onzichtbaar. Lezen blijft werken, dus de Krant
-        zelf staat er gewoon onder. %>
-    <div class="alert alert-error tl-moved">
-      <strong><%= t('tl.moved_title') %></strong>
-      <p><%= t('tl.moved_lead') %> <a href="<%= movedTo %>"><%= movedTo %></a></p>
-      <p class="form-hint"><%= t('tl.moved_hint') %></p>
-    </div>
-  <% } %>
-  <p class="tl-lead"><%= t('tl.lead') %></p>
-
-  <div class="tl-paste" id="tl-paste">
-    <div class="tl-paste-card" role="dialog" aria-label="<%= t('fedi.remote_interact') %>">
-      <button type="button" class="tl-paste-x" aria-label="<%= t('fedi.cancel') %>">&times;</button>
-      <h3 class="tl-paste-title"><%= t('fedi.remote_interact') %></h3>
-      <input type="url" class="tl-paste-input" inputmode="url" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="<%= t('tl.paste_ph') %>">
-      <div class="tl-paste-actions">
-        <button type="button" class="tl-paste-cancel"><%= t('fedi.cancel') %></button>
-        <button type="button" class="tl-paste-go"><%= t('tl.paste_go') %></button>
-      </div>
-    </div>
-  </div>
-  <style>
-    .tl-titlebar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
-    .tl-boost-by { display: flex; align-items: center; gap: .35rem; font-size: .8rem; color: var(--ink-soft); margin: 0 0 .45rem .2rem; }
-    .tl-boost-by svg { color: #16a34a; flex: 0 0 auto; }
-    .tl-boost-by strong { color: var(--ink); font-weight: 600; }
-    .tl-paste-btn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px;
-      border: 1px solid var(--rule); border-radius: 999px; background: var(--paper-2); color: var(--ink-soft); cursor: pointer; transition: color .15s, border-color .15s, background .15s; }
-    .tl-paste-btn:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
-    .tl-paste { position: fixed; inset: 0; z-index: 2147483601; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,.55); }
-    .tl-paste.is-open { display: flex; }
-    .tl-paste-card { position: relative; width: 100%; max-width: 460px; background: var(--paper); color: var(--ink); border: 1px solid var(--rule);
-      border-radius: 16px; padding: 1.4rem 1.4rem 1.2rem; box-shadow: 0 18px 50px rgba(0,0,0,.4); }
-    .tl-paste-x { position: absolute; top: .5rem; right: .6rem; width: 2rem; height: 2rem; border: 0; border-radius: 999px; background: transparent; color: var(--ink-soft); font-size: 1.4rem; line-height: 1; cursor: pointer; }
-    .tl-paste-title { margin: 0 0 .8rem; font-size: 1.1rem; }
-    .tl-paste-input { width: 100%; box-sizing: border-box; padding: .6rem .8rem; border: 1.5px solid var(--rule); border-radius: 10px; background: var(--paper-2); color: var(--ink); font: inherit; font-size: .95rem; }
-    .tl-paste-input:focus { outline: none; border-color: var(--accent); }
-    .tl-paste-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
-    .tl-paste-actions button { padding: .5rem 1.1rem; border-radius: 999px; font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; border: 0; }
-    .tl-paste-cancel { background: transparent; border: 1.5px solid var(--rule); color: var(--ink); }
-    .tl-paste-go { background: var(--accent); color: var(--paper); }
-  </style>
-  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <% if (!timeline || !timeline.length) { %>
-    <div class="tl-empty">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-      <p><%= t('tl.empty') %></p>
-    </div>
-  <% } else { %>
-    <ol class="tl-feed" id="tl-feed">
-      <% timeline.forEach(function(p){ %>
-        <%- include('../partials/tl-item', { p: p }) %>
-      <% }); %>
-    </ol>
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news' }) %>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto 3rem; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .15rem; font-size: clamp(1.5rem, 4vw, 1.8rem); }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.5rem; }
-  .tl-empty { text-align: center; color: var(--ink-soft, #888); margin: 3rem auto; }
-  .tl-empty svg { width: 44px; height: 44px; color: color-mix(in srgb, var(--accent, #888) 60%, transparent); margin: 0 auto .75rem; display: block; }
-
-  .tl-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
-  .tl-item { padding: 1.1rem 1.15rem; border-radius: 16px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .tl-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 32%, transparent);
-    box-shadow: 0 2px 14px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-
-  .tl-head { display: flex; align-items: center; gap: .65rem; margin: 0 0 .65rem; }
-  .tl-avatar { flex: 0 0 44px; width: 44px; height: 44px; border-radius: 50%; overflow: hidden;
-    display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1.1rem;
-    background: color-mix(in srgb, var(--accent, #888) 22%, transparent); color: var(--accent, #555);
-    box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #888) 18%, transparent); }
-  .tl-avatar img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-id { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
-  .tl-author { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .tl-author:hover { text-decoration: underline; }
-  .tl-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; }
-
-  /* .tl-content, .tl-quote* and .tl-media* now live in partials/shared-styles,
-     next to partials/note-body: the markup is shared with Berichten and the
-     Guardian PWA, so the styling has to be too. What stays here is Krant-only. */
-  /* Long posts collapse to a max height with a fade + "read more" (added by JS only when it overflows). */
-  .tl-content.tl-clamp { max-height: 20em; overflow: hidden;
-    -webkit-mask-image: linear-gradient(180deg, #000 72%, transparent);
-    mask-image: linear-gradient(180deg, #000 72%, transparent); }
-  .tl-readmore { display: inline-block; margin: .15rem 0 .1rem; padding: .15rem 0;
-    background: none; border: 0; color: var(--accent, #06c); font: inherit; font-weight: 600;
-    font-size: .85rem; cursor: pointer; }
-  .tl-readmore:hover { text-decoration: underline; }
-
-  .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; }
-  .tl-poll-form { display: flex; flex-direction: column; gap: 8px; }
-  .tl-poll-choice { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line, rgba(128,128,128,.3)); border-radius: 10px; cursor: pointer; }
-  .tl-poll-choice:hover { border-color: var(--accent); }
-  .tl-poll-choice input { accent-color: var(--accent); }
-  .tl-poll-btn { align-self: flex-start; margin-top: 2px; }
-  .tl-poll-res { position: relative; padding: 9px 12px; border-radius: 10px; overflow: hidden; background: var(--paper-2, rgba(128,128,128,.08)); display: flex; align-items: center; gap: 8px; }
-  .tl-poll-fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 22%, transparent); z-index: 0; }
-  .tl-poll-res.is-mine .tl-poll-fill { background: color-mix(in srgb, var(--accent) 40%, transparent); }
-  .tl-poll-name { position: relative; z-index: 1; flex: 1; font-size: .95rem; }
-  .tl-poll-pct { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 500; }
-  .tl-poll-foot { font-size: .82rem; color: var(--ink-soft, #888); }
-
-  .tl-embed { margin: .75rem 0 0; border-radius: 12px; overflow: hidden; background: var(--paper-2, #111); }
-  .tl-embed-frame { width: 100%; border: 0; display: block; aspect-ratio: 16 / 9; }
-  .tl-embed-spotify { aspect-ratio: auto; height: 152px; }
-  .tl-embed-sc { aspect-ratio: auto; height: 166px; }
-  .tl-embed-klonkt { aspect-ratio: auto; height: 180px; background: var(--paper, #fff); }
-  .tl-embed-bandcamp { aspect-ratio: auto; height: 470px; }
-  .tl-embed-apple { aspect-ratio: auto; height: 175px; }
-  .tl-embed-open { display: inline-block; margin: .45rem .9rem 0 0; font-size: .82rem; font-weight: 600; color: var(--accent); text-decoration: none; }
-  .tl-embed-open:hover { text-decoration: underline; }
-
-  .tl-orig { display: inline-block; margin: .7rem 0 0; font-size: .82rem; color: var(--accent, #06c); text-decoration: none; }
-  .tl-orig:hover { text-decoration: underline; }
-
-  /* Uniform circular action buttons with coloured icons (no emoji). */
-  .tl-actions { display: flex; gap: .5rem; margin-top: .9rem; align-items: center; }
-  /* Verhuisd account: liken, boosten en reageren worden door de service toch
-     geweigerd, dus toon dat ook. Blokkeren blijft aanklikbaar (veiligheidsklep). */
-  .tl-act[disabled] { opacity: .35; cursor: not-allowed; }
-  .tl-actions.is-moved { position: relative; }
-  .tl-act-form { margin: 0; display: inline-flex; }
-  .tl-act { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 50%; padding: 0; box-sizing: border-box;
-    display: inline-flex; align-items: center; justify-content: center;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 3%, transparent);
-    color: var(--ink-soft, #888); cursor: pointer; -webkit-tap-highlight-color: transparent;
-    transition: background .12s, border-color .12s, transform .1s; }
-  .tl-act svg { width: 18px; height: 18px; display: block; }
-  .tl-act:active { transform: scale(.9); }
-  .tl-act-like svg { color: #e8b04b; }
-  .tl-act-like:hover { background: color-mix(in srgb, #e8b04b 15%, transparent); border-color: color-mix(in srgb, #e8b04b 50%, transparent); }
-  .tl-act-like.is-on { background: color-mix(in srgb, #e8b04b 18%, transparent); border-color: color-mix(in srgb, #e8b04b 60%, transparent); }
-  .tl-act-boost svg { color: #2fa85a; }
-  .tl-act-boost:hover { background: color-mix(in srgb, #2fa85a 15%, transparent); border-color: color-mix(in srgb, #2fa85a 50%, transparent); }
-  .tl-act-boost.is-on { background: color-mix(in srgb, #2fa85a 18%, transparent); border-color: color-mix(in srgb, #2fa85a 60%, transparent); }
-  .tl-act-reply svg { color: var(--accent, #06c); }
-  .tl-act-reply:hover { background: color-mix(in srgb, var(--accent, #888) 14%, transparent); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent); }
-  .tl-act-block svg { color: #e0524d; }
-  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
-</style>
-<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
Index: src/views/pages/newsletter.ejs
===================================================================
--- src/views/pages/newsletter.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,54 +1,0 @@
-<%
-  var _site = site || {};
-  var _title = _site.title || t('news.this_artist');
-  var st = (typeof nlState !== 'undefined') ? nlState : 'form';
-%>
-<section class="nl">
-  <div class="nl-card">
-    <% if (st === 'form') { %>
-      <h1 class="nl-h1"><%= t('news.form_title') %></h1>
-      <p class="nl-sub"><%= t('news.form_sub_before') %><strong><%= _title %></strong><%= t('news.form_sub_after') %></p>
-      <form method="POST" action="<%= siteUrlBase %>/nieuwsbrief" class="nl-form">
-        <input type="email" name="email" required placeholder="<%= t('news.email_ph') %>" value="<%= (typeof nlPrefill !== 'undefined') ? nlPrefill : '' %>" autocomplete="email">
-        <button type="submit" class="nl-btn"><%= t('news.subscribe') %></button>
-      </form>
-    <% } else if (st === 'check') { %>
-      <h1 class="nl-h1"><%= t('news.check_title') %></h1>
-      <p class="nl-sub"><%= t('news.check_sub_before') %><strong><%= (typeof nlEmail!=='undefined')?nlEmail:t('news.your_address') %></strong><%= t('news.check_sub_after') %></p>
-    <% } else if (st === 'done') { %>
-      <h1 class="nl-h1"><%= t('news.done_title') %></h1>
-      <p class="nl-sub"><%= t('news.done_sub_before') %><strong><%= _title %></strong><%= t('news.done_sub_after') %></p>
-    <% } else if (st === 'confirmed') { %>
-      <h1 class="nl-h1"><%= t('news.confirmed_title') %></h1>
-      <p class="nl-sub"><%= t('news.confirmed_sub_before') %><strong><%= _title %></strong><%= t('news.confirmed_sub_after') %></p>
-    <% } else if (st === 'unsubbed') { %>
-      <h1 class="nl-h1"><%= t('news.unsubbed_title') %></h1>
-      <p class="nl-sub"><%= t('news.unsubbed_sub') %></p>
-    <% } else if (st === 'invalid') { %>
-      <h1 class="nl-h1"><%= t('news.invalid_title') %></h1>
-      <p class="nl-sub"><%= t('news.invalid_sub') %></p>
-      <p><a class="nl-btn nl-btn-ghost" href="<%= siteUrlBase %>/nieuwsbrief"><%= t('news.back') %></a></p>
-    <% } else if (st === 'smtperror') { %>
-      <h1 class="nl-h1"><%= t('news.smtperror_title') %></h1>
-      <p class="nl-sub"><%= t('news.smtperror_sub') %></p>
-    <% } else if (st === 'badtoken') { %>
-      <h1 class="nl-h1"><%= t('news.badtoken_title') %></h1>
-      <p class="nl-sub"><%= t('news.badtoken_sub') %></p>
-      <p><a class="nl-btn nl-btn-ghost" href="<%= siteUrlBase %>/nieuwsbrief"><%= t('news.to_subscribe') %></a></p>
-    <% } else { %>
-      <h1 class="nl-h1"><%= t('news.error_title') %></h1>
-      <p class="nl-sub"><%= t('news.error_sub') %></p>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .nl { max-width: 560px; margin: 0 auto; padding: 48px 18px; }
-  .nl-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 32px 28px; }
-  .nl-h1 { font-size: clamp(24px, 5vw, 34px); margin: 0 0 10px; }
-  .nl-sub { font-size: 15.5px; line-height: 1.6; opacity: .85; margin: 0 0 20px; }
-  .nl-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .nl-form input { flex: 1 1 220px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .nl-btn { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent, #6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
-  .nl-btn-ghost { background: transparent; color: inherit; border: 1px solid rgba(128,128,128,.4); }
-</style>
Index: src/views/pages/oauth-consent.ejs
===================================================================
--- src/views/pages/oauth-consent.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,60 +1,0 @@
-<div class="oa-wrap">
-  <h1 class="oa-title"><%= t('oauth.title') %></h1>
-  <p class="oa-lead">
-    <strong><%= client.client_name %></strong>
-    <%= t('oauth.wants_access') %>
-  </p>
-
-  <form method="post" action="/oauth/authorize" class="oa-form">
-    <input type="hidden" name="client_id" value="<%= params.client_id %>">
-    <input type="hidden" name="redirect_uri" value="<%= params.redirect_uri %>">
-    <input type="hidden" name="code_challenge" value="<%= params.code_challenge %>">
-    <input type="hidden" name="scope" value="<%= params.scope %>">
-    <input type="hidden" name="state" value="<%= params.state %>">
-
-    <label class="oa-label"><%= t('oauth.post_as') %></label>
-    <% if (sites.length === 1) { %>
-      <input type="hidden" name="site_slug" value="<%= sites[0].slug %>">
-      <div class="oa-single"><%= sites[0].title || sites[0].slug %> <span class="oa-slug">@<%= sites[0].slug %></span></div>
-    <% } else { %>
-      <select name="site_slug" class="oa-select">
-        <% sites.forEach(function(s){ %>
-          <option value="<%= s.slug %>"><%= s.title || s.slug %> (@<%= s.slug %>)</option>
-        <% }); %>
-      </select>
-    <% } %>
-
-    <ul class="oa-scopes">
-      <li><%= t('oauth.scope_read') %></li>
-      <li><%= t('oauth.scope_write') %></li>
-    </ul>
-
-    <div class="oa-actions">
-      <button type="submit" name="decision" value="deny" class="btn oa-deny"><%= t('oauth.deny') %></button>
-      <button type="submit" name="decision" value="allow" class="btn btn-primary oa-allow"><%= t('oauth.allow') %></button>
-    </div>
-  </form>
-  <p class="oa-foot"><%= t('oauth.foot') %></p>
-</div>
-
-<style>
-  .oa-wrap { max-width: 460px; margin: 2.5rem auto; padding: 1.75rem 1.5rem; border-radius: 16px;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .oa-title { margin: 0 0 .6rem; font-size: 1.4rem; }
-  .oa-lead { color: var(--ink-soft, #999); line-height: 1.55; margin: 0 0 1.4rem; }
-  .oa-lead strong { color: var(--ink, inherit); }
-  .oa-label { display: block; font-weight: 700; font-size: .82rem; margin: 0 0 .4rem; color: var(--ink-soft, #888); text-transform: uppercase; letter-spacing: .03em; }
-  .oa-single { padding: .6rem .8rem; border-radius: 10px; background: color-mix(in srgb, var(--ink, #000) 5%, transparent); font-weight: 600; }
-  .oa-slug { color: var(--ink-soft, #999); font-weight: 400; }
-  .oa-select { width: 100%; padding: .6rem .8rem; border-radius: 10px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
-  .oa-scopes { list-style: none; padding: .9rem 0 0; margin: .9rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
-    display: flex; flex-direction: column; gap: .5rem; color: var(--ink-soft, #aaa); }
-  .oa-scopes li { position: relative; padding-left: 1.5rem; line-height: 1.4; }
-  .oa-scopes li::before { content: "✓"; position: absolute; left: 0; color: #2fa85a; font-weight: 700; }
-  .oa-actions { display: flex; gap: .7rem; margin-top: 1.5rem; }
-  .oa-deny { flex: 0 0 auto; }
-  .oa-allow { flex: 1; }
-  .oa-foot { color: var(--ink-soft, #888); font-size: .78rem; line-height: 1.5; margin: 1.1rem 0 0; }
-</style>
Index: src/views/pages/owa-consent.ejs
===================================================================
--- src/views/pages/owa-consent.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,69 +1,0 @@
-<%
-// Het toestemmingsscherm van /magic.
-//
-// Dit scherm bestaat omdat de FEP er onder "Information leakage" om vraagt:
-// OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die erom
-// vraagt, en desnoods zonder dat je het merkt. De omweg langs je eigen server is
-// het enige moment waarop je nog kunt zeggen: deze site niet.
-//
-// Daarom staat de doelhost groot en apart. Niet als versiering: wie hier
-// doorklikt vertelt die host wie hij is, en dat hoort de zin te zijn die hij
-// gelezen heeft.
-var _sites = (typeof owaSites !== 'undefined' && owaSites) ? owaSites : [];
-%>
-
-<section class="owa">
-  <div class="owa-card">
-    <h1 class="owa-h1"><%= t('owa.title') %></h1>
-
-    <p class="owa-host"><%= owaHost %></p>
-    <p class="owa-sub"><%= t('owa.sub') %></p>
-
-    <form method="post" action="/magic">
-      <input type="hidden" name="bdest" value="<%= owaBdest %>">
-
-      <% if (_sites.length === 1) { %>
-        <input type="hidden" name="slug" value="<%= _sites[0].slug %>">
-        <p class="owa-as"><%= t('owa.as') %> <strong><%= _sites[0].title || _sites[0].slug %></strong></p>
-      <% } else { %>
-        <%# Meer dan één site: kiezen is hier geen luxe. Ondertekenen en
-            ontsleutelen kan alleen met een sleutel die hij ook echt beheert. %>
-        <p class="owa-as"><%= t('owa.choose') %></p>
-        <ul class="owa-list">
-          <% _sites.forEach(function(s, i) { %>
-            <li>
-              <label>
-                <input type="radio" name="slug" value="<%= s.slug %>"<%= i === 0 ? ' checked' : '' %>>
-                <span><%= s.title || s.slug %></span>
-              </label>
-            </li>
-          <% }); %>
-        </ul>
-      <% } %>
-
-      <div class="owa-actions">
-        <button class="owa-btn" type="submit"><%= t('owa.go') %></button>
-        <a class="owa-no" href="/"><%= t('owa.cancel') %></a>
-      </div>
-    </form>
-
-    <p class="owa-fine"><%= t('owa.fine') %></p>
-  </div>
-</section>
-
-<style>
-  .owa { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .owa-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 36px 28px; }
-  .owa-h1 { font-size: 22px; margin: 0 0 14px; }
-  .owa-host { font-size: 20px; font-weight: 600; margin: 0 0 6px; word-break: break-all; }
-  .owa-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .owa-as { font-size: .95rem; margin: 0 0 10px; }
-  .owa-list { list-style: none; padding: 0; margin: 0 0 18px; text-align: left; }
-  .owa-list li { padding: 6px 0; }
-  .owa-list label { display: flex; gap: 10px; align-items: center; cursor: pointer; }
-  .owa-actions { display: flex; gap: 14px; align-items: center; justify-content: center; flex-wrap: wrap; }
-  .owa-btn { padding: 12px 22px; border-radius: 10px; border: 0; background: var(--accent,#6b8f71);
-             color: #fff; font-weight: 600; font-family: inherit; font-size: 1rem; cursor: pointer; }
-  .owa-no { font-size: .9rem; opacity: .7; }
-  .owa-fine { font-size: .8rem; opacity: .6; line-height: 1.5; margin: 18px 0 0; }
-</style>
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,66 +1,0 @@
-<div class="container pg-navwrap">
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-</div>
-
-<article class="pg-page" id="pg-page">
-  <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %>
-
-  <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
-    <div class="pg-teaser"><p><%= pgTeaser %></p></div>
-  <% } %>
-
-  <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
-      hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
-      Alleen de tekst zit achter de poort, en die staat hier niet -- de route
-      geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
-  <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
-    <div class="pg-audio post-content"><%- pgAudio %></div>
-  <% } %>
-
-  <% var _hasPatron = (typeof pgPatronUrl !== 'undefined' && pgPatronUrl); %>
-  <section class="pg-card">
-    <div class="pg-lock">💶</div>
-    <h2 class="pg-h2"><%= t('pgate.h') %></h2>
-    <p class="pg-sub">
-      <% if (typeof pgCents !== 'undefined' && pgCents) { %><%= t('pgate.sub_cents', { eur: (pgCents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
-    </p>
-
-    <div class="pg-actions">
-      <% if (_hasPatron) { %>
-        <a class="pg-btn" href="<%= pgPatronUrl %>" target="_blank" rel="noopener"><%= t('pgate.join') %></a>
-        <button type="button" id="pg-unlock" class="pg-btn pg-btn-ghost"><%= t('pgate.unlock_have') %></button>
-      <% } else { %>
-        <button type="button" id="pg-unlock" class="pg-btn"><%= t('pgate.unlock') %></button>
-      <% } %>
-    </div>
-    <p id="pg-status" class="pg-status" hidden></p>
-  </section>
-</article>
-
-<%# 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>
-  .pg-navwrap { max-width: 720px; margin: 0.5rem auto 1.5rem; padding: 0 1rem; }
-  .pg-page { max-width: 720px; margin: 0 auto 3rem; padding: 0 1rem; }
-  .pg-title { font-family: var(--font-display, serif); font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 1rem; }
-  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
-    -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
-  .pg-audio { margin: 1.5rem 0 0; }
-  .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
-  .pg-lock { font-size: 38px; margin-bottom: 6px; }
-  .pg-h2 { font-size: 22px; margin: 0 0 8px; }
-  .pg-sub { opacity: .85; line-height: 1.6; margin: 0 auto 12px; max-width: 34em; }
-  .pg-btn { display: inline-block; text-align: center; text-decoration: none; padding: 12px 24px; border: none; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer;
-    background: var(--accent, #6b8f71); color: #fff; }
-  .pg-btn:disabled { opacity: .6; cursor: default; }
-  .pg-btn:hover { filter: brightness(1.05); }
-  .pg-actions { display: flex; flex-direction: column; gap: 10px; align-items: stretch; max-width: 320px; margin: 0 auto; }
-  .pg-btn-ghost { background: transparent; color: var(--ink, #222); border: 1px solid color-mix(in srgb, var(--ink, #000) 22%, transparent); }
-  .pg-status { margin: 12px 0 0; opacity: .9; }
-  .pg-status.is-err { color: #c0392b; }
-</style>
-<%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %>
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,30 +1,0 @@
-<section class="pk">
-  <div class="pk-card">
-    <div class="pk-ic">🔑</div>
-    <h1 class="pk-h1"><%= t('ppk.h') %></h1>
-    <p class="pk-sub">
-      <%= t('ppk.sub') %>
-    </p>
-    <button type="button" id="pk-go" class="pk-btn"><%= t('ppk.make') %></button>
-    <p id="pk-status" class="pk-status" hidden></p>
-  </div>
-</section>
-
-<%# 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>
-  .pk { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .pk-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
-  .pk-ic { font-size: 40px; margin-bottom: 8px; }
-  .pk-h1 { font-size: 24px; margin: 0 0 6px; }
-  .pk-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .pk-btn { padding: 12px 24px; border: none; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font: inherit; font-weight: 600; cursor: pointer; }
-  .pk-btn:disabled { opacity: .6; cursor: default; }
-  .pk-status { margin: 14px 0 0; opacity: .9; }
-  .pk-status.is-err { color: #c0392b; }
-</style>
-<%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %>
Index: src/views/pages/paid-result.ejs
===================================================================
--- src/views/pages/paid-result.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-<%
-  var _slug = (typeof postSlug !== 'undefined' && postSlug) ? postSlug : '';
-  var _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-  var _patron = (typeof patronUrl !== 'undefined' && patronUrl) ? patronUrl : '';
-%>
-<section class="pr">
-  <div class="pr-card">
-    <div class="pr-ic">🔒</div>
-    <% if (reason === 'notpatron') { %>
-      <h1 class="pr-h1"><%= t('pres.notpatron_h') %></h1>
-      <p class="pr-sub"><%= t('pres.notpatron_p') %></p>
-    <% } else if (reason === 'tier') { %>
-      <h1 class="pr-h1"><%= t('pres.tier_h') %></h1>
-      <p class="pr-sub"><%= t('pres.tier_p', { need: ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2), have: ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) }) %></p>
-    <% } else if (reason === 'expired') { %>
-      <h1 class="pr-h1"><%= t('pres.expired_h') %></h1>
-      <p class="pr-sub"><%= t('pres.expired_p') %></p>
-    <% } else { %>
-      <h1 class="pr-h1"><%= t('pres.declined_h') %></h1>
-      <p class="pr-sub"><%= t('pres.declined_p') %></p>
-    <% } %>
-
-    <div class="pr-actions">
-      <% if ((reason === 'notpatron' || reason === 'tier') && _patron) { %>
-        <a class="pr-btn" href="<%= _patron %>" target="_blank" rel="noopener"><%= t('pres.join') %></a>
-      <% } %>
-      <% if (_slug) { %>
-        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/<%= _slug %>"><%= t('pres.back_post') %></a>
-      <% } else { %>
-        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/"><%= t('pres.back_site') %></a>
-      <% } %>
-    </div>
-  </div>
-</section>
-
-<style>
-  .pr { max-width: 460px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .pr-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
-  .pr-ic { font-size: 38px; margin-bottom: 6px; }
-  .pr-h1 { font-size: 23px; margin: 0 0 8px; }
-  .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .pr-actions { display: flex; flex-direction: column; gap: 10px; align-items: stretch; }
-  .pr-btn { display: inline-block; padding: 11px 20px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
-  .pr-btn-ghost { background: transparent; color: var(--ink,#222); border: 1px solid color-mix(in srgb, var(--ink,#000) 22%, transparent); }
-</style>
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/post-edit.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -9,100 +9,29 @@
 // and styling changed.
 const _hasCover = !!post.cover_image_url;
-// In hub mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise
-// the request falls back to the primary site (siteUrlBase empty) and the post
-// renders headerless (bareChrome) after saving. In solo mode _base is empty.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 <div class="container post-edit-page">
-  <h1><%= isNew ? t('pedit.title_new') : t('pedit.title_edit') %></h1>
-
-  <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
-
-    <%# ── TYPE (segmented) + type-specific inputs ────────────────
-        Post type lives at the TOP (above the body), and the selected type
-        reveals its own input: Audio → inline upload, Video → embed URL,
-        Foto → cover/insert hint. %>
-    <% var ptype = post.type || 'post'; %>
-    <% var TYPE_ICONS = { post: '📝', foto: '📷', video: '🎬', album: '💿', playlist: '📃', mixtape: '📼', audio: '🎵' }; %>
-    <%# Audio is geen keuze meer (shaer-cyg): alles wat muziek is landt op Album
-        of Playlist. De knop blijft alleen staan zolang deze post nog audio IS,
-        zodat een oude post niet stilzwijgend van type verandert bij het openen. %>
-    <% var TYPES = (typeof keuzeTypes !== 'undefined' && keuzeTypes.length)
-                     ? keuzeTypes.slice() : ['post','foto','video','album','playlist','mixtape']; %>
-    <% if (ptype === 'audio' && TYPES.indexOf('audio') < 0) TYPES.push('audio'); %>
-    <section class="pe-card pe-type-card">
-      <div class="pe-section-title"><%= t('pedit.s_type') %></div>
-      <input type="hidden" name="type" id="pe-type-input" value="<%= ptype %>">
-      <div class="pe-typeseg" role="radiogroup" aria-label="<%= t('pedit.s_type') %>">
-        <% TYPES.forEach(function (tt) { %>
-          <button type="button" class="pe-typeseg-btn<%= ptype === tt ? ' is-active' : '' %>"
-                  data-type="<%= tt %>" role="radio" aria-checked="<%= ptype === tt ? 'true' : 'false' %>">
-            <span class="pe-typeseg-ic" aria-hidden="true"><%= TYPE_ICONS[tt] %></span>
-            <span><%= t('pedit.type_' + tt) %></span>
-          </button>
-        <% }); %>
-      </div>
-
-      <%# Muziek: inline upload (transcodes + drops [[track]] into the post).
-          Album, Playlist en Mixtape delen dit paneel -- het verschil zit in de
-          playlist die je insluit, niet in wat je hier uploadt. Audio staat er
-          alleen nog voor oude posts. %>
-      <div class="pe-type-panel" data-panel="album playlist mixtape audio" hidden>
-        <div class="pe-audio-up" id="pe-audio-drop" tabindex="0" role="button" aria-label="<%= t('pedit.audio_up_drop') %>">
-          <input type="file" id="pe-audio-file" accept="audio/*,.mp3,.m4a,.ogg,.opus,.flac,.wav" multiple hidden>
-          <span class="pe-audio-up-ic" aria-hidden="true">🎵</span>
-          <strong><%= t('pedit.audio_up_drop') %></strong>
-          <small><%= t('pedit.audio_up_hint') %></small>
-        </div>
-        <ul class="pe-audio-list" id="pe-audio-list"></ul>
-      </div>
-
-      <%# Video: paste a URL → embed chip %>
-      <div class="pe-type-panel" data-panel="video" hidden>
-        <label class="pe-field">
-          <span><%= t('pedit.video_up_title') %></span>
-          <div class="pe-video-row">
-            <input type="url" id="pe-video-url" inputmode="url" autocapitalize="none" spellcheck="false"
-                   placeholder="<%= t('pedit.video_up_ph') %>">
-            <button type="button" class="pe-btn pe-btn-secondary" id="pe-video-insert"><%= t('pedit.video_up_btn') %></button>
-          </div>
-        </label>
-      </div>
-
-      <%# Foto: light hint toward cover + insert-image %>
-      <div class="pe-type-panel" data-panel="foto" hidden>
-        <p class="pe-foto-hint">🖼 <%= t('pedit.foto_up_hint') %></p>
-      </div>
-    </section>
+  <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
+
+  <form method="post" action="<%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
 
     <%# ── IDENTITY ─────────────────────────────────────────────── %>
     <section class="pe-card">
       <label class="pe-field">
-        <span><%= t('pedit.f_title') %></span>
-        <input type="text" name="title" value="<%= post.title %>" required>
+        <span>Titel</span>
+        <input type="text" name="title" value="<%= post.title %>" required autofocus>
       </label>
       <div class="pe-row pe-row-2">
         <label class="pe-field">
-          <span><%= t('pedit.f_slug') %></span>
-          <input type="text" name="slug" value="<%= post.slug %>" placeholder="<%= t('pedit.slug_placeholder') %>">
+          <span>Slug (URL)</span>
+          <input type="text" name="slug" value="<%= post.slug %>" placeholder="auto van titel als leeg">
         </label>
         <label class="pe-field">
-          <span><%= t('pedit.f_tags') %> <small><%= t('pedit.tags_hint') %></small></span>
+          <span>Tags <small>(komma-gescheiden)</small></span>
           <input type="text" name="tags" value="<%= Array.isArray(post.tags) ? post.tags.join(', ') : '' %>">
         </label>
       </div>
       <label class="pe-field">
-        <span><%= t('pedit.f_excerpt') %></span>
+        <span>Excerpt <small>(preview tekst)</small></span>
         <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
-        <small class="pe-hint" style="opacity:.7"><%- t('pedit.excerpt_hint') %></small>
-      </label>
-      <% var _postLang = (typeof post.language !== 'undefined' && post.language) ? post.language : (typeof lang !== 'undefined' ? lang : 'en'); %>
-      <label class="pe-field">
-        <span><%= t('pedit.f_language') %> <small><%= t('pedit.language_hint') %></small></span>
-        <select name="language" id="pe-language" style="max-width:220px">
-          <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
-            <option value="<%= l[0] %>" <%= l[0] === _postLang ? 'selected' : '' %>><%= l[1] %></option>
-          <% }); %>
-        </select>
       </label>
     </section>
@@ -110,5 +39,5 @@
     <%# ── COVER ────────────────────────────────────────────────── %>
     <section class="pe-card">
-      <div class="pe-section-title"><%= t('pedit.s_cover') %></div>
+      <div class="pe-section-title">Cover</div>
       <div class="pe-cover-row">
         <div class="pe-cover-thumb" id="cover-preview-wrap" <%= _hasCover ? '' : 'data-empty' %>>
@@ -118,21 +47,12 @@
         <div class="pe-cover-actions">
           <label class="pe-field">
-            <span><%= t('pedit.f_cover_url') %></span>
-            <input type="text" name="cover_image_url" id="cover-url-field"
+            <span>Cover URL</span>
+            <input type="url" name="cover_image_url" id="cover-url-field"
                    value="<%= post.cover_image_url || '' %>"
-                   inputmode="url" autocapitalize="none" spellcheck="false"
-                   placeholder="<%= t('pedit.cover_url_placeholder') %>">
+                   placeholder="https://…  of upload met de knop">
           </label>
-          <label class="pe-field">
-            <span><%= t('pedit.f_cover_alt') %></span>
-            <input type="text" name="cover_alt" id="cover-alt-field" maxlength="1500"
-                   value="<%= post.cover_alt || '' %>"
-                   placeholder="<%= t('pedit.cover_alt_placeholder') %>">
-          </label>
-          <%# Muted loop MP4 for an animated cover (auto-made from an animated WebP). Hidden — set by the uploader. %>
-          <input type="hidden" name="cover_video_url" id="cover-video-field" value="<%= post.cover_video_url || '' %>">
           <div class="pe-cover-upload">
             <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
-              📷 <%= t('pedit.cover_upload_btn') %>
+              📷 Upload nieuwe cover
             </button>
             <input type="file" id="cover-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
@@ -146,59 +66,51 @@
     <section class="pe-card pe-card-content">
       <div class="pe-section-title">
-        <%= t('pedit.s_content') %>
-        <small class="pe-content-hint"><%= t('pedit.content_hint') %></small>
+        Content
+        <small>Sleep een afbeelding om in te voegen · selecteer tekst om op te maken</small>
       </div>
       <div class="pe-editor-frame">
         <div class="pe-toolbar" id="pe-toolbar" role="toolbar" aria-label="Format">
-          <button type="button" id="pe-fs-done" class="pe-fs-done" title="<%= t('pedit.tb_done_title') %>">✓ <%= t('pedit.tb_done') %></button>
-          <button type="button" data-cmd="bold" title="<%= t('pedit.tb_bold_title') %>" aria-label="<%= t('pedit.tb_bold') %>">
+          <button type="button" data-cmd="bold" title="Vet (Ctrl+B)" aria-label="Vet">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/></svg>
           </button>
-          <button type="button" data-cmd="italic" title="<%= t('pedit.tb_italic_title') %>" aria-label="<%= t('pedit.tb_italic') %>">
+          <button type="button" data-cmd="italic" title="Cursief (Ctrl+I)" aria-label="Cursief">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
           </button>
-          <button type="button" data-cmd="underline" title="<%= t('pedit.tb_underline') %>" aria-label="<%= t('pedit.tb_underline') %>">
+          <button type="button" data-cmd="underline" title="Onderstreept" aria-label="Onderstreept">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
           </button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="<%= t('pedit.tb_h2') %>">H2</button>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="<%= t('pedit.tb_h3') %>">H3</button>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p"  title="<%= t('pedit.tb_p') %>">¶</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="Kop">H2</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="Subkop">H3</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p"  title="Paragraaf">¶</button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" data-cmd="insertUnorderedList" title="<%= t('pedit.tb_ul') %>" aria-label="<%= t('pedit.tb_ul') %>">
+          <button type="button" data-cmd="insertUnorderedList" title="Lijst" aria-label="Lijst">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg>
           </button>
-          <button type="button" data-cmd="insertOrderedList" title="<%= t('pedit.tb_ol') %>" aria-label="<%= t('pedit.tb_ol') %>">
+          <button type="button" data-cmd="insertOrderedList" title="Genummerde lijst" aria-label="Genummerde lijst">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>
           </button>
-          <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="<%= t('pedit.tb_quote') %>" aria-label="<%= t('pedit.tb_quote') %>">
+          <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="Quote" aria-label="Quote">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5C6 17.5 4 19 3 19v2z"/><path d="M14 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5c-.5 4.5-2.5 6-3.5 6v2z"/></svg>
           </button>
-          <button type="button" data-cmd="link-prompt" title="<%= t('pedit.tb_link_title') %>" aria-label="<%= t('pedit.tb_link') %>">
+          <button type="button" data-cmd="link-prompt" title="Link (Ctrl+K)" aria-label="Link">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
           </button>
-          <button type="button" data-cmd="code-wrap" title="<%= t('pedit.tb_code_title') %>" aria-label="<%= t('pedit.tb_code') %>">
+          <button type="button" data-cmd="code-wrap" title="Code (inline)" aria-label="Code">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
           </button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" id="insert-image-btn" title="<%= t('pedit.tb_image_title') %>" aria-label="<%= t('pedit.tb_image') %>">
+          <button type="button" id="insert-image-btn" title="Afbeelding invoegen" aria-label="Afbeelding">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
           </button>
-          <button type="button" id="insert-track-btn" title="<%= t('pedit.tb_track_title') %>" aria-label="<%= t('pedit.tb_track') %>">
+          <button type="button" id="insert-track-btn" title="Track invoegen" aria-label="Track">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
           </button>
-          <button type="button" id="insert-playlist-btn" title="<%= t('pedit.tb_playlist_title') %>" aria-label="<%= t('pedit.tb_playlist') %>">
+          <button type="button" id="insert-playlist-btn" title="Playlist invoegen" aria-label="Playlist">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>
           </button>
-          <button type="button" id="insert-embed-btn" title="<%= t('pedit.tb_embed_title') %>" aria-label="<%= t('pedit.tb_embed') %>">
-            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>
-          </button>
           <span class="pe-toolbar-spacer"></span>
-          <button type="button" data-cmd="removeFormat" title="<%= t('pedit.tb_clear') %>" aria-label="<%= t('pedit.tb_clear') %>">
+          <button type="button" data-cmd="removeFormat" title="Wis opmaak" aria-label="Wis opmaak">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M5 5l14 14" stroke-linecap="round"/></svg>
-          </button>
-          <button type="button" id="pe-fullscreen-btn" title="<%= t('pedit.tb_fullscreen') %>" aria-label="<%= t('pedit.tb_fullscreen') %>" aria-pressed="false">
-            <svg class="fs-icon-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
-            <svg class="fs-icon-compress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
           </button>
         </div>
@@ -209,15 +121,10 @@
              role="textbox"
              aria-multiline="true"
-             aria-label="<%= t('pedit.editor_aria') %>"></div>
-
-        <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen
-            via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %>
-        <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
+             aria-label="Content"
+             data-placeholder="Begin met schrijven…"></div>
 
         <%# Hidden field is what actually submits to the server. The visible
             contenteditable's serialized HTML is copied here on submit. %>
-        <%# value = de BESTAANDE inhoud: laadt de module niet (oude cache, js uit),
-    dan slaat het formulier de post ongewijzigd op in plaats van hem te wissen. %>
-        <input type="hidden" name="content" id="content-hidden" value="<%= post.content || '' %>">
+        <input type="hidden" name="content" id="content-hidden" value="">
 
         <%# Initial content is injected as a JSON string in a script tag so we
@@ -228,5 +135,5 @@
         <div class="pe-editor-status">
           <small id="content-upload-status" class="pe-status"></small>
-          <span class="pe-char-count"><span id="char-count">0</span> <%= t('pedit.chars') %></span>
+          <span class="pe-char-count"><span id="char-count">0</span> tekens</span>
         </div>
 
@@ -237,130 +144,38 @@
     <%# ── META ─────────────────────────────────────────────────── %>
     <section class="pe-card">
-      <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
-      <label class="pe-field">
-        <span><%= t('pedit.f_status') %></span>
-        <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
-        <select name="status">
-          <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
-          <option value="draft"     <%= _st === 'draft'     ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
-          <option value="archived"  <%= _st === 'archived'  ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
-        </select>
-      </label>
+      <div class="pe-section-title">Publicatie</div>
+      <div class="pe-row pe-row-2">
+        <label class="pe-field">
+          <span>Status</span>
+          <select name="status">
+            <option value="draft"     <%= post.status === 'draft'     ? 'selected' : '' %>>Draft</option>
+            <option value="published" <%= post.status === 'published' ? 'selected' : '' %>>Published</option>
+            <option value="archived"  <%= post.status === 'archived'  ? 'selected' : '' %>>Archived</option>
+          </select>
+        </label>
+        <label class="pe-field">
+          <span>Type</span>
+          <select name="type">
+            <% var ptype = post.type || 'post'; %>
+            <option value="post"  <%= ptype === 'post'  ? 'selected' : '' %>>Post</option>
+            <option value="foto"  <%= ptype === 'foto'  ? 'selected' : '' %>>Foto</option>
+            <option value="video" <%= ptype === 'video' ? 'selected' : '' %>>Video</option>
+            <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>>Audio</option>
+          </select>
+        </label>
+      </div>
       <div class="pe-checkboxes">
-        <div class="pe-pin">
-          <label class="pe-checkbox">
-            <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
-            <span>📌 <%= t('pedit.pin_label') %></span>
-          </label>
-          <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
-          <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
-            <span class="pe-pin-steps">
-              <button type="button" class="pe-pin-btn" id="pin-up" aria-label="<%= t('pedit.pin_up') %>">▲</button>
-              <button type="button" class="pe-pin-btn" id="pin-down" aria-label="<%= t('pedit.pin_down') %>">▼</button>
-            </span>
-            <span class="pe-pin-label" id="pin-label"></span>
-          </div>
-        </div>
+        <label class="pe-pinned-rank">
+          <span>📌 Pinned rank</span>
+          <input type="number" name="pinned" min="0" step="1"
+                 inputmode="numeric" pattern="[0-9]*"
+                 value="<%= post.pinned || 0 %>"
+                 title="0 = niet pinned. 1 = bovenaan, 2 = daaronder, etc.">
+          <small>0 = niet pinned · 1 = bovenaan · 2 daaronder · …</small>
+        </label>
         <label class="pe-checkbox">
           <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
-          <span>🚫 <%= t('pedit.noindex_label') %></span>
+          <span>🚫 noindex (verberg voor zoekmachines)</span>
         </label>
-        <label class="pe-checkbox">
-          <input type="checkbox" name="nsfw" value="1" id="pe-nsfw" <%= post.nsfw ? 'checked' : '' %>>
-          <span>🔞 <%= t('pedit.nsfw_label') %></span>
-        </label>
-        <input type="text" name="content_warning" id="pe-cw" value="<%= post.content_warning || '' %>" maxlength="200"
-               placeholder="<%= t('pedit.nsfw_cw_ph') %>"
-               style="width:100%;box-sizing:border-box;margin:6px 0 2px;font-size:13px;padding:7px 9px">
-        <% var _fediOpen = (typeof fediOpenAudio !== 'undefined' && fediOpenAudio); %>
-        <label class="pe-checkbox">
-          <%# One-way: once opened, the file has federated — re-gating would be false security, so the box locks. %>
-          <input type="checkbox" name="fedi_open_audio" value="1" id="pe-fedi-audio" <%= _fediOpen ? 'checked disabled' : '' %>>
-          <span>🌐 <%= t('pedit.fedi_audio_label') %></span>
-        </label>
-        <% if (_fediOpen) { %>
-          <p style="font-size:12px;opacity:.7;margin:2px 0 0 26px"><%= t('pedit.fedi_audio_locked') %></p>
-        <% } else { %>
-          <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
-          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-        <% } %>
-        
-        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
-           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
-           var _pollLocked = (typeof pollLocked !== 'undefined' && pollLocked);
-           var _pollOpts = (_poll && Array.isArray(_poll.options) && _poll.options.length) ? _poll.options : [{ name: '' }, { name: '' }]; %>
-        <label class="pe-checkbox" style="margin-top:8px">
-          <input type="checkbox" name="poll_enabled" value="1" id="pe-poll-toggle" <%= _poll ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
-          <span>📊 <%= t('pedit.poll_label') %></span>
-        </label>
-        <style>
-          .pe-poll { margin-top: 6px; }
-          .pe-poll-locked { font-size: 12px; opacity: .7; margin: 0 0 6px; }
-          .pe-poll-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
-          .pe-poll-opt { flex: 1; min-width: 0; box-sizing: border-box; font-size: 13px; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--rule, rgba(128,128,128,.35)); background: transparent; color: inherit; }
-          .pe-poll-opt:focus { outline: none; border-color: var(--accent); }
-          .pe-poll-del { flex: 0 0 auto; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; border: 1px solid var(--rule, rgba(128,128,128,.35)); border-radius: 7px; background: transparent; color: var(--ink-muted, #999); cursor: pointer; transition: color .12s, border-color .12s; }
-          .pe-poll-del:hover { border-color: #c0392b; color: #c0392b; }
-          .pe-poll-del[hidden] { display: none; }
-          .pe-poll-add { display: block; width: fit-content; font-size: 12.5px; padding: 6px 12px; border-radius: 7px; border: 1px dashed var(--rule, rgba(128,128,128,.45)); background: transparent; color: inherit; cursor: pointer; margin: 0 0 10px; transition: color .12s, border-color .12s; }
-          .pe-poll-add:hover { border-color: var(--accent); color: var(--accent); }
-          .pe-poll-add:disabled { opacity: .4; cursor: default; border-style: dashed; }
-          .pe-poll-durlabel { display: block; font-size: 12.5px; opacity: .8; margin: 8px 0 4px; }
-          .pe-poll-dur { padding: 7px 9px; border-radius: 7px; border: 1px solid var(--rule, rgba(128,128,128,.35)); background: transparent; color: inherit; font-size: 13px; }
-        </style>
-        <%# NB: keep the condition INSIDE the quoted attribute — escaped output (equals-tag)
-            turns a whole emitted style attribute into entity soup (fields showed always). %>
-        <div id="pe-poll-fields" class="pe-poll" style="<%= _poll ? '' : 'display:none' %>">
-          <% if (_pollLocked) { %><p class="pe-poll-locked"><%= t('pedit.poll_locked') %></p><% } %>
-          <div id="pe-poll-opts" data-ph="<%= t('pedit.poll_option_ph') %>" data-del="<%= t('pedit.poll_remove') %>">
-            <% _pollOpts.forEach(function (o) { %>
-              <div class="pe-poll-row">
-                <input type="text" name="poll_option" class="pe-poll-opt" maxlength="100" value="<%= (o && o.name) || '' %>" placeholder="<%= t('pedit.poll_option_ph') %>" <%= _pollLocked ? 'disabled' : '' %>>
-                <% if (!_pollLocked) { %><button type="button" class="pe-poll-del" aria-label="<%= t('pedit.poll_remove') %>" title="<%= t('pedit.poll_remove') %>">&times;</button><% } %>
-              </div>
-            <% }); %>
-          </div>
-          <% if (!_pollLocked) { %><button type="button" id="pe-poll-add" class="pe-poll-add">+ <%= t('pedit.poll_add') %></button><% } %>
-          <label class="pe-checkbox">
-            <input type="checkbox" name="poll_multiple" value="1" <%= (_poll && _poll.multiple) ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
-            <span><%= t('pedit.poll_multiple') %></span>
-          </label>
-          <label class="pe-poll-durlabel"><%= t('pedit.poll_duration') %></label>
-          <select name="poll_duration" class="pe-poll-dur" <%= _pollLocked ? 'disabled' : '' %>>
-            <% [['300','5m'],['1800','30m'],['3600','1h'],['21600','6h'],['43200','12h'],['86400','1d'],['259200','3d'],['604800','7d']].forEach(function (d) { %>
-              <option value="<%= d[0] %>" <%= d[0] === '86400' ? 'selected' : '' %>><%= t('pedit.poll_dur_' + d[1]) %></option>
-            <% }); %>
-          </select>
-        </div>
-        
-        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-          <label class="pe-checkbox">
-            <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
-            <span>🔒 <%= t('pedit.fan_only_label') %></span>
-          </label>
-          <label class="pe-checkbox" style="margin-top:8px">
-            <input type="checkbox" name="paid" value="1" id="pe-paid" <%= post.paid ? 'checked' : '' %>>
-            <span>💶 Betaalde post (supporters ontgrendelen met Patreon)</span>
-          </label>
-          <div id="pe-paid-price" style="margin:6px 0 0 26px;<%= post.paid ? '' : 'display:none' %>">
-            <label style="font-size:.85rem;opacity:.8">Vereist steunbedrag (euro, leeg = standaard)
-              <input type="text" name="paid_min_eur" inputmode="decimal" style="width:100px"
-                     value="<%= post.paid_min_cents ? (post.paid_min_cents/100).toFixed(2) : '' %>">
-            </label>
-          </div>
-          
-          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
-          <label class="pe-checkbox" style="margin-top:8px">
-            <input type="checkbox" name="schedule_enabled" value="1" id="pe-sched-toggle" <%= _scheduled ? 'checked' : '' %>>
-            <span>📅 <%= t('pedit.schedule_label') %></span>
-          </label>
-          <div id="pe-sched-fields" style="margin-top:6px;<%= _scheduled ? '' : 'display:none' %>">
-            <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px"><%= t('pedit.publish_at_label') %></label>
-            <input type="datetime-local" id="pe-publish-at" name="publish_at" <%= _scheduled ? '' : 'disabled' %> data-iso="<%= post.publish_at || '' %>" value="" style="padding:8px 10px;border-radius:8px;border:1px solid var(--rule,rgba(128,128,128,.4));background:transparent;color:inherit">
-            <% if (post.status === 'scheduled' && post.publish_at) { %><div style="font-size:12px;opacity:.7;margin-top:4px"><span id="pe-sched-when" data-iso="<%= post.publish_at %>" data-label="<%= t('pedit.scheduled_prefix') %>">⏳ <%= t('pedit.scheduled_for', { d: post.publish_at }) %></span></div><% } %>
-            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
-          </div>
-          
-        <% } %>
       </div>
     </section>
@@ -368,10 +183,10 @@
     <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
     <div class="pe-actions">
-      <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn"><%= t('pedit.cancel') %></a>
+      <a href="<%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
       <div class="pe-actions-spacer"></div>
       <% if (!isNew && post.status !== 'published') { %>
-        <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 <%= t('pedit.publish') %></button>
+        <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 Publish</button>
       <% } %>
-      <button type="submit" class="pe-btn pe-btn-primary">💾 <%= t('pedit.save') %></button>
+      <button type="submit" class="pe-btn pe-btn-primary">💾 Opslaan</button>
     </div>
   </form>
@@ -526,86 +341,4 @@
 }
 
-/* ─── Post type: segmented control + type-aware panels ──────────── */
-.pe-typeseg {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 0.4rem;
-}
-.pe-typeseg-btn {
-  display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
-  padding: 0.7rem 0.4rem;
-  border: 1px solid var(--rule);
-  border-radius: 9px;
-  background: var(--paper-2);
-  color: var(--ink-soft);
-  font-size: 0.85rem; font-weight: 600;
-  cursor: pointer;
-  transition: border-color 120ms, background 120ms, color 120ms;
-}
-.pe-typeseg-btn:hover { border-color: var(--accent); color: var(--ink); }
-.pe-typeseg-btn .pe-typeseg-ic { font-size: 1.3rem; line-height: 1; }
-.pe-typeseg-btn.is-active {
-  border-color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
-  color: var(--ink);
-}
-.pe-typeseg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
-@media (max-width: 480px) {
-  .pe-typeseg-btn { font-size: 0.78rem; padding: 0.6rem 0.25rem; }
-  .pe-typeseg-btn .pe-typeseg-ic { font-size: 1.15rem; }
-}
-
-.pe-type-panel { margin-top: 0.2rem; }
-
-/* Audio drop/upload zone */
-.pe-audio-up {
-  display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
-  text-align: center;
-  padding: 1.4rem 1rem;
-  border: 2px dashed var(--rule);
-  border-radius: 10px;
-  background: var(--paper-2);
-  color: var(--ink-soft);
-  cursor: pointer;
-  transition: border-color 120ms, background 120ms;
-}
-.pe-audio-up:hover,
-.pe-audio-up:focus-visible { border-color: var(--accent); outline: none; }
-.pe-audio-up.is-drag {
-  border-color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
-}
-.pe-audio-up .pe-audio-up-ic { font-size: 1.7rem; line-height: 1; }
-.pe-audio-up strong { color: var(--ink); font-size: 0.95rem; }
-.pe-audio-up small { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); max-width: 42ch; }
-
-.pe-audio-list { list-style: none; margin: 0.7rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
-.pe-audio-item {
-  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
-  padding: 0.5rem 0.7rem;
-  border: 1px solid var(--rule);
-  border-radius: 7px;
-  background: var(--paper-2);
-  font-size: 0.85rem;
-}
-.pe-audio-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pe-audio-item-state { flex: none; font-size: 0.8rem; color: var(--ink-soft); }
-.pe-audio-item.is-done { border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); }
-.pe-audio-item.is-done .pe-audio-item-state { color: var(--accent); }
-.pe-audio-item.is-fail .pe-audio-item-state { color: #d9534f; }
-
-/* Video URL row */
-.pe-video-row { display: flex; gap: 0.5rem; }
-.pe-video-row input { flex: 1 1 auto; min-width: 0; }
-.pe-video-row input {
-  box-sizing: border-box; padding: 0.6rem 0.75rem;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background: var(--paper-2); color: var(--ink); font-size: 0.95rem;
-}
-.pe-video-row input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.pe-video-row .pe-btn { flex: none; }
-
-.pe-foto-hint { margin: 0; font-size: 0.88rem; color: var(--ink-soft); }
-
 /* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
 .pe-editor-frame {
@@ -633,6 +366,4 @@
   width: 32px; height: 32px;
   padding: 0;
-  touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
-  -webkit-user-select: none; user-select: none;
   background: transparent;
   border: 1px solid transparent;
@@ -645,21 +376,14 @@
   -webkit-tap-highlight-color: transparent;
 }
-/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
-   after a tap, making the active/inactive state unreadable. */
-@media (hover: hover) {
-  .pe-toolbar button:hover {
-    background: var(--paper-2);
-    color: var(--accent);
-  }
+.pe-toolbar button:hover {
+  background: var(--paper-2);
+  color: var(--accent);
 }
 .pe-toolbar button:active { transform: scale(.94); }
-/* Active formatting = unmistakably filled with the accent colour. On mobile
-   it's immediately clear when e.g. bold is ON (tap again = off). */
 .pe-toolbar button.is-active {
-  background: var(--accent);
-  color: #fff;
-  border-color: var(--accent);
-}
-.pe-toolbar button.is-active svg { color: #fff; }
+  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
+  color: var(--accent);
+  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+}
 .pe-toolbar button svg { width: 16px; height: 16px; }
 .pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
@@ -671,42 +395,4 @@
 }
 .pe-toolbar-spacer { flex: 1; }
-
-/* ─── Full-screen writing mode ─── */
-.fs-icon-compress { display: none; }
-.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
-.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
-.pe-editor-frame.pe-fs {
-  position: fixed; inset: 0; z-index: 1000;
-  margin: 0; border-top: 0;
-  height: 100dvh;
-  background: var(--paper);
-  overflow: hidden;   /* only the text field scrolls, not the frame itself */
-}
-/* In fullscreen the writing field fills the remaining space and scrolls itself
-   (max-height: none overrides the mobile box limit below). */
-.pe-editor-frame.pe-fs .pe-editor {
-  flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
-}
-/* iOS: in fullscreen the toolbar sits at the very top → push it below the
-   camera / Dynamic Island with the top safe-area inset. */
-.pe-editor-frame.pe-fs .pe-toolbar { padding-top: calc(.4rem + env(safe-area-inset-top, 0px)); }
-/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
-   stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
-   whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
-   and on touch scrollbars are hidden → no double bar needed. */
-
-/* "Done" button: only visible in fullscreen (left side of toolbar), clear
-   accent-pill instead of a square icon. */
-/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
-   Done button also shows inline as a small button). Only shown in fullscreen → large. */
-.pe-toolbar button.pe-fs-done { display: none; }
-.pe-editor-frame.pe-fs .pe-fs-done {
-  display: inline-flex; align-items: center; gap: .35rem;
-  width: auto !important; height: auto !important; min-height: 40px;
-  padding: .55rem 1.3rem !important; margin-right: .5rem;
-  background: var(--accent); color: #fff;
-  font-weight: 700; font-size: 1.05rem; border-radius: 999px;
-}
-.pe-editor-frame.pe-fs .pe-fs-done:hover { background: var(--accent); color: #fff; }
 
 /* Editor body — looks like prose, behaves like a textarea */
@@ -726,40 +412,14 @@
 }
 .pe-editor:focus { outline: none; }
-/* Inline (non-fullscreen) the writing field simply grows with the content — no
-   internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
-   always goes fullscreen (see JS), where the field fills the page and is the sole
-   scroller. */
-.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
-
-/* Touch: the inline content field is not a text field but a tap target → fullscreen
-   editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
-   so the hint is always visible without sitting in the middle of the text. */
-.pe-editor.pe-tap-to-edit { cursor: pointer; }
-.pe-edit-hint { display: none; }
-@media (pointer: coarse) {
-  .pe-editor-frame:not(.pe-fs) .pe-edit-hint {
-    display: block;
-    position: sticky;
-    bottom: 4.5rem;   /* above the sticky Save/Cancel bar */
-    width: max-content;
-    max-width: calc(100% - 2rem);
-    margin: .4rem auto;
-    background: var(--accent); color: #fff;
-    font-size: .9rem; font-weight: 700; padding: .5rem 1.1rem; border-radius: 999px;
-    box-shadow: 0 4px 14px rgba(0,0,0,.35);
-    pointer-events: none; text-align: center; white-space: nowrap;
-  }
-  /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
-  .pe-content-hint { display: none; }
-  /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
-     so no formatting toolbar and no border. Just the content preview + the
-     "tap to edit" pill. The toolbar only appears in fullscreen. */
-  .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
-  .pe-editor-frame:not(.pe-fs) { border-top: 0; }
-  .pe-editor-frame:not(.pe-fs) .pe-editor { min-height: 8rem; }
-}
 .pe-editor.is-dragover {
   outline: 2px dashed var(--accent);
   outline-offset: -10px;
+}
+.pe-editor[data-placeholder]:empty::before,
+.pe-editor[data-placeholder]:has(br:only-child)::before {
+  content: attr(data-placeholder);
+  color: var(--ink-muted, #999);
+  pointer-events: none;
+  opacity: .55;
 }
 /* Inline prose styles inside the editor — match the public post styling so
@@ -1118,19 +778,29 @@
 }
 
-/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
-.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
-.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
-.pe-pin-pos[hidden] { display: none; }
-.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
-.pe-pin-btn {
-  width: 30px; height: 28px; padding: 0;
-  border: none; background: var(--paper-2); color: var(--ink);
-  cursor: pointer; font-size: 0.68rem; line-height: 1;
-  display: inline-flex; align-items: center; justify-content: center;
-}
-.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
-.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
-.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
-.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
+/* Pinned rank: replaces the old boolean checkbox with a number input.
+   Stack the label / input / hint vertically — small footprint. */
+.pe-pinned-rank {
+  display: flex; flex-direction: column; gap: 0.25rem;
+  font-size: 0.95rem;
+  color: var(--ink);
+}
+.pe-pinned-rank input[type="number"] {
+  width: 80px;
+  padding: 0.4rem 0.6rem; min-height: 36px;
+  border: 1px solid var(--rule); border-radius: 6px;
+  background: var(--paper-2); color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.95rem;
+  font-variant-numeric: tabular-nums;
+  -webkit-appearance: none; appearance: textfield;
+}
+.pe-pinned-rank input[type="number"]:focus {
+  outline: 2px solid var(--accent); outline-offset: -1px;
+  border-color: var(--accent);
+}
+.pe-pinned-rank small {
+  font-size: 0.78rem;
+  color: var(--ink-muted, var(--ink-soft));
+}
 
 /* ─── Sticky action footer ──────────────────────────────────────── */
@@ -1141,5 +811,5 @@
   gap: 0.5rem;
   padding: 0.85rem 1rem;
-  margin: 0.5rem -1rem 0;   /* extend to viewport edges on a narrow container */
+  margin: 0.5rem -1rem 0;   /* extend to viewport edges on tight container */
   background: color-mix(in srgb, var(--paper) 94%, transparent);
   -webkit-backdrop-filter: blur(8px);
@@ -1150,48 +820,507 @@
 .pe-actions-spacer { flex: 1; }
 
-/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
-   so two bars don't stack at the bottom (Save bar + tab bar). The
-   Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
-   no tab offset needed. On desktop the tab bar is already hidden. */
-body:has(.post-edit-page) .bottom-tab { display: none; }
-/* Audio player (if playing) no longer lifted 56px for the now-hidden
-   tab bar, otherwise it would float above the action bar. */
-body:has(.post-edit-page) .audio-player { bottom: 0; }
-
-/* ── Image editor (rotate / crop / mirror) ── */
-.imed-backdrop {
-  position: fixed; inset: 0; z-index: 9999;
-  display: flex; align-items: center; justify-content: center;
-  background: rgba(0,0,0,.62); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
-  padding: 14px;
-}
-.imed-modal {
-  display: flex; flex-direction: column; gap: 12px;
-  width: 100%; max-width: 640px; max-height: 92vh;
-  background: var(--paper, #fff); color: var(--ink, #111);
-  border: 1px solid var(--rule, rgba(128,128,128,.3)); border-radius: 14px;
-  padding: 14px; box-sizing: border-box;
-}
-.imed-stage {
-  height: 58vh; flex: 0 0 auto;
-  background: var(--paper-2, rgba(128,128,128,.08)); border-radius: 10px; overflow: hidden;
-}
-.imed-stage img { display: block; max-width: 100%; }
-/* Cropper's container must fill the full stage height — otherwise it collapses
-   in a flex column without an explicit height → empty edit window (no image). */
-.imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
-.imed-tools {
-  display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
-}
-.imed-tools button {
-  width: 42px; height: 42px; font-size: 18px; line-height: 1;
-  border: 1px solid var(--rule, rgba(128,128,128,.4)); border-radius: 10px;
-  background: var(--paper-2, transparent); color: inherit; cursor: pointer;
-}
-.imed-tools button:hover { border-color: var(--accent); }
-.imed-actions { display: flex; gap: 8px; justify-content: flex-end; }
+/* On a body that already has a fixed bottom-tab nav (mobile), nudge the
+   sticky footer up so it doesn't sit under it. The bottom-tab is ~64px tall. */
+body.has-bottom-tab .pe-actions {
+  bottom: 64px;
+}
 </style>
 
-
+<script>
+(function() {
+
+  // ── Cover upload ────────────────────────────────────────────────
+  const coverField   = document.getElementById('cover-upload-field');
+  const coverTrigger = document.getElementById('cover-upload-trigger');
+  const coverUrl     = document.getElementById('cover-url-field');
+  const coverStatus  = document.getElementById('cover-upload-status');
+  const coverWrap    = document.getElementById('cover-preview-wrap');
+  const coverImg     = document.getElementById('cover-preview-img');
+
+  async function uploadImage(file) {
+    const fd = new FormData();
+    fd.append('image', file);
+    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
+    if (!res.ok) {
+      const j = await res.json().catch(() => ({}));
+      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
+    }
+    return await res.json();   // {url, size, mime}
+  }
+
+  function showCoverPreview(url) {
+    if (!coverWrap || !coverImg) return;
+    if (url) {
+      coverImg.src = url;
+      coverImg.hidden = false;
+      coverWrap.removeAttribute('data-empty');
+      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
+      if (emptyIcon) emptyIcon.remove();
+    } else {
+      coverImg.hidden = true;
+      coverImg.src = '';
+      coverWrap.setAttribute('data-empty', '');
+      if (!coverWrap.querySelector('.pe-cover-empty')) {
+        const span = document.createElement('span');
+        span.className = 'pe-cover-empty';
+        span.textContent = '🖼';
+        coverWrap.appendChild(span);
+      }
+    }
+  }
+
+  if (coverTrigger && coverField) {
+    coverTrigger.addEventListener('click', () => coverField.click());
+  }
+  if (coverField) {
+    coverField.addEventListener('change', async () => {
+      if (!coverField.files[0]) return;
+      coverStatus.classList.remove('is-error');
+      coverStatus.textContent = 'Uploaden…';
+      try {
+        const j = await uploadImage(coverField.files[0]);
+        coverUrl.value = j.url;
+        showCoverPreview(j.url);
+        coverStatus.textContent = 'Geüpload ✓';
+        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
+      } catch (e) {
+        coverStatus.classList.add('is-error');
+        coverStatus.textContent = 'Mislukt: ' + e.message;
+      }
+    });
+  }
+  // Live-update preview when user pastes a URL manually
+  if (coverUrl) {
+    coverUrl.addEventListener('input', () => {
+      const v = coverUrl.value.trim();
+      if (v) showCoverPreview(v); else showCoverPreview('');
+    });
+  }
+
+  // ── WYSIWYG editor (P58) ────────────────────────────────────────
+  // Architecture:
+  //   - Visible <div contenteditable> (`#content-editor`) is what the user
+  //     types in; it shows real HTML (formatted, not raw markup).
+  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
+  //     On submit we serialize the editor's HTML into it, with shortcode
+  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
+  //   - Initial content comes from a <script type="application/json"> tag
+  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
+  //     and walk text nodes to render shortcode tokens as chips.
+  const contentField  = document.getElementById('content-upload-field');
+  const contentBtn    = document.getElementById('insert-image-btn');
+  const contentStatus = document.getElementById('content-upload-status');
+  const editor        = document.getElementById('content-editor');
+  const hiddenField   = document.getElementById('content-hidden');
+  const charCountEl   = document.getElementById('char-count');
+  const initialEl     = document.getElementById('initial-content');
+  const toolbar       = document.getElementById('pe-toolbar');
+  const form          = editor && editor.closest('form');
+
+  if (!editor) return;
+
+  // ── Shortcode chip rendering / serialization ────────────────────
+  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
+  // — but we DON'T want to chipify text the user is mid-typing inside an
+  // HTML attribute; since chipify only walks text nodes (never attribute
+  // values) that's already safe.
+  const SC_RE = /\[\[(track|album|playlist):([^\]]+)\]\]/g;
+
+  const SC_ICONS = {
+    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
+    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
+    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
+  };
+
+  function chipLabel(kind, value) {
+    if (kind === 'track') {
+      // UUIDs are noisy — show a 6-char prefix for visual hint
+      const v = String(value || '');
+      return 'Track ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
+    }
+    if (kind === 'album')    return 'Album: ' + value;
+    if (kind === 'playlist') return 'Playlist: ' + value;
+    return value;
+  }
+
+  function makeChip(kind, value) {
+    const span = document.createElement('span');
+    span.className = 'sc-chip';
+    span.contentEditable = 'false';
+    span.setAttribute('data-sc', kind + ':' + value);
+    span.innerHTML =
+      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
+      '<span class="sc-chip-label"></span>';
+    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
+    return span;
+  }
+
+  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
+  function chipifyShortcodes(root) {
+    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
+    const targets = [];
+    while (walker.nextNode()) {
+      const n = walker.currentNode;
+      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
+      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
+      if (SC_RE.test(n.nodeValue)) targets.push(n);
+      SC_RE.lastIndex = 0;
+    }
+    for (const node of targets) {
+      const txt = node.nodeValue;
+      const frag = document.createDocumentFragment();
+      let last = 0;
+      let m;
+      SC_RE.lastIndex = 0;
+      while ((m = SC_RE.exec(txt)) !== null) {
+        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
+        frag.appendChild(makeChip(m[1], m[2].trim()));
+        last = m.index + m[0].length;
+      }
+      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
+      node.parentNode.replaceChild(frag, node);
+    }
+  }
+
+  // Inverse of chipify: clone the editor, replace every chip with its text.
+  function serializeChips(rootClone) {
+    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
+    for (const c of chips) {
+      const txt = '[[' + c.getAttribute('data-sc') + ']]';
+      c.replaceWith(document.createTextNode(txt));
+    }
+  }
+
+  // ── Boot: load initial content as HTML, then render shortcodes as chips
+  try {
+    const initial = JSON.parse(initialEl.textContent || '""');
+    editor.innerHTML = initial || '';
+    chipifyShortcodes(editor);
+  } catch (e) {
+    console.error('[editor] could not parse initial content', e);
+    editor.innerHTML = '';
+  }
+
+  // ── Char counter
+  function updateCharCount() {
+    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
+    if (charCountEl) charCountEl.textContent = String(text.length);
+  }
+  updateCharCount();
+  editor.addEventListener('input', updateCharCount);
+
+  // ── Toolbar wiring
+  function execCmd(cmd, arg) {
+    editor.focus();
+    document.execCommand(cmd, false, arg);
+    updateToolbarState();
+    updateCharCount();
+  }
+  function wrapCode() {
+    const sel = window.getSelection();
+    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
+    const range = sel.getRangeAt(0);
+    const code = document.createElement('code');
+    code.textContent = sel.toString();
+    range.deleteContents();
+    range.insertNode(code);
+    // Move caret after the new node
+    range.setStartAfter(code);
+    range.collapse(true);
+    sel.removeAllRanges();
+    sel.addRange(range);
+    editor.focus();
+  }
+  function linkPrompt() {
+    const url = window.prompt('Link URL (https://… of /pad)');
+    if (!url) return;
+    execCmd('createLink', url);
+  }
+
+  if (toolbar) {
+    toolbar.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-cmd]');
+      if (!btn) return;
+      e.preventDefault();
+      const cmd = btn.dataset.cmd;
+      const arg = btn.dataset.arg || null;
+      if (cmd === 'link-prompt') linkPrompt();
+      else if (cmd === 'code-wrap') wrapCode();
+      else execCmd(cmd, arg);
+    });
+  }
+
+  // Reflect bold/italic/list state on the toolbar buttons
+  function updateToolbarState() {
+    if (!toolbar) return;
+    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
+    for (const cmd of cmds) {
+      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
+      if (!btn) continue;
+      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
+    }
+  }
+  document.addEventListener('selectionchange', () => {
+    if (document.activeElement === editor) updateToolbarState();
+  });
+
+  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
+  editor.addEventListener('keydown', (e) => {
+    const mod = e.ctrlKey || e.metaKey;
+    if (!mod) return;
+    const k = e.key.toLowerCase();
+    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
+    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
+    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
+    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
+  });
+
+  // Paste: keep it simple — strip formatting unless user wants it. Default
+  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
+  // styles from clipboard only when shift is held — otherwise plain text.
+  editor.addEventListener('paste', (e) => {
+    if (e.shiftKey) return; // user wants formatted paste
+    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+    if (text == null) return;
+    e.preventDefault();
+    document.execCommand('insertText', false, text);
+  });
+
+  // ── Image upload (button + drag-drop into the editor)
+  async function uploadAndInsertImage(file) {
+    contentStatus.classList.remove('is-error');
+    contentStatus.textContent = 'Uploaden…';
+    try {
+      const j = await uploadImage(file);
+      const img = '<img src="' + j.url + '" alt="">';
+      editor.focus();
+      document.execCommand('insertHTML', false, img);
+      contentStatus.textContent = 'Ingevoegd ✓';
+      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
+      updateCharCount();
+    } catch (e) {
+      contentStatus.classList.add('is-error');
+      contentStatus.textContent = 'Mislukt: ' + e.message;
+    }
+  }
+
+  if (contentBtn && contentField) {
+    contentBtn.addEventListener('click', () => contentField.click());
+    contentField.addEventListener('change', () => {
+      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
+      contentField.value = '';
+    });
+
+    editor.addEventListener('dragover', (e) => {
+      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+        e.preventDefault();
+        editor.classList.add('is-dragover');
+      }
+    });
+    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
+    editor.addEventListener('drop', async (e) => {
+      editor.classList.remove('is-dragover');
+      const files = e.dataTransfer && e.dataTransfer.files;
+      if (!files || !files.length) return;
+      e.preventDefault();
+      for (const f of files) {
+        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
+      }
+    });
+  }
+
+  // ── Insert chip helpers (track / playlist)
+  function insertChip(kind, value) {
+    editor.focus();
+    const chip = makeChip(kind, value);
+    // Insert at caret using the Selection API (execCommand insertNode)
+    const sel = window.getSelection();
+    if (sel && sel.rangeCount > 0) {
+      const range = sel.getRangeAt(0);
+      range.deleteContents();
+      range.insertNode(chip);
+      // Insert a trailing space so the user can keep typing after the chip
+      const space = document.createTextNode('\u00A0');
+      chip.after(space);
+      range.setStartAfter(space);
+      range.collapse(true);
+      sel.removeAllRanges();
+      sel.addRange(range);
+    } else {
+      editor.appendChild(chip);
+      editor.appendChild(document.createTextNode('\u00A0'));
+    }
+    updateCharCount();
+  }
+
+  // ── Track insert: opens the track-picker modal (P59)
+  const trackBtn = document.getElementById('insert-track-btn');
+  const trackPicker = document.getElementById('track-picker');
+  if (trackBtn && trackPicker) {
+    const tpList   = document.getElementById('tp-list');
+    const tpEmpty  = document.getElementById('tp-empty');
+    const tpSearch = document.getElementById('tp-search');
+    let tpCache = null;       // cached tracks list (fetched once per page load)
+    let tpLastFocus = null;   // element to restore focus to on close
+
+    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
+
+    function fmtDur(sec) {
+      sec = Math.max(0, Math.floor(sec || 0));
+      const m = Math.floor(sec / 60), s = sec % 60;
+      return m + ':' + String(s).padStart(2, '0');
+    }
+    function escAttr(s) {
+      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+      }[c]));
+    }
+
+    function renderList(filter) {
+      if (!Array.isArray(tpCache)) return;
+      const q = (filter || '').trim().toLowerCase();
+      const filtered = q
+        ? tpCache.filter(t =>
+            (t.title  || '').toLowerCase().includes(q) ||
+            (t.artist || '').toLowerCase().includes(q))
+        : tpCache;
+
+      if (!filtered.length) {
+        tpList.innerHTML = '';
+        tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer → Audio.';
+        tpList.appendChild(tpEmpty);
+        return;
+      }
+
+      tpList.innerHTML = filtered.map(t => {
+        const cov = t.cover
+          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
+          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
+        const dis = t.playable ? '' : ' aria-disabled="true"';
+        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
+        return (
+          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
+            cov +
+            '<span class="tp-meta">' +
+              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
+              sub +
+            '</span>' +
+            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
+          '</button>'
+        );
+      }).join('');
+    }
+
+    async function loadTracks() {
+      if (Array.isArray(tpCache)) return tpCache;
+      tpEmpty.textContent = 'Tracks laden…';
+      try {
+        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
+        const j = await r.json();
+        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
+      } catch (e) {
+        tpCache = [];
+        tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
+      }
+      return tpCache;
+    }
+
+    function openPicker() {
+      tpLastFocus = document.activeElement;
+      trackPicker.hidden = false;
+      trackPicker.setAttribute('aria-hidden', 'false');
+      document.body.classList.add('tp-locked');
+      tpSearch.value = '';
+      renderList('');
+      // Defer focus so the open animation doesn't get jumped
+      setTimeout(() => tpSearch.focus(), 30);
+    }
+    function closePicker() {
+      trackPicker.hidden = true;
+      trackPicker.setAttribute('aria-hidden', 'true');
+      document.body.classList.remove('tp-locked');
+      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
+        try { tpLastFocus.focus(); } catch(_) {}
+      }
+    }
+
+    trackBtn.addEventListener('click', async () => {
+      openPicker();
+      await loadTracks();
+      renderList(tpSearch.value);
+    });
+
+    // Close: backdrop click, [data-tp-close], or Escape
+    trackPicker.addEventListener('click', (e) => {
+      if (e.target.closest('[data-tp-close]')) {
+        closePicker();
+        return;
+      }
+      const row = e.target.closest('.tp-row[data-track-id]');
+      if (row) {
+        if (row.getAttribute('aria-disabled') === 'true') return;
+        const id = row.dataset.trackId;
+        if (id) {
+          insertChip('track', id);
+          closePicker();
+        }
+      }
+    });
+    document.addEventListener('keydown', (e) => {
+      if (!trackPicker.hidden && e.key === 'Escape') {
+        e.preventDefault();
+        closePicker();
+      }
+    });
+
+    // Live filter
+    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
+  }
+
+  // ── Playlist insert (open existing or create new via modal)
+  const playlistBtn = document.getElementById('insert-playlist-btn');
+  if (playlistBtn) {
+    playlistBtn.addEventListener('click', async () => {
+      if (typeof window.openPlaylistEditor !== 'function') {
+        alert('Playlist editor niet geladen');
+        return;
+      }
+      try {
+        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
+        const j = await r.json();
+        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
+          const choice = prompt(
+            'Bestaande playlists:\n\n' +
+            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
+            '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
+          );
+          if (choice && /^\d+$/.test(choice.trim())) {
+            const idx = parseInt(choice.trim(), 10) - 1;
+            if (idx >= 0 && idx < j.playlists.length) {
+              insertChip('playlist', j.playlists[idx].id);
+              return;
+            }
+          }
+          if (choice === null) return;
+        }
+      } catch (_) { /* fall through to create */ }
+
+      window.openPlaylistEditor({
+        mode: 'create',
+        onSaved: ({ id }) => insertChip('playlist', id),
+      });
+    });
+  }
+
+  // ── Submit: serialize editor contents into the hidden field
+  if (form && hiddenField) {
+    form.addEventListener('submit', () => {
+      const clone = editor.cloneNode(true);
+      serializeChips(clone);
+      hiddenField.value = clone.innerHTML;
+    });
+  }
+})();
+</script>
 
 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
@@ -1202,6 +1331,6 @@
   <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
     <header class="tp-header">
-      <h2 id="tp-title" class="tp-h2"><%= t('pedit.tp_title') %></h2>
-      <button type="button" class="tp-close" data-tp-close aria-label="<%= t('pedit.tp_close') %>">
+      <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
+      <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
       </button>
@@ -1211,8 +1340,8 @@
         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
       </span>
-      <input type="search" class="tp-search" id="tp-search" placeholder="<%= t('pedit.tp_search_placeholder') %>" autocomplete="off" inputmode="search">
+      <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiest…" autocomplete="off" inputmode="search">
     </div>
-    <div class="tp-list" id="tp-list" role="listbox" aria-label="<%= t('pedit.tp_list_aria') %>">
-      <div class="tp-empty" id="tp-empty"><%= t('pedit.js_tracks_loading') %></div>
+    <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
+      <div class="tp-empty" id="tp-empty">Tracks laden…</div>
     </div>
   </div>
@@ -1225,3 +1354,2 @@
   <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 <% } %>
-<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/post.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,28 +1,6 @@
-<%
-// In hub mode the site is accessible under /user/<slug>; all site-scoped links
-// (tag, edit, delete, comments) must carry that prefix otherwise they fall back
-// to the primary site → 404 / wrong site. In solo mode _base = ''.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-%>
-<article class="container post-page<%= post.nsfw ? ' nsfw-gate' : '' %>">
-
-  <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
-  <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
-
-  <% if (post.nsfw) { %>
-    <div class="nsfw-banner">
-      <span class="nsfw-banner-icon">🔞</span>
-      <span class="nsfw-banner-text"><%= post.content_warning || t('post.nsfw_warning') %></span>
-      <button type="button" class="nsfw-banner-btn nsfw-reveal"><%= t('post.nsfw_show') %></button>
-    </div>
-  <% } %>
-
+<article class="container post-page">
   <% if (post.cover_image_url) { %>
     <figure class="post-cover">
-      <img src="<%= post.cover_image_url %>" alt="<%= post.cover_alt || '' %>"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-    </figure>
-  <% } else if (post.cover_video_url) { %>
-    <figure class="post-cover">
-      <video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 1280) %>" controls loop muted playsinline preload="metadata"></video>
+      <img src="<%= post.cover_image_url %>" alt="">
     </figure>
   <% } %>
@@ -49,34 +27,11 @@
   </div>
 
-  <% if (typeof poll !== 'undefined' && poll) { %>
-    <section class="poll" aria-label="<%= t('poll.aria') %>">
-      <% poll.options.forEach(function(o){ var _lead = poll.total > 0 && o.count === Math.max.apply(null, poll.options.map(function(x){return x.count;})); %>
-        <div class="poll-opt<%= _lead ? ' is-lead' : '' %>">
-          <div class="poll-bar" style="width:<%= o.pct %>%"></div>
-          <span class="poll-name"><%= o.name %></span>
-          <span class="poll-pct"><%= o.pct %>%</span>
-        </div>
-      <% }); %>
-      <div class="poll-meta">
-        <span><%= poll.voters %> <%= poll.voters === 1 ? t('poll.voter_one') : t('poll.voter_many') %></span>
-        <span aria-hidden="true">·</span>
-        <% if (poll.closed) { %>
-          <span><%= t('poll.closed') %></span>
-        <% } else if (poll.endTime) { %>
-          <span><%= t('poll.closes') %> <%= new Date(poll.endTime).toLocaleString() %></span>
-        <% } %>
-        <% if (poll.multiple) { %><span aria-hidden="true">·</span><span><%= t('poll.multiple') %></span><% } %>
-      </div>
-      <p class="poll-note"><%= t('poll.fedi_only') %></p>
-    </section>
-  <% } %>
-
   <% if (post.tags && post.tags.length > 0) { %>
     <div class="post-tags">
       <% post.tags.forEach(t => { %>
-        <a class="tag" href="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
-           hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1"
+        <a class="tag" href="/tag/<%= encodeURIComponent(t) %>"
+           hx-get="/tag/<%= encodeURIComponent(t) %>?partial=1"
            hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
+           hx-push-url="/tag/<%= encodeURIComponent(t) %>"
            hx-indicator="#pcms-loading">#<%= t %></a>
       <% }); %>
@@ -84,31 +39,16 @@
   <% } %>
 
-  <div class="post-share" style="margin:1.25rem 0;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
-    <button type="button" class="btn post-share-btn" data-share data-share-title="<%= post.title %>">🔗 <%= t('post.share') %></button>
-    <%# Interact via the fediverse — for VISITORS (reply/like/boost from their own
-        server). Same flow as the CTA in the fediverse section; hidden for the
-        owner/admin (their own post) and when AP is off. %>
-    <% if (post.status === 'published' && (typeof apEnabled === 'undefined' || apEnabled) && (typeof canManageSite === 'undefined' || !canManageSite)) { %>
-      <% var _shareAbsUrl = (typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + (typeof _base !== 'undefined' && _base ? _base : '') + '/' + post.slug; %>
-      <button type="button" class="btn fedi-remote-reply-btn" data-fedi-uri="<%= _shareAbsUrl %>" data-fedi-ph="<%= t('fedi.remote_ph') %>">
-        <svg viewBox="0 0 24 24" width="16" height="16" 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>
-        <%= t('fedi.remote_interact') %>
-      </button>
-    <% } %>
-    <span class="post-share-feedback" id="post-share-feedback" style="color:var(--accent);font-size:.85rem" hidden><%= t('post.share_copied') %></span>
-  </div>
-
   <!-- Inline admin actions: only visible if user has permission -->
   <% if (user && (permissions.canEditPost(user, post, site) || permissions.canDeletePost(user, post, site))) { %>
     <aside class="post-actions">
       <% if (permissions.canEditPost(user, post, site)) { %>
-        <a href="<%= _base %>/posts/<%= post.slug %>/edit" class="btn"
-           hx-get="<%= _base %>/posts/<%= post.slug %>/edit?partial=1" hx-target="#pcms-main"
-           hx-push-url="<%= _base %>/posts/<%= post.slug %>/edit" hx-indicator="#pcms-loading">
+        <a href="/posts/<%= post.slug %>/edit" class="btn"
+           hx-get="/posts/<%= post.slug %>/edit?partial=1" hx-target="#pcms-main"
+           hx-push-url="/posts/<%= post.slug %>/edit" hx-indicator="#pcms-loading">
           ✏️ Edit
         </a>
       <% } %>
       <% if (permissions.canDeletePost(user, post, site)) { %>
-        <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" data-confirm="Delete this post?">
+        <form method="post" action="/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
           <button type="submit" class="btn btn-danger">🗑 Delete</button>
         </form>
@@ -117,38 +57,140 @@
   <% } %>
 
-  <!-- Fediverse interactions — single place: ⭐ like (clickable) + 🔁/💬 counts + reply -->
-  <% var _fedi = (typeof fediverse !== 'undefined' && fediverse) ? fediverse : { thread: [], likeCount: 0, announceCount: 0 }; %>
-  <% if (post.status === 'published' && (typeof apEnabled === 'undefined' || apEnabled)) { %>
-    <section class="post-fediverse post-comments" id="fediverse">
-      <h2 class="comments-heading"><%= t('fedi.heading') %></h2>
-      <div class="fedi-stats">
-        <span class="fedi-stat" title="<%= t('fedi.likes') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
-          <span><%= _fedi.likeCount %></span>
-        </span>
-        <span class="fedi-stat" title="<%= t('fedi.boosts') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" 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>
-          <span><%= _fedi.announceCount %></span>
-        </span>
-        <span class="fedi-stat" title="<%= t('fedi.replies') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
-          <span><%= (_fedi.thread || []).length %></span>
-        </span>
-      </div>
-      <% if (_fedi.thread && _fedi.thread.length) { %>
-        <ol class="comments-list">
-          <% _fedi.thread.forEach(function(n){ %>
-            <li class="comment"><%- include('../partials/fedi-node', { n: n, t: t, canManageSite: (typeof canManageSite !== 'undefined' ? canManageSite : false), _base: _base, siteAvatar: (typeof siteAvatar !== 'undefined' ? siteAvatar : null), formatDateTime: formatDateTime, postSlug: post.slug }) %></li>
-          <% }); %>
-        </ol>
-      <% } %>
-      <%# The "Interact via the fediverse" button for visitors now lives next to
-          the Share button at the top of the post (single, prominent place). %>
-    </section>
-  <% } %>
-  <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt
-    door de CSP geweigerd zodra je deze pagina via een link binnen de site opent
-    -- zie shaer-0i6. %>
-
+  <!-- Comments -->
+  <section class="post-comments" id="comments">
+    <h2 class="comments-heading">
+      <%= totalComments %> comment<%= totalComments === 1 ? '' : 's' %>
+    </h2>
+
+    <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %>
+      <% /* If the URL has ?pending=1, the previous submit landed in the queue */ %>
+    <% } %>
+    <script>
+      (function() {
+        if (location.search.indexOf('pending=1') !== -1) {
+          var s = document.createElement('div');
+          s.className = 'comments-pending-flash';
+          s.textContent = 'Your comment is awaiting moderation. It will appear once an admin approves it.';
+          document.currentScript.parentNode.insertBefore(s, document.currentScript);
+        }
+      })();
+    </script>
+
+    <% if (!comments || !comments.length) { %>
+      <p class="comments-empty">No comments yet.<% if (!user) { %> <a href="/auth/login?next=<%= encodeURIComponent('/' + post.slug + '#comments') %>">Log in</a> to start the conversation.<% } %></p>
+    <% } else { %>
+      <ol class="comments-list">
+        <% comments.forEach(function(c) { %>
+          <li class="comment" id="comment-<%= c.id %>">
+            <div class="comment-avatar">
+              <% if (c.author_avatar) { %>
+                <img src="<%= c.author_avatar %>" alt="">
+              <% } else { %>
+                <span><%= c.author_username.charAt(0).toUpperCase() %></span>
+              <% } %>
+            </div>
+            <div class="comment-body">
+              <div class="comment-meta">
+                <a class="comment-author" href="/users/<%= encodeURIComponent(c.author_username) %>"><%= c.author_username %></a>
+                <span class="comment-time" title="<%= c.created_at %>"><%= formatDateTime(c.created_at) %></span>
+              </div>
+              <div class="comment-content"><%= c.content %></div>
+              <div class="comment-actions">
+                <% if (user) { %>
+                  <button type="button" class="comment-reply-btn" data-reply-to="<%= c.id %>">Reply</button>
+                <% } %>
+                <% if (user && permissions.canDeleteComment(user, c, site)) { %>
+                  <form method="post" action="/comments/<%= c.id %>/delete" style="display:inline" onsubmit="return confirm('Delete this comment?')">
+                    <button type="submit" class="comment-delete-btn">Delete</button>
+                  </form>
+                <% } %>
+              </div>
+
+              <!-- Inline reply form (hidden by default) -->
+              <% if (user) { %>
+                <form method="post" action="/comments" class="comment-reply-form" hidden data-reply-form-for="<%= c.id %>">
+                  <input type="hidden" name="post_slug" value="<%= post.slug %>">
+                  <input type="hidden" name="parent_comment_id" value="<%= c.id %>">
+                  <textarea name="content" rows="3" maxlength="4000" placeholder="Reply to <%= c.author_username %>…" required></textarea>
+                  <div class="comment-reply-form-actions">
+                    <button type="submit" class="btn btn-primary">Reply</button>
+                    <button type="button" class="btn comment-cancel-reply">Cancel</button>
+                  </div>
+                </form>
+              <% } %>
+
+              <!-- Replies (1 level deep) -->
+              <% if (c.replies && c.replies.length) { %>
+                <ol class="comment-replies">
+                  <% c.replies.forEach(function(r) { %>
+                    <li class="comment comment-reply" id="comment-<%= r.id %>">
+                      <div class="comment-avatar">
+                        <% if (r.author_avatar) { %>
+                          <img src="<%= r.author_avatar %>" alt="">
+                        <% } else { %>
+                          <span><%= r.author_username.charAt(0).toUpperCase() %></span>
+                        <% } %>
+                      </div>
+                      <div class="comment-body">
+                        <div class="comment-meta">
+                          <a class="comment-author" href="/users/<%= encodeURIComponent(r.author_username) %>"><%= r.author_username %></a>
+                          <span class="comment-time"><%= formatDateTime(r.created_at) %></span>
+                        </div>
+                        <div class="comment-content"><%= r.content %></div>
+                        <div class="comment-actions">
+                          <% if (user && permissions.canDeleteComment(user, r, site)) { %>
+                            <form method="post" action="/comments/<%= r.id %>/delete" style="display:inline" onsubmit="return confirm('Delete this comment?')">
+                              <button type="submit" class="comment-delete-btn">Delete</button>
+                            </form>
+                          <% } %>
+                        </div>
+                      </div>
+                    </li>
+                  <% }); %>
+                </ol>
+              <% } %>
+            </div>
+          </li>
+        <% }); %>
+      </ol>
+    <% } %>
+
+    <!-- Top-level comment form (only for logged-in users for now) -->
+    <% if (user) { %>
+      <form method="post" action="/comments" class="comment-form">
+        <input type="hidden" name="post_slug" value="<%= post.slug %>">
+        <label class="comment-form-label">
+          <span>Add a comment as <strong><%= user.username %></strong></span>
+          <textarea name="content" rows="3" maxlength="4000" placeholder="Share your thoughts…" required></textarea>
+        </label>
+        <button type="submit" class="btn btn-primary">Post comment</button>
+      </form>
+    <% } else { %>
+      <p class="comments-login-cta">
+        <a href="/auth/login?next=<%= encodeURIComponent('/' + post.slug + '#comments') %>" class="btn">Log in to comment</a>
+      </p>
+    <% } %>
+  </section>
+
+  <!-- Reply form toggle -->
+  <script>
+  (function() {
+    document.querySelectorAll('.comment-reply-btn').forEach(function(btn) {
+      btn.addEventListener('click', function() {
+        var id = btn.dataset.replyTo;
+        var form = document.querySelector('[data-reply-form-for="' + id + '"]');
+        if (form) {
+          form.hidden = !form.hidden;
+          if (!form.hidden) form.querySelector('textarea').focus();
+        }
+      });
+    });
+    document.querySelectorAll('.comment-cancel-reply').forEach(function(btn) {
+      btn.addEventListener('click', function() {
+        btn.closest('.comment-reply-form').hidden = true;
+      });
+    });
+  })();
+  </script>
 
   <%# ── Related posts (3-card grid) ─────────────────────────────
@@ -158,15 +200,17 @@
   <% if (relatedPosts && relatedPosts.length > 0) { %>
     <section class="post-related" aria-labelledby="related-heading">
-      <h2 class="post-related-heading" id="related-heading"><%= t('related.title') %></h2>
+      <h2 class="post-related-heading" id="related-heading">Gerelateerde posts</h2>
       <ul class="post-related-grid">
         <% relatedPosts.forEach(function(rp) { %>
           <li class="post-related-card">
-            <a href="<%= rp._urlBase || '' %>/<%= rp.slug %>"<% if (!rp._urlBase) { %>
+            <a href="/<%= rp.slug %>"
                hx-get="/<%= rp.slug %>?partial=1" hx-target="#pcms-main"
-               hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading"<% } %>>
+               hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading">
               <% if (rp.cover_image_url) { %>
-                <span class="post-related-cover<%= rp.nsfw ? ' nsfw-media' : '' %>" aria-hidden="true"><img class="post-related-img" src="<%= rp.cover_image_url %>" alt="" loading="lazy" decoding="async"<% if (rp.cover_video_url) { %> data-ios-mp4="<%= rp.cover_video_url %>"<% } %>><% if (rp.nsfw) { %><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: rp.content_warning }) %></span><% } %></span>
+                <span class="post-related-cover"
+                      style="background-image: url('<%= rp.cover_image_url %>')"
+                      aria-hidden="true"></span>
               <% } else { %>
-                <span class="post-related-cover post-related-cover--empty<%= rp.nsfw ? ' nsfw-media' : '' %>" aria-hidden="true"><% if (rp.nsfw) { %><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: rp.content_warning }) %></span><% } %></span>
+                <span class="post-related-cover post-related-cover--empty" aria-hidden="true"></span>
               <% } %>
               <span class="post-related-meta">
@@ -181,9 +225,80 @@
   <% } %>
 
+  <%# ── Pinned navigation (only when current post is pinned) ───
+      Two card-style links: "Vorige vastgepinde" / "Volgende vastgepinde".
+      Edge-of-stack states show "← BOVENAAN — Nieuwste post" or the equivalent
+      for the bottom. Layout mirrors v9 exactly. %>
+  <% if (post.pinned) { %>
+    <nav class="post-pinned-nav" aria-label="Pinned navigation">
+      <%# LEFT card: post one step UP the pinned stack (towards #1).
+          Uses prevPinnedPost which now means "smaller rank". %>
+      <% if (prevPinnedPost) { %>
+        <a class="post-pinned-card post-pinned-card--prev" href="/<%= prevPinnedPost.slug %>"
+           hx-get="/<%= prevPinnedPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= prevPinnedPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-pinned-arrow" aria-hidden="true">←</span>
+          <span class="post-pinned-body">
+            <span class="post-pinned-label">📌 Vorige vastgepinde</span>
+            <span class="post-pinned-title"><%= prevPinnedPost.title %></span>
+            <span class="post-pinned-cta">Lees deze post →</span>
+          </span>
+        </a>
+      <% } else if (pinnedTopOfStack) { %>
+        <span class="post-pinned-card post-pinned-card--edge">
+          <span class="post-pinned-edge-label">— BOVENAAN —</span>
+          <span class="post-pinned-edge-title">Nieuwste post</span>
+        </span>
+      <% } %>
+
+      <%# RIGHT card: post one step DOWN the pinned stack (away from #1).
+          Uses nextPinnedPost which now means "larger rank". %>
+      <% if (nextPinnedPost) { %>
+        <a class="post-pinned-card post-pinned-card--next" href="/<%= nextPinnedPost.slug %>"
+           hx-get="/<%= nextPinnedPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= nextPinnedPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-pinned-arrow" aria-hidden="true">→</span>
+          <span class="post-pinned-body">
+            <span class="post-pinned-label">📌 Volgende vastgepinde</span>
+            <span class="post-pinned-title"><%= nextPinnedPost.title %></span>
+            <span class="post-pinned-cta">← Lees deze post</span>
+          </span>
+        </a>
+      <% } else if (pinnedBottomOfStack) { %>
+        <span class="post-pinned-card post-pinned-card--edge">
+          <span class="post-pinned-edge-label">— ONDERAAN —</span>
+          <span class="post-pinned-edge-title">Oudste post</span>
+        </span>
+      <% } %>
+    </nav>
+  <% } %>
+
+  <%# ── Chronological prev/next (kept for non-pinned posts) ──────
+      For pinned posts the pinned-nav above replaces this. Non-pinned
+      regular posts still get the simple older/newer pair. %>
+  <% if (!post.pinned && (prevPost || nextPost)) { %>
+    <nav class="post-nav">
+      <% if (prevPost) { %>
+        <a class="post-nav-prev" href="/<%= prevPost.slug %>"
+           hx-get="/<%= prevPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= prevPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-nav-label">← Older</span>
+          <span class="post-nav-title"><%= prevPost.title %></span>
+        </a>
+      <% } else { %><span></span><% } %>
+      <% if (nextPost) { %>
+        <a class="post-nav-next" href="/<%= nextPost.slug %>"
+           hx-get="/<%= nextPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= nextPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-nav-label">Newer →</span>
+          <span class="post-nav-title"><%= nextPost.title %></span>
+        </a>
+      <% } %>
+    </nav>
+  <% } %>
 </article>
 <style>
-.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
+.post-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
 .post-cover { margin: 0 0 2rem; border-radius: 8px; overflow: hidden; }
-.post-cover img, .post-cover video { width: 100%; height: auto; display: block; }
+.post-cover img { width: 100%; height: auto; display: block; }
 .post-header { margin-bottom: 2rem; }
 .post-title { font-family: var(--font-display, serif); font-size: 2.5rem; line-height: 1.1; margin: 0 0 1rem; }
@@ -194,25 +309,4 @@
 .post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
 .post-content p { margin: 1.2em 0; }
-/* Poll (a hosted AS2 Question) — display-only; voting happens from the fediverse. */
-.poll { margin: 1.75rem 0; display: flex; flex-direction: column; gap: .5rem; }
-.poll-opt { position: relative; display: flex; align-items: center; gap: .5rem; padding: .55rem .75rem; border: 1px solid var(--line, rgba(128,128,128,.25)); border-radius: 8px; overflow: hidden; font-size: .98rem; }
-.poll-bar { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 18%, transparent); z-index: 0; transition: width .3s ease; }
-.poll-opt.is-lead .poll-bar { background: color-mix(in srgb, var(--accent) 30%, transparent); }
-.poll-name { position: relative; z-index: 1; flex: 1; color: var(--ink); }
-.poll-opt.is-lead .poll-name { font-weight: 600; }
-.poll-pct { position: relative; z-index: 1; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
-.poll-meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .85rem; color: var(--ink-muted); }
-.poll-note { font-size: .8rem; color: var(--ink-muted); margin: .1rem 0 0; }
-.like-btn {
-  display: inline-flex; align-items: center; gap: 0.45rem;
-  padding: 0.4rem 0.85rem; border: 1px solid var(--rule); border-radius: 999px;
-  background: var(--paper-2); color: var(--ink); font: inherit; font-size: 0.95rem;
-  cursor: pointer; text-decoration: none; transition: border-color 120ms, color 120ms, background 120ms;
-}
-.like-btn:hover { border-color: var(--accent); }
-.like-btn .like-heart { font-size: 1.1rem; line-height: 1; }
-.like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
-.like-btn.is-liked .like-heart { color: var(--accent); }
-.like-btn .like-count { font-variant-numeric: tabular-nums; }
 .post-content h2 { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 2rem 0 1rem; }
 .post-content h3 { font-family: var(--font-display, serif); font-size: 1.3rem; margin: 1.5rem 0 0.75rem; }
@@ -226,4 +320,10 @@
 .tag:hover { background: var(--accent); color: white; }
 .post-actions { display: flex; gap: 0.5rem; margin: 2rem 0; padding: 1rem; background: var(--paper-2); border-radius: 6px; flex-wrap: wrap; }
+.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
+.post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
+.post-nav-next { text-align: right; align-items: flex-end; }
+.post-nav-label { font-size: 0.8rem; color: var(--ink-muted); margin-bottom: 0.25rem; }
+.post-nav-title { font-weight: 500; }
+@media (max-width: 600px) { .post-nav { grid-template-columns: 1fr; } }
 
 /* ============================================================
@@ -290,6 +390,4 @@
   gap: 0.5rem;
   align-items: baseline;
-  flex-wrap: wrap;
-  row-gap: 0.1rem;
   font-size: 0.85rem;
   margin-bottom: 0.3rem;
@@ -298,8 +396,4 @@
 .comment-author:hover { color: var(--accent); }
 .comment-time { color: var(--ink-muted, var(--ink-soft)); }
-.comment-meta .fedi-handle { overflow-wrap: anywhere; }
-@media (max-width: 560px) {
-  .comment-meta .comment-time { flex-basis: 100%; font-size: 0.78rem; color: var(--ink-faint, var(--ink-muted)); }
-}
 .comment-content {
   white-space: pre-wrap;
@@ -314,10 +408,5 @@
   gap: 0.5rem;
   margin-top: 0.4rem;
-  flex-wrap: wrap;
-  align-items: center;
-}
-.fedi-owner-react { display: inline; }
-.fedi-owner-reply { display: inline-block; }
-.fedi-owner-reply[open] { flex-basis: 100%; }
+}
 .comment-reply-btn, .comment-delete-btn {
   background: none;
@@ -334,23 +423,4 @@
 .comment-delete-btn:hover { color: #c33; }
 
-/* Owner comment-action icons — same visual language as the News feed, compact (30px). */
-.fedi-cact { width: 30px; height: 30px; flex: 0 0 30px; border-radius: 50%; padding: 0; box-sizing: border-box;
-  display: inline-flex; align-items: center; justify-content: center; list-style: none;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 3%, transparent);
-  color: var(--ink-soft, #888); cursor: pointer; -webkit-tap-highlight-color: transparent;
-  transition: background .12s, border-color .12s, transform .1s; }
-.fedi-cact::-webkit-details-marker { display: none; }
-.fedi-cact svg { width: 15px; height: 15px; display: block; }
-.fedi-cact:active { transform: scale(.9); }
-.fedi-cact-like svg { color: #e8b04b; }
-.fedi-cact-like:hover { background: color-mix(in srgb, #e8b04b 15%, transparent); border-color: color-mix(in srgb, #e8b04b 50%, transparent); }
-.fedi-cact-like.is-on { background: color-mix(in srgb, #e8b04b 18%, transparent); border-color: color-mix(in srgb, #e8b04b 60%, transparent); }
-.fedi-cact-boost svg { color: #2fa85a; }
-.fedi-cact-boost:hover { background: color-mix(in srgb, #2fa85a 15%, transparent); border-color: color-mix(in srgb, #2fa85a 50%, transparent); }
-.fedi-cact-boost.is-on { background: color-mix(in srgb, #2fa85a 18%, transparent); border-color: color-mix(in srgb, #2fa85a 60%, transparent); }
-.fedi-cact-reply svg { color: var(--accent, #06c); }
-.fedi-cact-reply:hover { background: color-mix(in srgb, var(--accent, #888) 14%, transparent); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent); }
-
 .comment-replies {
   margin-top: 1rem;
@@ -380,5 +450,4 @@
   resize: vertical;
   min-height: 70px;
-  max-height: 200px;
   padding: 0.65rem 0.85rem;
   border: 1px solid var(--rule);
@@ -393,13 +462,4 @@
 .comment-form button { align-self: flex-start; }
 .comment-reply-form-actions { display: flex; gap: 0.5rem; }
-/* Show the reply form only AFTER clicking REPLY. The display:flex above would
-   otherwise override the [hidden] attribute → form was always open. */
-.comment-reply-form[hidden] { display: none; }
-.fedi-owner-reply summary { list-style: none; cursor: pointer; }
-.fedi-owner-reply summary::-webkit-details-marker { display: none; }
-.fedi-owner-reply[open] summary { margin-bottom: .45rem; }
-/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
-   of REPLY → pixel-perfect alignment + equal gap. */
-.comment-actions form { display: contents; }
 
 .comments-login-cta { margin-top: 1.5rem; text-align: center; }
@@ -447,10 +507,7 @@
 .post-related-cover {
   display: block;
-  position: relative;
-  overflow: hidden;
   aspect-ratio: 16 / 10;
   background: var(--paper-2) center/cover no-repeat;
 }
-.post-related-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
 .post-related-cover--empty {
   background-image: linear-gradient(135deg,
Index: src/views/pages/prutter-conversation.ejs
===================================================================
--- src/views/pages/prutter-conversation.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/prutter-conversation.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,165 @@
+<div class="container prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
+  <header class="prutter-conv-header">
+    <p class="prutter-back"><a href="<%= siteUrlBase %>/prutter">&larr; Inbox</a></p>
+    <div class="prutter-conv-headline">
+      <div class="prutter-conv-avatar prutter-conv-avatar--lg">
+        <% if (other && other.avatar_url) { %>
+          <img src="<%= other.avatar_url %>" alt="">
+        <% } else { %>
+          <span><%= (other && other.username || '?').charAt(0).toUpperCase() %></span>
+        <% } %>
+      </div>
+      <div>
+        <h1><%= other ? other.username : 'Unknown' %></h1>
+        <% if (other) { %>
+          <p class="prutter-conv-sub"><a href="/users/<%= encodeURIComponent(other.username) %>">View profile</a></p>
+        <% } %>
+      </div>
+    </div>
+  </header>
+
+  <ol class="prutter-thread" id="prutter-thread">
+    <% messages.forEach(function(m) { %>
+      <li class="prutter-msg <%= m.author_id === user.id ? 'prutter-msg--mine' : 'prutter-msg--theirs' %>" data-msg-id="<%= m.id %>">
+        <div class="prutter-msg-bubble"><%= m.content %></div>
+        <div class="prutter-msg-time" title="<%= m.created_at %>"><%= formatDateTime(m.created_at) %></div>
+      </li>
+    <% }); %>
+  </ol>
+
+  <form class="prutter-composer"
+        method="post"
+        action="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
+        hx-post="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
+        hx-target="#prutter-thread"
+        hx-swap="beforeend"
+        hx-on::after-request="this.querySelector('textarea').value=''; window.__prutterScroll && window.__prutterScroll();">
+    <textarea name="content" rows="2" maxlength="2000" placeholder="Type a message…" required></textarea>
+    <button type="submit" class="btn btn-primary">Send</button>
+  </form>
+</div>
+
+<script>
+(function() {
+  // Auto-scroll to bottom on load and after appends.
+  var thread = document.getElementById('prutter-thread');
+  function scrollToBottom() { thread.scrollTop = thread.scrollHeight; }
+  scrollToBottom();
+  window.__prutterScroll = scrollToBottom;
+
+  // Live updates via WebSocket.
+  // Browser auto-resolves protocol/host; same origin as the page.
+  try {
+    var page = document.querySelector('.prutter-conv-page');
+    var convId = page.dataset.conversationId;
+    var me = page.dataset.me;
+    var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    var ws = new WebSocket(proto + '//' + location.host + '/ws/prutter');
+
+    ws.addEventListener('message', function(ev) {
+      try {
+        var data = JSON.parse(ev.data);
+        if (data.type !== 'new_message' || data.conversationId !== convId) return;
+        var m = data.message;
+        // Don't double-render our own messages (already added via HTMX response)
+        if (m.author_id === me) return;
+
+        var li = document.createElement('li');
+        li.className = 'prutter-msg prutter-msg--theirs';
+        li.dataset.msgId = m.id;
+        var bubble = document.createElement('div');
+        bubble.className = 'prutter-msg-bubble';
+        bubble.textContent = m.content;  // text content = automatic escaping
+        var time = document.createElement('div');
+        time.className = 'prutter-msg-time';
+        time.textContent = new Date(m.created_at).toLocaleString();
+        li.appendChild(bubble);
+        li.appendChild(time);
+        thread.appendChild(li);
+        scrollToBottom();
+      } catch (e) { /* ignore malformed */ }
+    });
+  } catch (e) {
+    console.warn('Prutter WS unavailable:', e);
+  }
+})();
+</script>
+
+<style>
+.prutter-conv-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1rem; }
+.prutter-back { margin: 0; }
+.prutter-back a { color: var(--accent); text-decoration: none; }
+
+.prutter-conv-headline {
+  display: flex; align-items: center; gap: 0.85rem;
+  padding-bottom: 1rem; border-bottom: 1px solid var(--rule);
+}
+.prutter-conv-headline h1 {
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem; margin: 0;
+}
+.prutter-conv-sub { margin: 0; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
+.prutter-conv-sub a { color: var(--accent); }
+
+.prutter-conv-avatar--lg { width: 56px; height: 56px; }
+.prutter-conv-avatar--lg span { font-size: 1.2rem; }
+
+.prutter-thread {
+  list-style: none; padding: 0; margin: 0;
+  display: flex; flex-direction: column; gap: 0.5rem;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  padding: 1rem;
+  height: 60vh;
+  overflow-y: auto;
+}
+
+.prutter-msg { display: flex; flex-direction: column; max-width: 80%; }
+.prutter-msg-bubble {
+  padding: 0.55rem 0.85rem;
+  border-radius: 14px;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  line-height: 1.4;
+  font-size: 0.95rem;
+}
+.prutter-msg-time {
+  font-size: 0.7rem;
+  color: var(--ink-muted, var(--ink-soft));
+  margin-top: 0.15rem;
+}
+
+.prutter-msg--theirs { align-self: flex-start; }
+.prutter-msg--theirs .prutter-msg-bubble {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-bottom-left-radius: 4px;
+}
+.prutter-msg--theirs .prutter-msg-time { padding-left: 0.5rem; }
+
+.prutter-msg--mine { align-self: flex-end; align-items: flex-end; }
+.prutter-msg--mine .prutter-msg-bubble {
+  background: var(--accent);
+  color: white;
+  border-bottom-right-radius: 4px;
+}
+.prutter-msg--mine .prutter-msg-time { padding-right: 0.5rem; }
+
+.prutter-composer {
+  display: flex; gap: 0.5rem; align-items: flex-end;
+  padding-bottom: env(safe-area-inset-bottom, 0);
+}
+.prutter-composer textarea {
+  flex: 1;
+  padding: 0.6rem 0.85rem;
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  background: var(--paper);
+  color: var(--ink);
+  font-family: inherit;
+  font-size: 0.95rem;
+  resize: vertical;
+  min-height: 44px;
+}
+</style>
Index: src/views/pages/prutter-inbox.ejs
===================================================================
--- src/views/pages/prutter-inbox.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/prutter-inbox.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,108 @@
+<div class="container prutter-inbox-page">
+  <header class="prutter-header">
+    <h1>Prutter</h1>
+    <p class="prutter-tagline">Your direct messages on this site.</p>
+  </header>
+
+  <% if (!conversations.length) { %>
+    <p class="prutter-empty">
+      No conversations yet. Open someone's profile and click <em>Send DM</em> to start one.
+    </p>
+  <% } else { %>
+    <ol class="prutter-conv-list">
+      <% conversations.forEach(function(c) { %>
+        <li class="prutter-conv-row">
+          <a class="prutter-conv-link" href="<%= siteUrlBase %>/prutter/<%= c.id %>">
+            <div class="prutter-conv-avatar">
+              <% if (c.other_avatar) { %>
+                <img src="<%= c.other_avatar %>" alt="">
+              <% } else { %>
+                <span><%= (c.other_username || '?').charAt(0).toUpperCase() %></span>
+              <% } %>
+            </div>
+            <div class="prutter-conv-body">
+              <div class="prutter-conv-meta">
+                <strong class="prutter-conv-name"><%= c.other_username %></strong>
+                <% if (c.last_message_at) { %>
+                  <span class="prutter-conv-time"><%= formatDateTime(c.last_message_at) %></span>
+                <% } %>
+              </div>
+              <% if (c.last_message_preview) { %>
+                <div class="prutter-conv-preview"><%= c.last_message_preview %></div>
+              <% } else { %>
+                <div class="prutter-conv-preview muted">Empty conversation</div>
+              <% } %>
+            </div>
+            <% if (c.unread_count > 0) { %>
+              <span class="prutter-unread-badge"><%= c.unread_count %></span>
+            <% } %>
+          </a>
+        </li>
+      <% }); %>
+    </ol>
+  <% } %>
+</div>
+
+<style>
+.prutter-inbox-page { max-width: 720px; margin: 3rem auto; padding: 0 1rem; }
+.prutter-header h1 {
+  font-family: var(--font-display, serif);
+  font-size: 2rem; margin: 0 0 0.25rem;
+}
+.prutter-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1.5rem; }
+.prutter-empty {
+  text-align: center; padding: 2rem; color: var(--ink-muted, var(--ink-soft));
+  background: var(--paper-2); border: 1px dashed var(--rule); border-radius: 8px;
+}
+
+.prutter-conv-list { list-style: none; padding: 0; margin: 0; }
+.prutter-conv-row { border-bottom: 1px solid var(--rule); }
+.prutter-conv-row:last-child { border-bottom: 0; }
+.prutter-conv-link {
+  display: flex; gap: 0.85rem; align-items: center;
+  padding: 0.85rem 0;
+  text-decoration: none; color: var(--ink);
+  transition: background 100ms;
+}
+.prutter-conv-link:hover { background: var(--paper-2); }
+
+.prutter-conv-avatar {
+  flex-shrink: 0;
+  width: 44px; height: 44px;
+  border-radius: 50%;
+  overflow: hidden;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  display: inline-flex; align-items: center; justify-content: center;
+}
+.prutter-conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.prutter-conv-avatar span {
+  font-family: var(--font-display, serif);
+  font-weight: 700; color: var(--accent);
+}
+
+.prutter-conv-body { flex: 1; min-width: 0; }
+.prutter-conv-meta {
+  display: flex; justify-content: space-between; gap: 0.5rem;
+  margin-bottom: 0.15rem;
+}
+.prutter-conv-name { font-weight: 600; }
+.prutter-conv-time {
+  color: var(--ink-muted, var(--ink-soft));
+  font-size: 0.8rem; flex-shrink: 0;
+}
+.prutter-conv-preview {
+  color: var(--ink-soft);
+  font-size: 0.9rem;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.prutter-conv-preview.muted { font-style: italic; color: var(--ink-muted, var(--ink-soft)); }
+
+.prutter-unread-badge {
+  background: var(--accent); color: white;
+  border-radius: 10px;
+  padding: 0.1rem 0.5rem;
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+</style>
Index: src/views/pages/search.ejs
===================================================================
--- src/views/pages/search.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/search.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,84 +1,41 @@
-<% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
-<% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %>
-<% const _events = (typeof events !== 'undefined' && events) ? events : []; %>
-<% const _pages = (typeof pages !== 'undefined' && pages) ? pages : []; %>
 <div class="container search-page">
-  <h1><%= t('nav.search') %></h1>
+  <h1>Search</h1>
 
-  <form method="get" action="<%= _base %>/search" class="search-page-form">
+  <form method="get" action="/search" class="search-page-form">
     <input
       type="search"
       name="q"
       value="<%= query %>"
-      placeholder="<%= t('search.placeholder') %>"
+      placeholder="Search posts…"
       autocomplete="off"
       autofocus
-      aria-label="<%= t('nav.search') %>">
-    <button type="submit" class="btn btn-primary"><%= t('search.button') %></button>
+      aria-label="Search query">
+    <button type="submit" class="btn btn-primary">Search</button>
   </form>
 
   <% if (queryError) { %>
-    <p class="search-error"><%= t('search.error') %></p>
+    <p class="search-error">Couldn't run that query. Try a simpler term.</p>
   <% } %>
 
   <% if (query && !queryError) { %>
     <p class="search-meta">
-      <%= t(total === 1 ? 'search.results_one' : 'search.results_other', { n: total, q: query }) %>
+      <%= total %> result<%= total === 1 ? '' : 's' %> for &ldquo;<%= query %>&rdquo;
     </p>
   <% } %>
 
-  <% if (_tracks.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_tracks') %></h2>
-    <% var _inPost = t('search.in_post'); /* t() vóór de loop ophalen: function(t) overschaduwt 'm */ %>
-    <div class="search-tracks">
-      <% _tracks.forEach(function(t) {
-           const tj = JSON.stringify({ id: t.id, url: t.url, title: t.title, artist: t.artist || '', cover: t.cover || '' })
-             .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;'); %>
-        <div class="post-audio-track search-track" id="track-<%= t.id %>"
-             data-pcms-track-id="<%= t.id %>" data-pcms-track-url="<%= t.url %>"
-             data-pcms-track='<%- tj %>'>
-          <button type="button" class="pat-play" aria-label="Speel <%= t.title %>">
-            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
-          </button>
-          <% if (t.cover) { %>
-            <span class="search-track-cover" style="background-image:url('<%= t.cover %>')" aria-hidden="true"></span>
-          <% } %>
-          <div class="pat-info">
-            <div class="pat-title"><%= t.title %></div>
-            <% if (t.artist || t.album) { %>
-              <div class="pat-artist">
-                <%= t.artist %><% if (t.artist && t.album) { %> &middot; <% } %><%= t.album %>
-              </div>
-            <% } %>
-          </div>
-          <% if (t.postUrl) { %>
-            <a class="search-track-link"
-               href="<%= t.postUrl %>"
-               hx-get="<%= t.postUrl %>?partial=1"
-               hx-target="#pcms-main"
-               hx-swap="innerHTML"
-               hx-push-url="<%= t.postUrl %>"
-               aria-label="<%= _inPost %>"><%= _inPost %></a>
-          <% } %>
-        </div>
-      <% }); %>
-    </div>
-  <% } %>
-
   <% if (results && results.length) { %>
-    <% if (_tracks.length) { %><h2 class="search-section-title"><%= t('search.section_posts') %></h2><% } %>
     <ol class="search-results">
       <% results.forEach(function(r) { %>
         <li class="search-result">
-          <h3 class="search-result-title">
-            <a href="<%= _base %>/<%= r.slug %>"
-               hx-get="<%= _base %>/<%= r.slug %>?partial=1"
+          <h2 class="search-result-title">
+            <a href="/<%= r.slug %>"
+               hx-get="/<%= r.slug %>?partial=1"
                hx-target="#pcms-main"
                hx-swap="innerHTML"
-               hx-push-url="<%= _base %>/<%= r.slug %>"
+               hx-push-url="/<%= r.slug %>"
                hx-indicator="#pcms-loading">
-              <%= r.title || '(zonder titel)' %>
+              <%= r.title || '(untitled)' %>
             </a>
-          </h3>
+          </h2>
           <p class="search-result-meta">
             <%= r.author_username %>
@@ -89,29 +46,6 @@
       <% }); %>
     </ol>
-  <% } %>
-
-  <% if (_events.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_events') %></h2>
-    <ol class="search-results">
-      <% _events.forEach(function(ev) { %>
-        <li class="search-result">
-          <h3 class="search-result-title"><a href="<%= ev.url %>"><%= ev.where || '—' %></a></h3>
-          <p class="search-result-meta"><%= [ev.date, ev.time].filter(Boolean).join(' ') %></p>
-        </li>
-      <% }); %>
-    </ol>
-  <% } %>
-
-  <% if (_pages.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_pages') %></h2>
-    <ul class="search-pages">
-      <% _pages.forEach(function(pg) { %>
-        <li><a class="search-page-link" href="<%= pg.url %>"><%= pg.label %> <span aria-hidden="true">→</span></a></li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% if (query && !queryError && !results.length && !_tracks.length && !_events.length && !_pages.length) { %>
-    <p class="search-empty"><%= t('search.empty') %></p>
+  <% } else if (query && !queryError) { %>
+    <p class="search-empty">No posts found.</p>
   <% } %>
 </div>
@@ -145,12 +79,4 @@
   margin: 0 0 1rem;
 }
-.search-section-title {
-  font-family: var(--font-display, serif);
-  font-size: 1.1rem;
-  margin: 1.75rem 0 0.75rem;
-  color: var(--ink-soft);
-  text-transform: uppercase;
-  letter-spacing: 0.05em;
-}
 .search-error {
   background: rgba(200, 60, 60, 0.15);
@@ -166,37 +92,9 @@
 }
 
-/* Nummer-resultaten */
-.search-tracks { display: flex; flex-direction: column; gap: 0.25rem; }
-.search-track.post-audio-track {
-  display: flex;
-  align-items: center;
-  gap: 0.75rem;
-  padding: 0.5rem 0.6rem;
-  border-radius: 8px;
-}
-.search-track .search-track-cover {
-  width: 38px; height: 38px;
-  flex: 0 0 38px;
-  border-radius: 5px;
-  background-size: cover;
-  background-position: center;
-  background-color: var(--paper-2);
-}
-.search-track .pat-info { flex: 1; min-width: 0; }
-.search-track .pat-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.search-track .pat-artist { font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.search-track-link {
-  flex: 0 0 auto;
-  font-size: 0.82rem;
-  color: var(--accent);
-  text-decoration: none;
-  white-space: nowrap;
-}
-.search-track-link:hover { text-decoration: underline; }
-
 .search-results {
   list-style: none;
   margin: 0;
   padding: 0;
+  counter-reset: search-result;
 }
 .search-result {
@@ -234,10 +132,3 @@
   border-radius: 3px;
 }
-.search-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
-.search-page-link {
-  display: inline-flex; align-items: center; gap: 0.3rem;
-  padding: 0.45rem 0.8rem; border: 1px solid var(--rule); border-radius: 999px;
-  color: var(--ink); text-decoration: none; font-size: 0.9rem; background: var(--paper-2);
-}
-.search-page-link:hover { border-color: var(--accent); color: var(--accent); }
 </style>
Index: src/views/pages/shows.ejs
===================================================================
--- src/views/pages/shows.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,67 +1,0 @@
-<%
-  var ss = (typeof shows !== 'undefined') ? shows : [];
-  var st = (typeof notifyState !== 'undefined') ? notifyState : null;
-%>
-<section class="sh">
-  <h1 class="sh-h1"><%= t('agenda.title') %></h1>
-
-  <% if (st === 'done') { %><div class="sh-msg"><%= t('agenda.msg_done') %></div>
-  <% } else if (st === 'check') { %><div class="sh-msg"><%= t('agenda.msg_check') %></div>
-  <% } else if (st === 'error') { %><div class="sh-msg sh-bad"><%= (typeof notifyMsg!=='undefined')?notifyMsg:'Er ging iets mis.' %></div><% } %>
-
-  <% if (!ss.length) { %>
-    <p class="sh-empty"><%= t('agenda.empty') %></p>
-  <% } else { %>
-    <ul class="sh-list">
-      <% ss.forEach(function(s){ %>
-        <li class="sh-item">
-          <span class="sh-date">
-            <span class="sh-day"><%= (s.date||'').slice(8) %></span>
-            <span class="sh-mon"><%= (s.date||'').slice(5,7) %>/<%= (s.date||'').slice(0,4) %></span>
-          </span>
-          <span class="sh-meta">
-            <span class="sh-city"><%= s.city %><% if (s.country) { %>, <%= s.country %><% } %></span>
-            <% if (s.venue) { %><span class="sh-venue"><%= s.venue %><% if (s.time) { %> · <%= s.time %><% } %></span><% } else if (s.time) { %><span class="sh-venue"><%= s.time %></span><% } %>
-            <% if (s.notes) { %><span class="sh-notes"><%= s.notes %></span><% } %>
-          </span>
-          <% if (s.ticket_url) { %><a class="sh-tix" href="<%= s.ticket_url %>" target="_blank" rel="noopener"><%= t('agenda.tickets') %></a><% } %>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% if (!(typeof footerNewsletter !== 'undefined' && footerNewsletter)) { %>
-  <div class="sh-notify">
-    <h2 class="sh-h2"><%= t('agenda.notify_h') %></h2>
-    <p class="sh-sub"><%= t('agenda.notify_sub') %></p>
-    <form method="POST" action="<%= siteUrlBase %>/shows/notify" class="sh-form">
-      <input type="email" name="email" required placeholder="<%= t('common.email_placeholder') %>" autocomplete="email">
-      <button type="submit" class="sh-btn"><%= t('agenda.notify_btn') %></button>
-    </form>
-  </div>
-  <% } %>
-</section>
-
-<style>
-  .sh { max-width: 640px; margin: 0 auto; padding: 32px 18px 64px; }
-  .sh-h1 { font-size: clamp(26px,5vw,38px); margin: 0 0 18px; }
-  .sh-msg { padding: 11px 14px; border-radius: 10px; background: rgba(60,160,90,.15); margin-bottom: 16px; }
-  .sh-bad { background: rgba(200,60,60,.15); }
-  .sh-list { list-style: none; margin: 0 0 36px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .sh-item { display: flex; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; }
-  .sh-date { flex: 0 0 auto; text-align: center; min-width: 54px; }
-  .sh-day { display: block; font-size: 22px; font-weight: 700; line-height: 1; }
-  .sh-mon { font-size: 11px; opacity: .6; }
-  .sh-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .sh-city { font-weight: 600; }
-  .sh-venue { font-size: 13px; opacity: .7; }
-  .sh-notes { font-size: 12.5px; opacity: .6; margin-top: 2px; }
-  .sh-tix { flex: 0 0 auto; padding: 8px 14px; border-radius: 999px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; font-size: 13px; }
-  .sh-empty { opacity: .7; margin-bottom: 36px; }
-  .sh-notify { border-top: 1px solid rgba(128,128,128,.2); padding-top: 24px; }
-  .sh-h2 { margin: 0 0 6px; font-size: 18px; }
-  .sh-sub { opacity: .8; margin: 0 0 14px; }
-  .sh-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .sh-form input { flex: 1 1 200px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .sh-btn { padding: 12px 18px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-</style>
Index: src/views/pages/tag.ejs
===================================================================
--- src/views/pages/tag.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/tag.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,13 +1,13 @@
 <div class="container tag-page">
   <header class="tag-page-header">
-    <p class="tag-eyebrow"><%= t('ptag.eyebrow') %></p>
+    <p class="tag-eyebrow">Tag</p>
     <h1>#<%= tag %></h1>
     <p class="tag-meta">
-      <%= posts.length === 1 ? t('ptag.count_one', { n: posts.length }) : t('ptag.count_many', { n: posts.length }) %>
+      <%= posts.length %> post<%= posts.length === 1 ? '' : 's' %>
     </p>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="tag-empty"><%= t('ptag.empty') %></p>
+    <p class="tag-empty">No posts with this tag yet.</p>
   <% } else { %>
     <ol class="tag-results">
@@ -26,5 +26,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('ptag.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h2>
Index: src/views/pages/type.ejs
===================================================================
--- src/views/pages/type.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/type.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,11 +1,11 @@
 <div class="container type-page">
   <header class="type-page-header">
-    <p class="type-eyebrow"><%= t('ptype.eyebrow') %></p>
+    <p class="type-eyebrow">Type</p>
     <h1><%= type.charAt(0).toUpperCase() + type.slice(1) %></h1>
-    <p class="type-meta"><%= posts.length === 1 ? t('ptype.count_one', { n: posts.length }) : t('ptype.count_many', { n: posts.length }) %></p>
+    <p class="type-meta"><%= posts.length %> post<%= posts.length === 1 ? '' : 's' %></p>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="type-empty"><%= t('ptype.empty') %></p>
+    <p class="type-empty">No posts of this type yet.</p>
   <% } else { %>
     <ol class="type-results">
@@ -24,5 +24,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('ptype.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h2>
Index: src/views/pages/user.ejs
===================================================================
--- src/views/pages/user.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/user.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,5 +3,5 @@
     <div class="user-avatar-large">
       <% if (author.avatar_url) { %>
-        <img src="<%= avatar(author.avatar_url, 320) %>" alt="">
+        <img src="<%= author.avatar_url %>" alt="">
       <% } else { %>
         <span><%= author.username.charAt(0).toUpperCase() %></span>
@@ -19,19 +19,24 @@
       <% } %>
       <p class="user-stats">
-        <%= posts.length %> <%= posts.length === 1 ? t('pusr.post_one') : t('pusr.post_many') %> <%= t('pusr.on_this_site') %>
+        <%= posts.length %> post<%= posts.length === 1 ? '' : 's' %> on this site
         <% if (totalPosts > posts.length) { %>
-          &middot; <%= t('pusr.total', { n: totalPosts }) %>
+          &middot; <%= totalPosts %> total
         <% } %>
         <% if (author.created_at) { %>
-          &middot; <%= t('pusr.joined', { date: formatDate(author.created_at) }) %>
+          &middot; joined <%= formatDate(author.created_at) %>
         <% } %>
       </p>
+      <% if (user && user.id !== author.id && site && site.enable_prutter) { %>
+        <p class="user-actions">
+          <a href="<%= siteUrlBase %>/prutter/new?to=<%= encodeURIComponent(author.username) %>" class="btn btn-primary">💬 Send DM</a>
+        </p>
+      <% } %>
     </div>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="user-empty"><%= t('pusr.empty') %></p>
+    <p class="user-empty">No posts on this site yet.</p>
   <% } else { %>
-    <h2 class="user-posts-heading"><%= t('pusr.posts_heading') %></h2>
+    <h2 class="user-posts-heading">Posts</h2>
     <ol class="user-posts">
       <% posts.forEach(function(p) { %>
@@ -49,5 +54,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('pusr.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h3>
Index: src/views/pages/viewer-blocked.ejs
===================================================================
--- src/views/pages/viewer-blocked.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-<div class="container vb-page">
-  <div class="vb-card">
-    <div class="vb-ico" aria-hidden="true">👁️</div>
-    <h1 class="vb-title"><%= t('vblk.title') %></h1>
-    <p class="vb-text">
-      <%= t('vblk.text_before') %>
-      <strong><%= t('vblk.text_strong') %></strong> <%= t('vblk.text_after') %>
-    </p>
-    <div class="vb-actions">
-      <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>
-  </div>
-</div>
-
-<style>
-.vb-page { max-width: 540px; margin: 3rem auto 4rem; padding: 0 1rem; }
-.vb-card {
-  text-align: center;
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 16px;
-  padding: 2.5rem 1.75rem;
-}
-.vb-ico {
-  width: 64px; height: 64px; margin: 0 auto 1rem;
-  display: flex; align-items: center; justify-content: center;
-  font-size: 1.8rem; border-radius: 50%;
-  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
-}
-.vb-title { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 0 0 0.5rem; }
-.vb-text { color: var(--ink-muted, var(--ink-soft)); font-size: 0.95rem; line-height: 1.55; margin: 0 auto 1.5rem; max-width: 42ch; }
-.vb-actions { display: flex; gap: 0.6rem; justify-content: center; flex-wrap: wrap; }
-.vb-btn {
-  display: inline-flex; align-items: center; justify-content: center;
-  padding: 0.6rem 1.1rem; min-height: 42px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper-2); color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif; font-size: 0.9rem; font-weight: 600;
-  text-decoration: none; cursor: pointer; transition: border-color 120ms, opacity 120ms;
-}
-.vb-btn:hover { border-color: var(--accent); }
-.vb-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
-.vb-btn-primary:hover { opacity: 0.92; }
-</style>
Index: src/views/pages/welcome.ejs
===================================================================
--- src/views/pages/welcome.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/welcome.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,14 @@
 <div class="container welcome-page">
-  <h1><%= t('welcome.title') %></h1>
-  <p class="welcome-tagline"><%= t('welcome.tagline') %></p>
-
+  <h1>Welcome to PrutCMS</h1>
+  <p class="welcome-tagline">A self-hosted publishing platform.</p>
+  
   <% if (!user) { %>
     <div class="welcome-actions">
-      <a href="/auth/register" class="btn btn-primary"><%= t('auth.create_admin') %></a>
-      <a href="/auth/login" class="btn"><%= t('welcome.have_account') %></a>
+      <a href="/auth/register" class="btn btn-primary">Create your account</a>
+      <a href="/auth/login" class="btn">Already have one? Login</a>
     </div>
-    <p class="welcome-note"><%= t('welcome.note') %></p>
+    <p class="welcome-note">The first user becomes <strong>god</strong> and gets a personal site auto-created.</p>
   <% } else { %>
-    <p><%= t('welcome.nosite', { user: user.username }) %></p>
+    <p>Hi <%= user.username %>! No site is configured yet.</p>
   <% } %>
 </div>
Index: src/views/partials/admin-back.ejs
===================================================================
--- src/views/partials/admin-back.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,11 +1,0 @@
-<%# De terugknop naar /admin.
-
-    Een partial voor een regel van niets, om dezelfde reden als de tabbalk:
-    hij stond op vijf pagina's los, drie met class="ax-btn" en twee met de
-    globale class="btn". Dat scheelt zichtbaar -- .btn is 0.55rem/1rem met
-    radius 5 en geen minimumhoogte, .ax-btn is 0.6rem/1.1rem met radius 8 en
-    min-height 44px. Robin zag het meteen (16-8).
-
-    Het wordt .ax-btn: deze pagina's zijn ax-pagina's, en 44px is de maat waar
-    een vinger op past. %>
-<p><a href="/admin" class="ax-btn">&larr; <%= t('nav.admin') %></a></p>
Index: src/views/partials/admin-styles.ejs
===================================================================
--- src/views/partials/admin-styles.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,227 +1,0 @@
-<%# De ax-*-primitieven voor de beheerpagina's, op EEN plek.
-
-    WAAROM DIT BESTAAT. Deze regels stonden vijf keer los in vijf pagina's, en
-    admin-playlists schreef er zelf boven waarom: "Define them here too so this
-    page is self-contained -- both pages get refreshed independently." Dat is
-    precies wat er daarna gebeurde. Gemeten op 16-8: .ax-header, .ax-header h1,
-    .ax-back en .ax-back:hover stonden in vijf bestanden byte voor byte gelijk,
-    .ax-flash-ok/-err in vier -- terwijl .ax-page in de ene 880px was en in
-    account 720px, en admin-media en admin-videos helemaal buiten het systeem
-    vielen met alles inline.
-
-    Gelijktrekken zonder dit is eenmalig: de volgende die een knop aanpast doet
-    dat in een van de acht kopieen en dan lopen ze weer uiteen. De vorm moet de
-    fout onmogelijk maken, niet alleen onwaarschijnlijk -- zelfde afweging als
-    bij de luisteraarstabel.
-
-    HOE JE HEM GEBRUIKT: neem hem met een include bovenaan de pagina op, en zet
-    daarna alleen nog in een eigen style-blok wat ECHT bij die ene pagina hoort.
-    Staat een regel in twee pagina's, dan hoort hij hier.
-
-    Een <style>-partial en geen los .css-bestand omdat shell.ejs geen
-    pageCss-haak heeft (wel pageJs) en een globale link deze regels op elke
-    pagina van de site zou laden. %>
-<style>
-/* ─── Paginaromp ───────────────────────────────────────────────── */
-.ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
-.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
-.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
-.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }
-.ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
-.ax-back {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper); color: var(--ink); text-decoration: none;
-  font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
-}
-.ax-back:hover { border-color: var(--accent); }
-
-.ax-actions-bar { display: flex; margin-bottom: 1rem; }
-
-/* ─── Tabbalk ──────────────────────────────────────────────────── */
-/* Stond volledig inline in partials/media-tabs.ejs -- een gedeeld element dat
-   zijn eigen opmaak meedroeg, en daardoor niet mee kon met de rest. */
-.ax-tabs {
-  display: flex; gap: 0.4rem; flex-wrap: wrap;
-  margin: 0 0 1.25rem;
-  border-bottom: 1px solid var(--rule);
-}
-.ax-tab {
-  display: inline-flex; align-items: center;
-  padding: 0.55rem 0.9rem;
-  border: 1px solid var(--rule); border-bottom: none;
-  border-radius: 8px 8px 0 0;
-  background: var(--paper-2); color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif;
-  font-size: 0.9rem; text-decoration: none;
-  opacity: 0.8; transition: opacity 120ms, border-color 120ms;
-}
-.ax-tab:hover { opacity: 1; border-color: var(--accent); }
-.ax-tab[aria-current="page"] {
-  opacity: 1; font-weight: 600;
-  background: var(--paper);
-  border-bottom: 2px solid var(--accent);
-}
-
-/* ─── Meldingen ────────────────────────────────────────────────── */
-.ax-flash { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
-.ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
-.ax-flash-err { background: rgba(200,60,60,.15); color: #c33;    border: 1px solid rgba(200,60,60,.3); }
-
-/* ─── Kaart ────────────────────────────────────────────────────── */
-.ax-card {
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 12px;
-  padding: 1.25rem;
-  margin-bottom: 1rem;
-}
-
-/* ─── Knoppen ──────────────────────────────────────────────────── */
-.ax-btn {
-  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
-  padding: 0.6rem 1.1rem;
-  border: 1px solid var(--rule); background: var(--paper-2);
-  color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif;
-  font-size: 0.95rem; font-weight: 500;
-  text-decoration: none; border-radius: 8px; cursor: pointer;
-  min-height: 44px;
-  transition: background 120ms, border-color 120ms;
-  -webkit-tap-highlight-color: transparent;
-}
-.ax-btn:hover { border-color: var(--accent); }
-.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
-.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
-/* admin-audio heeft paper-2, admin-playlists transparent. Hier audio's waarde:
-   .ax-btn is al paper-2, dus dit is daar een no-op en verandert niets aan wat
-   er nu staat. Wie de twee ooit echt wil laten verschillen, kies dan bewust. */
-.ax-btn-secondary { background: var(--paper-2); }
-.ax-btn-danger { border-color: #b4452e; color: #b4452e; background: transparent; }
-.ax-btn-danger:hover { background: rgba(180,69,46,.1); border-color: #b4452e; }
-.ax-btn-sm { min-height: 0; padding: 0.3rem 0.6rem; font-size: 0.78rem; }
-
-.ax-icon-btn {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper-2); color: var(--ink);
-  cursor: pointer; font-size: 1rem;
-  transition: background 120ms, border-color 120ms, color 120ms;
-}
-.ax-icon-btn:hover { border-color: var(--accent); }
-.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
-
-/* ─── Lege toestand ────────────────────────────────────────────── */
-.ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }
-.ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }
-.ax-empty p { margin: 0; }
-.ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }
-
-/* ─── Lijstrij (audio, playlists, luisteraars) ─────────────────── */
-.ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
-.ax-track {
-  display: grid;
-  grid-template-columns: 64px 1fr auto;
-  gap: 0.85rem; align-items: center;
-  padding: 0.85rem;
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 12px;
-  transition: border-color 120ms;
-}
-.ax-track:hover { border-color: var(--accent); }
-/* object-fit staat OOK op het element zelf, niet alleen op een img erbinnen:
-   admin-audio zet .ax-track-cover rechtstreeks op een <img>, admin-listeners
-   op een omhulsel met een img erin. Op een niet-vervangen element doet
-   object-fit niets, dus het kost de tweede vorm niets. */
-.ax-track-cover {
-  width: 64px; height: 64px; border-radius: 8px;
-  background: var(--paper-2);
-  display: flex; align-items: center; justify-content: center;
-  font-family: var(--font-display, serif);
-  font-size: 1.5rem; color: var(--accent);
-  flex-shrink: 0;
-  overflow: hidden;
-  object-fit: cover;
-}
-.ax-track-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
-.ax-track-title {
-  font-family: var(--font-display, serif);
-  font-weight: 600; font-size: 1.05rem; color: var(--ink);
-  display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
-}
-.ax-pill {
-  font-family: var(--font-ui);
-  font-size: 0.7rem; font-weight: 500;
-  color: var(--ink-soft);
-  background: var(--paper-2);
-  padding: 0.15em 0.55em; border-radius: 999px;
-  letter-spacing: 0;
-}
-.ax-track-sub {
-  font-size: 0.85rem;
-  color: var(--ink-muted, var(--ink-soft));
-  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
-}
-.ax-track-sep { opacity: 0.5; }
-.ax-embed {
-  display: inline-block;
-  background: var(--paper-2);
-  padding: 0.15rem 0.5rem;
-  border-radius: 4px;
-  font-family: var(--font-mono, ui-monospace, monospace);
-  font-size: 0.75rem;
-  color: var(--ink-soft);
-  cursor: pointer; user-select: all;
-  margin-top: 0.15rem;
-  word-break: break-all;
-  align-self: flex-start;
-  transition: background 120ms;
-}
-.ax-embed:hover { background: var(--rule); }
-.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
-.ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
-
-/* ─── Tegelraster (afbeeldingen, video's) ──────────────────────── */
-/* Dezelfde randen, hoeken en achtergrond als .ax-track, alleen in een raster
-   in plaats van een rij -- zodat een plaatjespagina en een lijstpagina van
-   hetzelfde ontwerp zijn en niet van twee. */
-.ax-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(var(--ax-tile, 150px), 1fr)); }
-.ax-tile {
-  border: 1px solid var(--rule); border-radius: 12px;
-  overflow: hidden; background: var(--paper);
-  transition: border-color 120ms;
-}
-.ax-tile:hover { border-color: var(--accent); }
-.ax-tile-media { position: relative; background: var(--paper-2); }
-.ax-tile-media img, .ax-tile-media video { width: 100%; height: 100%; display: block; }
-.ax-tile-body { padding: 0.55rem 0.65rem; font-size: 0.8rem; }
-.ax-tile-meta { color: var(--ink-muted, var(--ink-soft)); }
-.ax-tile-actions { display: flex; gap: 0.4rem; margin-top: 0.45rem; }
-.ax-tile-badge {
-  position: absolute; top: 6px; left: 6px; z-index: 1;
-  background: #b4452e; color: #fff;
-  border-radius: 6px; padding: 1px 6px; font-size: 0.68rem;
-}
-.ax-tile-badge-r { left: auto; right: 6px; background: rgba(0,0,0,.6); }
-
-@media (max-width: 480px) {
-  .ax-track {
-    grid-template-columns: 56px 1fr;
-    grid-template-areas: "cover meta" "actions actions";
-    gap: 0.6rem;
-  }
-  .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }
-  .ax-track-meta  { grid-area: meta; }
-  .ax-track-actions {
-    grid-area: actions;
-    justify-content: flex-end;
-    border-top: 1px solid var(--rule);
-    padding-top: 0.5rem;
-    margin-top: 0.25rem;
-  }
-}
-</style>
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/bottom-tab.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -4,14 +4,14 @@
 // Tabs:
 //   logged-out:  Home · Zoek · Inloggen        (3 tabs)
-//   logged-in:   Home · Zoek · [Plus] · Profiel
+//   logged-in:   Home · Zoek · [Plus] · [Prutter] · Profiel
 //   The Plus tab is a FAB-style center action (accent bg, slightly raised).
 //   It only appears for users with create-post permission.
+//   Prutter only appears when the site has DMs enabled.
 
 const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 const _path        = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
-const _canPost     = !!(user && (typeof canMutate === 'undefined' || canMutate)
-                        && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
+const _canPost     = !!(user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
+const _hasPrutter  = !!(user && site && site.enable_prutter);
 const _ownProfile  = user ? ('/users/' + user.username) : null;
-const _homeHref    = _siteUrlBase + '/';
 
 // Strip site prefix for cleaner matching
@@ -26,4 +26,5 @@
 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
 else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create';
+else if (_p.indexOf('/prutter') === 0) _active = 'prutter';
 else if (_p.indexOf('/account') === 0 || (_ownProfile && _p.indexOf(_ownProfile) === 0)) _active = 'profile';
 else if (_p.indexOf('/auth/login') === 0) _active = 'login';
@@ -33,11 +34,8 @@
 
   <!-- Home -->
-  <%# htmx navigation (no full reload): the persistent audio player in document.body
-      survives, so music does not skip when navigating. On mobile the top bar is hidden
-      anyway, so a context reset via full-load is not needed here. %>
   <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
-     href="<%= _homeHref %>"
-     hx-get="<%= _homeHref %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-     hx-push-url="<%= _homeHref %>" hx-indicator="#pcms-loading"
+     href="<%= _siteUrlBase %>/"
+     hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+     hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"
      aria-label="Home" <%= _active === 'home' ? 'aria-current="page"' : '' %>>
     <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -45,15 +43,15 @@
       <polyline points="9 22 9 12 15 12 15 22"/>
     </svg>
-    <span class="bottom-tab-label"><%= t('tab.home') %></span>
+    <span class="bottom-tab-label">Home</span>
   </a>
 
   <!-- Zoek -->
   <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>"
-          id="bottom-tab-search-toggle" aria-label="<%= t('nav.search') %>">
+          id="bottom-tab-search-toggle" aria-label="Zoeken">
     <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
       <circle cx="11" cy="11" r="7"/>
       <line x1="21" y1="21" x2="16.65" y2="16.65"/>
     </svg>
-    <span class="bottom-tab-label"><%= t('tab.search') %></span>
+    <span class="bottom-tab-label">Zoek</span>
   </button>
 
@@ -61,7 +59,7 @@
   <% if (_canPost) { %>
     <a class="bottom-tab-item bottom-tab-fab<%= _active === 'create' ? ' is-active' : '' %>"
-       href="<%= _siteUrlBase %>/posts/new"
-       hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-       hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading"
+       href="/posts/new"
+       hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="/posts/new" hx-indicator="#pcms-loading"
        aria-label="Nieuwe post">
       <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -69,8 +67,22 @@
         <line x1="5" y1="12" x2="19" y2="12"/>
       </svg>
-      <span class="bottom-tab-label"><%= t('tab.write') %></span>
+      <span class="bottom-tab-label">Schrijven</span>
     </a>
   <% } %>
 
+  <!-- Prutter (DMs) -->
+  <% if (_hasPrutter) { %>
+    <a class="bottom-tab-item<%= _active === 'prutter' ? ' is-active' : '' %>"
+       href="<%= _siteUrlBase %>/prutter"
+       aria-label="Prutter berichten" <%= _active === 'prutter' ? 'aria-current="page"' : '' %>>
+      <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+        <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
+      </svg>
+      <span class="bottom-tab-label">Prutter</span>
+      <% if (typeof unreadDmCount !== 'undefined' && unreadDmCount > 0) { %>
+        <span class="bottom-tab-badge" aria-label="<%= unreadDmCount %> ongelezen"><%= unreadDmCount > 99 ? '99+' : unreadDmCount %></span>
+      <% } %>
+    </a>
+  <% } %>
 
   <!-- Profiel / Inloggen -->
@@ -81,18 +93,13 @@
             aria-label="Profiel menu" aria-haspopup="dialog">
       <% if (user.avatar_url) { %>
-        <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+        <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= user.avatar_url %>" alt="">
       <% } else { %>
         <span class="bottom-tab-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
       <% } %>
-      <span class="bottom-tab-label"><%= t('tab.profile') %></span>
-      <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %>
-        <span class="bottom-tab-badge" aria-label="<%= notifUnread %> ongelezen meldingen"><%= notifUnread > 99 ? '99+' : notifUnread %></span>
-      <% } %>
+      <span class="bottom-tab-label">Profiel</span>
     </button>
   <% } else { %>
     <a class="bottom-tab-item<%= _active === 'login' ? ' is-active' : '' %>"
        href="/auth/login"
-       hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-       hx-push-url="/auth/login" hx-indicator="#pcms-loading"
        aria-label="Inloggen">
       <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -101,5 +108,5 @@
         <line x1="15" y1="12" x2="3" y2="12"/>
       </svg>
-      <span class="bottom-tab-label"><%= t('nav.login') %></span>
+      <span class="bottom-tab-label">Inloggen</span>
     </a>
   <% } %>
@@ -302,5 +309,16 @@
 </style>
 
-<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
-    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
-    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
+<script>
+// Wire the bottom-tab search button into the existing #search-overlay
+(function() {
+  const btn = document.getElementById('bottom-tab-search-toggle');
+  const overlay = document.getElementById('search-overlay');
+  if (btn && overlay) {
+    btn.addEventListener('click', function() {
+      overlay.hidden = false;
+      const inp = overlay.querySelector('input');
+      if (inp) inp.focus();
+    });
+  }
+})();
+</script>
Index: src/views/partials/chrome.ejs
===================================================================
--- src/views/partials/chrome.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,39 +1,0 @@
-<%
-// Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
-//
-// Waarom een slot: bij htmx-navigatie verversen we deze hele chrome out-of-band
-// (oob=true), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort — terwijl de
-// audioplayer (los in document.body, buiten dit slot) blijft leven → geen
-// verspringen. Op de hub-landing (bareChrome) is het slot leeg: hero = de header.
-//
-// Wordt zowel door shell.ejs (oob niet gezet) als door render.js bij partials
-// (oob=true) gerenderd. De logica staat hier zodat beide identiek zijn.
-//
-//   _isAuth     → focus-scherm (in/uitloggen): helemaal geen chrome.
-//   _headerless → hub-homepagina én full-height views: WÉL de mini topnav-balk,
-//                 GEEN profielkop (hub: hero = header).
-//   anders      → topnav + profielkop (+ view-switcher buiten admin).
-var _isAdmin   = typeof bodyClass === 'string' && bodyClass.indexOf('on-admin') >= 0;
-var _isAuth    = typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0;
-var _isChat    = typeof bodyClass === 'string' && bodyClass.indexOf('on-chat') >= 0;
-var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0);
-// Zelfstandige premium-feature-pagina's hebben hun eigen kop → geen profielkop/
-// switcher, wél de topnav (Robin 2026-06-18: "bovenste weg, behoud de nav").
-// NB: on-shows (agenda) én on-downloads (de downloads-LIJST) horen hier NIET bij —
-// die willen de volledige chrome (profielkop + pill) behouden. De downloads-lijst is
-// vaak gepind en prominent, dus hij mag niet kaal zijn. De losse e-mail-capture
-// (/download/:id → on-download) blijft wél een focus-pagina zonder kop.
-var _featurePage = typeof bodyClass === 'string' &&
-  bodyClass.indexOf('on-downloads') < 0 &&
-  ['on-epk', 'on-download', 'on-linkbio', 'on-newsletter', 'on-special'].some(function (c) { return bodyClass.indexOf(c) >= 0; });
-var _headerless = hubLanding || _isChat || _featurePage;
-%>
-<div id="pcms-chrome"<% if (typeof oob !== 'undefined' && oob) { %> hx-swap-oob="true"<% } %>>
-<% if (!_isAuth) { %>
-  <%- include('topnav') %>
-  <% if (!_headerless) { %>
-    <%- include('profile-header') %>
-    <% if (!_isAdmin && (typeof site !== 'undefined' && site)) { %><%- include('view-switcher') %><% } %>
-  <% } %>
-<% } %>
-</div>
Index: src/views/partials/fedi-node.ejs
===================================================================
--- src/views/partials/fedi-node.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,86 +1,0 @@
-<%# Renders one fediverse thread node (n). Expects: n, t, canManageSite, _base, siteAvatar, formatDateTime, postSlug %>
-<div class="comment-avatar">
-  <% if (n.actor_icon) { %><img src="<%= avatar(n.actor_icon, 96) %>" alt="" loading="lazy">
-  <% } else { %><span><%= (n.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
-</div>
-<div class="comment-body">
-  <div class="comment-meta">
-    <% if (n.actor_url) { %><a class="comment-author" href="<%= n.actor_url %>" rel="nofollow noopener" target="_blank"><%- emojiName(n.actor_name, n.actor_emoji_json) %></a>
-    <% } else { %><span class="comment-author"><%- emojiName(n.actor_name, n.actor_emoji_json) %></span><% } %>
-    <% if (n.actor_handle) { %><span class="fedi-handle"><%= n.actor_handle %></span><% } %>
-    <% if (n.created_at) { %><span class="comment-time"><%= formatDateTime(n.created_at) %></span><% } %>
-  </div>
-  <div class="comment-content"><%- emojiHtml(n.content, n.emoji_json) %></div>
-  <%# Rich replies: media on our own sent replies (attachments on the Note).
-      Visitors see these too, and the reply editor (owner-only) may not render,
-      so make sure the stylesheet is on the page either way. %>
-  <% if (n.media && n.media.length) { %>
-    <% if (!locals.__reAssets) { locals.__reAssets = 1; %><link rel="stylesheet" href="/assets/css/reply-editor.css"><% } %>
-    <div class="re-reply-media">
-      <% n.media.forEach(function (m) { %>
-        <% if ((m.mediaType || '').indexOf('image/') === 0) { %>
-          <a href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= m.url %>" alt="<%= m.name || '' %>" loading="lazy"></a>
-        <% } else if ((m.mediaType || '').indexOf('audio/') === 0) { %>
-          <audio controls preload="none" src="<%= m.url %>"></audio>
-        <% } else { %>
-          <video controls preload="metadata" src="<%= m.url %>"></video>
-        <% } %>
-      <% }); %>
-    </div>
-  <% } %>
-  <div class="comment-actions">
-    <% if (n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.outboxId) { %>
-      <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>
-    <% } else if (!n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.id && typeof postSlug !== 'undefined' && postSlug) { %>
-      <%# Owner: like / boost / reply directly AS the site — no "your server" detour (it's your own site). %>
-      <form method="post" action="<%= _base %>/posts/<%= postSlug %>/fedi-react" class="fedi-owner-react">
-        <input type="hidden" name="interaction_id" value="<%= n.id %>">
-        <input type="hidden" name="kind" value="like">
-        <button type="submit" class="fedi-cact fedi-cact-like<%= n.acted_like ? ' is-on' : '' %>" title="<%= n.acted_like ? t('fedi.unlike_short') : t('fedi.like_short') %>" aria-label="<%= n.acted_like ? t('fedi.unlike_short') : t('fedi.like_short') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button>
-      </form>
-      <form method="post" action="<%= _base %>/posts/<%= postSlug %>/fedi-react" class="fedi-owner-react">
-        <input type="hidden" name="interaction_id" value="<%= n.id %>">
-        <input type="hidden" name="kind" value="boost">
-        <button type="submit" class="fedi-cact fedi-cact-boost<%= n.acted_boost ? ' is-on' : '' %>" title="<%= n.acted_boost ? t('tl.unboost') : t('fedi.boost_short') %>" aria-label="<%= n.acted_boost ? t('tl.unboost') : t('fedi.boost_short') %>"><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>
-      <% if (locals.mayReply !== false) { %>
-      <details class="fedi-owner-reply">
-        <summary class="fedi-cact fedi-cact-reply" title="<%= t('fedi.remote_reply_short') %>" aria-label="<%= t('fedi.remote_reply_short') %>"><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></summary>
-        <%- include('reply-editor', {
-          action: _base + '/posts/' + postSlug + '/fedi-reply',
-          hiddenFields: [{ name: 'interaction_id', value: n.id }],
-          placeholder: t('fedi.reply_ph'),
-          submitLabel: t('fedi.send'),
-          rows: 2,
-          participants: n.participants || [],
-        }) %>
-      </details>
-      <% } %>
-      <%# Owner moderation: report to their instance + remove (tombstoned: never comes back via re-delivery or thread-crawl). %>
-      <form method="post" action="<%= _base %>/interactions/<%= n.id %>/report" class="fedi-owner-react" data-confirm="<%= t('fedi.mod_report_confirm') %>">
-        <button type="submit" class="fedi-cact fedi-cact-report" title="<%= t('fedi.report_send') %>" aria-label="<%= t('fedi.report_send') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg></button>
-      </form>
-      <form method="post" action="<%= _base %>/interactions/<%= n.id %>/remove" class="fedi-owner-react" data-confirm="<%= t('fedi.mod_remove_confirm') %>">
-        <button type="submit" class="fedi-cact fedi-cact-remove" title="<%= t('comments.delete') %>" aria-label="<%= t('comments.delete') %>"><svg viewBox="0 0 24 24" 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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg></button>
-      </form>
-    <% } else if (n.noteId) { %>
-      <%# Visitor: reply via YOUR OWN instance. Also on the site's own (mine) comments —
-          those are dereferenceable Notes (/ap/notes/<id>) too; without this a visitor
-          could not reply to the owner's comments at all (only the owner branches above
-          catch mine-nodes for managers, so this else-if never renders for the owner). %>
-      <button type="button" class="comment-reply-btn fedi-remote-reply-btn" data-fedi-uri="<%= n.noteId %>" data-fedi-ph="<%= t('fedi.remote_ph') %>">
-        <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="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
-        <%= t('fedi.remote_reply_short') %>
-      </button>
-    <% } %>
-  </div>
-  <% if (n.children && n.children.length) { %>
-    <ol class="comment-replies">
-      <% n.children.forEach(function(c){ %>
-        <li class="comment comment-reply"><%- include('../partials/fedi-node', { n: c, t: t, canManageSite: (typeof canManageSite !== 'undefined' ? canManageSite : false), _base: _base, siteAvatar: (typeof siteAvatar !== 'undefined' ? siteAvatar : null), formatDateTime: formatDateTime, postSlug: (typeof postSlug !== 'undefined' ? postSlug : null) }) %></li>
-      <% }); %>
-    </ol>
-  <% } %>
-</div>
Index: src/views/partials/fedi-tabs.ejs
===================================================================
--- src/views/partials/fedi-tabs.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,33 +1,0 @@
-<%# Shared tab bar for the unified Fediverse section. Owner/admin-only.
-    Param: active = 'feed' | 'connect' | 'berichten' | 'blokkeren'
-    (legacy values 'following'/'followers' → Connect, 'reacties'/'meldingen' → Berichten).
-    All tabs share the content width equally (flex:1) and always fit (ellipsis).
-    "Back to site" lives in the top nav, not here. %>
-<% var _a = (typeof active !== 'undefined') ? active : ''; %>
-<% var _nu = (typeof notifUnread !== 'undefined') ? notifUnread : 0; %>
-<nav class="fedi-tabs" aria-label="Fediverse">
-  <a href="/news" class="fedi-tab<%= _a === 'feed' ? ' is-active' : '' %>"
-     hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/news" hx-indicator="#pcms-loading"><%= t('tl.tab_feed') %></a>
-  <a href="/connect" class="fedi-tab<%= (_a === 'connect' || _a === 'following' || _a === 'followers') ? ' is-active' : '' %>"
-     hx-get="/connect?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/connect" hx-indicator="#pcms-loading"><%= t('tl.tab_connect') %></a>
-  <a href="/messages" class="fedi-tab<%= (_a === 'berichten' || _a === 'reacties' || _a === 'meldingen') ? ' is-active' : '' %>"
-     hx-get="/messages?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/messages" hx-indicator="#pcms-loading"><span class="fedi-tab-label"><%= t('msg.tab') %></span><% if (_nu > 0) { %><span class="fedi-tab-badge"><%= _nu > 9 ? '9+' : _nu %></span><% } %></a>
-  <a href="/blocking" class="fedi-tab<%= _a === 'blokkeren' ? ' is-active' : '' %>"
-     hx-get="/blocking?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/blocking" hx-indicator="#pcms-loading"><%= t('blk.title') %></a>
-</nav>
-<style>
-/* All 5 tabs share the width equally and always fit (truncate with ellipsis if needed). */
-.fedi-tabs { display: flex; margin: 0 0 1.25rem;
-  border-bottom: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-.fedi-tab { flex: 1 1 0; min-width: 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-  padding: .6rem .4rem; text-decoration: none; font-weight: 600; font-size: .95rem;
-  color: var(--ink-soft, #888); border-bottom: 2px solid transparent; margin-bottom: -1px;
-  display: inline-flex; align-items: center; justify-content: center; gap: .3rem; }
-.fedi-tab:hover { color: var(--ink, #000); }
-.fedi-tab.is-active { color: var(--accent, #06c); border-bottom-color: var(--accent, #06c); }
-.fedi-tab-label { overflow: hidden; text-overflow: ellipsis; }
-.fedi-tab-badge { flex: 0 0 auto; min-width: 1.15em; padding: 0 .3em; border-radius: 999px;
-  background: var(--accent, #e8b04b); color: #fff; font-size: .66rem; line-height: 1.5; text-align: center; }
-@media (max-width: 520px) { .fedi-tab { padding: .55rem .25rem; font-size: .82rem; } }
-@media (max-width: 380px) { .fedi-tab { font-size: .74rem; } }
-</style>
Index: src/views/partials/footer.ejs
===================================================================
--- src/views/partials/footer.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/footer.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,30 +1,9 @@
 <footer class="site-footer">
-  <% if (typeof footerNewsletter !== 'undefined' && footerNewsletter && (typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof site !== 'undefined' && site) { %>
-    <div class="container footer-nl">
-      <span class="footer-nl-label">📬 <%= t('footer.subscribe_cta') %></span>
-      <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/nieuwsbrief" class="footer-nl-form">
-        <input type="email" name="email" required placeholder="<%= t('common.email_placeholder') %>" autocomplete="email" aria-label="E-mailadres">
-        <button type="submit"><%= t('footer.subscribe') %></button>
-      </form>
-    </div>
-  <% } %>
   <div class="container site-footer-inner">
     <div>
-      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span>
+      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'PrutFolio' %></span>
     </div>
-    <%# Install-app button — placed in the footer row (centre), saves a whole extra row.
-        JS detects the platform & opens a modal with PWA install steps. %>
-    <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app>
-      <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
-      <span><%= t('footer.install') %></span>
-    </button>
     <div class="footer-meta">
-      <% if (typeof langs !== 'undefined' && langs && langs.length > 1) { %>
-        <select class="footer-lang" aria-label="<%= t('nav.language') %>"
-                data-lang-switch>
-          <% langs.forEach(function(l){ %><option value="<%= l.code %>"<%= l.active ? ' selected' : '' %>><%= l.name %></option><% }); %>
-        </select>
-      <% } %>
-      <a class="footer-version" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/changelog" title="Bekijk wat er per release veranderd is">Klonkt<%= (typeof appVersion !== 'undefined' && appVersion) ? ' v' + appVersion : '' %></a>
+      <span>PrutFolio v1</span>
       <%# Mobile-only theme toggle. On desktop the topnav has its own toggle
           so we hide this one to avoid two visible toggles at once. The same
@@ -41,4 +20,13 @@
   </div>
 
+  <%# P64 — Install-app button. JS detects platform & opens modal with steps.
+      data-apk-url is the direct download for Android users who want the
+      native APK route instead of the PWA. %>
+  <div class="site-footer-install container">
+    <button type="button" class="install-app-btn" data-pcms-install-app data-apk-url="/assets/apk/soundfabrics.apk">
+      <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
+      <span>Installeer app</span>
+    </button>
+  </div>
 </footer>
 
@@ -77,20 +65,4 @@
   }
 }
-.footer-nl {
-  max-width: 800px; margin: 0 auto 1.5rem; padding: 0 1rem;
-  display: flex; align-items: center; justify-content: center; gap: .8rem; flex-wrap: wrap;
-}
-.footer-nl-label { font-weight: 600; color: var(--ink); }
-.footer-nl-form { display: flex; gap: .5rem; flex-wrap: wrap; }
-.footer-nl-form input {
-  padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--rule);
-  background: var(--paper); color: var(--ink); font-size: .9rem; min-width: 200px;
-}
-.footer-nl-form button {
-  padding: .5rem 1rem; border-radius: 8px; border: none; cursor: pointer;
-  background: var(--accent); color: #fff; font-weight: 600; font-size: .9rem;
-}
-@media (max-width: 600px) { .footer-nl { flex-direction: column; } .footer-nl-form input { min-width: 0; flex: 1; } }
-
 .site-footer-inner {
   max-width: 800px;
@@ -107,17 +79,4 @@
   display: inline-flex; align-items: center; gap: 0.75rem;
 }
-.footer-lang {
-  background: transparent; color: var(--ink); opacity: 1;
-  border: 1px solid var(--rule); border-radius: 999px;
-  padding: 0.3rem 0.5rem; font-size: 0.78rem; cursor: pointer;
-  font-family: inherit;
-}
-.footer-lang:hover { border-color: var(--accent); }
-/* Make open dropdown options readable (otherwise white-on-white in dark mode). */
-.footer-lang option { background: var(--paper); color: var(--ink); }
-/* On desktop the language picker lives in the header nav → hide the footer version. */
-@media (min-width: 768px) { .footer-lang { display: none; } }
-.footer-version { color: inherit; text-decoration: none; }
-.footer-version:hover { color: var(--accent); text-decoration: underline; }
 .pcms-main { min-height: 50vh; }
 
@@ -144,17 +103,9 @@
 }
 
-/* Compact install button inside the footer row (centre) instead of a large standalone
-   CTA on its own line — saves vertical space. */
-.install-app-btn--footer {
-  flex: 0 0 auto;
-  width: auto !important;          /* override the mobile .install-app-btn width:100% */
-  padding: 0.42rem 0.95rem;
-  font-size: 0.82rem;
-  gap: 0.4rem;
-}
-.install-app-btn--footer svg { width: 15px; height: 15px; }
-/* On narrow screens: let the three items centre neatly when they wrap. */
-@media (max-width: 600px) {
-  .site-footer-inner { justify-content: center; text-align: center; }
+/* Install-app button container — sits centered under the footer-inner row */
+.site-footer-install {
+  display: flex;
+  justify-content: center;
+  margin-top: 1.25rem;
 }
 </style>
Index: src/views/partials/home-append.ejs
===================================================================
--- src/views/partials/home-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-<% posts.forEach(function(post){ %><%- include('post-card', { post: post }) %><% }); %>
-<%# Grid view: OOB-append the same posts as tiles. htmx inserts the wrapper node
-    itself (beforeend), so display:contents lets the tiles lay out as direct grid
-    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/load-more.ejs
===================================================================
--- src/views/partials/load-more.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-<%
-  // Reusable "Load more" control. On the full page it sits after the feed; the
-  // append response re-emits it with oob=true so htmx swaps the fresh button
-  // (next offset) in place of the old one, and drops it on the last page.
-  var _oob = (typeof oob !== 'undefined' && oob);
-  var _target = (typeof moreTarget !== 'undefined' && moreTarget) ? moreTarget : '#feed';
-  var _path = (typeof morePath !== 'undefined' && morePath) ? morePath : '';
-  var _base = (typeof moreBase !== 'undefined' && moreBase) ? moreBase : '';
-%>
-<div id="load-more" class="load-more-wrap"<% if (_oob) { %> hx-swap-oob="true"<% } %>>
-  <% if (hasMore) { %>
-    <button type="button" class="btn load-more-btn"
-      hx-get="<%= _base %><%= _path %>?append=1&offset=<%= nextOffset %>"
-      hx-target="<%= _target %>" hx-swap="beforeend"><%= t('feed.load_more') %></button>
-  <% } %>
-</div>
Index: src/views/partials/media-tabs.ejs
===================================================================
--- src/views/partials/media-tabs.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,24 +1,0 @@
-<%# Gedeelde tabbalk voor Media (Afbeeldingen / Video's / Audio / Playlists /
-    Luisteraars).
-
-    Params: active = 'images'|'videos'|'audio'|'playlists'|'listeners';
-            audioOn (bool, standaard true).
-
-    De opmaak stond hier volledig inline en zit nu in partials/admin-styles.ejs
-    bij de rest van de ax-*-primitieven. Een gedeeld element dat zijn eigen
-    opmaak meedroeg kon niet meebewegen met de pagina's eromheen -- en dat was
-    zichtbaar: de balk zag er hetzelfde uit op vijf pagina's die verder van
-    twee verschillende ontwerpen waren.
-
-    De actieve tab is `aria-current="page"` en niet een klasse: dat is wat een
-    schermlezer nodig heeft, en de opmaak kan er net zo goed op haken. %>
-<% var _a = (typeof audioOn === 'undefined') ? true : audioOn; %>
-<nav class="ax-tabs" aria-label="Media">
-  <a href="/admin/media" class="ax-tab"<%- active === 'images' ? ' aria-current="page"' : '' %>><%= t('admin.media_images') %></a>
-  <a href="/admin/media/videos" class="ax-tab"<%- active === 'videos' ? ' aria-current="page"' : '' %>><%= t('admin.media_videos') %></a>
-  <% if (_a) { %>
-  <a href="/admin/audio" class="ax-tab"<%- active === 'audio' ? ' aria-current="page"' : '' %>><%= t('admin.b_audio') %></a>
-  <a href="/admin/playlists" class="ax-tab"<%- active === 'playlists' ? ' aria-current="page"' : '' %>><%= t('admin.b_playlists') %></a>
-  <a href="/admin/listeners" class="ax-tab"<%- active === 'listeners' ? ' aria-current="page"' : '' %>><%= t('admin.b_listeners') %></a>
-  <% } %>
-</nav>
Index: src/views/partials/messages-append.ejs
===================================================================
--- src/views/partials/messages-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-<% items.forEach(function(n){ %><%- include('msg-item', { n: n, seen: seen }) %><% }); %>
-<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages', oob: true }) %>
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,251 +1,0 @@
-<% if (n.type === 'thread') { %>
-<%
-           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
-           // Een draad is nieuw zodra EEN bericht erin nieuw is.
-           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
-             return m.created_at && Date.parse(m.created_at) > _tseen;
-           });
-           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
-           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
-           // je schreef, niet een lege naam.
-           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
-           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
-           // Zwaaide er iemand in dit gesprek? Dan hoort dat in de KOP
-           // (Robins aanwijzing, 25-8). Ingeklapt zijn de bubbels weg, en juist
-           // een zwaai is het bericht dat om een antwoord vraagt zonder iets te
-           // zeggen -- die mag niet achter een dichtgeklapt gesprek verdwijnen.
-           // Alleen een ONTVANGEN zwaai: je eigen zwaai staat er als 'sent' en
-           // is geen uitnodiging aan jezelf.
-           var _twave = (n.messages || []).some(function (m) { return m.type === 'mention' && m.wave; });
-      %>
-        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
-          <div class="msg-thread-head">
-            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
-                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
-
-                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
-                (Barts aanwijzing): tikken op de tegenpartij is waar je van
-                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
-                geen span met een klikhandler, want dan werkt hij ook met het
-                toetsenbord en leest een schermlezer de stand voor.
-
-                Zonder JavaScript doet hij niets, en dan staat het gesprek
-                gewoon open -- dat is ook de stand waar we mee beginnen. %>
-            <button type="button" class="msg-thread-who" aria-expanded="true">
-              <span class="msg-thread-name"><%= _tlabel %></span>
-              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
-              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-            </button>
-            <%# Blijft staan als het gesprek dicht is, net als het aantal in de
-                uitsparing hierboven -- daar is hij juist het meest waard. %>
-            <% if (_twave) { %><span class="msg-thread-wave" role="img" aria-label="<%= t('msg.waved_at_you') %>" title="<%= t('msg.waved_at_you') %>">&#128075;</span><% } %>
-            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
-            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
-                link is een antwoord in een lijst niet te plaatsen. %>
-            <% if (n.post) { %>
-              <a class="msg-thread-post" href="/<%= n.post.slug %>">
-                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
-                <%= n.post.title || n.post.slug %>
-              </a>
-            <% } %>
-          </div>
-          <%# Antwoorden vanuit de draad zelf, BOVEN de berichten (Robins
-              besluit, 25-8). Sinds het nieuwste bovenaan staat, is de bovenkant
-              waar het gesprek nu is: antwoorden hoort bij het laatste bericht,
-              en dat staat er direct onder. Onderaan zou de knop bij het OUDSTE
-              bericht komen te staan, en dat is precies waar je niet op
-              antwoordt.
-
-              Nog steeds ingeklapt, want een open editor onder elk gesprek maakt
-              de lijst onleesbaar -- juist het probleem dat deze weergave moest
-              oplossen. De route kiest het pad: een draad aan een post krijgt
-              een reply, een draad aan een persoon een direct bericht. %>
-          <%# shaer-r4c: precies de tak die de server ook kiest. /messages/reply
-              gaat naar deliverReply als er een interaction+post is (dat is een
-              ANTWOORD en valt onder de poort), en anders naar deliverDirectNote
-              (dat is een bericht, andere poort). Alleen de eerste verbergen. %>
-          <% var _rt = n.replyTo || {}; var _thread = _rt.interactionId && _rt.postSlug; %>
-          <% var _kan = canMutate && ((_thread && locals.mayReply !== false) || (!_thread && _rt.actorUri)); %>
-          <% if (_kan) { %>
-            <%# Heette msg-thread-foot toen hij onderaan stond. Een naam die
-                zegt waar iets staat is een leugen zodra het verhuist, dus nu
-                zegt hij wat het IS. %>
-            <div class="msg-thread-actions">
-              <details class="msg-reply">
-                <summary><%= t('fedi.reply') %></summary>
-                <div class="msg-reply-form">
-                  <%- include('../partials/reply-editor', {
-                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
-                    hiddenFields: [
-                      { name: 'interaction_id', value: _rt.interactionId || '' },
-                      { name: 'post_slug', value: _rt.postSlug || '' },
-                      { name: 'to', value: _rt.actorUri || '' },
-                    ],
-                    placeholder: t('fedi.reply_ph'),
-                    submitLabel: t('fedi.reply'),
-                    rows: 2,
-                  }) %>
-                </div>
-              </details>
-              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
-                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
-              <% if (_rt.actorUri) { %>
-                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave" data-sent="<%= t('msg.wave_sent') %>">
-                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
-                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
-                </form>
-              <% } %>
-            </div>
-          <% } %>
-          <%# NIEUWSTE BOVENAAN binnen een gesprek (Robins besluit, 25-8). De
-              LIJST van gesprekken en de meldingen blijven staan zoals ze
-              stonden; alleen binnen een draad leest het van nieuw naar oud, dus
-              wat er net bij kwam staat er meteen en niet onderaan een lange
-              draad.
-
-              Op een KOPIE, want groupConversations sorteert `messages`
-              oplopend en de rest van deze pagina rekent daarop -- `_tnew` en
-              `_tto` hierboven lezen dezelfde array. Een reverse() zonder
-              slice() draait het origineel om en dat werkt door naar alles wat
-              er daarna nog naar kijkt.
-
-              In de MARKUP en niet met column-reverse: die draait alleen het
-              beeld om, terwijl een schermlezer en het toetsenbord de oude
-              volgorde houden. %>
-          <ul class="msg-thread-msgs">
-            <% (n.messages || []).slice().reverse().forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
-          </ul>
-        </li>
-<% } else { %>
-<%
-           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
-           var _t = n.type === 'announce' ? 'boost' : n.type; // reply|mention|report|like|boost|follow|sent
-           var _new = _seen && n.created_at ? (Date.parse(n.created_at) > _seen) : false;
-           var _who = n.name || n.handle || '';
-           var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
-           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
-           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
-           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
-           // samenvielen -- en dat is geen cosmetische hernoeming: een
-           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
-           // post, geen herkenbare tegenpartij) hield anders een data-kind
-           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
-           // behalve Alles onzichtbaar.
-           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
-             : _t === 'report' ? 'mod'
-             : 'conv';
-           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
-           // de draad, niet op de losse zinnen erin.
-           var _sub = (typeof inThread !== 'undefined' && inThread);
-           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
-           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
-      %>
-        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
-          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
-            <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
-            <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
-            <% } else if (n.icon) { %><img src="<%= avatar(n.icon, 96) %>" alt="" loading="lazy">
-            <% } else { %><%= _init %><% } %>
-            <span class="msg-dot msg-dot-<%= _t %>">
-              <% if (_t === 'like') { %><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-              <% } else if (_t === 'boost') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><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>
-              <% } else if (_t === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
-              <% } else if (_t === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
-              <% } else if (_t === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>
-              <% } else if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
-              <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
-              <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %>
-            </span>
-          </span>
-
-          <div class="msg-body">
-            <div class="msg-line">
-              <% if (_t === 'sent') { %>
-                <span class="msg-who"><%= t('msg.you') %></span>
-                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
-              <% } else { %>
-                <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
-                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
-                      De handle erachter niet -- die staat in de kop. */ %>
-                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
-              <% } %>
-              <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
-              <% /* When the post was written, like de Krant and de Guardian PWA
-                    show it. created_at (when it reached us) stays the sort key
-                    and drives the "new since your last visit" dot above. */ %>
-              <% if (n.published || n.created_at) { %><span class="msg-time"><%= formatDateTime(n.published || n.created_at) %></span><% } %>
-            </div>
-
-            <% var _hasWhat = !_sub || _priv
-                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
-                 || (_t === 'sent' && n.in_reply_to); %>
-            <% if (_hasWhat) { %>
-            <div class="msg-what">
-              <% if (n.count && n.count > 1) { %>
-                <strong><%= t('msg.and_more', { n: n.count - 1 }) %></strong>
-                <%= _t === 'like' ? t('msg.liked_many') : t('msg.boosted_many') %>
-              <% } else if (_t === 'follow') { %><%= t('notif.followed') %>
-              <% } else if (_t === 'like') { %><%= t('notif.liked') %>
-              <% } else if (_t === 'boost') { %><%= t('notif.boosted') %>
-              <% } else if (_t === 'report') { %><%= t('notif.reported') %>
-              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
-              <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
-              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
-              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
-              <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
-              <% } else if (_t === 'poll_done') { %><strong><%= t('msg.poll_done') %></strong>
-              <% } else { %><%= t('notif.replied') %><% } %>
-              <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
-              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
-              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-              <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-            </div>
-            <% } %>
-
-            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
-            <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div>
-              <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %>
-                <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" data-sent="<%= t('msg.wave_sent') %>" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
-                  <input type="hidden" name="to" value="<%= n.actorUri %>">
-                  <button class="btn small" name="text" value="<%= t('msg.wave_r1') %>"><%= t('msg.wave_r1') %></button>
-                  <button class="btn small" name="text" value="<%= t('msg.wave_r2') %>"><%= t('msg.wave_r2') %></button>
-                  <button class="btn small" name="text" value="👋"><%= t('msg.wave_back') %></button>
-                </form>
-              <% } %>
-            <% } else if (_t === 'poll_done' && n.poll) { %>
-              <div class="msg-poll" role="group">
-                <% n.poll.options.forEach(function (o) { %>
-                  <div class="msg-poll-opt">
-                    <div class="msg-poll-top"><span class="msg-poll-name"><%= o.name %></span><span class="msg-poll-pct"><%= o.pct %>%</span></div>
-                    <div class="msg-poll-bar"><span style="width:<%= o.pct %>%"></span></div>
-                  </div>
-                <% }); %>
-                <div class="msg-poll-total"><%= t('msg.poll_total', { n: n.poll.voters }) %></div>
-              </div>
-            <% } %>
-
-            <% if (_t === 'sent' && n.outboxId) { %>
-              <div class="msg-actions">
-                <details class="msg-edit">
-                  <summary><%= t('fedi.edit') %></summary>
-                  <div class="msg-edit-form">
-                    <%- include('../partials/reply-editor', {
-                      action: '/fediverse/' + n.outboxId + '/edit',
-                      placeholder: t('fedi.reply_ph'),
-                      submitLabel: t('fedi.save_edit'),
-                      rows: 2,
-                      initialHtml: n.content,
-                      initialText: n.editable,
-                      defaultLang: n.language,
-                      noAttach: true,
-                    }) %>
-                  </div>
-                </details>
-                <form method="post" action="/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
-                  <button type="submit" class="msg-del"><%= t('comments.delete') %></button>
-                </form>
-              </div>
-            <% } %>
-          </div>
-        </li>
-<% } %>
Index: src/views/partials/news-append.ejs
===================================================================
--- src/views/partials/news-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-<% timeline.forEach(function(p){ %><%- include('tl-item', { p: p }) %><% }); %>
-<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news', oob: true }) %>
Index: src/views/partials/note-body.ejs
===================================================================
--- src/views/partials/note-body.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,54 +1,0 @@
-<%
-  /* The body of a post, rendered the same way wherever a post shows up: de
-     Krant, Berichten and the Guardian PWA. Takes `nb` with the column names an
-     ap_timeline row uses (content, emoji_json, media_json, quote_json,
-     embed_json, nsfw, cw); ap_mentions and ap_interactions carry the same names
-     so a mention or a reply can be handed straight to it.
-
-     Krant-only trimmings (the boost byline, polls, the audio player iframe, the
-     action bar) stay in tl-item: they are about the feed, not about the post. */
-  // De catch vangt KAPOTTE json; hij vangt niet geldige json van het verkeerde
-  // TYPE. Drie posts van een remote server droegen media_json = "[]", dus een
-  // string met daarin [], en JSON.parse geeft dan netjes een string terug. Een
-  // string heeft geen .filter, en daarmee lag de hele Krant plat: één vreemde
-  // note nam de pagina mee. Wat er binnenkomt is niet van ons, dus de vorm moet
-  // hier afgedwongen worden en niet aangenomen.
-  var _m = []; try { _m = JSON.parse(nb.media_json || '[]'); } catch (e) { _m = []; }
-  if (!Array.isArray(_m)) _m = [];
-  var _imgs = _m.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); });
-  var _vids = _m.filter(function (m) { return m && m.url && m.type && /^video\//.test(m.type); });
-  var _auds = _m.filter(function (m) { return m && m.url && m.type && /^audio\//.test(m.type); });
-  var _hasVisual = _imgs.length || _vids.length || _auds.length;
-  var _nsfwVisual = !!nb.nsfw && _hasVisual;
-  var _nsfwText = !!nb.nsfw && !_hasVisual;
-  // One card for both: a fediverse quote and an external link preview look
-  // identical; only where they came from differs. An embed carries a title and
-  // a thumbnail, never an iframe.
-  var _quote = noteQuote(nb.quote_json);
-  if (!_quote) {
-    var _emb = noteQuote(nb.embed_json);
-    // The title comes from a third-party oEmbed provider, so it is escaped
-    // here: the quote card renders `content` as HTML (fine for AP content,
-    // which we sanitise on the way in, but not for this).
-    if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
-                         content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
-  }
-  var _noAudio = !!nb.suppressAudio;   // the Klonkt player iframe already covers these tracks
-%>
-<% if (_nsfwText) { %>
-  <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></span><%- emojiHtml(nb.content, nb.emoji_json) %></div>
-<% } else { %>
-  <div class="tl-content"><%- emojiHtml(nb.content, nb.emoji_json) %></div>
-<% } %>
-<% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
-<% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
-<% if (_imgs.length) { %>
-  <div class="tl-media">
-    <% _imgs.forEach(function (m) { %>
-      <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
-    <% }); %>
-  </div>
-<% } %>
-<% _vids.forEach(function (m) { %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
-<% if (!_noAudio) { _auds.forEach(function (m) { %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
-<% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></div></div><% } %>
Index: src/views/partials/nsfw-veil.ejs
===================================================================
--- src/views/partials/nsfw-veil.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,3 +1,0 @@
-<%# NSFW veil contents (label + reveal button). The caller supplies the wrapper
-    element (<span|div> class="nsfw-veil"), which stays as-is so nothing shifts.
-    Param: cw = the post's content_warning (may be empty → generic warning). %><span class="nsfw-veil-label">🔞 <%= cw || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span>
Index: src/views/partials/page-data.ejs
===================================================================
--- src/views/partials/page-data.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,17 +1,0 @@
-<%#
-  Servergegevens naar een module (shaer-bqr).
-
-  Een module is een statisch bestand, dus er kan geen EJS-tag in. Alles wat een
-  script van de server nodig heeft -- vertalingen, ids, instellingen -- komt
-  hierlangs, en de module leest het met pageData() uit mod/lib.js.
-
-  Waarom dit GEEN CSP-probleem is terwijl het een script-element is: een script
-  met een ander type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus
-  script-src heeft er niets over te zeggen. Het is een gegevensblok dat toevallig
-  in een script-tag staat, en het werkt daarmee ook in inhoud die via htmx
-  binnenkomt.
-
-  De kleiner-dan wordt ontsnapt: zonder dat sluit een sluitende script-tag IN de
-  gegevens dit blok voortijdig af, en dan staat de rest als HTML op de pagina.
--%>
-<% if (typeof pageData !== 'undefined' && pageData) { %><script type="application/json" data-page-data><%- JSON.stringify(pageData).replace(/</g, '\\u003c') %></script><% } %>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/playlist-editor.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -99,8 +99,4 @@
 }
 .pl-field { display: flex; flex-direction: column; gap: .25rem; }
-/* `display: flex` wint van het hidden-attribuut, dus zonder deze regel doet
-   [hidden] op een .pl-field NIETS -- het veld blijft gewoon staan. De
-   uitgavevelden schakelen daarop (album versus afspeellijst). */
-.pl-field[hidden] { display: none; }
 .pl-field-full { grid-column: 1 / -1; }
 .pl-field > span {
@@ -284,30 +280,411 @@
 </style>
 
-<%# Het script staat in assets/js/mod/playlist-editor.js.
-
-    EIGEN GEGEVENSBLOK, niet dat van partials/page-data.ejs. pageData() pakt met
-    querySelector EEN blok, en deze modal wordt ingesloten op pagina's die er al
-    zelf een hebben (admin-playlists, post-edit) -- een tweede blok zou daar
-    stilzwijgend genegeerd worden en dan stond de hele modal weer in het
-    Nederlands. Eigen kenmerk, eigen lezer.
-
-    De teksten stonden hier tot 16-8 hard in het script. Dat viel niet op omdat
-    het toevallig de brontaal was: een Engelse of Duitse beheerder kreeg een
-    Nederlandse modal midden in een vertaalde pagina. %>
-<script type="application/json" data-playlist-editor-i18n><%- JSON.stringify({
-  titel: t('ple.titel'), artiest: t('ple.artiest'), jaar: t('ple.jaar'), type: t('ple.type'),
-  k_album: t('ple.k_album'), k_playlist: t('ple.k_playlist'), k_mixtape: t('ple.k_mixtape'),
-  uitgave: t('ple.uitgave'), mb_release: t('ple.mb_release'),
-  cover: t('ple.cover'), cover_kies: t('ple.cover_kies'), cover_url: t('ple.cover_url'),
-  tracks_in: t('ple.tracks_in'), sleep_hint: t('ple.sleep_hint'),
-  beschikbaar: t('ple.beschikbaar'), zoek: t('ple.zoek'), geen_res: t('ple.geen_res'),
-  leeg_sel: t('ple.leeg_sel'),
-  t_edit: t('ple.t_edit'), t_new: t('ple.t_new'), dialoog: t('ple.dialoog'),
-  sluiten: t('ple.sluiten'), annuleren: t('ple.annuleren'),
-  opslaan: t('ple.opslaan'), aanmaken: t('ple.aanmaken'),
-  bezig_opslaan: t('ple.bezig_opslaan'),
-  versleep: t('ple.versleep'), verwijder: t('ple.verwijder'),
-  geen_audio: t('ple.geen_audio'), e_geen_tracks: t('ple.e_geen_tracks'),
-  e_tracks: t('ple.e_tracks'), e_opslaan: t('ple.e_opslaan'), e_mislukt: t('ple.e_mislukt'),
-  e_alleen_afb: t('ple.e_alleen_afb'), bezig: t('ple.bezig'), e_upload: t('ple.e_upload'),
-}).replace(/</g, '\\u003c') %></script>
+<script>
+(function() {
+  // Idempotency: if window.openPlaylistEditor already defined (multiple
+  // partial includes on a single page), skip re-binding.
+  if (typeof window.openPlaylistEditor === 'function') return;
+
+  const CSRF = '<%= _csrf %>';
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+    }[c]));
+  }
+  function fmtDur(sec) {
+    if (!sec) return '';
+    const m = Math.floor(sec / 60), s = sec % 60;
+    return `${m}:${String(s).padStart(2, '0')}`;
+  }
+
+  async function api(method, url, body) {
+    const opts = {
+      method, credentials: 'same-origin',
+      headers: { 'X-CSRF-Token': CSRF },
+    };
+    if (body !== undefined) {
+      opts.headers['Content-Type'] = 'application/json';
+      opts.body = JSON.stringify(body);
+    }
+    const r = await fetch(url, opts);
+    return r.json();
+  }
+
+  /**
+   * Public entry point: open the editor.
+   *  opts: { mode: 'create'|'edit', id?, onSaved? }
+   *  onSaved is called with { id, playlist } after a successful save.
+   */
+  window.openPlaylistEditor = async function(opts) {
+    opts = opts || {};
+    const mode = opts.mode === 'edit' ? 'edit' : 'create';
+    const isEdit = mode === 'edit';
+
+    // Load all audio tracks for the picker
+    let tracks = [];
+    try {
+      const j = await api('GET', '/admin/playlists/api/tracks');
+      if (Array.isArray(j.tracks)) tracks = j.tracks;
+    } catch (e) {
+      alert('Tracks ophalen mislukt');
+      return;
+    }
+    if (!tracks.length) {
+      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
+      return;
+    }
+
+    // Existing playlist data when editing
+    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
+    if (isEdit && opts.id) {
+      try {
+        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
+        if (j.ok) initial = { ...initial, ...j.playlist };
+      } catch (e) {}
+    }
+
+    // ── DOM ────────────────────────────────────────────────────────
+    const backdrop = document.createElement('div');
+    backdrop.className = 'pl-modal-backdrop';
+    backdrop.innerHTML = `
+      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
+        <div class="pl-modal-header">
+          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
+          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
+        </div>
+        <div class="pl-modal-body">
+          <div class="pl-editor-cols">
+            <div class="pl-editor-left">
+              <div class="pl-meta-grid">
+                <label class="pl-field pl-field-full">
+                  <span>Titel *</span>
+                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
+                </label>
+                <label class="pl-field">
+                  <span>Artiest</span>
+                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
+                </label>
+                <label class="pl-field">
+                  <span>Jaar</span>
+                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
+                </label>
+                <label class="pl-field">
+                  <span>Type</span>
+                  <select id="pli-kind">
+                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
+                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
+                  </select>
+                </label>
+                <div class="pl-field pl-field-full">
+                  <span>Cover</span>
+                  <div class="pl-cover-row">
+                    <span class="pl-cover-thumb" id="pli-cover-thumb">
+                      ${initial.cover
+                        ? `<img src="${esc(initial.cover)}" alt="">`
+                        : `<span class="pl-cover-empty">🎨</span>`}
+                    </span>
+                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
+                  </div>
+                  ${isEdit ? `
+                  <div class="pl-cover-upload-row">
+                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
+                    <span class="pl-cover-status" id="pli-cover-status"></span>
+                  </div>` : `
+                  <small class="pl-cover-note">Upload van een afbeelding kan na het aanmaken van de playlist.</small>`}
+                </div>
+              </div>
+              <div class="pl-section-title">
+                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
+                <small>(sleep ⠿ om te ordenen)</small>
+              </div>
+              <div id="pli-selected" class="pl-selected-list"></div>
+            </div>
+            <div class="pl-editor-right">
+              <div class="pl-section-title">Beschikbare tracks</div>
+              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
+              <div id="pli-available" class="pl-available-list"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pl-modal-footer">
+          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
+          <button type="button" class="btn btn-primary" id="pli-save" disabled>
+            ${isEdit ? 'Opslaan' : 'Aanmaken'}
+          </button>
+        </div>
+      </div>
+    `;
+    document.body.appendChild(backdrop);
+
+    const $ = sel => backdrop.querySelector(sel);
+    const close = () => backdrop.remove();
+    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
+    $('.pl-modal-close').addEventListener('click', close);
+    $('#pli-cancel').addEventListener('click', close);
+
+    // Index tracks by id for fast lookup
+    const trackById = new Map(tracks.map(t => [t.id, t]));
+    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
+
+    const titleEl = $('#pli-title');
+    const artistEl = $('#pli-artist');
+    const yearEl = $('#pli-year');
+    const kindEl = $('#pli-kind');
+    const coverEl = $('#pli-cover');
+    const coverThumb = $('#pli-cover-thumb');
+    const saveBtn = $('#pli-save');
+    const selectedEl = $('#pli-selected');
+    const availEl = $('#pli-available');
+    const searchEl = $('#pli-search');
+    const countEl = $('#pli-count');
+
+    coverEl.addEventListener('input', () => {
+      const u = coverEl.value.trim();
+      coverThumb.innerHTML = u
+        ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
+        : `<span class="pl-cover-empty">🎨</span>`;
+    });
+
+    // ── Cover file upload (edit mode only) ────────────────────
+    // The upload widget only exists once a playlist has an id to attach to.
+    // In create mode the user types/pastes a URL, saves the playlist, then
+    // re-opens for upload. The route is the same as for tracks: returns
+    // { ok, url, cover_url }.
+    const coverFileInput = $('#pli-cover-file');
+    const coverPickBtn   = $('#pli-cover-pick');
+    const coverStatus    = $('#pli-cover-status');
+    if (coverFileInput && coverPickBtn) {
+      coverPickBtn.addEventListener('click', () => coverFileInput.click());
+      coverFileInput.addEventListener('change', async (e) => {
+        const file = e.target.files && e.target.files[0];
+        coverFileInput.value = '';
+        if (!file) return;
+        if (!/^image\//.test(file.type)) {
+          coverStatus.textContent = 'Alleen afbeeldingen';
+          coverStatus.className = 'pl-cover-status is-error';
+          return;
+        }
+        coverStatus.textContent = 'Uploaden…';
+        coverStatus.className = 'pl-cover-status';
+        const fd = new FormData();
+        fd.append('cover', file);
+        try {
+          const r = await fetch(
+            `/admin/playlists/api/${encodeURIComponent(opts.id)}/cover`,
+            { method: 'POST', body: fd, credentials: 'same-origin' }
+          );
+          const j = await r.json();
+          if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
+          const url = j.cover_url || j.url || '';
+          coverEl.value = url;
+          coverThumb.innerHTML = url
+            ? `<img src="${esc(url)}" alt="">`
+            : `<span class="pl-cover-empty">🎨</span>`;
+          coverStatus.textContent = '✓ Geüpload';
+          coverStatus.className = 'pl-cover-status is-ok';
+        } catch (err) {
+          coverStatus.textContent = 'Mislukt: ' + err.message;
+          coverStatus.className = 'pl-cover-status is-error';
+        }
+      });
+    }
+
+    function updateSaveBtn() {
+      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
+    }
+
+    function renderSelected() {
+      countEl.textContent = selected.length;
+      if (selected.length === 0) {
+        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
+        updateSaveBtn();
+        return;
+      }
+      selectedEl.innerHTML = selected.map((id, i) => {
+        const t = trackById.get(id);
+        if (!t) return '';
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
+          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
+          <span class="pl-row-num">${i + 1}</span>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
+          </span>
+          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
+        </div>`;
+      }).join('');
+
+      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
+        b.addEventListener('click', () => {
+          selected = selected.filter(x => x !== b.dataset.id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+      bindDrag();
+      updateSaveBtn();
+    }
+
+    function renderAvailable() {
+      const q = searchEl.value.trim().toLowerCase();
+      const matches = tracks.filter(t => {
+        if (!q) return true;
+        return (t.title || '').toLowerCase().includes(q)
+            || (t.artist || '').toLowerCase().includes(q);
+      });
+      if (matches.length === 0) {
+        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
+        return;
+      }
+      availEl.innerHTML = matches.map(t => {
+        const isAdded = selected.includes(t.id);
+        const cls = ['pl-avail-row'];
+        if (isAdded) cls.push('is-added');
+        if (!t.playable) cls.push('is-unplayable');
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
+          </span>
+          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
+        </div>`;
+      }).join('');
+
+      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
+        if (row.classList.contains('is-unplayable')) return;
+        row.addEventListener('click', () => {
+          const id = row.dataset.id;
+          if (selected.includes(id)) selected = selected.filter(x => x !== id);
+          else selected.push(id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+    }
+
+    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
+    function bindDrag() {
+      selectedEl.querySelectorAll('.pl-row').forEach(row => {
+        const handle = row.querySelector('.pl-row-handle');
+        if (!handle) return;
+        let dragging = false, originalIdx = -1;
+
+        handle.addEventListener('pointerdown', e => {
+          e.preventDefault();
+          handle.setPointerCapture(e.pointerId);
+          dragging = true;
+          originalIdx = parseInt(row.dataset.pos, 10);
+          row.classList.add('is-dragging');
+        });
+        handle.addEventListener('pointermove', e => {
+          if (!dragging) return;
+          e.preventDefault();
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            const r = rows[i]; if (r === row) continue;
+            const rect = r.getBoundingClientRect();
+            const mid = rect.top + rect.height / 2;
+            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
+            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
+              targetIdx = i; above = e.clientY < mid; break;
+            }
+          }
+          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
+        });
+        const finish = e => {
+          if (!dragging) return;
+          dragging = false;
+          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
+          row.classList.remove('is-dragging');
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
+            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
+          }
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          if (targetIdx === -1 || targetIdx === originalIdx) return;
+          const moved = selected[originalIdx];
+          selected.splice(originalIdx, 1);
+          let newIdx = targetIdx;
+          if (originalIdx < targetIdx) newIdx--;
+          if (!above) newIdx++;
+          newIdx = Math.max(0, Math.min(selected.length, newIdx));
+          selected.splice(newIdx, 0, moved);
+          renderSelected();
+        };
+        handle.addEventListener('pointerup', finish);
+        handle.addEventListener('pointercancel', () => {
+          dragging = false;
+          row.classList.remove('is-dragging');
+          selectedEl.querySelectorAll('.drop-above, .drop-below')
+            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
+        });
+      });
+    }
+
+    titleEl.addEventListener('input', updateSaveBtn);
+    searchEl.addEventListener('input', renderAvailable);
+    renderSelected();
+    renderAvailable();
+
+    saveBtn.addEventListener('click', async () => {
+      if (saveBtn.disabled) return;
+      const orig = saveBtn.textContent;
+      saveBtn.disabled = true;
+      saveBtn.textContent = 'Opslaan...';
+
+      const payload = {
+        title:  titleEl.value.trim(),
+        artist: artistEl.value.trim(),
+        year:   parseInt(yearEl.value, 10) || 0,
+        cover:  coverEl.value.trim(),
+        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
+        tracks: selected.slice(),
+      };
+
+      try {
+        const url = isEdit
+          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
+          : '/admin/playlists/api';
+        const j = await api('POST', url, payload);
+        if (!j.ok) {
+          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
+          saveBtn.disabled = false;
+          saveBtn.textContent = orig;
+          return;
+        }
+        const savedId = isEdit ? initial.id : j.id;
+        close();
+        if (typeof opts.onSaved === 'function') {
+          opts.onSaved({ id: savedId, playlist: payload });
+        }
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = orig;
+      }
+    });
+
+    // ESC to close
+    document.addEventListener('keydown', function onEsc(e) {
+      if (e.key === 'Escape' && document.body.contains(backdrop)) {
+        close();
+        document.removeEventListener('keydown', onEsc);
+      }
+    });
+  };
+})();
+</script>
Index: src/views/partials/post-body.ejs
===================================================================
--- src/views/partials/post-body.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,62 +1,0 @@
-<%
-// Het lijf van één bericht, IN de stroom.
-//
-// De poorten waren tot nu toe hele pagina's: paid-gate of fan-gate in plaats
-// van het bericht. Dat kan niet meer zodra de tijdlijn hele berichten toont --
-// dan moet een gesloten bericht zijn PLEK houden en ter plekke zeggen wat er
-// aan de hand is. Weglaten zou erger zijn dan tonen: dan zien een supporter en
-// een vreemde een andere tijdlijn, met andere buren, en klopt "vorige/volgende"
-// voor niemand meer.
-//
-// Het BESLUIT staat niet hier maar in PostAccessService, dezelfde die de
-// postroute gebruikt. Dit sjabloon kiest alleen de vorm. Twee plekken die
-// allebei zelf bepalen wie een betaalde tekst mag lezen, is een lek dat op een
-// gelegenheid wacht.
-//
-// Verwacht: post, access ('full'|'paid'|'fan'|'forbidden'), teaser, _base.
-var _acc = (typeof access !== 'undefined' && access) ? access : 'full';
-var _teaser = (typeof teaser !== 'undefined' && teaser) ? teaser : '';
-var _b = (typeof _base !== 'undefined' && _base) ? _base : '';
-var _href = _b + '/' + post.slug;
-// Het lijf komt als eigen waarde binnen en wordt NIET uit `post` gevist. De rij
-// draagt nog de ruwe `content`; door alleen te tonen wat de aanroeper bewust
-// meegeeft, kan een gesloten poort hier niets laten ontsnappen.
-var _html = (typeof content_html !== 'undefined' && content_html) ? content_html : '';
-%>
-
-<% if (_acc === 'full') { %>
-  <div class="post-content"><%- _html %></div>
-
-<% } else if (_acc === 'paid') { %>
-  <%# Dezelfde woorden als de hele pagina (pgate.*), want het is dezelfde
-      boodschap -- alleen op een andere plek. Eigen teksten hier zouden na één
-      wijziging uiteen gaan lopen. %>
-  <div class="post-content post-content--gated pb-paid">
-    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
-    <div class="pb-gate">
-      <h2 class="pg-h2"><%= t('pgate.h') %></h2>
-      <p class="pg-sub">
-        <% if (post.paid_min_cents) { %><%= t('pgate.sub_cents', { eur: (post.paid_min_cents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
-      </p>
-      <%# De ontgrendeling zelf blijft op de eigen pagina: daar staat de
-          passkey-code (pageJs paid-gate) die hier niet geladen is. Eén knop die
-          ernaartoe gaat is eerlijker dan een halve poort in de stroom. %>
-      <p><a class="pg-btn" href="<%= _href %>"><%= t('pgate.unlock') %></a></p>
-    </div>
-  </div>
-
-<% } else if (_acc === 'fan') { %>
-  <div class="post-content post-content--gated pb-fan">
-    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
-    <div class="pb-gate">
-      <h2 class="pg-h2"><%= t('fgate.title') %></h2>
-      <p class="pg-sub"><%= t('fgate.sub') %></p>
-      <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(_href) %>"><%= t('fgate.login') %></a></p>
-    </div>
-  </div>
-
-<% } else { %>
-  <%# 'forbidden' hoort de tijdlijn niet eens te bereiken: die haalt alleen
-      gepubliceerde berichten op. Staat er voor het geval een aanroeper ooit
-      iets anders doorgeeft -- dan liever niets dan een concept. %>
-<% } %>
Index: src/views/partials/post-card.ejs
===================================================================
--- src/views/partials/post-card.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/post-card.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,39 +11,8 @@
 //   Hover state on cover. Tap-target stays large.
 
-const _base      = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _external  = post.external_url || null;
-const _href      = _external || (_base + '/' + post.slug);
-const _ext       = !!_external;
-const _src       = post.source_name || '';
-const _realCover = !!post.cover_image_url || !!post.cover_video_url;
-// Content-derived fallback (see post-tile.ejs): a C2S post's media lives in
-// its content, and the card fronts it from there.
-let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
-if (!_realCover && post.content) {
-  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
-  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else {
-    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
-    if (im) _cImg = im[1];
-    else {
-      // An audio post (Robins vraag, 30-7): the upload leg drew a waveform
-      // (data-poster on the tag); the card fronts it like a cover.
-      const am = String(post.content).match(/<audio[^>]*>/i);
-      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
-    }
-  }
-}
-const _hasCover  = _realCover || !!_cVideo || !!_cImg || !!_cWave;
-// A C2S post has no title: fall back to the post's own words, then to a
-// friendly type label instead of "(zonder titel)".
-let _title = post.title || '';
-if (!_title && post.content) {
-  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
-  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
-}
-if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)';
+const _href      = '/' + post.slug;
+const _hasCover  = !!post.cover_image_url;
 const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : '';
 const _isPinned  = !!post.pinned;
-const _isBoost   = !!post.isBoost;
 const _isDraft   = post.status && post.status !== 'published';
 
@@ -69,22 +38,12 @@
 }
 %>
-<li class="post-list-item<%= (_hasCover || post.nsfw) ? ' has-cover' : '' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %>">
+<li class="post-list-item<%= _hasCover ? ' has-cover' : '' %><%= _isPinned ? ' is-pinned' : '' %>">
 
   <% if (_hasCover) { %>
-    <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
-       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= _title %>" tabindex="-1">
-      <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>"
-           srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
-           sizes="(min-width: 768px) 120px, 100vw"
-           alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-      <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="post-cover-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
-      <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
-    </a>
-  <% } else if (post.nsfw) { %>
-    <a class="post-list-cover nsfw-media" href="<%= _href %>"
-       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= _title %>" tabindex="-1">
-      <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span>
+    <a class="post-list-cover" href="<%= _href %>"
+       hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"
+       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
+      <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
     </a>
   <% } %>
@@ -92,7 +51,6 @@
   <div class="post-list-body">
     <div class="post-list-meta mono">
-      <% if (_isBoost) { %><span class="post-list-pin post-list-boost" aria-label="Boost"><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></span><% } else if (_isPinned) { %><span class="post-list-pin" aria-label="Vastgepind"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg></span><% } %>
+      <% if (_isPinned) { %><span class="post-list-pin" aria-label="Vastgepind">★</span><% } %>
       <time datetime="<%= post.published_at || post.created_at %>"><%= _ddmmyyyy(post.published_at || post.created_at) %></time>
-      <% if (_src) { %><span class="post-list-type">&middot; via <%= _src %></span><% } %>
       <% if (_typeLabel) { %>
         <span class="post-list-type">· <%= _typeLabel.charAt(0).toUpperCase() + _typeLabel.slice(1) %></span>
@@ -105,6 +63,7 @@
     <h3 class="post-list-title">
       <a href="<%= _href %>"
-         <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
-        <%= _title %>
+         hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+         hx-push-url="<%= _href %>" hx-indicator="#pcms-loading">
+        <%= post.title || '(zonder titel)' %>
       </a>
     </h3>
@@ -117,7 +76,7 @@
       <div class="post-list-tags">
         <% _tags.forEach(function(t) { %>
-          <a href="<%= _base %>/tag/<%= encodeURIComponent(t) %>" class="tag-chip"
-             hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1" hx-target="#pcms-main"
-             hx-swap="innerHTML" hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
+          <a href="/tag/<%= encodeURIComponent(t) %>" class="tag-chip"
+             hx-get="/tag/<%= encodeURIComponent(t) %>?partial=1" hx-target="#pcms-main"
+             hx-swap="innerHTML" hx-push-url="/tag/<%= encodeURIComponent(t) %>"
              hx-indicator="#pcms-loading">#<%= t %></a>
         <% }); %>
@@ -168,16 +127,11 @@
     margin-right: calc(-1 * clamp(1.25rem, 4vw, 2rem));
     width: calc(100% + 2 * clamp(1.25rem, 4vw, 2rem)) !important;
-    /* Accent-gradient placeholder shown while the cover loads (the image fades in over it). */
-    background-image: linear-gradient(135deg,
-        color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2)) 0%,
-        color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2)) 100%);
-  }
-  .post-list-cover img, .post-list-cover video {
+    background: var(--paper-2);
+  }
+  .post-list-cover img {
     width: 100%; height: 100%;
     object-fit: cover;
     display: block;
-    transition: opacity .4s ease;
-  }
-  .post-list-cover img.is-loading { opacity: 0; }
+  }
 
   /* Re-apply rounded corners on slightly larger phones */
@@ -206,7 +160,4 @@
   }
 
-  /* Audio cover (Shaer): white-on-transparent waveform on its own accent
-     gradient, contained so the shape reads at 120px and full-bleed alike. */
-  /* (see also .grid-tile-wave in style.css for the grid tiles) */
   /* Pinned indicator */
   .post-list-item.is-pinned .post-list-title a {
@@ -253,18 +204,4 @@
 
 /* ============================================================
-   AUDIO COVER (shared) — Shaer audio post (Robins vraag, 30-7):
-   the waveform PNG is white on transparent; give it its own accent
-   gradient and contain it so the shape reads at 120px and full-bleed.
-   ============================================================ */
-.post-list-cover .post-cover-wave {
-  width: 100%; height: 100%;
-  object-fit: contain !important;
-  padding: 14% 8%;
-  background-image: linear-gradient(135deg,
-      color-mix(in srgb, var(--accent, #888) 26%, var(--paper-2, var(--paper))) 0%,
-      color-mix(in srgb, var(--accent, #888) 8%, var(--paper-2, var(--paper))) 100%);
-}
-
-/* ============================================================
    PIN + DRAFT INDICATORS (shared)
    ============================================================ */
@@ -272,10 +209,6 @@
   color: var(--accent);
   margin-right: .35rem;
-  display: inline-flex;
-  align-items: center;
-  vertical-align: middle;
-}
-.post-list-pin svg { width: 13px; height: 13px; display: block; }
-.post-list-boost { color: #2fa85a; }
+  font-size: .9rem;
+}
 .post-list-draft {
   color: var(--accent);
Index: src/views/partials/post-nav.ejs
===================================================================
--- src/views/partials/post-nav.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,49 +1,0 @@
-<%# Post navigation (Newer link left / Older right) — shared by post.ejs and
-    fan-gate.ejs. Expects newerPost + olderPost in scope. If a neighbour is missing,
-    a greyed-out placeholder keeps every post in the same two-card structure. %>
-<% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %>
-  <nav class="post-nav post-nav--top">
-    <% if (newerPost) { %>
-      <a class="post-nav-prev" href="<%= newerPost._urlBase || '' %>/<%= newerPost.slug %>"<% if (!newerPost._urlBase) { %>
-         hx-get="/<%= newerPost.slug %>?partial=1" hx-target="#pcms-main"
-         hx-push-url="/<%= newerPost.slug %>" hx-indicator="#pcms-loading"<% } %>>
-        <span class="post-nav-label"><% if (newerPost.pinned) { %><span class="post-nav-pin" title="Vastgepind">📌</span> <% } %>← <%= t('postnav.newer') %></span>
-        <span class="post-nav-title"><%= newerPost.title %></span>
-      </a>
-    <% } else { %>
-      <div class="post-nav-prev is-empty" aria-hidden="true">
-        <span class="post-nav-label">← Newer</span>
-        <span class="post-nav-title"><%= t('postnav.newest') %></span>
-      </div>
-    <% } %>
-    <% if (olderPost) { %>
-      <a class="post-nav-next" href="<%= olderPost._urlBase || '' %>/<%= olderPost.slug %>"<% if (!olderPost._urlBase) { %>
-         hx-get="/<%= olderPost.slug %>?partial=1" hx-target="#pcms-main"
-         hx-push-url="/<%= olderPost.slug %>" hx-indicator="#pcms-loading"<% } %>>
-        <span class="post-nav-label"><%= t('postnav.older') %> →<% if (olderPost.pinned) { %> <span class="post-nav-pin" title="Vastgepind">📌</span><% } %></span>
-        <span class="post-nav-title"><%= olderPost.title %></span>
-      </a>
-    <% } else { %>
-      <div class="post-nav-next is-empty" aria-hidden="true">
-        <span class="post-nav-label">Older →</span>
-        <span class="post-nav-title"><%= t('postnav.oldest') %></span>
-      </div>
-    <% } %>
-  </nav>
-<% } %>
-
-<style>
-.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
-/* Above the post: no line/gap above it, but a dividing line BELOW (toward the content). */
-.post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); }
-.post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
-/* Empty slot (no newer/older post): same card, greyed out + not clickable,
-   so every post keeps the same two-card structure. */
-.post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; }
-.post-nav-prev.is-empty .post-nav-title, .post-nav-next.is-empty .post-nav-title { font-weight: 400; font-style: italic; color: var(--ink-muted); }
-.post-nav-next { text-align: right; align-items: flex-end; }
-.post-nav-label { font-size: 0.8rem; color: var(--ink-muted); margin-bottom: 0.25rem; }
-.post-nav-title { font-weight: 500; }
-.post-nav-pin { font-size: 0.85em; }
-@media (max-width: 600px) { .post-nav { grid-template-columns: 1fr; } }
-</style>
Index: src/views/partials/post-tile.ejs
===================================================================
--- src/views/partials/post-tile.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/post-tile.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -5,67 +5,29 @@
 const _tileHue = ((post.id || post.slug || '0').charCodeAt(0) * 7) % 360;
 const _isPinned = !!post.pinned;
-const _isBoost = !!post.isBoost;
-const _hasCover = !!post.cover_image_url || !!post.cover_video_url;
-// A C2S post carries its media IN the content (a cover next to it showed the
-// video twice on the post page). The tile derives its picture from the
-// content instead, like the Cirkel card: first <video> (with its poster) or
-// first <img>.
-let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
-if (!_hasCover && post.content) {
-  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
-  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else {
-    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
-    if (im) _cImg = im[1];
-    else {
-      // An audio post (Robins vraag, 30-7): the waveform the upload leg drew
-      // (data-poster) goes OVER the gradient, so the tile keeps its own hue.
-      const am = String(post.content).match(/<audio[^>]*>/i);
-      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
-    }
-  }
-}
-const _showCover = _hasCover || !!_cVideo || !!_cImg;   // the wave keeps the gradient
-// A C2S post has no title: fall back to the post's own words, and for a
-// media-only post to a friendly type label instead of "(untitled)". A tile
-// that already shows its picture needs no forced label at all.
-let _title = post.title || '';
-if (!_title && post.content) {
-  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
-  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
-}
-if (!_title) _title = _cAudio ? '♪ audio' : (_cVideo || _cImg) ? '' : '(untitled)';
+const _hasCover = !!post.cover_image_url;
 const _typeLabel = (post.type && post.type !== 'post') ? post.type : '';
-const _src = post.source_name || '';   // bron-site (cirkel-feed: van welke site komt deze post)
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _external = post.external_url || null;
-const _href = _external || (_base + '/' + post.slug);
-const _ext = !!_external;
 %>
-<a class="grid-tile<%= _showCover ? '' : ' grid-tile-gradient' %><%= _cWave ? ' grid-tile-audio' : '' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %><%= post.nsfw ? ' nsfw-media' : '' %>"
-   href="<%= _href %>"
-   style="--tile-hue: <%= _tileHue %>;"
-   <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
-
-  <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="grid-tile-img grid-tile-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
+<a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= _isPinned ? ' is-pinned' : '' %>"
+   href="/<%= post.slug %>"
+   <% if (_hasCover) { %>style="background-image: url('<%= post.cover_image_url %>'); --tile-hue: <%= _tileHue %>;"<% } else { %>style="--tile-hue: <%= _tileHue %>;"<% } %>
+   hx-get="/<%= post.slug %>?partial=1"
+   hx-target="#pcms-main"
+   hx-swap="innerHTML"
+   hx-push-url="/<%= post.slug %>"
+   hx-indicator="#pcms-loading">
 
   <% if (_typeLabel) { %>
     <span class="grid-tile-type"><%= _typeLabel %></span>
   <% } %>
-  <% if (_isBoost) { %>
-    <span class="grid-tile-pin grid-tile-boost"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>Boost</span>
-  <% } else if (_isPinned) { %>
-    <span class="grid-tile-pin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>Pinned</span>
+  <% if (_isPinned) { %>
+    <span class="grid-tile-pin">Pinned</span>
   <% } %>
 
   <% if (_hasCover) { %>
     <div class="grid-tile-overlay">
-      <strong><%= _title %></strong>
-      <% if (_src) { %><span class="grid-tile-overlay-src">van <%= _src %></span><% } %>
+      <strong><%= post.title || '(untitled)' %></strong>
     </div>
   <% } else { %>
-    <span class="grid-tile-title"><%= _title %></span>
-    <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
+    <span class="grid-tile-title"><%= post.title || '(untitled)' %></span>
   <% } %>
-  <% if (post.nsfw) { %><div class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></div><% } %>
 </a>
Index: src/views/partials/profile-header.ejs
===================================================================
--- src/views/partials/profile-header.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/profile-header.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -26,13 +26,4 @@
   try { _profileLinks = JSON.parse(site.profile_links) || []; } catch (e) { _profileLinks = []; }
 }
-
-// Profile-summary (lightbox) helpers — used by the avatar + the summary modal.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _avatarUrl = (site && site.profile_photo) || ((typeof siteOwnerAvatar !== 'undefined' && siteOwnerAvatar) || '');
-const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {};
-const _linkHref = function (p, u) { u = String(u || '').trim(); if (p === 'email' && u && u.indexOf('mailto:') !== 0) return 'mailto:' + u; if (p === 'phone' && u && !/^tel:/i.test(u)) return 'tel:' + u.replace(/[^\d+]/g, ''); return u; };
-const _linkVal = function (u) { return String(u || '').replace(/^(mailto:|tel:|https?:\/\/)/i, '').replace(/\/$/, ''); };
-const _isPrem = (typeof isPremium !== 'undefined' && isPremium);
-let _sinceStr = ''; try { if (site && site.created_at) _sinceStr = new Date(site.created_at).toLocaleDateString('nl-NL', { day: '2-digit', month: 'short', year: 'numeric' }); } catch (e) {}
 %>
 
@@ -41,9 +32,14 @@
   <div class="container profile-header-inner">
 
-    <%# Clicking the avatar — photo OR placeholder — opens the profile-summary modal. %>
-    <button type="button" class="profile-photo" data-pf-summary aria-label="<%= _displayName || 'Profile' %>">
-      <% if (_avatarUrl) { %>
-        <img src="<%= avatar(_avatarUrl, 256) %>" alt="">
-      <% } else if (site.enable_audio_player && (typeof audioEnabled === 'undefined' || audioEnabled)) { %>
+    <a class="profile-photo" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/"
+       hx-get="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/?partial=1"
+       hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/"
+       hx-indicator="#pcms-loading" aria-label="Home">
+      <% if (typeof user !== 'undefined' && user && user.avatar_url) { %>
+        <img src="<%= user.avatar_url %>" alt="">
+      <% } else if (site.profile_photo) { %>
+        <img src="<%= site.profile_photo %>" alt="">
+      <% } else if (site.enable_audio_player) { %>
         <span class="profile-photo-fallback profile-photo-fallback--music" aria-hidden="true">
           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -56,5 +52,5 @@
         <span class="profile-photo-fallback" aria-hidden="true"><%= (_displayName || '?').charAt(0).toUpperCase() %></span>
       <% } %>
-    </button>
+    </a>
 
     <div class="profile-info">
@@ -72,8 +68,5 @@
       <% } %>
 
-      <%# Follow-via-fediverse: a compact icon in the links row (keeps the header
-          short), and hidden for the owner — you don't follow yourself. %>
-      <% var _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled); %>
-      <% if (_profileLinks.length || _showFollow) { %>
+      <% if (_profileLinks.length) { %>
         <% const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {}; %>
         <ul class="profile-links" aria-label="External links">
@@ -93,10 +86,4 @@
             </li>
           <% }); %>
-          <% if (_showFollow) { %>
-            <li style="order:-1">
-              <button type="button" class="profile-link profile-fedi-link" data-fedi-actor-slug="<%= site.slug %>"
-                      aria-label="<%= t('fedi.profile_follow') %>" title="<%= t('fedi.profile_follow') %>"><svg viewBox="151 31 530 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g transform="translate(6.6789703,-32.495842)"><path d="M257.1,266.8c-4.9,9.4-12.6,17.1-22.1,22l121.3,121.8l29.2-14.8L257.1,266.8z M417.1,427.4l-29.2,14.8l61.5,61.7c4.9-9.4,12.6-17.1,22.1-22L417.1,427.4z"/><path d="M557.5,315l-68.7,34.8l5.1,32.4l77.7-39.4C564.1,335.2,559.2,325.5,557.5,315z M448.9,370l-162.4,82.3c7.5,7.6,12.4,17.3,14.1,27.8L454,402.4L448.9,370z"/><path d="M396.7,167.3l-78.4,153l23.1,23.2l83-162C414,179.7,404.3,174.7,396.7,167.3z M298,360l-39.7,77.5c10.5,1.8,20.2,6.7,27.7,14.2l35.1-68.5L298,360z"/><path d="M234.3,289.1c-8,4-16.9,5.9-25.8,5.4c-1.7-0.1-3.3-0.3-5-0.5l23.2,148.2c8-4,16.9-5.9,25.8-5.4c1.7,0.1,3.3,0.3,5,0.5L234.3,289.1z"/><path d="M300.8,480.8c0.5,3.4,0.7,6.9,0.5,10.4c-0.4,7.1-2.3,14-5.5,20.4l148.2,23.8c-0.5-3.4-0.7-6.9-0.5-10.4c0.4-7.1,2.3-14,5.5-20.4L300.8,480.8z"/><path d="M572.1,343.3l-68.4,133.6c10.5,1.8,20.2,6.7,27.7,14.2l68.4-133.6C589.4,355.8,579.7,350.8,572.1,343.3z"/><path d="M478.8,154.4c-4.9,9.4-12.6,17.1-22.1,22l105.9,106.4c4.9-9.4,12.6-17.1,22.1-22L478.8,154.4z"/><path d="M382.1,138.9l-133.9,67.9c7.5,7.6,12.4,17.3,14.1,27.8l133.9-67.9C388.7,159.1,383.8,149.4,382.1,138.9z"/><path d="M456.5,176.6c-8.1,4.1-17.1,6.1-26.2,5.6c-1.5-0.1-3-0.3-4.5-0.5l11.9,76l32.4,5.2L456.5,176.6z M444.5,301.8l28.1,179.6c7.9-3.9,16.7-5.7,25.4-5.2c1.8,0.1,3.6,0.3,5.4,0.6L476.8,307L444.5,301.8z"/><path d="M262.4,235.2c0.6,3.5,0.7,7,0.6,10.6c-0.4,7-2.2,13.9-5.4,20.2l76,12.2l14.9-29.2L262.4,235.2z M392.7,256.1l-14.9,29.2l179.6,28.9c-0.5-3.4-0.7-6.9-0.5-10.3c0.4-7.1,2.3-14.1,5.5-20.5L392.7,256.1z"/><circle cx="433" cy="130.6" r="47"/><circle cx="608.4" cy="306.6" r="47"/><circle cx="495.1" cy="527.8" r="47"/><circle cx="249.7" cy="488.4" r="47"/><circle cx="211.3" cy="242.9" r="47"/></g></svg></button>
-            </li>
-          <% } %>
         </ul>
       <% } %>
@@ -105,62 +92,4 @@
   </div>
 </aside>
-
-<%# Profile-summary modal — opened by clicking the avatar. Shows photo + title + subtitle
-    + contact details + start date + premium status. %>
-<div class="pf-summary">
-  <div class="pf-summary-card" role="dialog" aria-label="<%= _displayName %>">
-    <button type="button" class="pf-summary-close" aria-label="Sluiten">&times;</button>
-    <% if (_avatarUrl) { %>
-      <img class="pf-summary-photo" src="<%= avatar(_avatarUrl, 128) %>" alt="">
-    <% } else { %>
-      <div class="pf-summary-photo pf-summary-photo--ph" aria-hidden="true">
-        <% if (site.enable_audio_player && (typeof audioEnabled === 'undefined' || audioEnabled)) { %>
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>
-        <% } else { %><span><%= (_displayName || '?').charAt(0).toUpperCase() %></span><% } %>
-      </div>
-    <% } %>
-    <h3 class="pf-summary-name"><%= _displayName %></h3>
-    <span class="pf-summary-prem<%= _isPrem ? ' is-prem' : '' %>"><%= _isPrem ? '★ Premium' : t('profile.free') %></span>
-    <% if (_bioText) { %><p class="pf-summary-bio"><%= _bioText %></p><% } %>
-    <% if (_profileLinks.length) { %>
-      <ul class="pf-summary-links">
-        <% _profileLinks.forEach(function (l) { var meta = _platforms[l.platform]; if (!meta) return; var href = _linkHref(l.platform, l.url); var ext = (l.platform !== 'email' && l.platform !== 'phone'); %>
-          <li>
-            <a href="<%= href %>" <% if (ext) { %>target="_blank" rel="noopener noreferrer"<% } %> style="--brand: <%= meta.brand %>">
-              <span class="pf-summary-ico" aria-hidden="true"><%- meta.svg %></span>
-              <span class="pf-summary-rowtext"><span class="pf-summary-lbl"><%= meta.label %></span><span class="pf-summary-val"><%= _linkVal(l.url) %></span></span>
-            </a>
-          </li>
-        <% }); %>
-      </ul>
-    <% } %>
-    <% if (_showFollow) { %>
-      <button type="button" class="pf-summary-follow profile-fedi-link" data-fedi-actor-slug="<%= site.slug %>" aria-label="<%= t('fedi.profile_follow') %>">
-        <svg class="pf-summary-follow-ico" viewBox="151 31 530 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g transform="translate(6.6789703,-32.495842)"><path d="M257.1,266.8c-4.9,9.4-12.6,17.1-22.1,22l121.3,121.8l29.2-14.8L257.1,266.8z M417.1,427.4l-29.2,14.8l61.5,61.7c4.9-9.4,12.6-17.1,22.1-22L417.1,427.4z"/><path d="M557.5,315l-68.7,34.8l5.1,32.4l77.7-39.4C564.1,335.2,559.2,325.5,557.5,315z M448.9,370l-162.4,82.3c7.5,7.6,12.4,17.3,14.1,27.8L454,402.4L448.9,370z"/><path d="M396.7,167.3l-78.4,153l23.1,23.2l83-162C414,179.7,404.3,174.7,396.7,167.3z M298,360l-39.7,77.5c10.5,1.8,20.2,6.7,27.7,14.2l35.1-68.5L298,360z"/><path d="M234.3,289.1c-8,4-16.9,5.9-25.8,5.4c-1.7-0.1-3.3-0.3-5-0.5l23.2,148.2c8-4,16.9-5.9,25.8-5.4c1.7,0.1,3.3,0.3,5,0.5L234.3,289.1z"/><path d="M300.8,480.8c0.5,3.4,0.7,6.9,0.5,10.4c-0.4,7.1-2.3,14-5.5,20.4l148.2,23.8c-0.5-3.4-0.7-6.9-0.5-10.4c0.4-7.1,2.3-14,5.5-20.4L300.8,480.8z"/><path d="M572.1,343.3l-68.4,133.6c10.5,1.8,20.2,6.7,27.7,14.2l68.4-133.6C589.4,355.8,579.7,350.8,572.1,343.3z"/><path d="M478.8,154.4c-4.9,9.4-12.6,17.1-22.1,22l105.9,106.4c4.9-9.4,12.6-17.1,22.1-22L478.8,154.4z"/><path d="M382.1,138.9l-133.9,67.9c7.5,7.6,12.4,17.3,14.1,27.8l133.9-67.9C388.7,159.1,383.8,149.4,382.1,138.9z"/><path d="M456.5,176.6c-8.1,4.1-17.1,6.1-26.2,5.6c-1.5-0.1-3-0.3-4.5-0.5l11.9,76l32.4,5.2L456.5,176.6z M444.5,301.8l28.1,179.6c7.9-3.9,16.7-5.7,25.4-5.2c1.8,0.1,3.6,0.3,5.4,0.6L476.8,307L444.5,301.8z"/><path d="M262.4,235.2c0.6,3.5,0.7,7,0.6,10.6c-0.4,7-2.2,13.9-5.4,20.2l76,12.2l14.9-29.2L262.4,235.2z M392.7,256.1l-14.9,29.2l179.6,28.9c-0.5-3.4-0.7-6.9-0.5-10.3c0.4-7.1,2.3-14.1,5.5-20.5L392.7,256.1z"/><circle cx="433" cy="130.6" r="47"/><circle cx="608.4" cy="306.6" r="47"/><circle cx="495.1" cy="527.8" r="47"/><circle cx="249.7" cy="488.4" r="47"/><circle cx="211.3" cy="242.9" r="47"/></g></svg>
-        <span><%= t('fedi.profile_follow') %></span>
-      </button>
-    <% } %>
-    <% if (_sinceStr) { %><p class="pf-summary-since"><%= t('profile.since') %> <%= _sinceStr %></p><% } %>
-  </div>
-</div>
-<% if (_showFollow) { %>
-<%# Follow-via-fediverse modal — replaces the native prompt() (which looked ugly). %>
-<div class="pf-follow">
-  <div class="pf-follow-card" role="dialog" aria-label="<%= t('fedi.follow_heading') %>">
-    <button type="button" class="pf-follow-cancel pf-follow-x" aria-label="Sluiten">&times;</button>
-    <h3 class="pf-follow-title"><%= t('fedi.follow_heading') %></h3>
-    <p class="pf-follow-sub"><%= t('fedi.remote_prompt') %></p>
-    <input type="text" class="pf-follow-input" inputmode="url" autocomplete="off" autocapitalize="none" spellcheck="false"
-           placeholder="<%= t('fedi.remote_ph') %>">
-    <div class="pf-follow-actions">
-      <button type="button" class="pf-follow-cancel"><%= t('fedi.cancel') %></button>
-      <button type="button" class="pf-follow-go"><%= t('fedi.follow_btn') %></button>
-    </div>
-  </div>
-</div>
-<% } %>
-<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
-    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
-    (shaer-bqr). %>
 <% } %>
 
@@ -187,5 +116,4 @@
   width: 80px;
   height: 80px;
-  padding: 0;
   border-radius: 50%;
   overflow: hidden;
@@ -194,9 +122,5 @@
   transition: width 250ms, height 250ms;
   cursor: pointer;
-  font: inherit;
-  -webkit-appearance: none;
-  appearance: none;
-}
-.profile-photo[data-lightbox] { cursor: zoom-in; }
+}
 .profile-photo:hover {
   border-color: var(--accent);
@@ -326,65 +250,3 @@
 .on-special .profile-links,
 .on-archive .profile-links { display: none; }
-
-/* Follow-via-fediverse: compact icon button, sits in the links row */
-.profile-fedi-link { border: 0; padding: 0; background: var(--paper-2); cursor: pointer; -webkit-appearance: none; appearance: none; }
-/* The fediverse logo is an open line-network, so it reads smaller than a solid
-   glyph at 18px — render it larger INSIDE the same 36px circle (circle unchanged). */
-.profile-fedi-link svg { width: 22px; height: 22px; }
-
-/* Profile-summary modal (click the avatar) */
-.profile-photo[data-pf-summary] { cursor: zoom-in; }
-.pf-summary { position: fixed; inset: 0; z-index: 2147483600; display: none;
-  align-items: center; justify-content: center; padding: 4vmin;
-  background: rgba(0,0,0,.7); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
-.pf-summary.is-open { display: flex; }
-.pf-summary-card { position: relative; width: 100%; max-width: 380px; max-height: 90vh; overflow-y: auto;
-  background: var(--paper); color: var(--ink); border: 1px solid var(--rule); border-radius: 20px;
-  padding: 1.75rem 1.5rem; box-shadow: 0 20px 60px rgba(0,0,0,.4); text-align: center; }
-.pf-summary-close { position: absolute; top: .6rem; right: .7rem; width: 36px; height: 36px; border: 0;
-  border-radius: 50%; background: var(--paper-2); color: var(--ink-soft); font-size: 1.5rem; line-height: 1;
-  cursor: pointer; display: flex; align-items: center; justify-content: center; }
-.pf-summary-close:hover { color: var(--ink); }
-.pf-summary-photo { width: 110px; height: 110px; border-radius: 50%; object-fit: cover; margin: 0 auto .9rem;
-  display: block; border: 3px solid var(--paper-2); background: #fff; }
-.pf-summary-photo--ph { display: flex; align-items: center; justify-content: center; background: var(--paper-2); }
-.pf-summary-photo--ph svg { width: 46px; height: 46px; color: var(--accent); }
-.pf-summary-photo--ph span { font-family: var(--font-display, serif); font-size: 2.6rem; font-weight: 700; color: var(--ink-soft); }
-.pf-summary-name { font-family: var(--font-display, serif); font-size: 1.4rem; margin: 0 0 .35rem; }
-.pf-summary-prem { display: inline-block; margin: 0 0 .7rem; padding: .15rem .6rem; border-radius: 999px;
-  font-size: .75rem; font-weight: 600; background: var(--paper-2); color: var(--ink-soft); }
-.pf-summary-prem.is-prem { background: color-mix(in srgb, #e8b04b 22%, transparent); color: #b8860b; }
-.pf-summary-bio { color: var(--ink-soft); font-size: .92rem; line-height: 1.5; margin: 0 0 1rem; }
-.pf-summary-links { list-style: none; padding: 0; margin: .25rem 0 1rem; display: flex; flex-direction: column; gap: .4rem; text-align: left; }
-.pf-summary-links a { display: flex; align-items: center; gap: .7rem; padding: .5rem .7rem; border-radius: 12px;
-  text-decoration: none; color: var(--ink); background: var(--paper-2); transition: background .12s; }
-.pf-summary-links a:hover { background: color-mix(in srgb, var(--brand, var(--accent)) 16%, var(--paper-2)); }
-.pf-summary-ico { flex: 0 0 22px; width: 22px; height: 22px; color: var(--brand, var(--ink-soft)); display: inline-flex; }
-.pf-summary-ico svg { width: 22px; height: 22px; }
-.pf-summary-rowtext { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
-.pf-summary-lbl { font-size: .72rem; font-weight: 600; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .03em; }
-.pf-summary-val { font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pf-summary-since { color: var(--ink-soft); font-size: .8rem; margin: 0; }
-.pf-summary-follow { display: inline-flex; align-items: center; gap: .5rem; margin: .2rem auto 1rem; padding: .5rem 1.15rem;
-  border: 1.5px solid var(--accent); border-radius: 999px; background: transparent; color: var(--accent);
-  font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; transition: background .15s, color .15s; }
-.pf-summary-follow:hover { background: var(--accent); color: var(--paper); }
-.pf-summary-follow-ico { width: 18px; height: 18px; flex: 0 0 auto; }
-.pf-follow { position: fixed; inset: 0; z-index: 2147483601; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,.55); }
-.pf-follow.is-open { display: flex; }
-.pf-follow-card { position: relative; width: 100%; max-width: 380px; text-align: left;
-  background: var(--paper); color: var(--ink); border: 1px solid var(--rule); border-radius: 16px;
-  padding: 1.5rem 1.4rem 1.3rem; box-shadow: 0 18px 50px rgba(0,0,0,.4); }
-.pf-follow-x { position: absolute; top: .55rem; right: .65rem; width: 2rem; height: 2rem; border: 0; border-radius: 999px;
-  background: transparent; color: var(--ink-soft); font-size: 1.4rem; line-height: 1; cursor: pointer; }
-.pf-follow-title { margin: 0 0 .35rem; font-size: 1.15rem; }
-.pf-follow-sub { margin: 0 0 .8rem; color: var(--ink-soft); font-size: .86rem; line-height: 1.45; }
-.pf-follow-input { width: 100%; box-sizing: border-box; padding: .6rem .8rem; border: 1.5px solid var(--rule);
-  border-radius: 10px; background: var(--paper-2); color: var(--ink); font: inherit; font-size: .95rem; }
-.pf-follow-input:focus { outline: none; border-color: var(--accent); }
-.pf-follow-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
-.pf-follow-actions button { padding: .5rem 1.1rem; border-radius: 999px; font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; border: 0; }
-.pf-follow-actions .pf-follow-cancel { background: transparent; border: 1.5px solid var(--rule); color: var(--ink); }
-.pf-follow-actions .pf-follow-go { background: var(--accent); color: var(--paper); }
-.pf-follow-actions .pf-follow-go:hover { filter: brightness(1.06); }
 </style>
Index: src/views/partials/profile-sheet.ejs
===================================================================
--- src/views/partials/profile-sheet.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/profile-sheet.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -14,6 +14,5 @@
 
 const _isAdmin = (user.role === 'god' || user.role === 'admin');
-const _canPost = !!((typeof canMutate === 'undefined' || canMutate)
-                    && typeof permissions !== 'undefined'
+const _canPost = !!(typeof permissions !== 'undefined'
                     && permissions
                     && permissions.canCreatePost
@@ -22,6 +21,4 @@
                  : (user.role === 'editor') ? 'redacteur'
                  : '';
-// Site-scoped link (post-aanmaken) heeft in hub de /user/<slug>-prefix nodig.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 
@@ -37,5 +34,5 @@
     <div class="profile-sheet-header">
       <% if (user.avatar_url) { %>
-        <img class="profile-sheet-avatar profile-sheet-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="" aria-hidden="true">
+        <img class="profile-sheet-avatar profile-sheet-avatar-img" src="<%= user.avatar_url %>" alt="" aria-hidden="true">
       <% } else { %>
         <div class="profile-sheet-avatar" aria-hidden="true"><%= user.username.charAt(0).toUpperCase() %></div>
@@ -52,32 +49,13 @@
         <a href="/account" class="profile-sheet-item" role="menuitem" data-close-sheet>
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
-          <span class="psi-label"><%= t('nav.account') %></span>
+          <span class="psi-label">Account</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
       </li>
-      <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-      <li role="none">
-        <a href="/notifications" class="profile-sheet-item" role="menuitem" data-close-sheet>
-          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
-          <span class="psi-label"><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
-          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
-        </a>
-      </li>
-      <% } %>
-      <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-      <li role="none">
-        <a href="/news" class="profile-sheet-item" role="menuitem" data-close-sheet
-           hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/news" hx-indicator="#pcms-loading">
-          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-          <span class="psi-label"><%= t('nav.fediverse') %></span>
-          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
-        </a>
-      </li>
-      <% } %>
-      <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
+      <% if (_isAdmin) { %>
       <li role="none">
         <a href="/admin" class="profile-sheet-item" role="menuitem" data-close-sheet>
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/></svg>
-          <span class="psi-label"><%= t('nav.admin') %></span>
+          <span class="psi-label">Beheer</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
@@ -86,9 +64,9 @@
       <% if (_canPost) { %>
       <li role="none">
-        <a href="<%= _base %>/posts/new" class="profile-sheet-item" role="menuitem" data-close-sheet
-           hx-get="<%= _base %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="<%= _base %>/posts/new" hx-indicator="#pcms-loading">
+        <a href="/posts/new" class="profile-sheet-item" role="menuitem" data-close-sheet
+           hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+           hx-push-url="/posts/new" hx-indicator="#pcms-loading">
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
-          <span class="psi-label"><%= t('nav.new_post') %></span>
+          <span class="psi-label">Nieuwe post</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
@@ -104,6 +82,6 @@
         <button type="button" class="profile-sheet-item" id="profile-sheet-theme" role="menuitemcheckbox">
           <svg class="psi-icon" 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="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
-          <span class="psi-label"><%= t('nav.theme_label') %></span>
-          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
+          <span class="psi-label">Thema</span>
+          <span class="psi-meta" id="profile-sheet-theme-state">Donker</span>
         </button>
       </li>
@@ -117,5 +95,5 @@
         <a href="/auth/logout" class="profile-sheet-item is-destructive" role="menuitem">
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
-          <span class="psi-label"><%= t('nav.logout') %></span>
+          <span class="psi-label">Uitloggen</span>
         </a>
       </li>
@@ -326,3 +304,98 @@
 </style>
 
-<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
+<script>
+(function() {
+  const sheet = document.getElementById('profile-sheet');
+  if (!sheet) return;
+
+  const backdrop  = document.getElementById('profile-sheet-backdrop');
+  const panel     = sheet.querySelector('.profile-sheet-panel');
+  const closeBtn  = document.getElementById('profile-sheet-close');
+  const dragZone  = document.getElementById('profile-sheet-drag-zone');
+  const themeBtn  = document.getElementById('profile-sheet-theme');
+  const themeLbl  = document.getElementById('profile-sheet-theme-state');
+
+  function openSheet() {
+    sheet.classList.add('is-open');
+    sheet.setAttribute('aria-hidden', 'false');
+    document.body.classList.add('profile-sheet-locked');
+    syncTheme();
+  }
+  function closeSheet() {
+    sheet.classList.remove('is-open');
+    sheet.setAttribute('aria-hidden', 'true');
+    document.body.classList.remove('profile-sheet-locked');
+    panel.style.removeProperty('--pcms-drag-y');
+  }
+
+  // Open: any element with [data-profile-sheet-toggle]
+  document.addEventListener('click', function(e) {
+    const trigger = e.target.closest('[data-profile-sheet-toggle]');
+    if (trigger) {
+      e.preventDefault();
+      openSheet();
+    }
+  });
+
+  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
+  if (backdrop) backdrop.addEventListener('click', closeSheet);
+  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
+  });
+
+  // Menu items that navigate: close synchronously before nav fires
+  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
+    el.addEventListener('click', closeSheet);
+  });
+
+  // Drag-down-to-close on touch.
+  // Uses --pcms-drag-y custom property rather than overwriting the panel's
+  // transform string. This composes correctly with any horizontal centering
+  // (currently none here, but the pattern matches audio-sheet for safety).
+  let startY = 0, lastY = 0, dragging = false;
+  function onDown(e) {
+    if (e.pointerType !== 'touch') return;
+    if (panel.scrollTop > 0) return;
+    startY = lastY = e.clientY;
+    dragging = true;
+    panel.classList.add('is-dragging');
+  }
+  function onMove(e) {
+    if (!dragging) return;
+    lastY = e.clientY;
+    const dy = Math.max(0, lastY - startY);
+    panel.style.setProperty('--pcms-drag-y', dy + 'px');
+  }
+  function onUp() {
+    if (!dragging) return;
+    dragging = false;
+    panel.classList.remove('is-dragging');
+    const dy = lastY - startY;
+    if (dy > 80) closeSheet();
+    else panel.style.removeProperty('--pcms-drag-y');
+  }
+  if (window.PointerEvent && dragZone) {
+    dragZone.addEventListener('pointerdown', onDown);
+    document.addEventListener('pointermove', onMove);
+    document.addEventListener('pointerup', onUp);
+    document.addEventListener('pointercancel', onUp);
+  }
+
+  // Theme toggle inside sheet — syncs label
+  function syncTheme() {
+    if (!themeLbl) return;
+    const t = document.documentElement.getAttribute('data-theme') || 'dark';
+    themeLbl.textContent = t === 'dark' ? 'Donker' : 'Licht';
+  }
+  if (themeBtn) {
+    themeBtn.addEventListener('click', function() {
+      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+      const next = cur === 'dark' ? 'light' : 'dark';
+      document.documentElement.setAttribute('data-theme', next);
+      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+      syncTheme();
+    });
+  }
+})();
+</script>
Index: src/views/partials/quote-card.ejs
===================================================================
--- src/views/partials/quote-card.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-<%
-  // FEP-044f embedded quote card: the quoted post itself (author + content +
-  // first image), nested under a note — the web mirror of the Shaer QuoteCard.
-  // `q` is the resolved snapshot from noteQuote(p.quote_json).
-  var _qa = q.author || {};
-  var _qm = Array.isArray(q.media) ? q.media.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); }) : [];
-%>
-<div class="tl-quote">
-  <div class="tl-quote-head">
-    <% if (_qa.icon) { %><img class="tl-quote-avatar" src="<%= avatar(_qa.icon, 96) %>" alt="" loading="lazy"><% } %>
-    <span class="tl-quote-name"><%- emojiName(_qa.name || _qa.handle || '', _qa.emojis) %></span>
-    <% if (_qa.handle) { %><span class="tl-quote-handle"><%= _qa.handle %></span><% } %>
-  </div>
-  <% if (q.content) { %><div class="tl-quote-body"><%- emojiHtml(q.content, q.emojis) %></div><% } %>
-  <% if (_qm.length) { %><a class="tl-quote-media" href="<%= _qm[0].url %>" target="_blank" rel="noopener"><img src="<%= thumb(_qm[0].url, 640) %>" alt="" loading="lazy"></a><% } %>
-</div>
Index: src/views/partials/read-article.ejs
===================================================================
--- src/views/partials/read-article.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,95 +1,0 @@
-<%
-// Eén bericht in de leesstroom.
-//
-// Dit is het stuk dat de client AANVULT: bij de onderrand komt de oudere buur
-// eronder, bij de bovenrand de nieuwere erboven. Geen sprongen, geen
-// scrollpositie die stiekem verzet wordt -- de stroom groeit gewoon in de
-// richting waarin je leest, en de browser snapt naar de grenzen.
-//
-// Elk artikel draagt zijn eigen buren, dus de client hoeft niets te onthouden:
-// wat er nog te halen valt staat op het stuk dat er al is.
-var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-var _newer = (typeof newerPost !== 'undefined' && newerPost) ? newerPost : null;
-var _older = (typeof olderPost !== 'undefined' && olderPost) ? olderPost : null;
-%>
-<article class="read-post"
-         data-slug="<%= post.slug %>"
-         data-newer="<%= _newer ? _newer.slug : '' %>"
-         data-older="<%= _older ? _older.slug : '' %>"
-         data-base="<%= _b %>"
-         data-title="<%= post.title || '' %>"
-         aria-label="<%= post.title || '' %>">
-  <%# Het SNAPPUNT, en met opzet een leeg element van nul hoog in plaats van het
-      artikel zelf. Een snapgebied dat GROTER is dan het scherm mag van de spec
-      overal blijven staan waar het het scherm nog vult -- dus bij een bericht
-      van 1009px op een scherm van 812 accepteert de browser elke positie tussen
-      de bovenkant en "onderkant op de onderrand". Die tweede stand zet de
-      bovenkant van het VOLGENDE bericht op de onderrand van het scherm, en dat
-      is wat Robin zag (20-8). Een anker van nul hoog heeft maar een geldige
-      positie: zijn bovenkant op de bovenrand. %>
-  <span class="read-anker" aria-hidden="true"></span>
-  <%# De titel is een echte link naar het bericht zelf. De leesstroom toont de
-      TEKST, maar reacties, waarderingen en boosts staan op de berichtpagina --
-      en een stroom waar je niet uit kunt naar het gesprek is een doodlopende weg.
-      Een <a> en niet alleen een tik-afhandelaar: dat werkt ook met een
-      toetsenbord, met een schermlezer, en met cmd-klik. %>
-  <%# De cover hoort hier net zo goed als op de berichtpagina: het is het beeld
-      van het stuk, en zonder hem begint elk bericht in de stroom met kale tekst.
-      Boven de titel, in dezelfde volgorde als post.ejs.
-
-      Twee dingen komen hier van post-card en NIET van post.ejs:
-
-      - De SLUIER. Op de berichtpagina hangt de waarschuwing als banner boven de
-        hele pagina, want daar staat een bericht. In een stroom kan dat niet: de
-        buren rollen gewoon voorbij, dus het beeld moet zijn eigen sluier dragen
-        of het is onderweg al te zien. De afhandelaar zit gedelegeerd op
-        document (shell.ejs), dus hij werkt ook op wat de client later aanschuift.
-      - thumb(). De bron kan het volle bestand zijn en hier staan er meerdere
-        onder elkaar in een stroom die dooraanvult.
-
-      cover_image_url en cover_video_url komen mee: beide voedingen van
-      readerItems() halen `SELECT p.*` op (routes/posts.js, de pinned- en de
-      gewone feed-query). Nagekeken, want een versmalde lijst zou hier niets
-      opleveren zonder ergens te klagen. %>
-  <% if (post.cover_image_url || post.cover_video_url) { %>
-    <figure class="read-cover<%= post.nsfw ? ' nsfw-media' : '' %>">
-      <% if (post.cover_image_url) { %>
-        <img src="<%= thumb(post.cover_image_url, 1280) %>"
-             srcset="<%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
-             sizes="(min-width: 768px) 46rem, 100vw"
-             alt="<%= post.cover_alt || '' %>" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-      <% } else { %>
-        <video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 1280) %>"
-               controls loop muted playsinline preload="metadata"></video>
-      <% } %>
-      <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
-    </figure>
-  <% } %>
-
-  <header class="read-head">
-    <%# Vastgepind hoort zichtbaar te zijn in de titel: in de leesstroom staat een
-        gepind bericht bovenaan zonder dat je ziet WAAROM het daar staat. Zelfde
-        icoon als in de tijdlijn (post-card.ejs), zodat het hetzelfde ding is. %>
-    <h1 class="read-title">
-      <% if (post.pinned > 0) { %><span class="read-pin" title="<%= t('read.pinned') %>" aria-label="<%= t('read.pinned') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg></span><% } %><a href="<%= _b %>/<%= post.slug %>"><%= post.title || '' %></a>
-    </h1>
-    <% if (post.published_at) { %><p class="read-when"><%= formatDateTime(post.published_at) %></p><% } %>
-  </header>
-
-  <%- include('post-body', {
-        post: post, access: entry.access, teaser: entry.teaser,
-        content_html: entry.content_html, _base: _b,
-      }) %>
-
-  <%# Zegt waar je heen gaat, in plaats van te vertrouwen op een tik die niemand
-      ziet. De tik-afhandelaar in mod/read.js is het gemak; dit is de aankondiging.
-
-      #fediverse is de sectie op de berichtpagina met de ⭐/🔁/💬-tellers en de
-      reacties (post.ejs). Alleen DEZE link springt daarheen: de titel en de tik
-      openen het bericht bovenaan, want die zeggen "lees dit", niet "laat het
-      gesprek zien". Staat de sectie er niet -- AP uit, of een concept -- dan
-      negeert de browser de anker en beland je gewoon bovenaan. %>
-  <footer class="read-foot">
-    <a class="read-open" href="<%= _b %>/<%= post.slug %>#fediverse"><%= t('read.open') %> &rarr;</a>
-  </footer>
-</article>
Index: src/views/partials/reply-editor.ejs
===================================================================
--- src/views/partials/reply-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,85 +1,0 @@
-<%#
-  Rich reply editor (shared component) — progressive enhancement.
-
-  Renders a plain <textarea name="text"> form that works without JS; the shared
-  mod/reply-editor.js upgrades it to a rich contenteditable with a small
-  toolbar, a language select and (on ≤700px) a full-screen compose overlay,
-  which is the right pattern on mobile. On submit the JS fills the hidden
-  `content` field with the editor HTML and `text` with a plain-text fallback;
-  the server prefers `content` and sanitizes it (fedi-reply / authorize_interaction).
-
-  Locals:
-    action        POST target (required)
-    hiddenFields  [{ name, value }] extra hidden inputs (optional)
-    placeholder   textarea/editor placeholder (required)
-    submitLabel   submit button label (required)
-    rows          textarea rows for the no-JS fallback (default 3)
-    defaultLang   preselected reply language (default: UI lang)
-    initialHtml   prefill for EDIT mode (our own sanitized HTML) (optional)
-    initialText   plain-text prefill for the no-JS textarea (optional)
-    noAttach      true hides the media button (edits do not touch attachments)
-    participants  [{uri, url, handle}] conversation partners → the mentions bar
-                  (u02): implicit mentions live here as removable chips, not in
-                  the text. Absent = no bar, legacy addressing.
-%>
-<% var _reInitHtml = (typeof initialHtml !== 'undefined' && initialHtml) || ''; %>
-<% var _reInitText = (typeof initialText !== 'undefined' && initialText) || ''; %>
-<% var _reNoAttach = (typeof noAttach !== 'undefined' && noAttach); %>
-<% var _reParts = (typeof participants !== 'undefined' && participants && participants.length) ? participants : null; %>
-<% var _reLang = (typeof defaultLang !== 'undefined' && defaultLang) || (typeof lang !== 'undefined' ? lang : 'en'); %>
-<form method="post" action="<%= action %>" class="re-form" data-re
-      data-upload="/posts/upload-reply-media" data-upload-err="<%= t('re.attach_err') %>">
-  <% (typeof hiddenFields !== 'undefined' ? hiddenFields : []).forEach(function (h) { %>
-    <input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
-  <% }); %>
-  <input type="hidden" name="content" value="">
-  <%# Mentions bar (u02): the conversation partners as removable chips. The
-      hidden field always carries the CURRENT kept list (server-rendered full,
-      so the no-JS path addresses everyone). %>
-  <% if (_reParts) { %>
-    <input type="hidden" name="mentions" value="<%= JSON.stringify(_reParts) %>">
-    <div class="re-mentions">
-      <span class="re-mentions-label"><%= t('re.to') %></span>
-      <% _reParts.forEach(function (p) { %>
-        <span class="re-mention" data-uri="<%= p.uri %>"><%= p.handle && p.handle[0] === '@' ? p.handle : '@' + p.handle %><button type="button" class="re-mention-del" title="<%= t('re.mention_del') %>" aria-label="<%= t('re.mention_del') %>">×</button></span>
-      <% }); %>
-    </div>
-  <% } %>
-  <%# Full-screen top bar (mobile compose). Hidden until JS + fullscreen. %>
-  <div class="re-head" hidden>
-    <button type="button" class="re-cancel" aria-label="<%= t('comments.cancel') %>">×</button>
-    <strong class="re-head-title"><%= t('re.title') %></strong>
-    <button type="submit" class="btn re-send-top"><%= submitLabel %></button>
-  </div>
-  <div class="re-toolbar" role="toolbar" aria-label="<%= t('re.title') %>" hidden>
-    <button type="button" data-cmd="bold" title="<%= t('re.bold') %>" aria-label="<%= t('re.bold') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/></svg></button>
-    <button type="button" data-cmd="italic" title="<%= t('re.italic') %>" aria-label="<%= t('re.italic') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg></button>
-    <button type="button" data-cmd="link" title="<%= t('re.link') %>" aria-label="<%= t('re.link') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
-    <button type="button" data-cmd="list" title="<%= t('re.list') %>" aria-label="<%= t('re.list') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg></button>
-    <button type="button" data-cmd="quote" title="<%= t('re.quote') %>" aria-label="<%= t('re.quote') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5C6 17.5 4 19 3 19v2z"/><path d="M14 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5c-.5 4.5-2.5 6-3.5 6v2z"/></svg></button>
-    <% if (!_reNoAttach) { %>
-      <span class="re-toolbar-sep" aria-hidden="true"></span>
-      <button type="button" data-cmd="attach" title="<%= t('re.attach') %>" aria-label="<%= t('re.attach') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
-      <input type="file" class="re-file" accept="image/*,audio/*,video/*" multiple hidden>
-    <% } %>
-  </div>
-  <% if (!_reNoAttach) { %>
-    <input type="hidden" name="attachments" value="">
-    <div class="re-attachments" hidden></div>
-  <% } %>
-  <textarea name="text" rows="<%= typeof rows !== 'undefined' ? rows : 3 %>" required placeholder="<%= placeholder %>"><%= _reInitText %></textarea>
-  <div class="re-editor" contenteditable="true" role="textbox" aria-multiline="true"
-       data-ph="<%= placeholder %>" aria-label="<%= placeholder %>" hidden><%- _reInitHtml %></div>
-  <div class="re-foot">
-    <select name="language" class="re-lang" aria-label="<%= t('re.lang') %>" hidden>
-      <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
-        <option value="<%= l[0] %>" <%= l[0] === _reLang ? 'selected' : '' %>><%= l[1] %></option>
-      <% }); %>
-    </select>
-    <button type="submit" class="btn re-send"><%= submitLabel %></button>
-  </div>
-</form>
-<%# Assets once per render, even when this partial repeats per comment. %>
-<% if (!locals.__reAssets) { locals.__reAssets = 1; %>
-<link rel="stylesheet" href="/assets/css/reply-editor.css">
-<% } %>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/shared-styles.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,15 +6,12 @@
   border-bottom: 1px solid var(--rule);
   background: var(--paper);
-  /* No extra margin-top: this centres the pill between the profile-header
-     rule (above) and the own border (below). Equal space above/below the
-     pill comes from the symmetric container padding below. The margin-bottom
-     (1.5rem in style.css) keeps breathing room toward the feed. */
-  margin-top: 0;
+  /* Breathing room: profile-header has padding-bottom 1.5rem already, so a
+     small additional margin-top gives the switcher its own visual layer. */
+  margin-top: 0.5rem;
 }
 .view-switcher-wrap .container {
   display: flex; align-items: center; justify-content: center; gap: 0.75rem;
-  /* Symmetric padding → equal space above and below the pill (≈8px),
-     centred between the two dividing lines. */
-  padding: 0.5rem 1rem;
+  /* Slimmer bar: was 0.4rem (≈12px total chrome above pill); now 0.25rem. */
+  padding: 0.25rem 1rem;
   max-width: 1200px; margin: 0 auto;
   flex-wrap: wrap;
@@ -36,36 +33,5 @@
 }
 .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"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
-/* Lezen in de cirkel: zichtbaar maar dood. Niet weggelaten, want dan springt de
-   pil van breedte tussen Solo en Cirkel, en juist DAT verbergt dat er een
-   tweede weergave bestaat. Grijs + not-allowed zegt "bestaat, kan hier niet". */
-.view-switch-btn[disabled] { opacity: .38; cursor: not-allowed; }
-.view-switch-btn[disabled]:hover { color: var(--ink-muted, var(--ink-soft)); background: transparent; }
-
-/* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
-   disturbed). Same look as the view-switcher pill. Visible in all modes. */
-.agenda-pill { display: inline-flex; align-items: center; }   /* no box — standalone icon+text link */
-.vs-agenda {
-  display: inline-flex; align-items: center; gap: 0.4rem;
-  padding: 0.35rem 0.5rem;
-  color: var(--ink-muted, var(--ink-soft));
-  text-decoration: none;
-  font-size: 0.9rem; font-family: inherit;
-  transition: color 120ms;
-}
-.vs-agenda:hover { color: var(--ink); }
-/* Active (on the agenda page) = bright/white. Element-class (in the OOB-rendered
-   chrome) rather than body-class CSS → stays reliable after htmx navigation. */
-.vs-agenda.is-active { color: var(--ink); font-weight: 600; }
-
-/* Mobile: Agenda on its own row at the top, Solo/Circle + Timeline/Grid together
-   on the row below (together ~330px → fits within a phone, instead of the old
-   unbalanced split Agenda+Solo / Timeline). */
-@media (max-width: 520px) {
-  .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; }
-}
+.view-switch-btn[aria-selected="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.06); }
 
 .grid-cols-picker {
@@ -96,21 +62,4 @@
    visible so the user keeps a consistent return-to-feed control. */
 .on-admin .view-switcher-wrap { display: none; }
-
-/* On a standalone post there is no feed to switch, so no button should look "active"
-   (coloured). The active state lives in style.css on body[data-feed-view]
-   / body[data-grid-cols] (background:var(--ink)); we override that MORE SPECIFICALLY
-   via body.on-post so the buttons become neutral/grey — but remain clickable
-   (the click-handler then navigates to the feed in that view). The
-   body-class switches client-side on HTMX-nav, so this works without a reload too. */
-body.on-post[data-feed-view="reader"] .view-switch-btn[data-view="reader"],
-body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"],
-body.on-post[data-grid-cols="2"] .grid-cols-btn[data-cols="2"],
-body.on-post[data-grid-cols="3"] .grid-cols-btn[data-cols="3"],
-body.on-post[data-grid-cols="4"] .grid-cols-btn[data-cols="4"] {
-  background: transparent;
-  color: var(--ink-muted, var(--ink-soft));
-  border-color: var(--rule);
-}
-
 
 /* Spacing between switcher-bar and the page content below.
@@ -168,7 +117,4 @@
 .auth-link { text-align: center; color: var(--ink-muted); font-size: 0.9rem; margin: 1rem 0 0; }
 .auth-link a { color: var(--accent); }
-.auth-intro { text-align: center; color: var(--ink-muted); font-size: 0.95rem; margin: 0 0 1rem; }
-.auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.25rem 0; color: var(--ink-muted); font-size: 0.85rem; }
-.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--rule); }
 
 /* HTMX loading indicator */
@@ -188,69 +134,3 @@
 .htmx-request .pcms-loading { display: block; }
 @keyframes spin { to { transform: rotate(360deg); } }
-
-/* Load more (Solo, Cirkel, News, Messages) — page size 72, htmx append. */
-.load-more-wrap { display: flex; justify-content: center; margin: 1.5rem 0 .5rem; }
-.load-more-wrap:empty { margin: 0; }
-.load-more-btn { padding: .6rem 1.4rem; border-radius: 999px; cursor: pointer;
-  font: inherit; font-weight: 600; color: var(--ink, inherit);
-  background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-.load-more-btn:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
-.load-more-btn.htmx-request { opacity: .6; pointer-events: none; }
-
-/* FEP-9098 custom emojis: small inline images (Shaer parity), rendered in note
-   content and display names across the timeline, messages and threads. Never
-   styled as media (no rounding/background), and never blown up to media width. */
-img.emoji { height: 1.3em; width: auto; margin: 0 .05em; vertical-align: -0.2em;
-  display: inline-block; border-radius: 0; background: none; box-shadow: none; }
-.tl-content img.emoji, .tl-quote-body img.emoji, .msg-content img.emoji, .comment-content img.emoji,
-.tl-author img.emoji, .tl-boost-by img.emoji, .tl-quote-name img.emoji, .msg-who img.emoji, .comment-author img.emoji {
-  max-width: none; height: 1.3em; border-radius: 0; background: none; box-shadow: none; vertical-align: -0.2em; }
-
-/* ── The body of a post ────────────────────────────────────────────────────
-   Shared with partials/note-body.ejs, so a post looks the same in de Krant, in
-   Berichten and in the Guardian PWA. These used to live in the Krant's own
-   <style> block, which is why a post outside de Krant came out unstyled.
-   Krant-only behaviour (the clamp + "read more", the poll, the action bar)
-   stays in pages/news.ejs. */
-.tl-content { line-height: 1.55; overflow-wrap: anywhere; }
-.tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
-.tl-content a { color: var(--accent, #06c); }
-.tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
-
-/* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). Also carries
-   an external link preview: same card, different origin. */
-.tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
-.tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
-.tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
-.tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
-.tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-.tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
-  overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
-.tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
-.tl-quote-body a { color: var(--accent, #06c); }
-.tl-quote-media { display: block; margin: .45rem 0 0; }
-.tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
-
-/* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
-.tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
-.tl-media-img { display: block; border-radius: 12px; overflow: hidden;
-  background-image: linear-gradient(135deg,
-    color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
-    color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
-.tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
-.tl-media-img img.is-loading { opacity: 0; }
-/* Reserve placeholder space while the (natural-aspect) feed image loads. */
-.tl-media-img:has(img.is-loading) { min-height: 220px; }
-.tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
-.tl-media-video { max-height: 480px; background: #000; }
-
-/* In Berichten a post sits inside a notification row, so it starts tighter and
-   its media stays modest: the row is a pointer to the post, not the post page. */
-.msg-note .tl-content { line-height: 1.5; }
-.msg-note .tl-quote, .msg-note .tl-media, .msg-note .tl-media-video, .msg-note .tl-media-audio { margin-top: .5rem; }
-.msg-note .tl-media-img:has(img.is-loading) { min-height: 120px; }
-.msg-note .tl-media-video { max-height: 320px; }
 </style>
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,48 +1,0 @@
-<li class="tl-item">
-          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" 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> <strong><%- emojiName(p.reblog_name, p.reblog_emoji_json) %></strong> <%= t('tl.boosted') %></div><% } %>
-          <div class="tl-head">
-            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 96) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
-            <span class="tl-id">
-              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%- emojiName(p.author_name, p.author_emoji_json) %></a>
-              <span class="tl-handle"><%= p.author_handle %></span>
-            </span>
-            <% if (p.published || p.created_at) { %><time class="tl-time"><%= formatDateTime(p.published || p.created_at) %></time><% } %>
-          </div>
-
-          <% /* Content, quote/preview card and media: shared with Berichten and the
-                Guardian PWA so a post looks the same wherever it turns up.
-                suppressAudio: a Klonkt audio post carries an embed player below
-                that already covers these tracks, so don't ALSO render the raw
-                Audio attachments as bare players. */ %>
-          <%- include('../partials/note-body', { nb: Object.assign({}, p, { suppressAudio: !!(p.embedHtml || p.embedUrl) }) }) %>
-
-          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
-          <% if (p.embedUrl) { %><a class="tl-embed-open" href="<%= p.embedUrl %>" target="_blank" rel="noopener">▶ <%= t('tl.open_player') %></a><% } %>
-
-          <% if (p.poll) { var _pl=p.poll; var _tot=_pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _voteable=!_pl.closed&&!_pl.voted; %>
-          <div class="tl-poll">
-            <% if (_voteable) { %>
-              <form method="post" action="/news/vote" class="tl-poll-form">
-                <input type="hidden" name="note" value="<%= p.id %>">
-                <% _pl.options.forEach(function(o){ %><label class="tl-poll-choice"><input type="<%= _pl.multiple?'checkbox':'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label><% }); %>
-                <button type="submit" class="btn btn-primary tl-poll-btn"><%= t('poll.vote') %></button>
-              </form>
-            <% } else { _pl.options.forEach(function(o){ var _pct=_tot?Math.round((o.count||0)*100/_tot):0; var _mine=_pl.voted&&(Array.isArray(_pl.voted)?_pl.voted.indexOf(o.name)>=0:_pl.voted===o.name); %><div class="tl-poll-res<%= _mine?' is-mine':'' %>"><span class="tl-poll-fill" style="width:<%= _pct %>%"></span><span class="tl-poll-name"><%= _mine?'✓ ':'' %><%= o.name %></span><span class="tl-poll-pct"><%= _pct %>%</span></div><% }); } %>
-            <div class="tl-poll-foot"><%= (_pl.voters!=null?_pl.voters:_tot) %> <%= t('poll.votes') %><% if(_pl.closed){ %> · <%= t('poll.closed') %><% } %></div>
-          </div>
-          <% } %>
-
-          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-
-          <% var _mv = (typeof movedTo !== 'undefined' && movedTo) ? movedTo : null; %>
-          <%# Verhuisd: liken, boosten en reageren worden door de service toch
-              geweigerd. Grijs ze dan ook, want een knop die niets doet laat je
-              zoeken naar een storing die er niet is. Blokkeren blijft wel werken:
-              dat is een veiligheidsklep, net als rapporteren. %>
-          <div class="tl-actions<%= _mv ? ' is-moved' : '' %>">
-            <form method="post" action="/news/like" 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" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
-            <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" <%= _mv ? "disabled" : "" %> 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" <%= _mv ? "disabled" : "" %> 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" 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/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/topnav.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -9,14 +9,12 @@
 // Desktop (≥768px):
 //   - Full v9 masthead: brand left, action row right
-//     (Archief / Search / Install / Theme / User-menu).
+//     (Archief / Search / Install / Theme / Prutter / User-menu).
 //   - bottom-tab is hidden via CSS, so all nav must live here.
 
 const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 const _isAdmin     = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0;
-// Fediverse pages (on-special) get the same top-nav "back to site" treatment as admin.
-const _isSpecial   = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-special') >= 0;
 const _hasSearch   = !site || site.show_search === undefined || site.show_search;
-const _canPost     = user && (typeof canMutate === 'undefined' || canMutate)
-                     && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
+const _hasPrutter  = user && site && site.enable_prutter;
+const _canPost     = user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
 %>
 
@@ -26,8 +24,12 @@
     <!-- Brand block (always visible) -->
     <div class="masthead-brand">
-      <% if (_isAdmin || _isSpecial) { %>
-        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
+      <% if (_isAdmin) { %>
+        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin">← Terug naar site</a>
+      <% } else if (site) { %>
+        <a class="site-title" href="<%= _siteUrlBase %>/"
+           hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+           hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>
       <% } else { %>
-        <a class="site-title" href="/">Klonkt</a>
+        <a class="site-title" href="/">PrutFolio</a>
       <% } %>
     </div>
@@ -47,5 +49,5 @@
     <nav class="top-nav top-nav-desktop" aria-label="Hoofdnavigatie">
 
-      <% if (!_isAdmin && !_isSpecial) { %>
+      <% if (!_isAdmin) { %>
         <a class="nav-link" href="<%= _siteUrlBase %>/archive"
            hx-get="<%= _siteUrlBase %>/archive?partial=1" hx-target="#pcms-main"
@@ -53,10 +55,10 @@
            hx-indicator="#pcms-loading">
           <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5" rx="1"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
-          <span class="nav-label"><%= t('nav.archive') %></span>
+          <span class="nav-label">Archief</span>
         </a>
       <% } %>
 
       <% if (_hasSearch) { %>
-        <button type="button" class="nav-btn" id="search-toggle" aria-label="<%= t('nav.search') %>" title="<%= t('nav.search') %>">
+        <button type="button" class="nav-btn" id="search-toggle" aria-label="Zoeken" title="Zoeken">
           <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
         </button>
@@ -71,27 +73,7 @@
       </button>
 
-      <% if (typeof langs !== 'undefined' && langs && langs.length > 1) {
-           var _curLang = (langs.find(function(l){ return l.active; }) || { code: 'nl' }).code;
-           var _langR = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
-      %>
-        <details class="lang-menu">
-          <summary class="nav-btn lang-toggle" aria-label="<%= t('nav.language') %>" title="<%= t('nav.language') %>">
-            <svg class="icon" 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="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
-            <span class="lang-code"><%= _curLang.toUpperCase() %></span>
-            <svg class="lang-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-          </summary>
-          <div class="lang-dropdown" role="menu">
-            <% langs.forEach(function(l){ %>
-              <a class="lang-item<%= l.active ? ' is-active' : '' %>" role="menuitem" href="/lang/<%= l.code %>?r=<%= encodeURIComponent(_langR) %>"><span class="lang-item-code"><%= l.code.toUpperCase() %></span><%= l.name %></a>
-            <% }); %>
-          </div>
-        </details>
-      <% } %>
-
-      <% if (user && typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-        <a class="nav-btn nav-notif" href="/notifications" aria-label="<%= t('notif.title') %>" title="<%= t('notif.title') %>"
-           hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/notifications" hx-indicator="#pcms-loading">
-          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
-          <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %><span class="notif-badge"><%= notifUnread > 9 ? '9+' : notifUnread %></span><% } %>
+      <% if (_hasPrutter) { %>
+        <a class="nav-btn" href="<%= _siteUrlBase %>/prutter" aria-label="Prutter (DMs)" title="Prutter">
+          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
         </a>
       <% } %>
@@ -101,5 +83,5 @@
           <summary class="nav-btn user-toggle" aria-label="Gebruikersmenu">
             <% if (user.avatar_url) { %>
-              <img class="user-avatar user-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+              <img class="user-avatar user-avatar-img" src="<%= user.avatar_url %>" alt="">
             <% } else { %>
               <span class="user-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
@@ -109,5 +91,5 @@
             <div class="user-dropdown-header">
               <% if (user.avatar_url) { %>
-                <img class="udh-avatar" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+                <img class="udh-avatar" src="<%= user.avatar_url %>" alt="">
               <% } else { %>
                 <span class="udh-avatar udh-avatar-fallback"><%= user.username.charAt(0).toUpperCase() %></span>
@@ -116,5 +98,5 @@
                 <span class="udh-name"><%= user.username %></span>
                 <% if (user.role === 'god') { %>
-                  <span class="udh-role">God</span>
+                  <span class="udh-role">Admin</span>
                 <% } else if (user.role === 'admin' || user.role === 'moderator') { %>
                   <span class="udh-role"><%= user.role.charAt(0).toUpperCase() + user.role.slice(1) %></span>
@@ -123,32 +105,20 @@
             </div>
             <div class="user-dropdown-list">
-              <a href="/account" role="menuitem" class="udi"
-                 hx-get="/account?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                 hx-push-url="/account" hx-indicator="#pcms-loading">
+              <a href="/account" role="menuitem" class="udi">
                 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
-                <span><%= t('nav.account') %></span>
+                <span>Account</span>
               </a>
-              <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
-                <a href="/admin" role="menuitem" class="udi"
-                   hx-get="/admin?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="/admin" hx-indicator="#pcms-loading">
+              <% if (user.role === 'god' || user.role === 'admin') { %>
+                <a href="/admin" role="menuitem" class="udi">
                   <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3z"/><path d="M14 3h7v7h-7z"/><path d="M14 14h7v7h-7z"/><path d="M3 14h7v7H3z"/></svg>
-                  <span><%= t('nav.admin') %></span>
+                  <span>Beheer</span>
                 </a>
               <% } %>
               <% if (_canPost) { %>
-                <a href="<%= _siteUrlBase %>/posts/new" role="menuitem" class="udi"
-                   hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading">
+                <a href="/posts/new" role="menuitem" class="udi"
+                   hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+                   hx-push-url="/posts/new" hx-indicator="#pcms-loading">
                   <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
-                  <span><%= t('nav.new_post') %></span>
-                </a>
-              <% } %>
-              <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-                <a href="/news" role="menuitem" class="udi"
-                   hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="/news" hx-indicator="#pcms-loading">
-                  <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-                  <span><%= t('nav.fediverse') %></span>
+                  <span>Nieuwe post</span>
                 </a>
               <% } %>
@@ -158,5 +128,5 @@
               <a href="/auth/logout" role="menuitem" class="udi udi-danger">
                 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
-                <span><%= t('nav.logout') %></span>
+                <span>Uitloggen</span>
               </a>
             </div>
@@ -164,8 +134,6 @@
         </details>
       <% } else { %>
-        <a class="nav-link" href="/auth/login"
-           hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="/auth/login" hx-indicator="#pcms-loading">
-          <span class="nav-label"><%= t('nav.login') %></span>
+        <a class="nav-link" href="/auth/login">
+          <span class="nav-label">Inloggen</span>
         </a>
       <% } %>
@@ -176,23 +144,61 @@
 
 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
-<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
-    module is een statisch bestand en kan geen vertaling interpoleren. %>
-<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
-     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
+<div id="search-overlay" class="search-overlay" hidden>
   <div class="container">
     <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
-      <input type="search" name="q" placeholder="<%= t('search.placeholder') %>" autocomplete="off">
-      <button type="submit" class="nav-btn" aria-label="<%= t('search.button') %>">
+      <input type="search" name="q" placeholder="Zoeken in posts…" autocomplete="off">
+      <button type="submit" class="nav-btn" aria-label="Zoeken">
         <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
       </button>
-      <button type="button" class="nav-btn" id="search-close" aria-label="<%= t('imed.cancel') %>">
+      <button type="button" class="nav-btn" id="search-close" aria-label="Sluiten">
         <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
       </button>
     </form>
-    <div id="search-suggest" class="search-suggest" data-lenis-prevent></div>
   </div>
 </div>
 
-<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
+<script>
+(function() {
+  // Theme toggle (works for both mobile + desktop buttons)
+  function toggleTheme() {
+    const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+    const next = cur === 'dark' ? 'light' : 'dark';
+    document.documentElement.setAttribute('data-theme', next);
+    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+  }
+  // Theme toggle — event delegation so it works for ANY button with the
+  // matching ids, even ones rendered later in the page (footer is below
+  // topnav in source order, so getElementById would have returned null
+  // when this IIFE ran).
+  document.body.addEventListener('click', function(e) {
+    const btn = e.target.closest(
+      '#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer'
+    );
+    if (btn) toggleTheme();
+  });
+
+  // Search overlay
+  const overlay = document.getElementById('search-overlay');
+  document.getElementById('search-toggle')?.addEventListener('click', function() {
+    if (overlay) {
+      overlay.hidden = false;
+      const inp = overlay.querySelector('input');
+      if (inp) inp.focus();
+    }
+  });
+  document.getElementById('search-close')?.addEventListener('click', function() {
+    if (overlay) overlay.hidden = true;
+  });
+  document.addEventListener('keydown', function(e) {
+    if (e.key === 'Escape' && overlay && !overlay.hidden) overlay.hidden = true;
+  });
+
+  // Close user dropdown on outside click
+  document.addEventListener('click', function(e) {
+    const open = document.querySelector('.user-menu[open]');
+    if (open && !open.contains(e.target)) open.removeAttribute('open');
+  });
+})();
+</script>
 
 <style>
@@ -222,15 +228,4 @@
 }
 
-/* Hub-home brand IS visible — the home button on artist/hub pages. */
-.masthead-brand .nav-hub-home {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  color: var(--ink); text-decoration: none;
-  border-radius: 8px;
-  transition: color 120ms, background 120ms;
-}
-.masthead-brand .nav-hub-home:hover { color: var(--accent); background: var(--paper-2); }
-.masthead-brand .nav-hub-home svg { width: 22px; height: 22px; }
-
 /* On mobile, hide the entire masthead except for admin pages. The brand
    appears in the profile-header on content pages, and the bottom-tab
@@ -239,8 +234,4 @@
 @media (max-width: 767px) {
   body:not(.on-admin) .masthead { display: none; }
-  /* The masthead is gone on mobile, so it can't carry the iOS safe-area top inset
-     (env() resolves to 0 in the home-screen PWA; --ios-safe-top is set by JS in shell).
-     Pad the body instead so the profile-header clears the Dynamic Island / notch. */
-  body:not(.on-admin) { padding-top: var(--ios-safe-top, 0px); }
 }
 
@@ -349,8 +340,7 @@
   min-width: 220px;
   padding: .35rem;
-  /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */
-  background: color-mix(in srgb, var(--paper) 60%, transparent);
-  backdrop-filter: blur(34px) saturate(190%);
-  -webkit-backdrop-filter: blur(34px) saturate(190%);
+  background: color-mix(in srgb, var(--paper) 92%, transparent);
+  backdrop-filter: blur(14px) saturate(180%);
+  -webkit-backdrop-filter: blur(14px) saturate(180%);
   border: 1px solid var(--rule);
   border-radius: 12px;
@@ -466,24 +456,3 @@
   display: inline-flex; align-items: center; justify-content: center;
 }
-/* Live-resultaten dropdown */
-.search-suggest { margin-top: .6rem; max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; gap: .25rem; }
-.search-suggest:empty { display: none; }
-.ss-group { padding: .25rem 0; }
-.ss-title { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-soft, var(--ink-muted)); padding: .35rem .25rem .2rem; }
-.ss-item { display: block; padding: .55rem .6rem; border-radius: 8px; color: var(--ink); text-decoration: none; font-size: .95rem; }
-.ss-item:hover { background: var(--paper-2); color: var(--accent); }
-.ss-item.ss-noclick { opacity: .7; cursor: default; }
-.ss-sub { color: var(--ink-soft, var(--ink-muted)); font-size: .85em; }
-.ss-empty { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }
-.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
-.ss-all:hover { text-decoration: underline; }
-/* Notification bell + unread badge */
-.nav-notif { position: relative; }
-.notif-badge {
-  position: absolute; top: 1px; right: 1px;
-  min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
-  display: inline-flex; align-items: center; justify-content: center;
-  background: #e0245e; color: #fff; font-size: 10px; font-weight: 700;
-  border-radius: 999px; line-height: 1; pointer-events: none;
-}
 </style>
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/track-editor.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -195,17 +195,4 @@
 }
 
-/* ── Credit field with © insert button ──────────────────────────── */
-.te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; }
-.te-credit-row input { flex: 1; min-width: 0; }
-.te-sym-btn {
-  flex: 0 0 auto;
-  width: 44px; min-height: 44px;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background: var(--paper-2); color: var(--ink);
-  font-size: 1.1rem; cursor: pointer;
-  transition: border-color 120ms, background 120ms;
-}
-.te-sym-btn:hover { border-color: var(--accent); }
-
 /* ── Cover field with thumb + button + URL ─────────────────── */
 .te-cover-row {
@@ -284,3 +271,352 @@
 </style>
 
-<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
+<script>
+(function() {
+
+  function esc(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  }
+
+  async function api(method, url, body) {
+    const opts = { method, credentials: 'same-origin', headers: {} };
+    if (body !== undefined) {
+      if (body instanceof FormData) {
+        opts.body = body;
+      } else {
+        opts.headers['Content-Type'] = 'application/json';
+        opts.body = JSON.stringify(body);
+      }
+    }
+    const res = await fetch(url, opts);
+    let data;
+    try { data = await res.json(); }
+    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
+    if (!res.ok) data.ok = false;
+    return data;
+  }
+
+  /**
+   * Open the track editor.
+   * @param {object} opts
+   * @param {string} opts.id        — track id to edit
+   * @param {function?} opts.onSaved — called with updated track on success
+   */
+  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
+    if (!id) return;
+    let track, albumSuggestions = [];
+    try {
+      const [trackJson, listJson] = await Promise.all([
+        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
+        api('GET', '/admin/audio/api/albums'),
+      ]);
+      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
+      track = trackJson.track;
+      if (listJson.ok && Array.isArray(listJson.albums)) {
+        albumSuggestions = listJson.albums.filter(Boolean);
+      }
+    } catch (err) {
+      alert('Track ophalen mislukt: ' + err.message);
+      return;
+    }
+
+    const backdrop = document.createElement('div');
+    backdrop.className = 'te-backdrop';
+    backdrop.innerHTML = `
+      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
+        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
+
+        <div class="te-header">
+          <h3>✎ Track bewerken</h3>
+          <button type="button" class="te-close" aria-label="Sluiten">×</button>
+        </div>
+
+        <div class="te-body">
+
+          ${track.stream_url ? `
+          <div class="te-preview">
+            <div class="te-preview-cover">
+              ${track.cover_url
+                ? `<img src="${esc(track.cover_url)}" alt="">`
+                : `🎵`}
+            </div>
+            <div class="te-preview-meta">
+              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
+              <div class="te-preview-sub">
+                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
+              </div>
+            </div>
+            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
+          </div>
+          ` : ''}
+
+          <div class="te-form">
+
+            <label class="te-field">
+              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
+              <input type="text" id="te-title" maxlength="200" required
+                     autocomplete="off" autocapitalize="words" spellcheck="false"
+                     value="${esc(track.title || '')}">
+            </label>
+
+            <div class="te-row te-row-2">
+              <label class="te-field">
+                <span>Artiest</span>
+                <input type="text" id="te-artist" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       value="${esc(track.artist || '')}">
+              </label>
+              <label class="te-field">
+                <span>Album</span>
+                <input type="text" id="te-album" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       list="te-album-list" value="${esc(track.album || '')}">
+                <datalist id="te-album-list">
+                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
+                </datalist>
+              </label>
+            </div>
+
+            <label class="te-field">
+              <span>Duur <small>(seconden — bv. 142 voor 2:22)</small></span>
+              <input type="number" id="te-duration" min="0" step="1"
+                     inputmode="numeric" pattern="[0-9]*"
+                     value="${track.duration || ''}" placeholder="0">
+            </label>
+
+            <div class="te-field">
+              <span>Cover</span>
+              <div class="te-cover-row">
+                <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
+                     aria-label="Klik om cover te kiezen">
+                  ${track.cover_url
+                    ? `<img src="${esc(track.cover_url)}" alt="">`
+                    : `<span class="te-cover-empty">🎨</span>`}
+                </div>
+                <input type="file" id="te-cover-file"
+                       accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                <div class="te-cover-actions">
+                  <div class="te-cover-btn-row">
+                    <button type="button" class="te-cover-btn" id="te-cover-pick">
+                      📷 Foto kiezen
+                    </button>
+                    ${track.cover_url ? `
+                    <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
+                      × Verwijder
+                    </button>` : ''}
+                  </div>
+                  <input type="url" id="te-cover-url"
+                         placeholder="of plak URL: https://…"
+                         autocomplete="off" autocapitalize="none" spellcheck="false"
+                         value="${esc(track.cover_url || '')}">
+                  <div class="te-cover-status" id="te-cover-status"></div>
+                </div>
+              </div>
+            </div>
+
+          </div>
+        </div>
+
+        <div class="te-footer">
+          <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
+          <div class="te-footer-spacer"></div>
+          <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
+        </div>
+      </div>
+    `;
+
+    document.body.appendChild(backdrop);
+    document.body.classList.add('te-modal-open');
+
+    const $ = sel => backdrop.querySelector(sel);
+
+    // ── Inline preview player (routed through global mini-player) ──
+    // We don't build our own <audio>; instead we tell the global
+    // window.pcmsAudioPlayer to load this single track. Visual state
+    // syncs against the global audio element so toggle works correctly
+    // even if the user pauses from the mini-bar.
+    const previewBtn = $('#te-preview-play');
+    if (previewBtn) {
+      const setPreviewState = (playing) => {
+        previewBtn.textContent = playing ? '⏸' : '▶';
+        previewBtn.classList.toggle('is-playing', playing);
+        previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
+      };
+      const isOurTrack = () => {
+        const audio = document.getElementById('audio-element');
+        return audio && track.stream_url && audio.src.endsWith(track.stream_url);
+      };
+      const resync = () => {
+        const audio = document.getElementById('audio-element');
+        const playing = audio && !audio.paused && !audio.ended && isOurTrack();
+        setPreviewState(!!playing);
+      };
+
+      previewBtn.addEventListener('click', () => {
+        const player = window.pcmsAudioPlayer;
+        if (!player || !track.stream_url) {
+          console.warn('preview: miniplayer or url missing');
+          return;
+        }
+        const audio = document.getElementById('audio-element');
+        if (audio && isOurTrack()) {
+          // Same track loaded — toggle
+          if (audio.paused) player.play(); else player.pause();
+        } else {
+          player.setQueue([{
+            url:    track.stream_url,
+            title:  track.title  || '(zonder titel)',
+            artist: track.artist || '',
+            album:  track.album  || '',
+            cover:  track.cover_url || '',
+          }], 0);
+        }
+      });
+
+      const audio = document.getElementById('audio-element');
+      if (audio) {
+        const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
+        evs.forEach(ev => audio.addEventListener(ev, resync));
+        // Detach listeners on close so we don't leak them
+        backdrop._previewCleanup = () => {
+          evs.forEach(ev => audio.removeEventListener(ev, resync));
+        };
+        resync();   // initial state
+      }
+    }
+
+    function close() {
+      // Detach our resync listeners (mini-player stays running)
+      if (backdrop._previewCleanup) backdrop._previewCleanup();
+      document.body.classList.remove('te-modal-open');
+      document.removeEventListener('keydown', onEsc);
+      backdrop.remove();
+    }
+    function onEsc(e) { if (e.key === 'Escape') close(); }
+    document.addEventListener('keydown', onEsc);
+
+    backdrop.addEventListener('click', e => {
+      // Click on the backdrop itself (not modal content) closes
+      if (e.target === backdrop) close();
+    });
+    $('.te-close').addEventListener('click', close);
+    $('#te-cancel').addEventListener('click', close);
+
+    // ── Cover picking + URL paste + drag-drop ────────────────
+    const thumb     = $('#te-cover-thumb');
+    const fileInput = $('#te-cover-file');
+    const urlInput  = $('#te-cover-url');
+    const status    = $('#te-cover-status');
+    const pickBtn   = $('#te-cover-pick');
+    const removeBtn = $('#te-cover-remove');
+
+    function setStatus(text, kind) {
+      status.textContent = text || '';
+      status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
+    }
+    function setThumb(url) {
+      if (url) {
+        thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
+      } else {
+        thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
+      }
+    }
+
+    pickBtn.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('keydown', e => {
+      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
+    });
+
+    async function uploadCoverFile(file) {
+      if (!file) return;
+      if (!/^image\//.test(file.type)) {
+        setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
+      }
+      setStatus('Uploaden…', null);
+      const fd = new FormData();
+      fd.append('cover', file);
+      try {
+        const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
+          method: 'POST', body: fd, credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
+        urlInput.value = j.cover_url || '';
+        setThumb(j.cover_url);
+        setStatus('✓ Geüpload', 'ok');
+      } catch (err) {
+        setStatus('Mislukt: ' + err.message, 'error');
+      }
+    }
+
+    fileInput.addEventListener('change', e => {
+      const f = e.target.files && e.target.files[0];
+      if (f) uploadCoverFile(f);
+      fileInput.value = '';
+    });
+
+    // Drag-drop on thumb (desktop nicety)
+    ['dragenter', 'dragover'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
+    ['dragleave', 'drop'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
+    thumb.addEventListener('drop', e => {
+      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+      if (f) uploadCoverFile(f);
+    });
+
+    // URL paste auto-preview
+    urlInput.addEventListener('input', () => {
+      const v = urlInput.value.trim();
+      setThumb(v);
+    });
+
+    if (removeBtn) {
+      removeBtn.addEventListener('click', () => {
+        urlInput.value = '';
+        setThumb('');
+        removeBtn.remove();
+      });
+    }
+
+    // ── Save ────────────────────────────────────────────────
+    $('#te-save').addEventListener('click', async () => {
+      const titleEl = $('#te-title');
+      const title = titleEl.value.trim();
+      if (!title) {
+        titleEl.focus();
+        alert('Titel is verplicht');
+        return;
+      }
+      const saveBtn = $('#te-save');
+      saveBtn.disabled = true;
+      saveBtn.textContent = '⏳ Opslaan…';
+
+      try {
+        const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
+          title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
+          cover_url: urlInput.value.trim() || null,
+        });
+        if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
+        if (typeof onSaved === 'function') onSaved(j.track || { id, title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          cover_url: urlInput.value.trim() || null });
+        close();
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = '💾 Opslaan';
+      }
+    });
+
+    // Focus title for fast typing
+    setTimeout(() => $('#te-title').focus(), 60);
+  };
+})();
+</script>
Index: src/views/partials/view-switcher.ejs
===================================================================
--- src/views/partials/view-switcher.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/view-switcher.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,87 +3,18 @@
     handlers are wired in shell.ejs via event-delegation on document so
     HTMX-swapped content keeps working without rebinding. %>
-<%
-  var _bc = (typeof bodyClass === 'string') ? bodyClass : '';
-  var _onShows = _bc.indexOf('on-shows') >= 0;
-  // 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']
-                   .some(function(c){ return _bc.indexOf(c) >= 0; });
-  // Lezen bestaat alleen waar een leessectie is, en dat is de eigen feed. De
-  // cirkel toont berichten van ANDEREN, opgehaald van hun servers -- daar is
-  // "het hele stuk" niet iets wat wij in handen hebben.
-  //
-  // De knop verdween daar eerst helemaal, met het argument dat een knop die
-  // niets doet erger is dan geen knop. In de praktijk pakte dat andersom uit:
-  // de pil werd smaller op de cirkel en breder op solo, en die sprong leest als
-  // "er is hier iets weg" -- terwijl juist NIET duidelijk werd dat Lezen
-  // bestaat en hier alleen niet kan. Hij staat er nu dood bij: zichtbaar, grijs
-  // en niet aan te klikken. Dan is er maar een mogelijkheid, Grid, en je ziet
-  // meteen dat dat geen toeval is maar de enige.
-  var _leesUit = _bc.indexOf('on-cirkel') >= 0;
-  var _gridStandaard = (typeof site !== 'undefined' && site && site.feed_view_default === 'grid');
-  // Welke tweede weergave biedt deze site aan? Bij 'auto' tekenen we ZE ALLEBEI
-  // en laat de CSS er per viewport een zien -- Lezen op mobiel, Tijdlijn op
-  // desktop. Dat scheelt een resize-afhandeling en een knop die van naam
-  // verspringt terwijl je kijkt.
-  var _alt = (typeof site !== 'undefined' && site && site.feed_alt_view) || 'reader';
-  if (['reader', 'timeline', 'auto'].indexOf(_alt) < 0) _alt = 'reader';
-  var _tweede = _alt === 'auto' ? ['reader', 'timeline'] : [_alt];
-  // De tijdlijn WERKT wel in de cirkel -- dat is er zelfs de natuurlijke vorm.
-  // Alleen Lezen kan daar niet, want dat zijn berichten van anderen.
-  var _dood = function (v) { return v === 'reader' && _leesUit; };
-  // Staat er GEEN levende tweede knop, dan is Grid het enige dat overblijft en
-  // hoort die aan te staan. Bij een tijdlijn-site is dat in de cirkel niet zo:
-  // daar werkt de tijdlijn gewoon.
-  var _alleenGrid = _tweede.every(_dood);
-  var _icoon = {
-    reader: '<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"/>',
-    timeline: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
-  };
-%>
 <div class="view-switcher-wrap">
   <div class="container">
-    <% if (typeof premiumUnlocked !== 'undefined' && premiumUnlocked && typeof agendaEnabled !== 'undefined' && agendaEnabled) {
-         var _sbA = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-    %>
-    <div class="agenda-pill">
-      <a class="vs-agenda<%= _onShows ? ' is-active' : '' %>" href="<%= _sbA %>/shows" aria-label="Agenda — evenementen">
-        <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"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
-        <span><%= t('switch.agenda') %></span>
-      </a>
-    </div>
-    <% } %>
-    <% if (typeof hasCirkel !== 'undefined' && hasCirkel) {
-         var _onCirkel = (typeof bodyClass === 'string' && bodyClass.indexOf('on-cirkel') >= 0);
-         var _sb = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-    %>
-    <div class="feed-scope" role="tablist" aria-label="Solo of cirkel">
-      <a class="feed-scope-btn<%= (!_neutral && !_onCirkel) ? ' is-active' : '' %>" href="<%= _sb %>/"
-         role="tab" aria-selected="<%= (!_neutral && !_onCirkel) ? 'true' : 'false' %>"><%= t('switch.solo') %></a>
-      <a class="feed-scope-btn<%= (!_neutral && _onCirkel) ? ' is-active' : '' %>" href="<%= _sb %>/cirkel"
-         role="tab" aria-selected="<%= (!_neutral && _onCirkel) ? 'true' : 'false' %>"><%= t('switch.circle') %></a>
-    </div>
-    <% } %>
     <div class="view-switcher" role="tablist" aria-label="Weergave">
-
-      <%# De tweede weergave naast Grid. Welke dat is, staat in de instellingen
-          van de site (feed_alt_view). Bij 'auto' staan ze er allebei en kiest
-          een mediaquery; vandaar de klasse vs-alleen-*. %>
-      <% _tweede.forEach(function (v) { %>
-      <button type="button" class="view-switch-btn<%= _alt === 'auto' ? ' vs-alleen-' + (v === 'reader' ? 'mobiel' : 'desktop') : '' %>"
-              data-view="<%= v %>" role="tab"
-              <%- _dood(v) ? 'disabled aria-disabled="true" tabindex="-1" title="' + t('switch.reader_solo_only') + '"' : '' %>
-              aria-selected="<%= (!_dood(v) && !_neutral && !_gridStandaard) ? 'true' : '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"><%- _icoon[v] %></svg>
-        <span><%= t('switch.' + v) %></span>
+      <button type="button" class="view-switch-btn" data-view="timeline" role="tab"
+              aria-selected="<%= (typeof site !== 'undefined' && site && site.feed_view_default === 'grid') ? 'false' : 'true' %>">
+        <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"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
+        <span>Tijdlijn</span>
       </button>
-      <% }); %>
       <button type="button" class="view-switch-btn" data-view="grid" role="tab"
-              aria-selected="<%= (!_neutral && (_alleenGrid || _gridStandaard)) ? 'true' : 'false' %>">
+              aria-selected="<%= (typeof site !== 'undefined' && site && site.feed_view_default === 'grid') ? 'true' : '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"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
-        <span><%= t('switch.grid') %></span>
+        <span>Grid</span>
       </button>
     </div>
-    <% if (!_neutral) { %>
     <div class="grid-cols-picker" aria-label="Aantal kolommen">
       <button type="button" class="grid-cols-btn" data-cols="2" title="2 kolommen">2</button>
@@ -91,5 +22,4 @@
       <button type="button" class="grid-cols-btn" data-cols="4" title="4 kolommen">4</button>
     </div>
-    <% } %>
   </div>
 </div>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/shell.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,18 +1,13 @@
 <%
 // ── Helpers used inside this template ─────────────────────────────
-// Escape for double-quoted HTML attributes. IMPORTANT: emit this with the RAW EJS output
-// tag, never the escaping one — escaping it a second time turned og:title "Jason's" into the
-// double-escaped "Jason&amp;#39;s", and naive OG scrapers (Signal/WhatsApp) show that literally.
-// We deliberately do NOT escape the apostrophe: it is safe inside a double-quoted attribute and
-// a literal apostrophe is what link-preview scrapers expect.
 function _e(s) {
   return String(s == null ? '' : s)
     .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;');
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
 }
 
 const safeSite     = site || {};
 const safeUrlBase  = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const safeAccent   = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#e8b04b';
+const safeAccent   = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#c2410c';
 const lang         = safeSite.language || 'nl';
 const ogLocale     = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US'));
@@ -26,5 +21,5 @@
 // Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we
 // just use the site title alone, otherwise apply the template.
-const _siteTitle  = safeSite.title || 'Klonkt';
+const _siteTitle  = safeSite.title || 'PrutCMS';
 const _rawTitle   = pageTitle || _siteTitle;
 const _tpl        = safeSite.title_template || '{title} — {site}';
@@ -38,5 +33,5 @@
 // Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content)
 if (currentPath) {
-  if (/^\/(?:search|tag|type|archive|users|account|admin)(?:$|\/)/.test(currentPath)) {
+  if (/^\/(?:search|tag|type|archive|users|prutter|account|admin)(?:$|\/)/.test(currentPath)) {
     _shouldIndex = false;
   }
@@ -47,10 +42,8 @@
 }
 
-// ── Canonical URL: per-site override (admin SEO), else the .env base
-//    (PUBLIC_BASE_URL, via ogOrigin → falls back to the request host) ──
+// ── Canonical URL (only when we have one configured) ─────────────
 let _canonical = null;
-const _canonBase = safeSite.canonical || (typeof ogOrigin !== 'undefined' ? ogOrigin : '');
-if (_canonBase) {
-  const _base = _canonBase.replace(/\/+$/, '');
+if (safeSite.canonical) {
+  const _base = safeSite.canonical.replace(/\/+$/, '');
   let _path = '/';
   if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug;
@@ -64,20 +57,7 @@
   ? socialDescr
   : (safeSite.default_description || safeSite.description || '');
-// og:image — custom (post/site) first; otherwise the auto-generated themed card
-// (/og/<slug>.png), so every site has a branded social preview by default.
-let _socialImage = '', _ogGenerated = false;
-if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage;
-else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default;
-else if (safeSite.default_cover) _socialImage = safeSite.default_cover;
-else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) {
-  _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png';
-  _ogGenerated = true;
-}
-// og:image / twitter:image / JSON-LD image MUST be absolute (OGP spec). A post cover arrives as
-// a relative /media/... path; strict scrapers (WhatsApp/Signal/some fediverse clients) won't
-// resolve it against the page URL → no preview image. Absolutize against the canonical origin.
-if (_socialImage && _socialImage.charAt(0) === '/' && _socialImage.charAt(1) !== '/' && typeof ogOrigin !== 'undefined' && ogOrigin) {
-  _socialImage = ogOrigin + _socialImage;
-}
+const _socialImage = (typeof socialImage !== 'undefined' && socialImage)
+  ? socialImage
+  : (safeSite.og_image_default || safeSite.default_cover || '');
 const _ogType = isPostPage ? 'article' : 'website';
 
@@ -90,10 +70,4 @@
 if (safeSite.publisher_logo) {
   _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo };
-}
-// Dezelfde koppeling die de fediverse-actor draagt, hier in de JSON-LD
-// (shaer-mbz). sameAs is schema.org-eigen, dus dit is geen extra vocabulaire --
-// het is hetzelfde feit, verteld aan de andere lezer.
-if (safeSite.mb_artist_id) {
-  _publisher.sameAs = 'https://musicbrainz.org/artist/' + safeSite.mb_artist_id;
 }
 let _jsonLd = null;
@@ -120,17 +94,6 @@
   };
 }
-%>
-<%
-// De cache-buster voor alles onder assets/js/mod (en de gedeelde bestanden die
-// die modules importeren). Stond alleen in de bootstrap onderaan; hij staat nu
-// hier omdat de importmap in de <head> hem OOK nodig heeft, en twee losse
-// nummers lopen gegarandeerd een keer uit elkaar.
-//
-// Ophogen bij ELKE wijziging in assets/js/mod -- /assets wordt buiten
-// ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie
-// iedereen behalve wie de fout al heeft.
-const MOD_V = 64;
 %><!DOCTYPE html>
-<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
+<html lang="<%= _e(lang) %>" data-palette="<%= _e(safeSite.palette || 'sage') %>">
 <head>
 <meta charset="utf-8">
@@ -139,91 +102,58 @@
 
 <title><%= _finalTitle %></title>
-<meta name="description" content="<%- _e(_socialDescr) %>">
-<meta name="theme-color" content="<%- _e(safeAccent) %>">
+<meta name="description" content="<%= _e(_socialDescr) %>">
+<meta name="theme-color" content="<%= _e(safeAccent) %>">
 <meta name="robots" content="<%= _shouldIndex ? 'index,follow' : 'noindex,nofollow' %>">
-<% if (safeSite.author) { %><meta name="author" content="<%- _e(safeSite.author) %>"><% } %>
-<% if (_canonical) { %><link rel="canonical" href="<%- _e(_canonical) %>"><% } %>
+<% if (safeSite.author) { %><meta name="author" content="<%= _e(safeSite.author) %>"><% } %>
+<% if (_canonical) { %><link rel="canonical" href="<%= _e(_canonical) %>"><% } %>
 
 <!-- Search-engine verification -->
-<% if (safeSite.google_verification)    { %><meta name="google-site-verification" content="<%- _e(safeSite.google_verification) %>"><% } %>
-<% if (safeSite.bing_verification)      { %><meta name="msvalidate.01" content="<%- _e(safeSite.bing_verification) %>"><% } %>
-<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%- _e(safeSite.pinterest_verification) %>"><% } %>
-<% if (safeSite.yandex_verification)    { %><meta name="yandex-verification" content="<%- _e(safeSite.yandex_verification) %>"><% } %>
+<% if (safeSite.google_verification)    { %><meta name="google-site-verification" content="<%= _e(safeSite.google_verification) %>"><% } %>
+<% if (safeSite.bing_verification)      { %><meta name="msvalidate.01" content="<%= _e(safeSite.bing_verification) %>"><% } %>
+<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%= _e(safeSite.pinterest_verification) %>"><% } %>
+<% if (safeSite.yandex_verification)    { %><meta name="yandex-verification" content="<%= _e(safeSite.yandex_verification) %>"><% } %>
 
 <!-- Feed autodiscovery -->
 <% if (site) { %>
-<link rel="alternate" type="application/rss+xml"  title="<%- _e(_siteTitle) %> — RSS"  href="<%- _e(safeUrlBase + '/feed.xml') %>">
-<link rel="alternate" type="application/atom+xml" title="<%- _e(_siteTitle) %> — Atom" href="<%- _e(safeUrlBase + '/atom.xml') %>">
+<link rel="alternate" type="application/rss+xml"  title="<%= _e(_siteTitle) %> — RSS"  href="<%= _e(safeUrlBase + '/feed.xml') %>">
+<link rel="alternate" type="application/atom+xml" title="<%= _e(_siteTitle) %> — Atom" href="<%= _e(safeUrlBase + '/atom.xml') %>">
 <% } %>
 
 <!-- PWA -->
-<link rel="manifest" href="<%- _e(safeUrlBase + '/manifest.webmanifest') %>">
+<link rel="manifest" href="<%= _e(safeUrlBase + '/manifest.webmanifest') %>">
 <meta name="mobile-web-app-capable" content="yes">
 <meta name="apple-mobile-web-app-capable" content="yes">
 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
-<meta name="apple-mobile-web-app-title" content="<%- _e(_siteTitle.slice(0, 16)) %>">
-<link rel="apple-touch-icon" href="<%- _e(safeSite.profile_photo || '/favicon.ico') %>">
-<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
-<link rel="alternate icon" href="/favicon.ico?v=sf">
+<meta name="apple-mobile-web-app-title" content="<%= _e(_siteTitle.slice(0, 16)) %>">
+<link rel="apple-touch-icon" href="<%= _e(safeSite.profile_photo || '/favicon.ico') %>">
+<link rel="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="alternate icon" href="/favicon.ico">
 
 <!-- OpenGraph -->
 <meta property="og:type"        content="<%= _ogType %>">
-<meta property="og:title"       content="<%- _e(_socialTitle) %>">
-<meta property="og:description" content="<%- _e(_socialDescr) %>">
-<meta property="og:site_name"   content="<%- _e(_siteTitle) %>">
-<meta property="og:locale"      content="<%- _e(ogLocale) %>">
+<meta property="og:title"       content="<%= _e(_socialTitle) %>">
+<meta property="og:description" content="<%= _e(_socialDescr) %>">
+<meta property="og:site_name"   content="<%= _e(_siteTitle) %>">
+<meta property="og:locale"      content="<%= _e(ogLocale) %>">
 <% if (_socialImage) { %>
-<meta property="og:image"     content="<%- _e(_socialImage) %>">
-<meta property="og:image:alt" content="<%- _e(_socialTitle) %>">
-<% if (_ogGenerated) { %>
-<meta property="og:image:width"  content="1200">
-<meta property="og:image:height" content="630">
-<meta property="og:image:type"   content="image/png">
-<% } %>
-<% } %>
-<% if (_canonical) { %><meta property="og:url" content="<%- _e(_canonical) %>"><% } %>
-<%
-// Fediverse/social PLAYER card for posts with audio: instead of shipping the raw
-// mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an
-// inline player that streams via the gated /audio/stream (no downloadable file).
-const _postAudio = !!(typeof post !== 'undefined' && post
-  && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio
-  && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
-const _embedUrl = _postAudio
-  ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug))
-  : '';
-%>
-<% if (_postAudio) { %>
-<meta property="og:video"            content="<%- _e(_embedUrl) %>">
-<meta property="og:video:secure_url" content="<%- _e(_embedUrl) %>">
-<meta property="og:video:type"       content="text/html">
-<meta property="og:video:width"      content="480">
-<meta property="og:video:height"     content="480">
-<% } %>
+<meta property="og:image"     content="<%= _e(_socialImage) %>">
+<meta property="og:image:alt" content="<%= _e(_socialTitle) %>">
+<% } %>
+<% if (_canonical) { %><meta property="og:url" content="<%= _e(_canonical) %>"><% } %>
 <% if (typeof post !== 'undefined' && post && post.published_at) { %>
-<meta property="article:published_time" content="<%- _e(post.published_at) %>">
-<% if (post.author_username) { %><meta property="article:author" content="<%- _e(post.author_username) %>"><% } %>
-<% } %>
-<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%- _e(safeSite.facebook_app_id) %>"><% } %>
+<meta property="article:published_time" content="<%= _e(post.published_at) %>">
+<% if (post.author_username) { %><meta property="article:author" content="<%= _e(post.author_username) %>"><% } %>
+<% } %>
+<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%= _e(safeSite.facebook_app_id) %>"><% } %>
 
 <!-- Twitter Cards -->
-<meta name="twitter:card"        content="<%= _postAudio ? 'player' : (_socialImage ? 'summary_large_image' : 'summary') %>">
-<% if (_postAudio) { %>
-<meta name="twitter:player"        content="<%- _e(_embedUrl) %>">
-<meta name="twitter:player:width"  content="480">
-<meta name="twitter:player:height" content="480">
-<% } %>
-<meta name="twitter:title"       content="<%- _e(_socialTitle) %>">
-<meta name="twitter:description" content="<%- _e(_socialDescr) %>">
-<% if (_socialImage)         { %><meta name="twitter:image"   content="<%- _e(_socialImage) %>"><% } %>
-<% if (safeSite.twitter)     { %><meta name="twitter:creator" content="<%- _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%- _e(safeSite.twitter) %>"><% } %>
+<meta name="twitter:card"        content="<%= _socialImage ? 'summary_large_image' : 'summary' %>">
+<meta name="twitter:title"       content="<%= _e(_socialTitle) %>">
+<meta name="twitter:description" content="<%= _e(_socialDescr) %>">
+<% if (_socialImage)         { %><meta name="twitter:image"   content="<%= _e(_socialImage) %>"><% } %>
+<% if (safeSite.twitter)     { %><meta name="twitter:creator" content="<%= _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%= _e(safeSite.twitter) %>"><% } %>
 
 <% if (_jsonLd) { %>
 <script type="application/ld+json"><%- JSON.stringify(_jsonLd) %></script>
-<% } %>
-<% if (typeof musicLd !== 'undefined' && musicLd) { %>
-<%# Music posts also carry standard schema.org MusicRecording/MusicAlbum data (Phase 1 of
-    music federation): real web standard, read by search engines + generic consumers. %>
-<script type="application/ld+json"><%- JSON.stringify(musicLd) %></script>
 <% } %>
 
@@ -233,59 +163,10 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=110">
-<script>
-/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
-   BOTH Safari and the installed PWA (standalone), even with viewport-fit=cover, so the
-   masthead can't clear the camera. We measure env directly and, when it comes back empty
-   on a notched iPhone, fall back to a fixed inset (a bit larger in standalone for the
-   Dynamic Island). We set --ios-safe-top AND force the masthead padding inline — the
-   latter survives a stale cached stylesheet that lacks the var. Re-applied after htmx
-   chrome swaps (the top-nav is out-of-band swapped on navigation). */
-(function(){
-  var ua = navigator.userAgent || '';
-  var isIOS = /iP(hone|od|ad)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
-  if (!isIOS) return;
-  var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;
-  function apply(){
-    if (!document.body) return;
-    var p = document.createElement('div');
-    p.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;padding-top:env(safe-area-inset-top,0px);visibility:hidden;pointer-events:none';
-    document.body.appendChild(p);
-    var rawEnv = parseFloat(getComputedStyle(p).paddingTop) || 0;
-    p.remove();
-    var i = rawEnv;
-    if (i < 20 && Math.max(screen.width, screen.height) >= 812) {
-      // Portrait: the island sits at the TOP → full inset (UNCHANGED: standalone?59:47).
-      // Landscape: the island moves to the SIDE, so the top inset is ~0 → no top padding
-      // (only the masthead's own base padding remains). env() can't tell us (returns 0),
-      // so we key off orientation directly.
-      i = (window.innerWidth > window.innerHeight) ? 0 : (standalone ? 59 : 47);
-    }
-    document.documentElement.style.setProperty('--ios-safe-top', i + 'px');
-    var mh = document.querySelector('.masthead');
-    var mhHidden = !mh || getComputedStyle(mh).display === 'none' || mh.offsetHeight === 0;
-    if (mhHidden) {
-      /* Mobile: the masthead is hidden (bottom-tab replaces it ≤767px) so it can't carry
-         the inset. Pad the body instead → the profile-header clears the island/notch. */
-      document.body.style.paddingTop = i + 'px';
-      if (mh) mh.style.paddingTop = '';
-    } else {
-      /* Desktop/landscape: the sticky masthead carries the inset in its own padding. */
-      document.body.style.paddingTop = '';
-      mh.style.paddingTop = 'calc(.55rem + ' + i + 'px)';
-    }
-  }
-  if (document.body) apply(); else document.addEventListener('DOMContentLoaded', apply);
-  document.addEventListener('htmx:afterSettle', apply);
-  window.addEventListener('orientationchange', function(){ setTimeout(apply, 250); });
-})();
-</script>
+<link rel="stylesheet" href="/assets/css/style.css">
 
 <!-- Audio player styles: loaded on every page so the mini-player works
      anywhere (admin previews, post embeds, etc). The player itself is
      a singleton — see the script tag near </body>. -->
-<link rel="stylesheet" href="/assets/css/audio.css?v=11">
-<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
-<link rel="stylesheet" href="/assets/css/embed.css?v=9">
+<link rel="stylesheet" href="/assets/css/audio.css">
 
 <%- include('partials/shared-styles') %>
@@ -299,7 +180,7 @@
   :root,
   [data-palette] {
-    --accent: <%- _e(safeAccent) %>;
-    --accent-soft: color-mix(in srgb, <%- _e(safeAccent) %> 80%, white);
-    --accent-tint: color-mix(in srgb, <%- _e(safeAccent) %> 12%, transparent);
+    --accent: <%= _e(safeAccent) %>;
+    --accent-soft: color-mix(in srgb, <%= _e(safeAccent) %> 80%, white);
+    --accent-tint: color-mix(in srgb, <%= _e(safeAccent) %> 12%, transparent);
   }
 </style>
@@ -323,5 +204,5 @@
     try {
       var siteDefault   = '<%= safeSite.theme_override || "" %>';
-      var sitePalette   = '<%= (typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt') %>';
+      var sitePalette   = '<%= safeSite.palette || "sage" %>';
 
       // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
@@ -350,23 +231,4 @@
 <script src="/assets/js/htmx.min.js"></script>
 
-<%# De cache-buster reikt ook tot wat de modules ONDERLING importeren (shaer-724).
-    De bootstrap hangt ?v= aan elke module die hij zelf laadt, maar een import
-    BINNEN een module is relatief, en een relatieve specifier erft de query
-    niet: `./lib.js` naast `post.js?v=63` wordt gewoon `/assets/js/mod/lib.js`.
-    Dat bestand wordt met max-age van een jaar geserveerd, dus het bleef bij
-    terugkerende bezoekers staan terwijl de modules eromheen ververst werden --
-    en juist lib.js is gedeeld, dus een fout daarin overleeft elke MOD_V-bump.
-
-    Een importmap lost dat op zonder die elf imports aan te raken: hij vertaalt
-    de OPGELOSTE URL naar zijn geversioneerde vorm. Hij moet in de head staan,
-    voor de eerste module-import. De toets bij dit bestand bewaakt dat elke
-    relatieve import onder assets/js hier een ingang heeft. %>
-<script type="importmap">
-{"imports":{
-  "/assets/js/mod/lib.js": "/assets/js/mod/lib.js?v=<%= MOD_V %>",
-  "/assets/js/shared/post-music-type.js": "/assets/js/shared/post-music-type.js?v=<%= MOD_V %>"
-}}
-</script>
-
 <!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
 <% if (safeSite.custom_head_html) { %>
@@ -375,40 +237,23 @@
 </head>
 
-<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default === 'grid' ? 'grid' : (["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader")) %>" data-feed-alt="<%- _e(["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader") %>" data-reader-pages="<%- safeSite.reader_full_page ? 1 : 0 %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
-
-<% if (typeof isViewer !== 'undefined' && isViewer) { %>
-  <div class="viewer-banner" role="status">
-    <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
-    <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
-  </div>
-  <style>
-    .viewer-banner {
-      position: sticky; top: 0; z-index: 60;
-      display: flex; align-items: center; justify-content: center; gap: 0.5rem;
-      padding: 0.5rem 1rem;
-      background: linear-gradient(90deg,
-        color-mix(in srgb, var(--accent) 88%, #000) 0%,
-        var(--accent) 100%);
-      color: #fff;
-      font-size: 0.85rem; line-height: 1.3;
-      box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
-    }
-    .viewer-banner-ico { font-size: 1rem; }
-    .viewer-banner-text strong { font-weight: 700; }
-  </style>
-<% } %>
-
-<%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
-    Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs +
-    render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de
-    audioplayer (los in document.body) blijft leven → geen verspringen. Op de
-    hub-landing is het slot leeg: de hero is daar de header. %>
-<%- include('partials/chrome') %>
-
-<%# Geen hx-history-elt: htmx' eigen history staat uit (zie de link-boost).
-    Back/forward wordt door onze popstate-listener gedaan, die de partial
-    her-fetcht (incl. correcte OOB-chrome). Met hx-history-elt + htmx-history
-    aan dumpte htmx de partial ongefilterd hier → dubbele kop. %>
-<main id="pcms-main" class="pcms-main">
+<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%= _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3">
+
+<%- include('partials/topnav') %>
+
+<%- include('partials/profile-header') %>
+
+<%# View switcher — visible on every non-admin page. On feed pages
+    (home, archive, tag, type, user) it toggles the visual layout;
+    on non-feed pages (post, account, search, auth) it navigates to /
+    in the chosen view. Click logic is in shell.ejs at the bottom. %>
+<% if (!isAdminPage) { %>
+  <%- include('partials/view-switcher') %>
+<% } %>
+
+<%# hx-history-elt scopes HTMX history snapshots to #pcms-main only, so
+    when the user navigates back the switcher (which lives in the shell
+    OUTSIDE this element) keeps its place rather than being replaced
+    along with the rest of the body. %>
+<main id="pcms-main" class="pcms-main" hx-history-elt>
   <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
   <%- pageContent %>
@@ -417,10 +262,6 @@
 <%- include('partials/footer') %>
 
-<!-- Mobile bottom-tab navigation (auto-hidden ≥768px). Toont overal — óók op de
-     hub-landing (zodat mobiel altijd Home/Zoek/Inloggen heeft) — behalve op de
-     auth-focusschermen. -->
-<% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %>
+<!-- Mobile bottom-tab navigation (auto-hidden ≥768px) -->
 <%- include('partials/bottom-tab') %>
-<% } %>
 
 <!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
@@ -432,12 +273,6 @@
      is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
      enable_audio_player since it's a public-page concept (auto-discovered
-     tracks from rendered post embeds).
-
-     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
-     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
-<script src="/assets/js/audio-player.js?v=39"></script>
-<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
-     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
-<script src="/assets/js/embed-player.js?v=17" defer></script>
+     tracks from rendered post embeds). -->
+<script src="/assets/js/audio-player.js"></script>
 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
   <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
@@ -445,5 +280,5 @@
 
 <!-- Install-app button: detects platform + shows install instructions modal -->
-<script src="/assets/js/install-app.js?v=2" defer></script>
+<script src="/assets/js/install-app.js" defer></script>
 
 <!-- Service Worker registration -->
@@ -461,112 +296,20 @@
 <script>
 (function() {
-  // De page-context body-classes. Bij pcmsNav strippen we ze allemaal en zetten
-  // we opnieuw wat de server stuurde. 'on-auth' staat erbij zodat het login-
-  // 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.
-  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'];
+  // The set of mutually-exclusive page-context body classes. When pcmsNav
+  // fires we strip ALL of these and apply just the one the server told us.
+  var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin'];
 
   document.body.addEventListener('pcmsNav', function(ev) {
-    var d = ev.detail || {};
-    // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
-    // dus zonder dit erft een artiest de kleuren van de vorige pagina).
-    if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
-      var sa = document.getElementById('pcms-site-accent');
-      if (sa) {
-        sa.textContent =
-          ':root,[data-palette]{--accent:' + d.accent +
-          ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
-          ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
-      }
-    }
-    if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
-      document.documentElement.setAttribute('data-palette', d.palette);
-    }
-
-    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
-    // dit zodra deze handler klaar is.
-    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
-      document.body.setAttribute('data-js', d.js);
-    }
-
-    var next = d.bodyClass;
+    var next = ev.detail && ev.detail.bodyClass;
     if (!next) return;
-    // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
-    // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
-    // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
-    // de chrome/tab van de vorige pagina houden.
-    var matched = String(next).split(/\s+/).filter(function(c) {
+    // Server may send a multi-class string ("on-post extra"). Only the first
+    // page-class is what we care about — strip and replace.
+    var firstClass = String(next).split(/\s+/).find(function(c) {
       return PAGE_CLASSES.indexOf(c) >= 0;
     });
-    if (!matched.length) return;
+    if (!firstClass) return;
     PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
-    matched.forEach(function(c) { document.body.classList.add(c); });
+    document.body.classList.add(firstClass);
   });
-
-  // (Back/forward wordt afgehandeld door de popstate-listener in de link-boost
-  //  hieronder — die her-fetcht de partial via htmx.ajax, wat de OOB-chrome +
-  //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
-  //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
-})();
-</script>
-
-<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
-     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
-     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
-     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
-     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
-     wél de goede nonce heeft.
-
-     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
-     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
-<script>
-(function () {
-  if (window.__modBoot) return;
-  window.__modBoot = true;
-
-  // ?v=N — cache-buster voor ALLE modules in assets/js/mod, net als bij
-  // audio-player.js hierboven. BUMP BIJ ELKE WIJZIGING IN DIE MAP. Zonder
-  // query staat /assets op max-age=1y, en dan blijft een browser die de
-  // pagina eerder bezocht een jaar lang de oude module draaien: een
-  // reparatie bereikt precies de bezoekers die hem al hebben.
-  // 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 = <%= MOD_V %>;   // uit de EJS-variabele bovenaan; zie de importmap in de head
-
-  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
-  // die `init` exporteert draait die bij ELKE paginawissel waarop hij actief
-  // is -- dat is het gedrag van de oude inline scripts, die per render
-  // draaiden. Een module zonder init draait alleen zijn top-level, een keer.
-  var loaded = {};
-  function start(name, m) {
-    if (!m || typeof m.init !== 'function') return;
-    try { m.init(); } catch (e) { console.warn('[mod] ' + name + ' init:', e && e.message); }
-  }
-  function load() {
-    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
-    names.forEach(function (name) {
-      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
-      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
-      // streep.
-      if (!name || !/^[a-z0-9-]+$/.test(name)) return;
-      if (loaded[name]) { start(name, loaded[name]); return; }
-      loaded[name] = 1;
-      import('/assets/js/mod/' + name + '.js?v=' + MOD_V).then(function (m) {
-        loaded[name] = m;
-        start(name, m);
-      }).catch(function (e) {
-        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
-      });
-    });
-  }
-  load();
-  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
-  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
-  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
-  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
-  document.body.addEventListener('pcmsNav', load);
 })();
 </script>
@@ -577,61 +320,8 @@
 (function() {
   var body = document.body;
-
-  // DE CIRKEL HEEFT MAAR EEN WEERGAVE: Grid.
-  //
-  // Lezen is daar geen optie -- dat zijn berichten van ANDEREN, van hun servers,
-  // en "het hele stuk" hebben wij daar niet in handen. De knop staat er wel,
-  // maar dood (zie view-switcher.ejs). Er valt dus niets te kiezen en dus ook
-  // niets te onthouden: de eigen cirkel-voorkeur die hier stond
-  // ('pcms-cirkel-view') is vervallen, want hij kon nog maar een waarde hebben.
-  //
-  // Wat WEL blijft: de cirkel raakt je solo-keuze niet aan. Die staat in
-  // 'pcms-feed-view' en wordt hier nergens geschreven, zodat je thuiskomt in de
-  // weergave die je achterliet.
-  var CIRKEL_SLEUTEL = 'pcms-cirkel-view';
-  function opCirkel() { return body.classList.contains('on-cirkel'); }
-
-  /** Zet de weergave die bij DEZE pagina hoort. Draait bij init en bij elke nav. */
-  function pasWeergaveToe() {
-    try {
-      if (opCirkel()) {
-        // Lezen kan hier niet -- dat zijn berichten van ANDEREN, van hun servers,
-        // en "het hele stuk" hebben wij niet in handen. De knop staat er wel maar
-        // dood. Een TIJDLIJN kan hier juist bij uitstek: dat is de natuurlijke
-        // vorm van een cirkel.
-        //
-        // Beschikbaar, niet verplicht. Dit dwong eerst 'timeline' af zodra de
-        // site geen Lezen-site was, en dan kon je in de cirkel geen Grid meer
-        // kiezen. Nu is Grid de landing en is Tijdlijn een keuze ernaast.
-        var altC = body.dataset.feedAlt || 'reader';
-        var tijdlijnHier = altC === 'timeline'
-          || (altC === 'auto' && window.matchMedia('(min-width: 768px)').matches);
-        var magC = tijdlijnHier ? ['grid', 'timeline'] : ['grid'];
-        // Een EIGEN geheugen, en niet dat van solo. Twee redenen: hier kun je
-        // alleen uit Grid en Tijdlijn kiezen, dus een solo-keuze 'reader' zegt
-        // hier niets; en andersom mag een tuimeling hier je thuisweergave niet
-        // veranderen -- dan kom je thuis in iets anders dan je achterliet.
-        // (Dit geheugen stond er eerder al, verdween toen de cirkel nog maar een
-        // mogelijkheid had, en is nu weer nodig omdat Tijdlijn een echte keuze is.)
-        var vC = null;
-        try { vC = localStorage.getItem(CIRKEL_SLEUTEL); } catch (e) { /* geen opslag */ }
-        body.dataset.feedView = magC.indexOf(vC) >= 0 ? vC : 'grid';
-        return;
-      }
-      var v = localStorage.getItem('pcms-feed-view');
-      // 'timeline' is sinds 20-8 weer een geldige keuze, maar alleen op een site
-      // die hem AANBIEDT. Wie de tijdlijn koos op de ene site en daarna een
-      // Lezen-site bezoekt, moet daar niet op een weergave landen die er geen
-      // knop heeft: dan staat de pil grijs en is de feed niet wat hij koos.
-      // Vandaar de toets tegen wat deze site biedt, in plaats van een vaste
-      // vertaling naar 'reader'.
-      var alt = body.dataset.feedAlt || 'reader';
-      var mag = alt === 'auto' ? ['grid', 'reader', 'timeline'] : ['grid', alt];
-      if (mag.indexOf(v) >= 0) body.dataset.feedView = v;
-    } catch (e) { /* geen opslag: de server-standaard blijft staan */ }
-  }
-  pasWeergaveToe();
-
+  // Restore feed view + grid cols from localStorage (overrides server default)
   try {
+    var v = localStorage.getItem('pcms-feed-view');
+    if (v === 'timeline' || v === 'grid') 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);
@@ -639,20 +329,6 @@
 
   function syncAria() {
-    // Alleen op een feed-pagina hoort Tijdlijn/Grid 'actief' (wit) te zijn; op
-    // agenda/downloads/post/etc. beide grijs. Inline feed-check (FEED_PAGE_CLASSES
-    // staat verderop, maar deze functie draait al bij init).
-    var _feedC = ['on-home','on-tag','on-type','on-user','on-cirkel'];
-    var _onFeed = _feedC.some(function(c){ return body.classList.contains(c); });
-    // Een weergave die op DEZE pagina niet KAN, kan ook niet actief zijn. In de
-    // cirkel staat Lezen er wel maar uitgeschakeld; pasWeergaveToe() heeft
-    // feedView daar al op 'grid' gezet, dus de terugval hieronder vuurt er niet.
-    // Hij blijft staan voor het geval een pagina een knop mist: zonder terugval
-    // staat de hele pil grijs terwijl er wel degelijk iets te zien is, en dan
-    // ziet de lezer nergens meer waar hij is. De solo-VOORKEUR blijft intact.
-    var _view = String(body.dataset.feedView || 'reader');
-    if (!/^[a-z]+$/.test(_view)) _view = 'reader';
-    if (!document.querySelector('.view-switch-btn[data-view="' + _view + '"]')) _view = 'reader';
     document.querySelectorAll('.view-switch-btn').forEach(function(b) {
-      b.setAttribute('aria-selected', (_onFeed && !b.disabled && b.dataset.view === _view) ? 'true' : 'false');
+      b.setAttribute('aria-selected', b.dataset.view === body.dataset.feedView ? 'true' : 'false');
     });
     document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
@@ -661,13 +337,6 @@
   }
   syncAria();
-  // Re-sync after HTMX brings in new content (e.g. navigating back to home).
-  // pcmsNav vuurt ná de body-class-update (zie de pcmsNav-listener hierboven), dus
-  // dáár weet syncAria de juiste pagina-class — los van de afterSettle-timing.
-  // Een vertraagde herhaling wint eventuele resterende races (OOB-chrome-swap).
-  // Eerst de weergave die bij de NIEUWE pagina hoort, dan pas de pil bijwerken:
-  // van de cirkel naar huis en terug wisselt niet alleen de knop maar ook welke
-  // onthouden keuze telt.
-  document.body.addEventListener('htmx:afterSettle', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
-  document.body.addEventListener('pcmsNav', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
+  // Re-sync after HTMX brings in new content (e.g. navigating back to home)
+  document.body.addEventListener('htmx:afterSettle', syncAria);
 
   // Pages where the body actually has a feed to toggle. On these the
@@ -676,8 +345,5 @@
   // navigate to home in the chosen view, so the switcher is never
   // a dead control.
-  // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
-  // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
-  // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
-  var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
+  var FEED_PAGE_CLASSES = ['on-home', 'on-archive', 'on-tag', 'on-type', 'on-user'];
   function isFeedPage() {
     for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
@@ -691,19 +357,7 @@
     var sw = e.target.closest('.view-switch-btn');
     if (sw) {
-      // Een dode knop doet niets. Een <button disabled> vuurt in de meeste
-      // browsers geen click, maar deze listener hangt op document en vangt ook
-      // wat er langs de randen komt -- dus expliciet, niet op goed vertrouwen.
-      if (sw.disabled) return;
       var view = sw.dataset.view;
       body.dataset.feedView = view;
-      // Op de cirkel schrijven we NIETS. Daar is Grid de enige mogelijkheid, dus
-      // er valt niets te onthouden -- en je solo-keuze mag er zeker niet door
-      // veranderen: die moet je terugvinden zoals je hem achterliet.
-      try {
-        // De cirkel heeft zijn eigen geheugen, zodat een keuze daar je
-        // thuisweergave niet aanraakt en andersom.
-        if (opCirkel()) localStorage.setItem(CIRKEL_SLEUTEL, view);
-        else localStorage.setItem('pcms-feed-view', view);
-      } catch(_) {}
+      try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
       syncAria();
       // On non-feed pages the switcher acts as a navigation: take the
@@ -711,12 +365,9 @@
       // the page transition matches the rest of the site's nav.
       if (!isFeedPage()) {
-        // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
-        // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
-        var base = body.dataset.siteBase || '';
         if (window.htmx) {
-          window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
-          history.pushState({}, '', base + '/');
+          window.htmx.ajax('GET', '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
+          history.pushState({}, '', '/');
         } else {
-          location.href = base + '/';
+          location.href = '/';
         }
       }
@@ -730,175 +381,4 @@
     }
   });
-})();
-</script>
-
-<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
-     zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
-     bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
-     elke link los htmx te maken. Links die écht een volledige load nodig hebben
-     (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
-     net als links die al hun eigen hx-* hebben. -->
-<script>
-(function () {
-  if (!window.htmx) return;
-  // htmx' EIGEN history-afhandeling volledig uitzetten. We doen back/forward zelf
-  // via de popstate-listener hieronder (htmx.ajax → verwerkt de OOB-chrome netjes).
-  // Lieten we htmx z'n gang gaan, dan herstelde 'ie #pcms-main door de partial
-  // (inclusief de <div id=pcms-chrome hx-swap-oob>) ONGEFILTERD in #pcms-main te
-  // dumpen → een tweede, geneste kop = de pagina dubbel. Eén mechanisme nu.
-  try { window.htmx.config.historyEnabled = false; } catch (_) {}
-
-  function fullLoad(a, url) {
-    if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
-    if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
-    if (a.target && a.target !== '_self') return true;
-    if (a.getAttribute('rel') === 'external') return true;
-    var p = url.pathname;
-    // Sessie/redirect-acties → volledige navigatie (cookies, Google-redirect).
-    // Maar de auth-FORMULIERpagina's (/auth/admin, /auth/login, /auth/register,
-    // /auth/reset…) mogen wél via htmx, zodat de audiospeler blijft doorspelen
-    // i.p.v. te herstarten/verspringen bij een volledige page-load.
-    if (/^\/(logout|oauth)(?:\/|$)/.test(p)) return true;
-    if (/^\/auth\/(logout|google)(?:\/|$)/.test(p)) return true;
-    // Feeds, PWA, service-worker, statics, media-streams, downloads.
-    if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
-    if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
-    return false;
-  }
-
-  var lastPath = location.pathname + location.search;
-  var navTimer = null;
-
-  // Eén plek voor alle programmatische navigatie-swaps. Annuleert eerst een nog
-  // lopende request op #pcms-main (anti-race: bij snel klikken/terug-gaan kan een
-  // trage oude response anders een nieuwe pagina overschrijven → "kale content").
-  function doNav(dest) {
-    try { window.htmx.trigger('#pcms-main', 'htmx:abort'); } catch (_) {}
-    window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
-  }
-
-  document.addEventListener('click', function (e) {
-    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-    var a = e.target.closest('a[href]');
-    if (!a) return;
-    var href = a.getAttribute('href');
-    if (!href || href.charAt(0) === '#') return;
-    var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
-    if (url.origin !== location.origin) return;
-    if (fullLoad(a, url)) return;
-    e.preventDefault();
-    var dest = url.pathname + url.search;
-    if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
-    lastPath = dest;
-    if (navTimer) { clearTimeout(navTimer); navTimer = null; }
-    doNav(dest);
-    try { window.scrollTo(0, 0); } catch (_) {}
-  });
-
-  window.addEventListener('popstate', function () {
-    var here = location.pathname + location.search;
-    if (here === lastPath) return;
-    lastPath = here;
-    // Debounce: bij heel snel/herhaald terug-vooruit niet elke tussenpagina ophalen,
-    // alleen de LAATSTE bestemming. Voorkomt overlappende swaps ("kale content").
-    if (navTimer) clearTimeout(navTimer);
-    navTimer = setTimeout(function () {
-      navTimer = null;
-      doNav(location.pathname + location.search);
-    }, 90);
-  });
-
-  // Links MÉT eigen hx-get + hx-push-url (post-card/post-tile/topnav/…) lopen NIET
-  // via de boost hierboven, en hx-push-url is een no-op nu htmx-history uitstaat.
-  // Doe daarom de adresbalk-update hier zelf zodra htmx swapt. Programmatische
-  // htmx.ajax-calls (boost/popstate) hebben geen elt met hx-push-url → geen dubbel.
-  document.body.addEventListener('htmx:beforeRequest', function (evt) {
-    try {
-      var elt = evt.detail && evt.detail.elt;       // het TRIGGERENDE element (de link)
-      if (!elt || !elt.closest) return;
-      var node = elt.closest('[hx-push-url]');
-      if (!node) return;
-      var u = node.getAttribute('hx-push-url');
-      if (!u || u === 'false') return;
-      if (u !== (location.pathname + location.search)) history.pushState({ b: 1 }, '', u);
-      lastPath = u;
-    } catch (_) {}
-  });
-
-  // Spring naar boven na ELKE navigatie-swap van #pcms-main. De boost hierboven
-  // scrollt al, maar links met hun eigen hx-get (post-nav Newer/Older, post-kaarten)
-  // lopen NIET via de boost → zonder dit blijf je op de oude scrollpositie hangen
-  // bij het openen van een gerelateerde/volgende post. Alleen #pcms-main, zodat
-  // in-page swaps (comments e.d.) en de OOB-chrome-swap niet meescrollen.
-  document.body.addEventListener('htmx:afterSwap', function (evt) {
-    var t = evt.detail && evt.detail.target;
-    if (t && t.id === 'pcms-main') { try { window.scrollTo(0, 0); } catch (_) {} }
-  });
-})();
-</script>
-
-<!-- Mobiel toetsenbord vs. site-layout: zet body.kb-open zodra het toetsenbord
-     open is (visual viewport fors korter dan het venster) ÉN er een invoerveld
-     focus heeft. CSS verbergt dan de vaste onderbalken (bottom-tab + mini-speler)
-     zodat ze niet over het invoerveld zweven. -->
-<script>
-(function () {
-  var vv = window.visualViewport;
-  if (!vv) return;
-  function isField(el) {
-    if (!el) return false;
-    var t = el.tagName;
-    return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
-  }
-  function update() {
-    var open = (window.innerHeight - vv.height) > 150 && isField(document.activeElement);
-    document.body.classList.toggle('kb-open', open);
-  }
-  vv.addEventListener('resize', update);
-  vv.addEventListener('scroll', update);
-  document.addEventListener('focusin', function () { setTimeout(update, 60); });
-  document.addEventListener('focusout', function () { setTimeout(update, 60); });
-})();
-</script>
-
-<!-- Afbeeldingen lastiger op te slaan: rechtsklik-menu + slepen blokkeren op <img>.
-     Frictie, geen echte beveiliging (DevTools/screenshot blijven kunnen). -->
-<script>
-(function () {
-  document.addEventListener('contextmenu', function (e) {
-    if (e.target && e.target.tagName === 'IMG') e.preventDefault();
-  });
-  document.addEventListener('dragstart', function (e) {
-    if (e.target && e.target.tagName === 'IMG') e.preventDefault();
-  });
-})();
-</script>
-
-<!-- Auto-resize: elk <textarea> groeit mee met de inhoud i.p.v. intern te scrollen
-     (scroll-binnen-scroll is verwarrend). Site-breed; ook na htmx-swaps. -->
-<script>
-(function () {
-  function autoSize(ta) {
-    if (!ta || ta.tagName !== 'TEXTAREA') return;
-    // Skip hidden textareas (e.g. inside a closed <details> or an unopened reply
-    // box): measuring scrollHeight there yields a bad height that sticks as inline
-    // style and makes the field open huge. They get sized on focus once visible.
-    if (ta.offsetParent === null && ta.offsetHeight === 0) return;
-    ta.style.height = 'auto';
-    var maxH = parseFloat(getComputedStyle(ta).maxHeight);
-    var sh = ta.scrollHeight;
-    var h = (maxH && !isNaN(maxH)) ? Math.min(sh, maxH) : sh;   // respect a CSS max-height
-    ta.style.height = h + 'px';
-    ta.style.overflowY = sh > h ? 'auto' : 'hidden';
-  }
-  function sizeAll(root) {
-    (root || document).querySelectorAll('textarea').forEach(autoSize);
-  }
-  document.addEventListener('input', function (e) { autoSize(e.target); });
-  document.addEventListener('focusin', function (e) { autoSize(e.target); });
-  // Init + opnieuw na htmx-navigatie/partials.
-  sizeAll();
-  document.body.addEventListener('htmx:afterSettle', function () { sizeAll(); });
-  window.addEventListener('load', function () { sizeAll(); });
 })();
 </script>
@@ -929,128 +409,4 @@
 </script>
 
-<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
-     click reveals instead of following the card link or firing htmx navigation. -->
-<script>
-// Cover fade-in: a cover image that's still loading is hidden so the accent-gradient
-// placeholder behind it shows; it fades in once loaded. Cached/complete images stay
-// visible (no flash). Runs on load + htmx swaps.
-(function () {
-  if (window.__coverFadeWired) return; window.__coverFadeWired = true;
-  function scan(root) {
-    (root || document).querySelectorAll('img.grid-tile-img, .post-list-cover img, .tl-media-img img').forEach(function (img) {
-      if (img.dataset.fade) return; img.dataset.fade = '1';
-      if (img.complete && img.naturalWidth > 0) return;   // already loaded → leave visible
-      img.classList.add('is-loading');
-      var done = function () { img.classList.remove('is-loading'); };
-      img.addEventListener('load', done, { once: true });
-      img.addEventListener('error', done, { once: true });
-    });
-  }
-  scan(document);
-  document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
-})();
-
-// Light anti-grab friction: suppress the right-click menu on visual media (covers, images,
-// videos) so the art isn't one right-click away from "Save as". Friction, NOT protection —
-// the files are public and reachable via devtools/network. Middle/Ctrl-click (open in new
-// tab) still works; only the context menu is blocked. Delegated → covers htmx-swapped content.
-(function () {
-  if (window.__noMediaCtxWired) return; window.__noMediaCtxWired = true;
-  document.addEventListener('contextmenu', function (e) {
-    if (e.target.closest('img, video, .grid-tile, .post-list-cover, .post-cover, .tl-media-img')) {
-      e.preventDefault();
-    }
-  });
-})();
-
-// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
-// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
-// other browser keeps the crisp WebP. Runs on load + htmx swaps.
-(function () {
-  if (window.__iosVideoWired) return; window.__iosVideoWired = true;
-  var ua = navigator.userAgent || '';
-  var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
-               (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
-  if (!IS_IOS) return;
-  function swap(root) {
-    (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
-      var mp4 = img.getAttribute('data-ios-mp4');
-      if (!mp4 || img.dataset.iosSwapped) return;
-      img.dataset.iosSwapped = '1';
-      var v = document.createElement('video');
-      v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
-      v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
-      v.poster = img.getAttribute('src') || '';
-      v.className = img.className;
-      if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
-      if (img.parentNode) img.parentNode.replaceChild(v, img);
-      var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
-    });
-  }
-  swap(document);
-  document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
-})();
-</script>
-
-<script>
-(function () {
-  if (window.__nsfwWired) return; window.__nsfwWired = true;
-  document.addEventListener('click', function (e) {
-    var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
-    if (!hit) return;
-    e.preventDefault(); e.stopPropagation();
-    var box = hit.closest('.nsfw-media, .nsfw-gate');
-    if (box) box.classList.add('is-shown');
-  }, true);
-})();
-</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],[data-share]');
-    if (!el) return;
-    if (el.dataset.selectall !== undefined && el.select) el.select();
-    if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
-    if (el.dataset.share !== undefined) {
-      e.preventDefault();
-      var url = location.href, title = el.dataset.shareTitle || document.title;
-      if (navigator.share) { navigator.share({ title: title, url: url }).catch(function () {}); }
-      else if (navigator.clipboard && navigator.clipboard.writeText) {
-        navigator.clipboard.writeText(url).then(function () {
-          var fb = document.getElementById('post-share-feedback');
-          if (fb) { fb.hidden = false; setTimeout(function () { fb.hidden = true; }, 2000); }
-        }).catch(function () { window.prompt('Copy link:', url); });
-      } else { window.prompt('Copy link:', url); }
-    }
-  });
-  // 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) { %>
