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>
