Index: src/assets/.well-known/assetlinks.json
===================================================================
--- src/assets/.well-known/assetlinks.json	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/assets/.well-known/assetlinks.json	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,12 @@
+[
+    {
+        "relation": ["delegate_permission/common.handle_all_urls"],
+        "target": {
+            "namespace": "android_app",
+            "package_name": "com.roboburr.app",
+            "sha256_cert_fingerprints": [
+                "92:2F:55:C4:36:4C:8D:DA:99:0C:63:18:BA:78:A3:76:AD:B9:AD:77:E5:AA:68:54:16:FE:FB:28:62:11:EB:DB"
+            ]
+        }
+    }
+]
Index: src/assets/css/audio.css
===================================================================
--- src/assets/css/audio.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/css/audio.css	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,3 +1,3 @@
-/* Klonkt — Audio CSS
+/* PrutCMS v10 — Audio CSS
    Two pieces:
      1. .post-audio-track — the play-button block inserted by [[track:id]] shortcode
@@ -73,52 +73,4 @@
   text-overflow: ellipsis;
 }
-/* Eigenaar/credit · licentie — subtiele regel onder de track. */
-.post-audio-track .pat-credit {
-  font-size: 0.72rem;
-  color: var(--ink-faint, var(--ink-soft));
-  margin-top: 0.1rem;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-/* "Open in" Spotify/YouTube/SoundCloud — kleine brand-iconen per track. */
-.pat-links {
-  display: inline-flex; align-items: center; gap: 0.1rem;
-  flex: 0 0 auto;
-}
-/* Single + album-rij (.post-audio-track is flex) → duw de links naar rechts. */
-.post-audio-track .pat-links { margin-left: auto; padding-right: 0.5rem; }
-/* Playlist-rij: de li flex maken zodat de links naast de afspeel-rij passen. */
-.post-album-track-compact { display: flex; align-items: center; }
-.post-album-track-compact .pat-row { flex: 1 1 auto; min-width: 0; }
-.post-album-track-compact .pat-links { padding-right: 0.6rem; }
-.pat-link {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 26px; height: 26px;
-  opacity: 0.75;
-  transition: opacity 120ms, transform 120ms;
-}
-.pat-link svg { width: 17px; height: 17px; }
-.pat-link:hover { opacity: 1; transform: scale(1.12); }
-.pat-link--spotify    { color: #1DB954; }
-.pat-link--youtube    { color: #FF0000; }
-.pat-link--soundcloud { color: #FF5500; }
-
-/* Link-only tracks (geen audiobestand): plek van de afspeelknop = stil muziek-icoon. */
-.pat-noplay {
-  display: inline-flex; align-items: center; justify-content: center;
-  flex: 0 0 38px; width: 38px; align-self: center;
-  color: var(--ink-faint, #a8a29e);
-}
-.pat-noplay svg { width: 18px; height: 18px; }
-/* Cover-thumbnail op een link-only track (i.p.v. het muzieknootje). */
-.pat-noplay--cover {
-  width: 64px; height: 64px; flex: 0 0 64px;
-  border-radius: 8px;
-  background-size: cover; background-position: center;
-  margin: 0 .5rem 0 0;
-}
-.pat-row.pat-static { cursor: default; }
 
 /* ============================================================
@@ -151,13 +103,4 @@
   }
 }
-/* Het auth/login-focusscherm rendert GEEN bottom-tab (zie shell.ejs), maar de body
-   houdt wel de has-bottom-tab-class → zonder dit zou de mini-player 56px boven de
-   onderrand zweven boven een niet-bestaande tab. Reset naar bottom:0 (alle breedtes;
-   op desktop is 't sowieso al 0). Robuuste class-selector — geen :has() in deze
-   kritieke regel. */
-body.on-auth .audio-player {
-  bottom: 0;
-  padding-bottom: env(safe-area-inset-bottom, 0);
-}
 [data-theme="dark"] .audio-player {
   background: color-mix(in srgb, var(--paper, #1a1a1a) 92%, #fff);
@@ -168,18 +111,4 @@
   opacity: 0;
   pointer-events: none;
-}
-
-/* Browser-autoplay-policy heeft de auto-advance gestopt — typisch
-   na 3-4 tracks op iOS Safari. Visuele hint dat user op play moet
-   tappen om door te gaan (PCMS v10.1, audio-player.js). */
-.audio-player.audio-needs-tap .audio-btn-play {
-  animation: audio-pulse-tap 1.2s ease-in-out infinite;
-  background: rgba(255, 165, 0, 0.18);
-  border-color: orange;
-  color: orange;
-}
-@keyframes audio-pulse-tap {
-  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.5); }
-  50%      { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
 }
 .audio-player-inner {
@@ -205,10 +134,4 @@
 .audio-player-expand-trigger:hover {
   background: color-mix(in srgb, var(--ink, #000) 6%, transparent);
-}
-/* DESKTOP (≥1200px): geen full player — de expand-trigger opent niets (JS),
-   dus 'm niet als klikbaar laten ogen. */
-@media (min-width: 1200px) {
-  .audio-player-expand-trigger { cursor: default; }
-  .audio-player-expand-trigger:hover { background: none; }
 }
 
@@ -413,6 +336,6 @@
 body.audio-sheet-locked { overflow: hidden; }
 
-/* TELEFOON (<768px): full-width sheet, slide up from bottom */
-@media (max-width: 767.98px) {
+/* MOBILE (<720px): full-width sheet, slide up from bottom */
+@media (max-width: 719.98px) {
   .audio-sheet-panel {
     /* Real full-screen, edge-to-edge, no rounded corners or shadow */
@@ -434,6 +357,22 @@
 }
 
-/* (De grote tablet/auto-variant staat onderaan deze sectie — NÁ de basis cover/
-   info/controls-regels — anders overschrijft de basis die overrides.) */
+/* DESKTOP (≥720px): 480px wide, horizontally centered (v9 design intent).
+   We MUST set width here because v9's style.css sets width:480px and
+   we don't want left/right:0 stretching to override it.
+   The -50% horizontal centering is preserved during drag by composing
+   --pcms-drag-y into the existing translate(-50%, ...) transform. */
+@media (min-width: 720px) {
+  .audio-sheet-panel {
+    left: 50%;
+    right: auto;
+    width: 480px;
+    transform: translate(-50%, 100%);
+    border-top-left-radius: 20px;
+    border-top-right-radius: 20px;
+  }
+  .audio-sheet.is-open .audio-sheet-panel {
+    transform: translate(-50%, var(--pcms-drag-y, 0px));
+  }
+}
 
 .audio-sheet-panel.is-dragging {
@@ -583,48 +522,4 @@
 }
 
-/* TABLET / AUTO (768–1199px): grote, landscape, knop-vriendelijke full-player
-   voor tablets + een tablet-in-de-auto. Bewust full-screen + grote touch-targets.
-   STAAT BEWUST NA de basis-sheet-regels hierboven, anders overschrijft de basis
-   deze overrides (cascade-volgorde). Desktop (≥1200px) opent de full player NIET
-   (afgehandeld in audio-player.js), dus daar is geen sheet-styling nodig. */
-@media (min-width: 768px) and (max-width: 1199.98px) {
-  .audio-sheet-panel {
-    inset: 0; left: 0; right: 0; width: auto;
-    max-height: none;
-    border-radius: 0;
-    box-shadow: none;
-    transform: translateY(100%);
-    padding: clamp(2rem, 5vh, 4rem) clamp(2rem, 6vw, 5rem);
-    gap: clamp(1.25rem, 3vh, 2.5rem);
-    /* 'safe center': verticaal centreren als alles past, maar bovenaan uitlijnen
-       (geen clip) zodra cover + queue hoger zijn dan het scherm — anders wordt de
-       cover boven de viewport geduwd en is 'ie niet te scrollen. */
-    justify-content: safe center;
-    align-items: center;
-  }
-  .audio-sheet.is-open .audio-sheet-panel { transform: translateY(var(--pcms-drag-y, 0px)); }
-
-  .audio-sheet-drag-zone { align-items: center; width: 100%; margin: 0; padding: 0; }
-  .audio-sheet-cover { width: min(48vh, 460px); max-width: none; border-radius: 20px; }
-  .audio-sheet-cover svg { width: 26%; height: 26%; }
-  .audio-sheet-info { width: 100%; max-width: 660px; }
-  .audio-sheet-title  { font-size: clamp(2rem, 4vw, 3rem); }
-  .audio-sheet-artist { font-size: clamp(1.1rem, 2vw, 1.5rem); margin-top: .5rem; }
-  .audio-sheet-album  { font-size: 1rem; margin-top: .25rem; }
-  .audio-sheet-progress { width: 100%; max-width: 660px; gap: 1.25rem; }
-  .audio-sheet-progress .audio-time { font-size: 1.05rem; min-width: 3.2em; }
-  .audio-sheet-controls { gap: clamp(1.5rem, 5vw, 3.5rem); }
-  .audio-sheet-btn { width: 76px; height: 76px; }
-  .audio-sheet-btn svg { width: 34px; height: 34px; }
-  .audio-sheet-play { width: 108px; height: 108px; }
-  .audio-sheet-play svg { width: 46px; height: 46px; }
-  /* Queue dezelfde breedte als info/progress (anders krimpt 'ie door
-     align-items:center tot z'n inhoud = smal), + grotere rijen voor touch. */
-  .audio-sheet-queue { width: 100%; max-width: 660px; }
-  .audio-sheet-queue-label { font-size: 0.95rem; }
-  .audio-sheet-queue-item { padding: 0.7rem 0.7rem; gap: 0.6rem; font-size: 1.05rem; }
-  .audio-sheet-queue-item .aqi-artist { font-size: 0.95rem; }
-}
-
 /* ============================================================
    External-link variant ([[link:url]])
Index: src/assets/css/embed.css
===================================================================
--- src/assets/css/embed.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,284 +1,0 @@
-/* ============================================================
- * embed.css — eigen, in-huisstijl media-embeds (embed-player.js)
- * YouTube/SoundCloud = volledig eigen controls; Spotify = onze frame + hun UI.
- * Gebruikt de site-tokens (--paper/--ink/--accent/--rule/--radius) → past zich
- * automatisch aan elk thema (light/dark/varianten) aan.
- * ============================================================ */
-
-.folio-embed.pcms-embed-card {
-  margin: 2rem 0;
-  border: 1px solid var(--rule, #e8e2d5);
-  border-radius: var(--radius, 8px);
-  background: var(--paper-2, #f2ede0);
-  overflow: hidden;
-  position: relative;
-}
-
-/* Placeholder vóór embed-player.js de kaart opbouwt (of als JS uitstaat). */
-.pcms-embed-card.pcms-embed-loading {
-  min-height: 64px;
-}
-/* Fallback-tekst zolang JS de kaart nog niet heeft opgebouwd (of als JS uitstaat).
- * buildCard() haalt .pcms-embed-loading weg → deze ::after verdwijnt vanzelf. */
-.pcms-embed-card.pcms-embed-loading::after {
-  content: "Speler laden\2026";
-  display: block;
-  padding: 1.25rem 1.5rem;
-  color: var(--ink-faint, #a8a29e);
-  font-size: .85rem;
-  font-family: var(--font-mono, monospace);
-}
-
-/* ---- Video-stage (YouTube): 16:9 ---- */
-.pcms-embed-stage {
-  position: relative;
-  width: 100%;
-  aspect-ratio: 16 / 9;
-  background: #000;
-}
-.pcms-embed-stage .pcms-embed-mount,
-.pcms-embed-stage iframe {
-  position: absolute;
-  inset: 0;
-  width: 100% !important;
-  height: 100% !important;
-  border: 0;
-}
-.pcms-embed-poster {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  border: 0;
-  padding: 0;
-  cursor: pointer;
-  background-size: cover;
-  background-position: center;
-  background-color: #000;
-  display: grid;
-  place-items: center;
-  transition: opacity var(--transition, 180ms);
-}
-.pcms-embed-card.is-mounted .pcms-embed-poster { display: none; }
-
-/* ---- Audio-layout (SoundCloud / Spotify-facade) ---- */
-.pcms-embed-audio {
-  display: flex;
-  align-items: stretch;
-  gap: 0;
-  min-height: 96px;
-}
-.pcms-embed-art {
-  flex: 0 0 96px;
-  width: 96px;
-  height: 96px;
-  border: 0;
-  padding: 0;
-  cursor: pointer;
-  background: var(--paper, #faf8f3) center / cover no-repeat;
-  display: grid;
-  place-items: center;
-  color: var(--ink, #1a1a1a);
-}
-.pcms-embed-art.has-art .pcms-embed-bigplay {
-  background: rgba(0,0,0,.45);
-  border-radius: 50%;
-}
-.pcms-embed-info {
-  flex: 1 1 auto;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  gap: .2rem;
-  padding: .75rem 1rem;
-}
-.pcms-embed-info .pcms-embed-title {
-  font-weight: 600;
-  font-size: .95rem;
-  color: var(--ink, #1a1a1a);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.pcms-embed-info .pcms-embed-sub {
-  font-size: .8rem;
-  color: var(--ink-muted, #6b6560);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-/* SoundCloud-iframe: functioneel maar onzichtbaar (we sturen via de Widget-API
- * en tonen onze eigen balk). Off-screen i.p.v. display:none zodat de widget
- * betrouwbaar initialiseert + audio blijft spelen. */
-.pcms-embed-card--soundcloud .pcms-embed-mount {
-  position: absolute;
-  left: -99999px;
-  top: 0;
-  width: 320px;
-  height: 60px;
-  opacity: 0;
-  pointer-events: none;
-}
-
-/* Spotify: hun iframe IS de speler → tonen. Onze facade (art+info) verdwijnt
- * zodra de controller gemount is. */
-.pcms-embed-card--spotify .pcms-embed-mount { display: block; width: 100%; }
-.pcms-embed-card--spotify .pcms-embed-mount iframe {
-  width: 100% !important;
-  border: 0;
-  display: block;
-}
-.pcms-embed-card--spotify.is-mounted .pcms-embed-art,
-.pcms-embed-card--spotify.is-mounted .pcms-embed-info { display: none; }
-.pcms-embed-card--spotify:not(.is-mounted) .pcms-embed-mount { display: none; }
-
-/* Spotify in een nette witte box (Robin 2026-06-15): witte rand + rounded +
- * schaduw. Hogere specificity (.folio-embed.pcms-embed-card--spotify) zodat dit
- * de generieke .folio-embed.pcms-embed-card-basis (gelijke specificity) wint —
- * staat ook later in het bestand. */
-.folio-embed.pcms-embed-card--spotify {
-  border: 3px solid #ffffff;          /* witte rand blijft */
-  border-radius: 16px;
-  box-shadow: 0 4px 18px rgba(0, 0, 0, .18);
-  /* bg + overflow:hidden erven van de basis-kaart → de binnenste box krijgt
-   * weer de thema-kleur (var(--paper-2)) i.p.v. wit; facade-tekst gebruikt de
-   * gewone thema-tokens en blijft dus leesbaar. */
-}
-
-/* ---- Grote play-knop (poster/art) ---- */
-.pcms-embed-bigplay {
-  width: 64px;
-  height: 64px;
-  display: grid;
-  place-items: center;
-  border-radius: 50%;
-  background: var(--accent, #c2410c);
-  color: #fff;
-  box-shadow: 0 4px 16px rgba(0,0,0,.35);
-  transition: transform var(--transition, 180ms);
-}
-.pcms-embed-art .pcms-embed-bigplay { width: 44px; height: 44px; }
-.pcms-embed-bigplay svg { width: 42%; height: 42%; }
-.pcms-embed-poster:hover .pcms-embed-bigplay,
-.pcms-embed-art:hover .pcms-embed-bigplay { transform: scale(1.08); }
-
-/* ---- Onze controlebalk (YouTube/SoundCloud) ---- */
-.pcms-embed-bar {
-  display: flex;
-  align-items: center;
-  gap: .65rem;
-  padding: .55rem .75rem;
-  border-top: 1px solid var(--rule, #e8e2d5);
-  background: var(--paper, #faf8f3);
-}
-.pcms-embed-pp {
-  flex: 0 0 auto;
-  width: 38px;
-  height: 38px;
-  border: 0;
-  border-radius: 50%;
-  cursor: pointer;
-  background: var(--accent, #c2410c);
-  color: #fff;
-  display: grid;
-  place-items: center;
-}
-.pcms-embed-pp svg { width: 18px; height: 18px; }
-.pcms-embed-cur, .pcms-embed-dur {
-  flex: 0 0 auto;
-  font-size: .75rem;
-  color: var(--ink-muted, #6b6560);
-  font-variant-numeric: tabular-nums;
-  min-width: 2.6em;
-  text-align: center;
-}
-/* Volume: mute-knop + compacte schuif (alleen YT/SC — eigen controls). */
-.pcms-embed-mute {
-  flex: 0 0 auto;
-  width: 28px; height: 28px;
-  border: 0; background: none; padding: 0;
-  color: var(--ink-muted, #6b6560);
-  cursor: pointer;
-  display: grid; place-items: center;
-}
-.pcms-embed-mute svg { width: 18px; height: 18px; }
-.pcms-embed-vol {
-  flex: 0 0 auto;
-  width: 64px;
-  cursor: pointer;
-  accent-color: var(--accent, #c2410c);
-}
-@media (max-width: 480px) {
-  /* Smal scherm: schuif weg, alleen de mute-knop houden (ruimte). */
-  .pcms-embed-vol { display: none; }
-}
-.pcms-embed-seek {
-  position: relative;
-  flex: 1 1 auto;
-  height: 16px;
-  cursor: pointer;
-}
-.pcms-embed-seek::before {
-  content: "";
-  position: absolute;
-  left: 0; right: 0; top: 50%;
-  transform: translateY(-50%);
-  height: 4px;
-  border-radius: 2px;
-  background: var(--rule-2, #d4ccb8);
-}
-.pcms-embed-seek-fill {
-  position: absolute;
-  left: 0; top: 50%;
-  transform: translateY(-50%);
-  height: 4px;
-  width: 0%;
-  border-radius: 2px;
-  background: var(--accent, #c2410c);
-  pointer-events: none;
-  z-index: 1;
-}
-.pcms-embed-badge {
-  flex: 0 0 auto;
-  font-size: .68rem;
-  letter-spacing: .04em;
-  text-transform: uppercase;
-  color: var(--ink-faint, #a8a29e);
-  text-decoration: none;
-}
-.pcms-embed-badge:hover { color: var(--accent, #c2410c); }
-
-/* Provider-accent op de badge */
-.pcms-embed-card--youtube .pcms-embed-badge { color: #ff0033; }
-.pcms-embed-card--soundcloud .pcms-embed-badge { color: #ff5500; }
-
-/* ---- Spotify-frame-badge (geen eigen balk) ---- */
-.pcms-embed-frame-badge {
-  padding: .4rem .75rem;
-  border-top: 1px solid var(--rule, #e8e2d5);
-  background: var(--paper, #faf8f3);
-  font-size: .68rem;
-  letter-spacing: .04em;
-  text-transform: uppercase;
-}
-.pcms-embed-frame-badge a { color: #1db954; text-decoration: none; }
-.pcms-embed-frame-badge a:hover { text-decoration: underline; }
-.pcms-embed-card--spotify.is-mounted .pcms-embed-frame-badge { display: none; }
-
-/* ---- Busy / error ---- */
-.pcms-embed-card.pcms-embed-busy { cursor: progress; }
-.pcms-embed-card.pcms-embed-error .pcms-embed-sub { color: #c0392b; }
-
-.pcms-embed-bigplay,
-.pcms-embed-pp,
-.pcms-embed-poster { -webkit-tap-highlight-color: transparent; }
-
-/* ---- Mobile / touch: plain platform iframe instead of the JS-mounted card.
-   padding-ratio reserves height on every browser (incl. old iOS Safari). ---- */
-.pcms-embed-plain { background: transparent; box-shadow: none; }
-.pcms-embed-ratio { position: relative; width: 100%; padding-bottom: 56.25%; height: 0; }
-.pcms-embed-ratio iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: 10px; }
-.pcms-embed-plain-frame { width: 100%; border: 0; display: block; border-radius: 10px; }
-.pcms-embed-plain-link { display: inline-block; padding: .5rem .9rem; }
Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,293 +1,0 @@
-/* Guardian PWA (FEP-633c): its own calm, dark surface; the buoy orange is
-   the accent. Standalone: this page never inherits the site theme. */
-:root {
-  --bg: #141a24;
-  --card: #1e2632;
-  --ink: #e8ecf2;
-  --sub: #93a0b4;
-  --line: #2c3646;
-  --accent: #ff6b35;
-  --ok: #4caf7d;
-  --danger: #e05a5a;   /* alleen voor onomkeerbare stappen, zoals een ward loslaten */
-}
-* { box-sizing: border-box; }
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--ink);
-  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-  padding-bottom: 48px;
-}
-.g-head {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 16px;
-  background: var(--card);
-  border-bottom: 1px solid var(--line);
-  position: sticky;
-  top: 0;
-  z-index: 2;
-}
-.g-head-txt { flex: 1; }
-.g-head h1 { font-size: 1.15rem; margin: 0; }
-.g-head .g-sub { margin: 0; color: var(--sub); font-size: .78rem; }
-.g-buoy { font-size: 1.7rem; }
-.g-me { color: var(--sub); font-size: .85rem; }
-#site-picker {
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
-}
-main { max-width: 640px; margin: 0 auto; padding: 8px 16px; }
-
-section { padding: 18px 0; border-bottom: 1px solid var(--line); }
-section:last-child { border-bottom: 0; }
-.g-sec-head { display: flex; align-items: center; gap: 8px; }
-.g-sec-head h2 { font-size: 1.02rem; margin: 0; }
-.g-badge {
-  background: var(--accent); color: #fff; font-size: .72rem; font-weight: 700;
-  min-width: 20px; text-align: center; border-radius: 10px; padding: 1px 7px;
-}
-.g-sec-sub { color: var(--sub); font-size: .84rem; margin: 4px 0 12px; }
-.g-empty { color: var(--sub); font-size: .9rem; margin: 6px 0 0; }
-.g-msg { font-size: .85rem; color: var(--ok); margin: 8px 0 0; }
-.g-msg.err { color: #ff7a7a; }
-.g-list { display: flex; flex-direction: column; gap: 8px; }
-
-.g-card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
-.g-card.help { border-left: 3px solid var(--accent); }
-.g-card .row { display: flex; align-items: center; gap: 10px; }
-.g-card .row .grow { flex: 1; min-width: 0; }
-.g-card .who { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.g-card .when { color: var(--sub); font-size: .76rem; white-space: nowrap; }
-.g-card .body { margin-top: 8px; font-size: .92rem; overflow-wrap: anywhere; }
-.g-card .body img { max-width: 100%; border-radius: 8px; margin-top: 6px; }
-.g-link { display: inline-block; margin-top: 8px; color: var(--accent); font-size: .82rem; text-decoration: none; }
-
-.tag { font-size: .72rem; font-weight: 600; padding: 3px 9px; border-radius: 20px; white-space: nowrap; }
-.tag.wait { background: #3a2f1a; color: #e8b04b; }
-.tag.ok { background: #17301f; color: var(--ok); }
-.tag.co { background: #2a1f3a; color: #c39bff; }
-
-#adopt-form { display: flex; gap: 8px; }
-#adopt-form input {
-  flex: 1; min-width: 0;
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 10px; padding: 11px 12px;
-}
-button {
-  background: var(--accent); color: #fff; border: 0;
-  border-radius: 10px; padding: 11px 18px; font-weight: 600; cursor: pointer;
-}
-button:disabled { opacity: .5; cursor: default; }
-button.quiet { background: transparent; color: var(--sub); border: 1px solid var(--line); font-weight: 500; }
-button.quiet.is-on { color: var(--ok); border-color: var(--ok); }
-button.small { padding: 6px 12px; font-size: .82rem; }
-
-/* Wards' corner (v2 meekijken) */
-.g-card.feed .g-when { color: var(--muted, #888); font-size: .8em; margin-left: auto; }
-.g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
-.g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
-
-/* ── The body of a post ────────────────────────────────────────────────────
-   The 🛟 card shows a real post, so it is rendered by the same partial de
-   Krant and Berichten use (partials/note-body). These are that partial's
-   classes, in the PWA's own colours: the dashboard is standalone and does not
-   load the site's stylesheet. */
-.g-note .tl-content { line-height: 1.5; overflow-wrap: anywhere; }
-.g-note .tl-content p { margin: .35rem 0; }
-.g-note .tl-content p:first-child { margin-top: 0; }
-.g-note .tl-content p:last-child { margin-bottom: 0; }
-.g-note .tl-content a { color: var(--accent); }
-.g-note img.emoji { height: 1.25em; width: auto; margin: 0 .05em; vertical-align: -.2em;
-  display: inline-block; border-radius: 0; background: none; }
-
-/* Quote card and link preview: one card, two origins. */
-.g-note .tl-quote { margin: 8px 0 0; padding: 8px 10px; border-radius: 10px;
-  border: 1px solid var(--line); background: var(--card-2, rgba(128,128,128,.07)); }
-.g-note .tl-quote-head { display: flex; align-items: center; gap: 6px; margin: 0 0 4px; min-width: 0; }
-.g-note .tl-quote-avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
-.g-note .tl-quote-name { font-weight: 600; font-size: .82rem; white-space: nowrap; }
-.g-note .tl-quote-handle { color: var(--sub); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
-.g-note .tl-quote-body { color: var(--sub); font-size: .88rem; line-height: 1.45; max-height: 14em; overflow: hidden; }
-.g-note .tl-quote-media { display: block; margin: 6px 0 0; }
-
-/* The capture a 🛟 usually carries. */
-.g-note .tl-media { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
-.g-note .tl-media-img { display: block; border-radius: 10px; overflow: hidden; }
-.g-note .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; }
-.g-note .tl-media-video, .g-note .tl-media-audio { width: 100%; margin: 8px 0 0; border-radius: 10px; display: block; }
-.g-note .tl-media-video { max-height: 320px; background: #000; }
-
-/* ── Paneel per ward ───────────────────────────────────────────────────────
-   Een guardian denkt per kind, niet per functie: achter de regel van een kind
-   zit alles over dat kind. De regel zelf draagt de tellers, zodat een dicht
-   paneel nooit iets verbergt dat een antwoord nodig heeft. */
-.g-card.ward > .row { gap: 8px; }
-.g-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); }
-/* HIDDEN MOET ALTIJD WINNEN, van elke display-regel hierboven of hieronder.
-   Dit stond eerst alleen op .g-panel, en toen de poortenlijst `display: grid`
-   kreeg klapte die niet meer dicht -- Barts melding (9-8): "die is er onderuit
-   gekomen". Ook het geschiedenislogje deelde die klasse en stond dus permanent
-   open.
-
-   Een auteursregel slaat de browserstijl `[hidden] { display: none }` ongeacht
-   specificiteit, dus elke nieuwe `display:` in dit bestand zou de fout opnieuw
-   maken. Dit is de zeldzame plek waar !important het juiste gereedschap is: de
-   regel hoort per definitie boven alles te staan. */
-[hidden] { display: none !important; }
-.g-panel-sec + .g-panel-sec { margin-top: 16px; }
-.g-panel-sec h3 { margin: 0 0 6px; font-size: .82rem; font-weight: 600; letter-spacing: .03em;
-  text-transform: uppercase; color: var(--sub); }
-.g-panel-sec .g-card { background: transparent; border-color: var(--line); }
-.g-panel-sec .g-card + .g-card { margin-top: 6px; }
-.g-panel-sec .row { flex-wrap: wrap; }
-.g-empty.small { margin: 0; font-size: .85rem; }
-.tag.help { color: var(--accent); border-color: var(--accent); }
-
-/* Loslaten van een ward: geen browser-confirm maar een stap in het paneel, met
-   de gevolgen erbij. Rustig van kleur; het is een besluit, geen alarm. */
-.g-warn { margin-top: 10px; padding: 12px 14px; border-radius: 10px;
-  border: 1px solid var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); }
-.g-warn strong { display: block; margin-bottom: 6px; }
-.g-warn p { margin: 0 0 8px; font-size: .88rem; line-height: 1.45; color: var(--sub); }
-.g-warn p.grave { color: var(--ink); font-weight: 500; }
-.g-warn .row { margin-top: 10px; gap: 8px; }
-button.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
-
-/* ── Beschikbaarheid (FEP-633c 3.6) ────────────────────────────────────────
-   De buddy-list-stip op de verantwoordelijkheids-as: groen beschikbaar, geel
-   afwezig-verklaard met een einde, grijs slapend-geconstateerd (een antwoord
-   herstelt alles). Labels zijn UI; de draad houdt de spec-termen. */
-.g-avdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: 0 0 auto; }
-.g-avdot.is-active { background: var(--ok); }
-.g-avdot.is-away { background: #e0a83a; }
-.g-avdot.is-dormant { background: #8a93a3; }
-.g-avlabel { color: var(--sub); font-size: .8rem; white-space: nowrap; }
-.row.g-guard { gap: 8px; margin-top: 6px; }
-/* The status of a proposal this guardian sent (5.6): one quiet line per
-   feature. The colour says the state before the words do. */
-.g-prop { margin: 6px 0 0; color: var(--sub); }
-.g-prop-accepted { color: var(--ok); }
-.g-prop-rejected, .g-prop-expired { color: var(--danger); }
-.g-away-row { display: flex; gap: 8px; }
-.g-card.lapse { border-left: 3px solid #8a93a3; }
-.g-card.lapse .row { margin-top: 8px; gap: 8px; }
-
-/* Een voorgestelde gated setting (FEP-633c 5.6) van een mede-guardian. */
-.g-card.gated { border-left: 3px solid var(--accent); }
-.g-card.gated .row { margin-top: 8px; gap: 8px; }
-
-/* Het gate-paneel per ward (shaer-ahy.1). Een rij per gate, met het soort en de
-   drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna
-   een plek om iets voor te stellen. */
-.g-gate { padding: .5rem 0; border-top: 1px solid var(--g-line, rgba(0,0,0,.08)); }
-.g-gate:first-of-type { border-top: 0; }
-.g-gate-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
-.g-gate-name { font-weight: 600; }
-/* Het soort is een fluistering, geen kop: het hoort erbij te staan zonder de
-   naam van de gate te overstemmen. */
-.g-gate-kind { font-size: .78em; opacity: .65; }
-.g-gate-meta { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .15rem; }
-.g-gate-meta .g-dim { opacity: .55; }
-/* Onomkeerbaar krijgt gewicht, want dat is het enige in dit paneel dat je niet
-   kunt terugdraaien. */
-.g-gate-warn { font-weight: 600; }
-.g-gate-wait { font-weight: 600; }
-
-/* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm,
-   het moet alleen onmogelijk te missen zijn wie er al op af is. */
-.g-help-state { margin-top: .5rem; }
-.g-help-pick { font-size: .9em; }
-.g-help-age { opacity: .6; }        /* oud, maar niet weg */
-.g-help-done { font-weight: 600; }
-.g-confirm { margin-top: .5rem; }
-
-/* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */
-.g-help-archive { margin-top: .5rem; opacity: .75; }
-
-/* De geschiedenis onderaan een wardpaneel: aanwezig, duidelijk afgesloten, en
-   visueel losgemaakt van wat nog wacht. */
-.g-panel-history { border-top: 2px solid var(--g-line, rgba(0,0,0,.12)); margin-top: 1rem; opacity: .8; }
-
-/* ── Het poortenpaneel als cockpit (shaer-ahy.1, Barts richting 8-8) ──
-   Elke gate is een toets zoals op een aircopaneel: lampje links, naam erop,
-   en een klein verzonken statusveld met de stand als tekst. Het lampje en het
-   veld zeggen hetzelfde in twee vormen -- kleur alleen is geen antwoord. */
-.g-gates { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .5rem; margin: .35rem 0; }
-.g-sw-wrap { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
-.g-sw {
-  display: flex; flex-direction: column; align-items: stretch; gap: .3rem;
-  text-align: left; padding: .5rem .6rem; border-radius: 10px;
-  border: 1px solid var(--g-line, rgba(0,0,0,.18));
-  background: var(--g-key, linear-gradient(180deg, rgba(255,255,255,.9), rgba(0,0,0,.04)));
-  box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 1px 2px rgba(0,0,0,.12);
-  cursor: pointer; font: inherit;
-  /* Expliciet, niet geerfd (Barts aanwijzing, 8-8): de paginakleur zakte weg
-     in het toetsvlak. Het opschrift is het donkerste op de toets. */
-  color: var(--g-key-ink, #1b1e22);
-}
-.g-sw:active:not([disabled]) { box-shadow: inset 0 1px 3px rgba(0,0,0,.25); transform: translateY(1px); }
-/* Niet verstelbaar is niet kapot: de toets drukt niet in, de stand blijft leesbaar. */
-.g-sw[disabled] { cursor: default; opacity: .8; }
-.g-sw-head { display: flex; align-items: center; gap: .45em; min-width: 0; }
-.g-sw-name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-/* Het lampje. Aan gloeit, uit is dof, onbeslist is een open kringetje (geen
-   besluit is de afwezigheid van een), wachten knippert -- de enige stand die
-   vanzelf verandert, dus de enige die om je blik mag vragen -- en gepland is
-   een lege fitting: een poort die nog niet bestaat is geen dichte deur. */
-.g-sw-lamp { flex: none; width: .6em; height: .6em; border-radius: 50%; box-shadow: inset 0 0 2px rgba(0,0,0,.35); }
-.g-lamp-on { background: var(--g-lamp-on, #2f9e5f); box-shadow: 0 0 6px 1px rgba(47,158,95,.55); }
-.g-lamp-off { background: var(--g-lamp-off, #7a8087); }
-.g-lamp-undecided { background: transparent; border: 1px dashed currentColor; opacity: .55; box-shadow: none; }
-.g-lamp-waiting { background: var(--g-warn, #b8860b); animation: g-lamp-pulse 1.4s ease-in-out infinite; }
-.g-lamp-planned { background: transparent; border: 1px dotted currentColor; opacity: .4; box-shadow: none; }
-@keyframes g-lamp-pulse { 50% { opacity: .3; } }
-@media (prefers-reduced-motion: reduce) { .g-lamp-waiting { animation: none; } }
-
-/* In het donker geen lichte toetsen: de site heeft een echt donker thema
-   (style.css), en een wit-op-lichtgrijs paneel is daar onleesbaar. Zelfde
-   vorm, andere verf; de vars blijven overschrijfbaar. */
-@media (prefers-color-scheme: dark) {
-  .g-sw {
-    background: var(--g-key, linear-gradient(180deg, rgba(255,255,255,.10), rgba(0,0,0,.25)));
-    box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 1px 2px rgba(0,0,0,.5);
-    color: var(--g-key-ink, rgba(255,255,255,.95));
-  }
-  .g-sw-state { background: var(--g-lcd, rgba(0,0,0,.4)); }
-}
-
-/* Het statusveld: verzonken venstertje, vaste letter, zoals de temperatuur op
-   het aircopaneel. */
-.g-sw-state {
-  align-self: flex-start; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: .74em; padding: .1em .5em; border-radius: 4px;
-  border: 1px solid var(--g-line, rgba(0,0,0,.18));
-  background: var(--g-lcd, rgba(0,0,0,.07));
-  box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
-  /* Het oplichtende display van de toets: de stand in de accentkleur van het
-     huis (Barts aanwijzing, 8-8), zoals de cijfers op het aircopaneel. */
-  color: var(--accent, #6b8f71);
-  font-weight: 600;
-}
-.g-sw-meta { display: flex; flex-wrap: wrap; gap: .3em .7em; opacity: .7; font-size: .82em; }
-
-/* De poortenlijst blijft ingeklapt tot je hem opent. Met twaalf toetsen duwt
-   een open paneel alles eronder van het scherm. */
-.g-gates-toggle { display: block; width: 100%; text-align: left; margin: .25rem 0; }
-
-/* De waarschuwing bij een gate-voorstel (shaer-nf9). Geen rode paniekbalk: dit
-   moet gelezen worden, niet weggeklikt. Een rand links, zoals een citaat. */
-.g-warn { margin: .5rem 0; padding: .4rem 0 .4rem .7rem; border-left: 3px solid var(--g-warn, #b8860b); }
-.g-warn p { margin: 0 0 .25rem; }
-.g-warn p:last-child { margin-bottom: 0; }
-.g-confirm .g-warn { border-left: 0; padding-left: 0; }
-
-/* De vraag van het kind zelf (shaer-8ru). Het enige op een poortrij dat van het
-   kind komt, dus niet grijs weggeschreven tussen de drempels. */
-.g-gate-asked { margin: .2rem 0 0; color: var(--g-asked, #7a5ea8); font-weight: 600; }
-
-/* "Jouw antwoord beslist dit" (shaer-8vt). Zwaarder dan de rest van de
-   waarschuwing: dit is het verschil tussen meedoen en beslissen. */
-.g-decisive { font-weight: 700; color: var(--g-decisive, #a33); }
Index: src/assets/css/reply-editor.css
===================================================================
--- src/assets/css/reply-editor.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,120 +1,0 @@
-/* Rich reply editor (partials/reply-editor.ejs + js/reply-editor.js).
-   Styled as a small sibling of the post editor (pe-*): same 32px icon buttons,
-   same accent-active state, same framed feel, scaled down for a reply. */
-
-/* !important: site CSS gives forms inside comment threads `display: contents`,
-   which collapses the form to a 0x0 non-box — flex layout AND the fixed
-   full-screen overlay silently break. This component must own its box. */
-.re-form[data-re] { display: flex !important; flex-direction: column; gap: .5rem; }
-
-/* ── Toolbar (matches .pe-toolbar) ─────────────────────────────────────── */
-.re-toolbar { display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
-.re-toolbar button {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 32px; height: 32px; padding: 0;
-  background: transparent; border: 1px solid transparent; border-radius: 6px;
-  color: var(--ink, currentColor); cursor: pointer;
-  touch-action: manipulation; -webkit-user-select: none; user-select: none;
-  -webkit-tap-highlight-color: transparent;
-  transition: background .15s ease, color .15s ease, border-color .15s ease;
-}
-.re-toolbar button svg { width: 16px; height: 16px; }
-@media (hover: hover) {
-  .re-toolbar button:hover { background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 8%, transparent)); color: var(--accent, #06c); }
-}
-.re-toolbar button:active { transform: scale(.94); }
-.re-toolbar button.is-active { background: var(--accent, #06c); color: #fff; border-color: var(--accent, #06c); }
-.re-toolbar button.is-active svg { color: #fff; }
-.re-toolbar-sep { width: 1px; height: 18px; background: var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent)); margin: 0 .3rem; flex-shrink: 0; }
-
-/* ── Editor box (matches .pe-editor) ───────────────────────────────────── */
-.re-editor {
-  min-height: 4rem; padding: .6rem .75rem; border-radius: 10px;
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent));
-  background: var(--paper, transparent); color: inherit;
-  overflow-wrap: anywhere; outline: none; line-height: 1.5;
-  transition: border-color .15s ease, box-shadow .15s ease;
-}
-.re-editor:focus {
-  border-color: var(--accent, #06c);
-  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #06c) 18%, transparent);
-}
-.re-editor:empty::before { content: attr(data-ph); color: color-mix(in srgb, var(--ink, #000) 40%, transparent); pointer-events: none; }
-.re-editor p { margin: 0 0 .5rem; }
-.re-editor p:last-child { margin-bottom: 0; }
-.re-editor ul, .re-editor ol { margin: .4rem 0; padding-left: 1.4rem; }
-.re-editor blockquote {
-  margin: .4rem 0; padding: .1rem 0 .1rem .75rem;
-  border-left: 3px solid color-mix(in srgb, var(--ink, #000) 22%, transparent);
-  color: color-mix(in srgb, var(--ink, #000) 78%, transparent);
-}
-.re-editor a { color: var(--accent, #06c); }
-.re-editor.re-drop { border-color: var(--accent, #06c); border-style: dashed; background: color-mix(in srgb, var(--accent, #06c) 6%, transparent); }
-
-/* ── Footer: language + send ───────────────────────────────────────────── */
-.re-foot { display: flex; align-items: center; gap: .6rem; }
-.re-foot .re-send { margin-left: auto; }
-.re-lang {
-  font: inherit; font-size: .82rem; padding: .32rem .5rem; border-radius: 8px;
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent));
-  background: transparent; color: inherit; max-width: 11rem;
-}
-
-/* ── Mentions bar (u02): removable partner chips ───────────────────────── */
-.re-mentions { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; }
-.re-mentions-label { font-size: .76rem; text-transform: uppercase; letter-spacing: .03em; color: color-mix(in srgb, var(--ink, #000) 50%, transparent); }
-.re-mention {
-  display: inline-flex; align-items: center; gap: .1rem;
-  padding: .12rem .2rem .12rem .5rem; border-radius: 999px; font-size: .78rem; line-height: 1.5;
-  background: color-mix(in srgb, var(--accent, #06c) 12%, transparent);
-  border: 1px solid color-mix(in srgb, var(--accent, #06c) 28%, transparent);
-  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.re-mention-del { display: inline-flex; align-items: center; justify-content: center; width: 1.2rem; height: 1.2rem; border: none; background: none; color: inherit; opacity: .65; font-size: 1rem; line-height: 1; cursor: pointer; }
-.re-mention-del:hover { opacity: 1; }
-
-/* ── Media attachment chips (while composing) ──────────────────────────── */
-.re-attachments { display: flex; flex-wrap: wrap; gap: .4rem; }
-.re-att {
-  display: inline-flex; align-items: center; gap: .4rem;
-  padding: .25rem .3rem .25rem .5rem; border-radius: 10px; font-size: .78rem;
-  background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 7%, transparent));
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 12%, transparent));
-  max-width: 100%; overflow: hidden;
-}
-.re-att img { width: 40px; height: 40px; object-fit: cover; border-radius: 7px; display: block; }
-.re-att-del { display: inline-flex; align-items: center; justify-content: center; width: 1.3rem; height: 1.3rem; border: none; background: none; color: inherit; opacity: .65; font-size: 1.05rem; line-height: 1; cursor: pointer; }
-.re-att-del:hover { opacity: 1; }
-.re-att-busy { opacity: .7; }
-.re-att-err { background: color-mix(in srgb, #c00 16%, transparent); border-color: color-mix(in srgb, #c00 35%, transparent); }
-
-/* ── Media on a sent reply in the thread (fedi-node) ───────────────────── */
-.re-reply-media { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
-.re-reply-media img { max-width: min(260px, 100%); max-height: 260px; border-radius: 12px; display: block; }
-.re-reply-media audio { max-width: 100%; }
-.re-reply-media video { max-width: min(320px, 100%); border-radius: 12px; }
-
-/* ── Full-screen compose (mobile). JS toggles .re-full on the form. ────── */
-.re-head { display: flex; align-items: center; gap: .6rem; padding-bottom: .2rem; border-bottom: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 12%, transparent)); }
-.re-head-title { flex: 1; text-align: center; font-size: .95rem; font-weight: 600; }
-.re-cancel { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border: none; background: none; color: inherit; font-size: 1.5rem; line-height: 1; cursor: pointer; border-radius: 8px; }
-@media (hover: hover) { .re-cancel:hover { background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 8%, transparent)); } }
-
-.re-form.re-full {
-  /* Above the bottom tab bar (1050) and the profile/audio sheets (1100). */
-  position: fixed; inset: 0; z-index: 1200;
-  background: var(--paper, #fff); padding: .8rem .9rem; gap: .55rem;
-  padding-top: calc(.8rem + env(safe-area-inset-top));
-  padding-bottom: calc(.8rem + env(safe-area-inset-bottom));
-}
-.re-form.re-full .re-head { order: -1; }   /* the top bar stays topmost, above the mentions bar */
-.re-form.re-full .re-editor { flex: 1; min-height: 0; overflow-y: auto; border: none; box-shadow: none; padding: .4rem .1rem; }
-.re-form.re-full .re-editor:focus { border: none; box-shadow: none; }
-.re-form.re-full .re-send { display: none; }         /* send lives in the top bar */
-/* ... EN ANDERSOM. Die regel hierboven stond er wel, deze niet: buiten
-   fullscreen bleven allebei de knoppen staan, dus twee keer Verzenden onder
-   elkaar. .re-send-top had helemaal geen CSS -- hij werd nooit verborgen.
-   Een knop hoort in de bovenbalk OF in de voet, nooit in allebei. */
-.re-form:not(.re-full) .re-send-top { display: none; }
-.re-form.re-full .re-foot { justify-content: flex-start; }
-html.re-lock, html.re-lock body { overflow: hidden; }
Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/css/style.css	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -54,5 +54,5 @@
 }
 
-/* Klonkt publieke stijl — editorial-ish, geen emoji-UI */
+/* PrutCMS publieke stijl — editorial-ish, geen emoji-UI */
 
 :root {
@@ -63,23 +63,6 @@
     --radius: 8px; --transition: 180ms cubic-bezier(.4,0,.2,1);
     --safe-top: env(safe-area-inset-top, 0); --safe-bot: env(safe-area-inset-bottom, 0);
-    /* iOS top inset: starts from env(), but a tiny boot script overrides it with a
-       real/measured value (env() resolves to 0 in some iOS standalone states). */
-    --ios-safe-top: env(safe-area-inset-top, 0px);
     color-scheme: light dark;
 }
-
-/* Native controls (datum/tijd-picker, scrollbars) het SITE-thema laten volgen i.p.v.
-   de OS-voorkeur, en de datum/tijd-velden de site-accentkleur geven. Zo staat de
-   ingebouwde kalender-popup in dark + accent (de popup zelf is een browser-widget,
-   dus fonts/randen blijven van de browser). */
-[data-theme="dark"] { color-scheme: dark; }
-[data-theme="light"] { color-scheme: light; }
-input[type="date"], input[type="time"], input[type="datetime-local"] { accent-color: var(--accent, #6b8f71); }
-input[type="date"]::-webkit-calendar-picker-indicator,
-input[type="time"]::-webkit-calendar-picker-indicator,
-input[type="datetime-local"]::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .7; }
-input[type="date"]:hover::-webkit-calendar-picker-indicator,
-input[type="time"]:hover::-webkit-calendar-picker-indicator,
-input[type="datetime-local"]:hover::-webkit-calendar-picker-indicator { opacity: 1; }
 
 /* ============================================================
@@ -112,23 +95,4 @@
 }
 
-/* ── KLONKT ── merk-standaard: clean wit → bijna-zwart (neutraal, gouden accent) ── */
-[data-palette="klonkt"] {
-    --paper: #ffffff; --paper-2: #f4f4f5;
-    --ink: #09090b; --ink-soft: #27272a; --ink-muted: #52525b; --ink-faint: #a1a1aa;
-    --rule: #e4e4e7; --rule-2: #d4d4d8;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="klonkt"]:not([data-theme="light"]) {
-        --paper: #0a0a0a; --paper-2: #18181b;
-        --ink: #fafafa; --ink-soft: #d4d4d8; --ink-muted: #a1a1aa; --ink-faint: #71717a;
-        --rule: #27272a; --rule-2: #3f3f46;
-    }
-}
-[data-theme="dark"][data-palette="klonkt"] {
-    --paper: #0a0a0a; --paper-2: #18181b;
-    --ink: #fafafa; --ink-soft: #d4d4d8; --ink-muted: #a1a1aa; --ink-faint: #71717a;
-    --rule: #27272a; --rule-2: #3f3f46;
-}
-
 /* ── PAPER ── minimalistisch wit → zwart ── */
 [data-palette="paper"] {
@@ -150,4 +114,22 @@
 }
 
+/* ── OCEAN ── koel blauw-grijs → diep navy ── */
+[data-palette="ocean"] {
+    --paper: #f0f6fb; --paper-2: #dce9f4;
+    --ink: #0f1e2e; --ink-soft: #1e3a52; --ink-muted: #475569; --ink-faint: #94a3b8;
+    --rule: #c7d9ea; --rule-2: #a8c0d6;
+}
+@media (prefers-color-scheme: dark) {
+    [data-palette="ocean"]:not([data-theme="light"]) {
+        --paper: #0a1929; --paper-2: #152a3f;
+        --ink: #e0f2fe; --ink-soft: #bfdbfe; --ink-muted: #93c5fd; --ink-faint: #64748b;
+        --rule: #1e3a52; --rule-2: #2a4a68;
+    }
+}
+[data-theme="dark"][data-palette="ocean"] {
+    --paper: #0a1929; --paper-2: #152a3f;
+    --ink: #e0f2fe; --ink-soft: #bfdbfe; --ink-muted: #93c5fd; --ink-faint: #64748b;
+    --rule: #1e3a52; --rule-2: #2a4a68;
+}
 
 /* ── FOREST ── olijfgroen → donker bos ── */
@@ -227,155 +209,21 @@
 }
 
-/* ── SAND ── warm papier (subtiel, niet fel geel) → warm donker ── (key blijft 'cream') */
+/* ── CREAM ── warm papier → caramel-donker ── */
 [data-palette="cream"] {
-    --paper: #f7f3ec; --paper-2: #ece5d8;
-    --ink: #232019; --ink-soft: #43392c; --ink-muted: #6f6149; --ink-faint: #a3957c;
-    --rule: #ddd3c0; --rule-2: #c9bca3;
+    --paper: #fefaf0; --paper-2: #f5ebd5;
+    --ink: #2a1f0f; --ink-soft: #4a3820; --ink-muted: #786140; --ink-faint: #ad9068;
+    --rule: #e8d9b0; --rule-2: #d1bc8a;
 }
 @media (prefers-color-scheme: dark) {
     [data-palette="cream"]:not([data-theme="light"]) {
-        --paper: #14120d; --paper-2: #221e15;
-        --ink: #efe9dd; --ink-soft: #d0c6b2; --ink-muted: #a3957c; --ink-faint: #6f6149;
-        --rule: #322c20; --rule-2: #463d2c;
+        --paper: #1a1208; --paper-2: #2e2012;
+        --ink: #fef3d6; --ink-soft: #e0cfa0; --ink-muted: #b0996b; --ink-faint: #7a6848;
+        --rule: #3d2c16; --rule-2: #523d1f;
     }
 }
 [data-theme="dark"][data-palette="cream"] {
-    --paper: #14120d; --paper-2: #221e15;
-    --ink: #efe9dd; --ink-soft: #d0c6b2; --ink-muted: #a3957c; --ink-faint: #6f6149;
-    --rule: #322c20; --rule-2: #463d2c;
-}
-
-/* ── OCEAN ── helder blauw → diep oceaanblauw ── */
-[data-palette="ocean"] {
-    --paper: #eef4fb; --paper-2: #d8e6f7;
-    --ink: #0f2942; --ink-soft: #1d4368; --ink-muted: #4a6e90; --ink-faint: #87a3c0;
-    --rule: #c2d8ee; --rule-2: #a3c2e0;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="ocean"]:not([data-theme="light"]) {
-        --paper: #081726; --paper-2: #102a42;
-        --ink: #d6e8fb; --ink-soft: #aecbe8; --ink-muted: #7e9fc0; --ink-faint: #547390;
-        --rule: #1d3a56; --rule-2: #2a4d6e;
-    }
-}
-[data-theme="dark"][data-palette="ocean"] {
-    --paper: #081726; --paper-2: #102a42;
-    --ink: #d6e8fb; --ink-soft: #aecbe8; --ink-muted: #7e9fc0; --ink-faint: #547390;
-    --rule: #1d3a56; --rule-2: #2a4d6e;
-}
-
-/* ── TEAL ── mint-turquoise → diep teal ── */
-[data-palette="teal"] {
-    --paper: #ecf7f5; --paper-2: #cfe9e4;
-    --ink: #0c2e2a; --ink-soft: #194a44; --ink-muted: #467168; --ink-faint: #84aaa2;
-    --rule: #bce0d9; --rule-2: #9bcbc1;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="teal"]:not([data-theme="light"]) {
-        --paper: #06201d; --paper-2: #0e332e;
-        --ink: #d4f2ec; --ink-soft: #aad9d0; --ink-muted: #7aaaa0; --ink-faint: #517a72;
-        --rule: #1a4a42; --rule-2: #275e54;
-    }
-}
-[data-theme="dark"][data-palette="teal"] {
-    --paper: #06201d; --paper-2: #0e332e;
-    --ink: #d4f2ec; --ink-soft: #aad9d0; --ink-muted: #7aaaa0; --ink-faint: #517a72;
-    --rule: #1a4a42; --rule-2: #275e54;
-}
-
-/* ── CANDY ── zacht roze-rood → diep kersenrood ── */
-[data-palette="candy"] {
-    --paper: #fdf1f3; --paper-2: #f8d8de;
-    --ink: #3a1018; --ink-soft: #5e1f2a; --ink-muted: #8e4450; --ink-faint: #c2868f;
-    --rule: #f0c5cd; --rule-2: #e3a0ab;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="candy"]:not([data-theme="light"]) {
-        --paper: #220810; --paper-2: #3a121d;
-        --ink: #fde0e6; --ink-soft: #f5b8c4; --ink-muted: #cd8b98; --ink-faint: #8e5f6a;
-        --rule: #4d1a28; --rule-2: #632536;
-    }
-}
-[data-theme="dark"][data-palette="candy"] {
-    --paper: #220810; --paper-2: #3a121d;
-    --ink: #fde0e6; --ink-soft: #f5b8c4; --ink-muted: #cd8b98; --ink-faint: #8e5f6a;
-    --rule: #4d1a28; --rule-2: #632536;
-}
-
-/* ── AMBER ── warm honing → diep amber ── */
-[data-palette="amber"] {
-    --paper: #fdf6e9; --paper-2: #f7e6c2;
-    --ink: #3a2a0c; --ink-soft: #5e451a; --ink-muted: #8e7340; --ink-faint: #c2a877;
-    --rule: #f0dca8; --rule-2: #e3c581;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="amber"]:not([data-theme="light"]) {
-        --paper: #221a08; --paper-2: #3a2c12;
-        --ink: #fdeecb; --ink-soft: #f0d49a; --ink-muted: #c2a877; --ink-faint: #8e7340;
-        --rule: #4d3a16; --rule-2: #634d20;
-    }
-}
-[data-theme="dark"][data-palette="amber"] {
-    --paper: #221a08; --paper-2: #3a2c12;
-    --ink: #fdeecb; --ink-soft: #f0d49a; --ink-muted: #c2a877; --ink-faint: #8e7340;
-    --rule: #4d3a16; --rule-2: #634d20;
-}
-
-/* ── ROSE ── zacht roze → wijnrood ── */
-[data-palette="rose"] {
-    --paper: #fdf2f4; --paper-2: #f8dde3;
-    --ink: #2e1419; --ink-soft: #4d242c; --ink-muted: #7a4651; --ink-faint: #b3838d;
-    --rule: #f0c9d1; --rule-2: #e0a3af;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="rose"]:not([data-theme="light"]) {
-        --paper: #1f0a0f; --paper-2: #33141c;
-        --ink: #fce4ea; --ink-soft: #f0bcc8; --ink-muted: #c98f9d; --ink-faint: #8a6470;
-        --rule: #4a1f2c; --rule-2: #5e2d3a;
-    }
-}
-[data-theme="dark"][data-palette="rose"] {
-    --paper: #1f0a0f; --paper-2: #33141c;
-    --ink: #fce4ea; --ink-soft: #f0bcc8; --ink-muted: #c98f9d; --ink-faint: #8a6470;
-    --rule: #4a1f2c; --rule-2: #5e2d3a;
-}
-
-
-/* ── TERRACOTTA ── warme klei → diep brick ── (key blijft 'mint' voor DB-compat) */
-[data-palette="mint"] {
-    --paper: #faf2ee; --paper-2: #f2ddd0;
-    --ink: #2e1a12; --ink-soft: #4a2c1e; --ink-muted: #7a5544; --ink-faint: #b3917e;
-    --rule: #eccdbb; --rule-2: #ddb49d;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="mint"]:not([data-theme="light"]) {
-        --paper: #1f120c; --paper-2: #2e1c13;
-        --ink: #f7e6da; --ink-soft: #e0c3b0; --ink-muted: #b3917c; --ink-faint: #7a5544;
-        --rule: #4a2c1e; --rule-2: #5e3a28;
-    }
-}
-[data-theme="dark"][data-palette="mint"] {
-    --paper: #1f120c; --paper-2: #2e1c13;
-    --ink: #f7e6da; --ink-soft: #e0c3b0; --ink-muted: #b3917c; --ink-faint: #7a5544;
-    --rule: #4a2c1e; --rule-2: #5e3a28;
-}
-
-/* ── LILAC ── zacht lila → donker violet ── */
-[data-palette="lilac"] {
-    --paper: #faf4fb; --paper-2: #f0e0f3;
-    --ink: #2a1830; --ink-soft: #46294f; --ink-muted: #7a5685; --ink-faint: #b393bd;
-    --rule: #e6cdec; --rule-2: #d4abdd;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="lilac"]:not([data-theme="light"]) {
-        --paper: #170a1c; --paper-2: #281433;
-        --ink: #f3e2f7; --ink-soft: #dabce4; --ink-muted: #b088c0; --ink-faint: #7a5a88;
-        --rule: #3d1f4a; --rule-2: #4f2b5e;
-    }
-}
-[data-theme="dark"][data-palette="lilac"] {
-    --paper: #170a1c; --paper-2: #281433;
-    --ink: #f3e2f7; --ink-soft: #dabce4; --ink-muted: #b088c0; --ink-faint: #7a5a88;
-    --rule: #3d1f4a; --rule-2: #4f2b5e;
+    --paper: #1a1208; --paper-2: #2e2012;
+    --ink: #fef3d6; --ink-soft: #e0cfa0; --ink-muted: #b0996b; --ink-faint: #7a6848;
+    --rule: #3d2c16; --rule-2: #523d1f;
 }
 
@@ -385,41 +233,9 @@
 
 * { box-sizing: border-box; }
-html {
-    -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent;
-    overscroll-behavior-y: none;
-    /* Horizontaal clippen op ROOT-niveau: dit propageert naar de viewport (geen
-       horizontale scroll) ZONDER position:fixed te breken — html is sowieso de
-       document-scroller. De body mag GEEN overflow-x krijgen, want dan zou die een
-       tweede scroll-container worden en zou de fixed bottom-tab meeschuiven. */
-    overflow-x: hidden;
-}
+html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; overscroll-behavior-y: none; }
 body {
     margin: 0;
-}
-
-/* ── Thema-scrollbar: slank, afgerond, subtiel — vervangt de standaard chunky
-   browser-scrollbar (met pijltjes) die niet bij het donkere thema past.
-   Werkt op de pagina én op interne scrollers (queue, chat-thread, dropdowns). ── */
-* {
-    scrollbar-width: thin;
-    scrollbar-color: color-mix(in srgb, var(--ink) 28%, transparent) transparent;
-}
-::-webkit-scrollbar { width: 10px; height: 10px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb {
-    background-color: color-mix(in srgb, var(--ink) 24%, transparent);
-    border-radius: 999px;
-    border: 2px solid transparent;   /* transparante rand → optisch slanker (≈6px) */
-    background-clip: content-box;
-}
-::-webkit-scrollbar-thumb:hover {
-    background-color: color-mix(in srgb, var(--accent) 60%, var(--ink));
-}
-::-webkit-scrollbar-corner { background: transparent; }
-
-/* Login/auth = focus-scherm. Bij htmx-navigatie naar login blijft de bottom-tab
-   uit de vorige pagina in de DOM staan → hier verbergen zodat het login-scherm
-   net zo "kaal" is als bij een full-load (waar de tab niet eens gerenderd wordt). */
-body.on-auth .bottom-tab { display: none; }
+    overflow-x: hidden;
+}
 body:not(.admin) {
     min-height: 100dvh;
@@ -436,13 +252,7 @@
 body:not(.admin)::before {
     content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
-    /* Per-palette achtergrond-gradient: opgebouwd uit de palette's EIGEN --paper/--paper-2
-       (diagonale verloop) + een zachte accent-glow erbovenop. Werkt automatisch voor elk
-       palet, light én dark — geen per-palette regels nodig. */
-    background: radial-gradient(ellipse at top, color-mix(in srgb, var(--accent) 6%, transparent), transparent 60%),
-                radial-gradient(ellipse at bottom right, color-mix(in srgb, var(--accent) 7%, transparent), transparent 55%),
-                linear-gradient(160deg, var(--paper) 0%, var(--paper-2) 100%);
-}
-/* Klonkt-palet gebruikt nu (net als de andere paletten) de generieke accent-glow
-   hierboven → de achtergrond-gradient volgt de gekozen accentkleur. */
+    background: radial-gradient(ellipse at top, color-mix(in srgb, var(--accent) 3%, transparent), transparent 60%),
+                radial-gradient(ellipse at bottom right, color-mix(in srgb, var(--accent) 4%, transparent), transparent 55%);
+}
 ::selection { background: var(--accent-tint); color: var(--accent); }
 
@@ -454,10 +264,4 @@
 a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--rule-2); text-underline-offset: 3px; transition: color var(--transition), text-decoration-color var(--transition); }
 a:hover { color: var(--accent); text-decoration-color: var(--accent); }
-/* Knop-ankers houden hun eigen tekstkleur bij hover. Zonder dit overschrijft de
-   globale a:hover{color:accent} de knop-tekst → die wordt dezelfde kleur als de
-   accent-achtergrond en valt weg (vooral op sites met een felle accent). */
-a.btn:hover, a.epk-btn:hover { color: var(--ink); }
-a.btn-primary:hover, a.btn-danger:hover, a.btn-success:hover, a.button:hover,
-a.epk-btn-primary:hover, a.dls-btn:hover, a.sh-tix:hover, a.dl-go:hover { color: #fff; text-decoration-color: currentColor; }
 a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; text-decoration: none; }
 blockquote { margin: 2em 0; padding: .25em 0 .25em 1.25em; border-left: 2px solid var(--accent); font-family: var(--font-display); font-style: italic; font-size: 1.15em; color: var(--ink-soft); }
@@ -476,9 +280,4 @@
 .container { max-width: 42rem; margin-inline: auto; padding-inline: clamp(1.25rem, 4vw, 2rem); }
 
-/* Post-/leesbreedte. Globaal + hogere specificity (.container.post-page) zodat 'ie
-   de responsive .container-breakpoints (56/72rem) overal overrult — posts én de
-   downloads-pagina krijgen zo exact dezelfde 720px-breedte. */
-.container.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
-
 /* Laptop / Full HD — 1440px viewport of groter */
 @media (min-width: 1440px) {
@@ -510,9 +309,7 @@
 .masthead {
     border-bottom: 1px solid var(--rule);
-    padding: calc(1rem + var(--ios-safe-top, 0px)) 0 1rem;
-    /* GEEN backdrop-filter hier: een voorouder met backdrop-filter blokkeert de
-       frosted blur van het user-menu-dropdown eronder. Masthead is toch bijna
-       dekkend, dus solide bg i.p.v. blur. */
-    background: color-mix(in srgb, var(--paper) 94%, transparent);
+    padding: calc(1rem + var(--safe-top)) 0 1rem;
+    background: color-mix(in srgb, var(--paper) 88%, transparent);
+    backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
     position: sticky; top: 0; z-index: 20;
 }
@@ -559,24 +356,4 @@
 
 .empty { text-align: center; padding: 4rem 1rem; }
-/* Moved account (FEP-7628 slice 3): the signpost above the feed. Loud on
-   purpose — a visitor must not read a stale timeline thinking it is current. */
-.moved-banner {
-    text-align: center;
-    margin: 1.5rem 0 2rem;
-    padding: 1.75rem 1.25rem;
-    border: 2px solid var(--accent, #e8b04b);
-    border-radius: var(--radius, 10px);
-    background: color-mix(in srgb, var(--accent, #e8b04b) 10%, var(--paper-2, var(--paper)));
-}
-.moved-banner-lead { margin: 0 0 .5rem; font-size: 1rem; opacity: .85; }
-.moved-banner-link {
-    display: inline-block;
-    font-family: var(--font-display);
-    font-size: clamp(1.5rem, 5vw, 2.4rem);
-    font-weight: 700;
-    line-height: 1.15;
-    overflow-wrap: anywhere;
-}
-.moved-banner-hint { margin: .75rem 0 0; font-size: .85rem; color: var(--ink-soft); }
 .empty h1 { font-size: 2rem; }
 .empty p { color: var(--ink-muted); }
@@ -584,6 +361,5 @@
 /* Embeds */
 .pcms-embed, .folio-embed { margin: 2rem 0; }
-.pcms-embed iframe, .pcms-embed video, .pcms-embed audio,
-.folio-embed iframe, .folio-embed video, .folio-embed audio { width: 100%; display: block; border: 0; border-radius: var(--radius); }
+.pcms-embed iframe, .folio-embed iframe { width: 100%; display: block; border: 0; border-radius: var(--radius); }
 
 /* Article footer */
@@ -692,5 +468,5 @@
 
 /* Footer */
-.site-footer { padding: 2rem 0 calc(2rem + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--rule); margin-top: 4rem; font-family: var(--font-ui); font-size: .85rem; color: var(--ink-muted); }
+.site-footer { padding: 2rem 0 calc(2rem + var(--safe-bot)); border-top: 1px solid var(--rule); margin-top: 4rem; font-family: var(--font-ui); font-size: .85rem; color: var(--ink-muted); }
 .site-footer-inner { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem; align-items: center; }
 .site-footer a { color: var(--ink-muted); text-decoration-color: var(--rule); }
@@ -721,7 +497,7 @@
     display: block;
     width: 100%;
-    height: auto;        /* volledige (niet-bijgesneden) hoogte */
-    /* geen max-height/object-fit: cover meer → de hele afbeelding toont,
-       volle containerbreedte, zijkanten gerespecteerd. */
+    height: auto;
+    max-height: 50vh;
+    object-fit: cover;
 }
 .post-title { font-size: clamp(1.6rem, 4vw, 2.6rem); line-height: 1.15; margin: 0 0 .5rem; letter-spacing: -.02em; }
@@ -783,33 +559,4 @@
 .post-image--right img { margin-left: auto; margin-right: 0; }
 
-/* Afbeeldingen in de post-tekst vullen ALTIJD de containerbreedte en houden hun
-   natuurlijke (niet-bijgesneden) hoogte — geen vaste hoogte / geen crop. */
-.post-content img {
-    display: block;
-    width: 100%;
-    max-width: 100%;
-    height: auto;
-    border-radius: 6px;
-    margin: 1.4em auto;
-}
-
-/* Video in de post-tekst (C2S-uploads uit Shaer): dezelfde maatvoering als
-   afbeeldingen, plus een plafond. Zonder deze regel rendert een <video> op
-   zijn natuurlijke maat en duwt een portret-video de hele kolom vol; met
-   object-fit blijft het beeld heel binnen het kader. Geldt ook voor de
-   Krant/Berichten (tl-content), waar dezelfde content langskomt. */
-.post-content video, .tl-content video {
-    display: block;
-    width: 100%;
-    max-width: 100%;
-    max-height: min(70vh, 540px);
-    height: auto;
-    object-fit: contain;
-    background: #000;
-    border-radius: 6px;
-    margin: 1.4em auto;
-}
-.post-content audio, .tl-content audio { width: 100%; display: block; margin: 1em auto; }
-
 /* Mini-player + bottom-tab clearance: applied to .pcms-main so the footer
    is NOT pushed below the viewport bottom. The bars are position:fixed so
@@ -819,25 +566,4 @@
 .pcms-main { padding-bottom: 5.5rem; }
 body.has-audio-player .pcms-main { padding-bottom: 5.5rem; }
-
-/* Mobiel toetsenbord open (body.kb-open, gezet via visualViewport in shell.ejs):
-   verberg de vaste onderbalken — anders zweven de bottom-tab + mini-speler boven
-   het toetsenbord of overlappen ze het invoerveld. Ze komen terug zodra het
-   toetsenbord sluit. De extra clearance onderaan is dan ook niet meer nodig. */
-body.kb-open .bottom-tab,
-body.kb-open .audio-player { display: none !important; }
-body.kb-open .pcms-main { padding-bottom: 1rem; }
-
-/* Mobiel & tablet (touch): scrollbalken verbergen — scrollen blijft werken, alleen
-   de balk-UI is weg (geen verwarrende/dubbele balken). */
-@media (pointer: coarse) {
-  * { scrollbar-width: none; -ms-overflow-style: none; }
-  *::-webkit-scrollbar { width: 0; height: 0; display: none; }
-}
-
-/* Afbeeldingen lastiger op te slaan (frictie, geen echte beveiliging): geen
-   sleep-naar-bureaublad, geen iOS long-press-bewaarmenu. Rechtsklik wordt in JS
-   geblokkeerd (zie shell.ejs). Covers/hero zijn al CSS-achtergronden = niet via
-   "afbeelding opslaan" te pakken. */
-img { -webkit-user-drag: none; -khtml-user-drag: none; -moz-user-drag: none; user-drag: none; -webkit-touch-callout: none; }
 
 /* ============================================================
@@ -1172,7 +898,8 @@
     }
 
-    /* v9.3 — horizontaal clippen gebeurt nu globaal op html (zie de html-regel
-       bovenaan). Hier bewust NIETS op body zetten: body als scroll-container zou
-       de fixed bottom-tab op mobiel laten meeschuiven. */
+    /* v9.3 — Forceer niets schuift horizontaal weg */
+    body, html {
+        overflow-x: hidden;
+    }
 }
 
@@ -1662,5 +1389,5 @@
         border-top-right-radius: 0;
         box-shadow: none;
-        padding-top: calc(.75rem + var(--ios-safe-top, 0px));
+        padding-top: calc(.75rem + env(safe-area-inset-top, 0px));
         padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
     }
@@ -1686,39 +1413,6 @@
     .pagination-item { width: 100%; }
 }
-
-/* Taal-keuze in de header-nav (NL/EN/DE) — eigen gestylede dropdown. */
-.lang-menu { position: relative; }
-.lang-menu > summary {
-    list-style: none; cursor: pointer;
-    display: inline-flex; align-items: center; gap: .3rem;
-    padding: .35rem .55rem; border-radius: 999px;
-    border: 1px solid var(--rule); background: transparent; color: var(--ink);
-}
-.lang-menu > summary::-webkit-details-marker { display: none; }
-.lang-menu > summary:hover { border-color: var(--accent); }
-.lang-toggle .lang-code { font-size: .8rem; font-weight: 700; letter-spacing: .02em; }
-.lang-toggle .icon { width: 16px; height: 16px; opacity: .8; }
-.lang-caret { width: 12px; height: 12px; opacity: .7; transition: transform .15s; }
-.lang-menu[open] .lang-caret { transform: rotate(180deg); }
-.lang-dropdown {
-    position: absolute; top: calc(100% + 8px); right: 0;
-    min-width: 170px; z-index: 60;
-    background: var(--paper); border: 1px solid var(--rule);
-    border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.22);
-    padding: .35rem; display: flex; flex-direction: column;
-}
-.lang-item {
-    display: flex; align-items: center; gap: .6rem;
-    padding: .5rem .65rem; border-radius: 8px;
-    color: var(--ink); text-decoration: none; font-size: .9rem;
-}
-.lang-item:hover { background: var(--paper-2); }
-.lang-item.is-active { color: var(--accent); font-weight: 600; }
-.lang-item-code {
-    font-size: .7rem; font-weight: 700; opacity: .6;
-    min-width: 1.6em;
-}
 @media (max-width: 400px) {
-    .masthead { padding: calc(.5rem + var(--ios-safe-top, 0px)) 0 .5rem; }
+    .masthead { padding: calc(.5rem + var(--safe-top)) 0 .5rem; }
     .masthead-inner { gap: .25rem; }
     .site-title { font-size: 1.05rem; }
@@ -1971,18 +1665,10 @@
     overflow: hidden;
     border-radius: var(--radius);
-    /* Accent-gradient placeholder while the cover image loads (image fades in over it). */
-    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%);
-}
-.post-list-cover img, .post-list-cover video {
+}
+.post-list-cover img {
     width: 100%; height: 100%;
-    object-fit: cover;                 /* fill the square, keep aspect, crop the overflow, centered */
-    object-position: center;
+    object-fit: cover;
     display: block;
-    background-color: #fff;            /* transparent logos sit on white once loaded */
-    transition: opacity .4s ease;
-}
-.post-list-cover img.is-loading { opacity: 0; }
+}
 .post-list-body { display: flex; flex-direction: column; gap: .35rem; }
 .post-list-meta {
@@ -2254,6 +1940,5 @@
 }
 .profile-links li { margin: 0; }
-.profile-links a,
-.profile-links .profile-fedi-link {
+.profile-links a {
     display: inline-flex;
     align-items: center;
@@ -2267,6 +1952,5 @@
     transition: background .15s, color .15s;
 }
-.profile-links a:hover,
-.profile-links .profile-fedi-link:hover {
+.profile-links a:hover {
     background: color-mix(in srgb, var(--accent) 15%, transparent);
     color: var(--accent);
@@ -2341,6 +2025,5 @@
     background: var(--paper);
     /* Bottom margin gives breathing room between the switcher bar and the
-       first post. margin-top staat in shared-styles.ejs (de winnende inline
-       <style>) — symmetrisch op 1.5rem. */
+       first post — without it the feed sits flush against the bar. */
     margin-bottom: 1.5rem;
 }
@@ -2372,27 +2055,18 @@
     border-color: var(--ink-muted);
 }
-/* Uitgeschakeld (Lezen in de cirkel) wint van :hover -- anders licht een knop
-   op die je niet kunt indrukken, en dat is precies de verkeerde belofte. */
-.view-switch-btn[disabled],
-.view-switch-btn[disabled]:hover {
-    opacity: .38;
-    cursor: not-allowed;
-    color: var(--ink-muted);
-    border-color: var(--rule);
-}
-/* Actieve state: puur afgeleid van body[data-feed-view]. Geen .is-active class meer.
-   Niet op de Archive-pagina: daar is geen "huidige" timeline/grid-keuze, dus beide
-   knoppen blijven neutraal (grijs). */
-/* Actieve Tijdlijn/Grid-state wordt nu gestuurd via [aria-selected="true"] (gezet
-   door syncAria, die óók op htmx:afterSettle draait) i.p.v. body[data-feed-view].
-   Reden: body-class/attribuut-CSS pakte niet betrouwbaar na een htmx-OOB-swap;
-   een element-attribuut wel. De witte active-regel staat in shared-styles. */
+/* Actieve state: puur afgeleid van body[data-feed-view]. Geen .is-active class meer. */
+body[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"],
+body[data-feed-view="grid"] .view-switch-btn[data-view="grid"] {
+    background: var(--ink);
+    color: var(--paper);
+    border-color: var(--ink);
+}
 .view-switch-btn .icon { width: 1rem; height: 1rem; }
 
 /* -------- FEED TOGGLE VIA BODY --------
-   Grid-modus werkt op de feed (home, cirkel). Het ARCHIEF negeert de grid-keuze
-   bewust: dat is altijd de chronologische jaar/maand-lijst (de switcher is daar
-   grijs en springt bij klik terug naar de feed). Post-pagina's tonen de
-   feed-timeline article sowieso altijd. Alle andere body-classes negeren
+   Grid-modus werkt op elke feed-pagina (home, archive). Op een post-pagina
+   is de feed-timeline article juist het hoofdonderwerp — daar moet 'ie
+   altijd zichtbaar zijn, dus de grid-toggle slaat alleen aan op
+   feed-pagina's (.on-home, .on-archive). Andere body-classes negeren de
    data-feed-view en houden timeline visible. */
 body.on-home[data-feed-view="grid"]    .feed-timeline,
@@ -2400,409 +2074,12 @@
 body.on-home[data-feed-view="grid"]    .related-posts,
 body.on-home[data-feed-view="grid"]    .article-footer,
-body.on-cirkel[data-feed-view="grid"]  .feed-timeline {
+body.on-archive[data-feed-view="grid"] .feed-timeline,
+body.on-archive[data-feed-view="grid"] .pagination {
     display: none !important;
 }
-/* Grid-sectie alleen zichtbaar op de feed in grid-mode (NIET op archief) */
+/* Grid-sectie alleen zichtbaar op feed-pagina's in grid-mode */
 .feed-grid { display: none; }
 body.on-home[data-feed-view="grid"]    .feed-grid,
-body.on-cirkel[data-feed-view="grid"]  .feed-grid { display: block; }
-
-/* -------- LEESWEERGAVE (data-feed-view="reader") --------
-   Dezelfde berichten als de tijdlijn, maar heel: één per scherm, grenzen door
-   scroll-snapping. GEEN eigen route en geen eigen shell -- de balken blijven
-   staan, want je bent nog op de feed en moet terug kunnen schakelen.
-   Pagina's zonder .feed-reader (cirkel, archief, post) negeren de keuze en
-   houden hun tijdlijn: er is hieronder simpelweg geen regel die die daar
-   verbergt. Zo is Lezen nooit een leeg scherm. */
-.feed-reader { display: none; }
-body.on-home[data-feed-view="reader"] .feed-reader { display: block; }
-/* :has(.feed-reader) is hier de LEGE STAAT, niet een truc. Zonder berichten
-   rendert de leessectie niet, en dan mag de tijdlijn niet verborgen worden --
-   want dáár staat "nog niets geschreven" met de knop om te beginnen. Zonder deze
-   voorwaarde is Lezen op een verse site een wit scherm. Hetzelfde geldt voor
-   feeds die (nog) geen leessectie hebben. */
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-timeline,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-grid,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .pagination,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .related-posts,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .article-footer {
-    display: none !important;
-}
-
-/* -------- AUTO (data-feed-view="auto") --------
-   Lezen op mobiel, Tijdlijn op desktop. GEEN JavaScript: de CSS kiest, dus geen
-   resize-afhandeling en geen flikkering waarin je eerst de verkeerde weergave
-   ziet. De tijdlijn is de terugval, dus boven de grens hoeft hier niets te
-   staan -- die is vanzelf al zichtbaar.
-   De :has(.feed-reader)-voorwaarde staat er om dezelfde reden als hierboven:
-   zonder berichten is er geen leessectie, en dan moet de tijdlijn blijven staan
-   met "nog niets geschreven" erin. */
-@media (max-width: 767px) {
-    body.on-home[data-feed-view="auto"] .feed-reader { display: block; }
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .feed-timeline,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .feed-grid,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .pagination,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .related-posts,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .article-footer {
-        display: none !important;
-    }
-}
-/* In de auto-stand staan BEIDE knoppen in de HTML en laat de mediaquery er een
-   zien. Zo verspringt de naam van de knop niet terwijl je ernaar kijkt, en is er
-   geen JavaScript nodig dat op resize moet luisteren. */
-.view-switch-btn.vs-alleen-mobiel { display: none; }
-@media (max-width: 767px) {
-    .view-switch-btn.vs-alleen-mobiel { display: inline-flex; }
-    .view-switch-btn.vs-alleen-desktop { display: none; }
-}
-
-/* Snappen doet de SCROLLCONTAINER, en dat is het document -- niet .read-stream,
-   die heeft overflow: visible en scrollt zelf niet. Proximity en geen mandatory:
-   in een bericht dat langer is dan het scherm moet je rustig door kunnen lezen. */
-html:has(body.on-home[data-feed-view="reader"]) {
-    scroll-snap-type: y proximity;
-    /* GEEN scroll-behavior:smooth hier. Dat maakte elke beweging op deze
-       scroller traag, ook het snappen zelf -- en een snap moet vlot zijn
-       (Robin, 20-8: "vlot maar geanimeerd, mag niet te lang duren"). De browser
-       animeert een snap uit zichzelf al, en korter dan zijn smooth-scroll.
-       De terug-naar-boven-knop vraagt zijn animatie expliciet aan in
-       mod/read.js, dus die blijft vloeiend. */
-}
-/* En de browser niet óók laten compenseren als Load more er berichten bij zet:
-   Chrome en Firefox schuiven bij invoegen zelf mee (scroll anchoring), en dat
-   vecht met het snappen. */
-html:has(body.on-home[data-feed-view="reader"]),
-.feed-reader .read-stream { overflow-anchor: none; }
-
-.feed-reader .read-post {
-    /* Snap naar de BOVENkant van een bericht, zacht (proximity op html) en nooit
-       dwingend (stop: normal). Een korte post vult het scherm, zodat hij niet als
-       snipper tussen twee lijnen hangt.
-       De uitzondering staat NIET hier maar in mod/read.js: een bericht dat langer
-       is dan het scherm krijgt scroll-snap-align:none, anders trekt de browser je
-       tijdens het lezen terug naar zijn bovenrand en lijkt het of het scrollen
-       stopt. "Past het op het scherm?" is een vraag over gemeten hoogte, en die
-       kan CSS niet stellen. */
-    /* Je LANDT op de bovenkant van een bericht -- dat is wat "snap to post"
-       hoort te betekenen. (align:end probeerde ik ook; dan land je op de
-       ONDERKANT van het vorige bericht, en bij een lang bericht zie je dus zijn
-       laatste schermvol in plaats van de kop van het volgende.)
-       Wanneer die bovenkant een snappunt IS, staat in mod/read.js: alleen zolang
-       je er nog niet voorbij bent. Eenmaal in een bericht snapt er niets meer,
-       ook niet naar zijn eigen bovenrand -- anders trekt proximity je terug
-       zodra je een paar regels verder leest. */
-    /* Het snappunt zit op .read-anker hierna, niet hier. Zie de toelichting
-       daar: een snapgebied groter dan het scherm heeft een BEREIK aan geldige
-       posities, en de bovenste daarvan zet het volgende bericht op de onderrand. */
-    scroll-snap-align: none;
-    scroll-snap-stop: normal;
-    /* GEEN min-height meer. Die rekte elk bericht tot een vol scherm, en bij een
-       kort bericht leverde dat een halve pagina leegte op onder "Replies and
-       reactions" -- je zat naar niets te kijken en moest verder scrollen om te
-       ontdekken dat er nog iets kwam. Robins vondst (20-8), met een
-       schermafbeelding erbij.
-       Zonder die hoogte is de stroom gewoon bericht na bericht: je ziet het
-       volgende meteen onder het vorige beginnen. Het snappen doet nog steeds
-       zijn werk aan de grens -- dat hangt aan scroll-snap-align en niet aan de
-       hoogte -- dus de streep lijnt nog altijd uit met de bovenkant van het
-       volgende bericht. */
-    max-width: 46rem;
-    margin: 0 auto;
-    /* De onderrand loopt achter de vaste tabbalk door; deze padding houdt de
-       TEKST erboven. De BOVENmarge is verdubbeld (Robin, 20-8: 2rem -> 4rem):
-       na een snap staat de scheidingslijn op de bovenrand, en de tekst
-       daaronder krijgt meer lucht. MOET GELIJK BLIJVEN aan scroll-margin-top
-       op .read-anker hieronder -- lopen die uit elkaar, dan landt de lijn niet
-       meer precies op de bovenrand. */
-    padding: 4rem 1.2rem 4rem;
-    box-sizing: border-box;
-}
-/* ONDERIN GEBEURT ER NIETS (Robin, 20-8: "enkel de bovenkant mag snappen, niet
-   de onderkant; onderin mag er niks met de zone gebeuren").
-   Het laatste bericht heeft een snappunt dat de browser NIET KAN BEREIKEN: er
-   zit niet genoeg pagina onder om zijn bovenkant naar de bovenrand te brengen.
-   Gemeten op dev: snappunt 2548, maar de scroll gaat maar tot 2356. De spec
-   laat de browser dan naar de dichtstbijzijnde BEREIKBARE positie snappen, en
-   dat is de bodem -- waardoor het laatste bericht ergens middenin het scherm
-   blijft hangen en het lijkt of er aan de onderkant wordt uitgelijnd.
-   Zonder snappunt scrolt dat laatste stuk gewoon vrij uit tot het einde. */
-/* HET SNAPPUNT. Nul hoog, dus precies EEN geldige positie: deze bovenrand aan de
-   bovenrand van het scherm. Zat de uitlijning op het artikel zelf, dan gold voor
-   elk bericht dat hoger is dan het scherm een heel BEREIK aan geldige posities
-   (spec: een snapgebied groter dan de snapport mag overal staan waar het de
-   snapport nog vult). Gemeten op dev: bericht van 1009px op een scherm van 812
-   -> geldig van 700 tot 897, en op 867 bleef hij dus gewoon staan. Die bovenste
-   stand, 897, is de onderkant van dat bericht op de onderrand -- oftewel de
-   bovenkant van het VOLGENDE bericht op de onderrand. Dat is wat er onderin
-   leek te snappen, en het kwam van de browser en niet uit onze code. */
-.feed-reader .read-anker {
-    display: block;
-    height: 0;
-    scroll-snap-align: start;
-    /* Het anker staat BINNEN de padding van het artikel, dus een padding-top
-       onder de streep. Zonder deze correctie landt de snap net zoveel te laag
-       en staat de scheidingslijn boven beeld -- gemeten: 733 waar 700 hoort.
-       scroll-margin verschuift alleen het SNAPPUNT en niet de opmaak, dus het
-       anker blijft waar het staat en de uitlijning klopt met de lijn.
-       GELIJK aan de padding-top van .read-post hierboven; die ging 20-8 van
-       2rem naar 4rem, dus deze ook. */
-    scroll-margin-top: 4rem;
-}
-/* HET LAATSTE BERICHT SNAPT OOK (Robin, 20-8). Dat kon eerst niet: zijn
-   bovenkant was onbereikbaar -- er zat te weinig pagina onder om hem naar de
-   bovenrand te brengen (gemeten: snappunt 2548, maxScroll 2356), en de browser
-   viel dan terug op de dichtstbijzijnde bereikbare positie: de bodem. Het
-   snappunt weghalen was de vorige oplossing; dit is de echte.
-   Een volle schermhoogte op het LAATSTE bericht maakt die bovenkant precies
-   bereikbaar. Korter dan het scherm: het bericht wordt even hoog en het snappunt
-   valt exact op maxScroll. Langer dan het scherm: dan was het al haalbaar en
-   doet min-height niets.
-   100lvh en niet 100svh: op een telefoon met een verdwijnende adresbalk is het
-   zichtbare scherm soms de GROTE viewport, en dan komt svh net tekort.
-   Zichtbaar kost dit niets: berichten hebben geen kader, alleen een scheidings-
-   lijn erboven, dus het laatste bericht krijgt hooguit witruimte eronder. */
-.feed-reader .read-post:last-child { min-height: 100lvh; }
-
-/* ELK BERICHT EEN EIGEN SCHERM, als de site dat wil (Instellingen -> Weergave).
-   Aan: een kort bericht vult het scherm en je ziet het volgende pas na een snap.
-   Uit (standaard): de stroom is bericht na bericht, zonder lege ruimte eronder.
-   Dit stond eerst vast op AAN, is toen vast op UIT gezet omdat een kort bericht
-   een halve pagina leegte gaf onder "Replies and reactions", en is nu een keuze
-   -- want welke van de twee prettiger leest hangt af van wat je schrijft. */
-body[data-reader-pages="1"] .feed-reader .read-post { min-height: 100svh; }
-/* OP MOBIEL ALTIJD, wat de instelling ook zegt (Robin, 20-8). Op een telefoon is
-   een bericht per scherm de vorm die werkt -- je veegt van bericht naar bericht.
-   Op desktop blijft het een keuze, want daar lees je in een kolom naast veel
-   witruimte en is een doorlopende stroom vaak prettiger. */
-@media (hover: none) and (pointer: coarse) {
-    /* Op mobiel draait Lezen in paginamodus (body.is-paged), en die zet hieronder
-       een vaste hoogte. Deze regel is de terugval voor het moment vóór de module
-       geladen is, zodat de berichten niet eerst als losse stroom in beeld
-       springen. */
-    .feed-reader .read-post { min-height: 100svh; }
-/* Geen native snap op mobiel: dat doet lenis/snap, met een instelbare vangzone
-       en duur. Twee snapsystemen op dezelfde scroller vechten. */
-    html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: none; }
-}
-
-/* -------- PAGINAMODUS --------
-   Elk bericht is een paneel van precies een scherm dat ZELF scrollt; tussen
-   berichten ga je met knoppen. Daarmee doet het scrollgevoel er niet meer toe:
-   geen momentum om na te bouwen en geen snap die vangt.
-   De stroom zelf vangt geen vingers meer -- dat regelt lenis.stop() in
-   mod/read.js -- en data-lenis-prevent op het paneel houdt de scroll BINNEN een
-   bericht ongemoeid (die controle staat in Lenis vóór de gestopt-controle).
-   Geldt waar een bericht een eigen scherm heeft: op mobiel altijd, op desktop
-   als de site dat zo instelt. */
-body.is-paged .feed-reader .read-post {
-    height: 100svh;
-    min-height: 0;
-    overflow-y: auto;
-    overscroll-behavior: contain;   /* raakt het einde, dan niet doorschuiven */
-    scroll-snap-align: none;
-    /* Ruimte voor de twee balken, zodat er nooit tekst onder een knop verdwijnt.
-       Het verloop laat een regel wel zacht wegvagen, maar wat je moet KUNNEN
-       lezen hoort er niet achter te komen -- vandaar padding en niet alleen die
-       fade. --read-balk staat hieronder bij .read-nav, zodat de twee niet uit
-       elkaar kunnen lopen. */
-    padding-top: calc(var(--read-balk) + .5rem);
-    padding-bottom: calc(var(--read-balk) + .5rem);
-    /* BOVENAAN beginnen, niet gecentreerd. Ik had de inhoud een ronde lang
-       verticaal gecentreerd om het gat onder een kort bericht te verdelen, maar
-       dan hangt zo'n bericht halverwege het scherm en begint niet elk paneel op
-       dezelfde plek -- dat leest onrustig. Robin ving het (20-8). Elk bericht
-       begint nu bovenaan, en de ruimte die overblijft valt onderaan. */
-}
-body.is-paged .feed-reader .read-anker { scroll-snap-align: none; }
-
-/* DE DYNAMIC ISLAND, en met opzet alleen op iOS (Robin, 20-8: "op android is het
-   een ander verhaal").
-   In paginamodus vult een bericht precies het scherm, dus de eerste regel komt
-   bovenaan -- en op een iPhone ligt daar de island of de notch overheen.
-   env(safe-area-inset-top) geeft daar de vrije ruimte; viewport-fit=cover staat
-   al in de viewport-meta, dus die waarde is echt.
-   De @supports-regel is de iOS-Safari-toets: -webkit-touch-callout bestaat alleen
-   daar. Op Android is env() vaak 0 of betekent het iets anders (camera-uitsparing,
-   statusbalk die al meegerekend is), en dan zou dit een gat bovenin maken dat er
-   niet hoort. Vandaar afgeschermd in plaats van env() overal toepassen.
-   De hoogte krimpt mee, anders duwt de padding het paneel groter dan het scherm
-   en krijg je alsnog scroll tussen de berichten. */
-@supports (-webkit-touch-callout: none) {
-    body.is-paged .feed-reader .read-post {
-        padding-top: calc(var(--read-balk) + .5rem + env(safe-area-inset-top, 0px));
-        height: calc(100svh - env(safe-area-inset-top, 0px));
-    }
-    /* De bovenbalk houdt zijn knop al vrij van de island via padding-top
-       hierboven; hier hoeft niets extra's meer. */
-}
-
-/* De knop onderaan een bericht: naar het volgende. */
-
-
-/* Omhoog en omlaag: twee balken, boven en onder, die BLIJVEN staan terwijl je in
-   een bericht scrolt (Robin, 20-8). Het verloop van paginakleur naar doorzichtig
-   laat de tekst eronderdoor wegvagen -- zonder dat zou een regel halverwege hard
-   achter de knop verdwijnen en oogt het als een afgeknipte pagina.
-   De BALK vangt geen aanrakingen (pointer-events: none), alleen de knop zelf --
-   anders zou een strook van 5rem boven- en onderaan je scroll opeten. */
-/* TWEE BALKEN over de volle breedte, boven en onder. Eerder waren dit losse
-   cirkels (te rommelig naast de tabbalk en de speler) en daarna onzichtbare
-   tikzones -- maar die werkten niet, omdat de onderste strook onder de
-   mini-speler en de tabbalk ligt en die de tik opvangen. Een balk met een eigen
-   z-index kan daar wel overheen.
-   Slank gehouden: ze eten hooguit 2.6rem aan elke kant, en het paneel houdt daar
-   al ruimte voor vrij. */
-:root { --read-balk: 2.6rem; }
-.read-bar {
-    position: fixed; left: 0; right: 0; z-index: 1060;   /* boven tabbalk (1050) */
-    display: flex; align-items: center;
-    /* Pijlen LINKS in de balk, niet gecentreerd (Robin, 20-8). */
-    justify-content: flex-start;
-    padding-left: max(1.1rem, env(safe-area-inset-left, 0px));
-    box-sizing: border-box;
-    height: var(--read-balk);
-    border: 0;
-    border-top: 1px solid transparent;
-    background: color-mix(in srgb, var(--paper) 90%, var(--ink));
-    backdrop-filter: blur(14px) saturate(140%);
-    -webkit-backdrop-filter: blur(14px) saturate(140%);
-    color: var(--ink);
-    cursor: pointer;
-    transition: opacity .15s ease;
-}
-.read-bar--top {
-    top: 0;
-    height: calc(var(--read-balk) + env(safe-area-inset-top, 0px));
-    padding-top: env(safe-area-inset-top, 0px);
-    border-bottom: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
-}
-.read-bar--bottom {
-    /* De waarde komt uit mod/read.js: die meet hoe hoog de tabbalk en de
-       mini-speler werkelijk staan. Geraden rem-waarden lieten hem zweven -- een
-       kier boven de speler, of er half achter. Dit is de terugval voor de eerste
-       frame en voor als de module niet laadt. */
-    bottom: 0;
-    border-top: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
-}
-.read-bar:active { background: color-mix(in srgb, var(--paper) 78%, var(--ink)); }
-.read-bar[hidden], body:not(.is-paged) .read-bar { display: none; }
-
-/* Een lijn tussen twee berichten: zonder grens is een stroom een lange brij. */
-.feed-reader .read-post + .read-post { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
-
-/* De cover vult de leeskolom. Eigen regel, niet .post-cover hergebruikt: die
-   heeft een marge van 2rem die hier tegen de scroll-snap aan duwt.
-   De hoogtebegrenzing is er met opzet. Een paneel is min-height:100svh en snapt,
-   dus een staande foto op volle breedte zou de titel en de eerste alinea onder
-   de vouw duwen -- dan begint elk bericht met een plaat waar je langs moet
-   scrollen om te zien wat het IS. */
-.feed-reader .read-cover {
-    /* Volle kolombreedte: de afbeelding erin neemt die breedte, dus de figure
-       hoeft niet meer om het beeld te krimpen. */
-    width: 100%;
-    margin: 0 0 1.2rem;
-    border-radius: 10px;
-    overflow: hidden;
-}
-.feed-reader .read-cover img,
-.feed-reader .read-cover video {
-    display: block;
-    /* Neemt de VOLLE kolombreedte, met de verhouding intact (object-fit
-       weggelaten, dus geen bijsnijden). Een cover die van zichzelf kleiner is
-       wordt netjes tot de kolom opgeschaald in plaats van als postzegeltje te
-       blijven staan -- dat was te klein. De hoogtegrens houdt een staande foto
-       nog binnen de perken zodat titel + eerste alinea boven de vouw blijven;
-       hij krimpt dan mee in plaats van bijgesneden te worden. */
-    display: block;
-    width: 100%;
-    height: auto;
-    max-height: 70svh;
-    object-fit: contain;
-    /* Een logo met doorzichtige achtergrond zweeft anders op de paginakleur;
-       dezelfde keuze als bij .post-list-cover in de tijdlijn. */
-    background-color: #fff;
-}
-
-.feed-reader .read-head { padding-bottom: .5rem; }
-.feed-reader .read-title { font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .3rem; }
-/* Het pin-icoon voor de titel: meeschalend met de kop, maar duidelijk kleiner --
-   het is een markering en geen deel van de titel. */
-.feed-reader .read-pin {
-    display: inline-block;
-    vertical-align: baseline;
-    width: .62em; height: .62em;
-    margin-right: .35em;
-    color: var(--ink-muted);
-}
-.feed-reader .read-pin svg { width: 100%; height: 100%; display: block; }
-/* De titel is een link, maar mag er niet als een link uitzien: dit is een
-   leesscherm, geen index. */
-.feed-reader .read-title a { color: inherit; text-decoration: none; }
-.feed-reader .read-title a:hover,
-.feed-reader .read-title a:focus-visible { text-decoration: underline; }
-.feed-reader .read-when { font-size: .85rem; opacity: .6; margin: 0; }
-
-.feed-reader .read-foot { margin-top: 2rem; }
-.feed-reader .read-open {
-    display: inline-block; font-size: .9rem; opacity: .75;
-    text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px;
-}
-.feed-reader .read-open:hover,
-.feed-reader .read-open:focus-visible { opacity: 1; }
-
-/* Terug naar boven. Rechtsonder, want daar zit de duim -- en boven de tabbalk,
-   die op mobiel 1050 is en anders overheen zou vallen. Onzichtbaar tot je ver
-   genoeg bent, zodat hij niet meteen in de weg staat bij het eerste bericht. */
-.read-top {
-    position: fixed;
-    right: max(1rem, env(safe-area-inset-right, 0));
-    bottom: calc(env(safe-area-inset-bottom, 0) + 4.75rem);
-    z-index: 1040;
-    display: grid;
-    place-items: center;
-    width: 44px;
-    height: 44px;                 /* 44px = de kleinste maat die een duim betrouwbaar raakt */
-    border-radius: 999px;
-    border: 1px solid var(--rule);
-    background: color-mix(in srgb, var(--paper) 88%, transparent);
-    backdrop-filter: blur(14px) saturate(140%);
-    -webkit-backdrop-filter: blur(14px) saturate(140%);
-    color: var(--ink);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink) 12%, transparent);
-    cursor: pointer;
-    opacity: 0;
-    pointer-events: none;
-    transition: opacity .18s ease, transform .18s ease;
-    transform: translateY(6px);
-}
-.read-top.is-zichtbaar { opacity: 1; pointer-events: auto; transform: none; }
-/* Buiten de leesweergave bestaat hij niet -- home.ejs rendert alle drie de
-   secties, dus zonder deze regel zweeft hij ook boven Tijdlijn en Grid. */
-body:not([data-feed-view="reader"]) .read-top,
-body:not(.on-home) .read-top { display: none; }
-/* Op desktop is er geen tabbalk, dus mag hij lager staan. */
-@media (min-width: 768px) {
-    .read-top { bottom: 1.5rem; right: 1.5rem; }
-}
-/* Staat de mini-speler onderin, dan wijkt de knop daarvoor -- anders komt hij er
-   bovenop te liggen (Robins schermafbeelding, 20-8). body.has-audio-player wordt
-   door audio-player.js gezet zodra de speler zichtbaar is, en weer weggehaald
-   als hij verdwijnt, dus dit volgt vanzelf.
-   Op mobiel staat de speler BOVEN de tabbalk gestapeld, dus daar komt zijn
-   hoogte bovenop de ruimte die er al gereserveerd was. */
-body.has-audio-player .read-top { bottom: calc(env(safe-area-inset-bottom, 0) + 4.75rem + 4.25rem); }
-@media (min-width: 768px) {
-    body.has-audio-player .read-top { bottom: calc(1.5rem + 4.25rem); }
-}
-@media (prefers-reduced-motion: reduce) {
-    .read-top { transition: none; transform: none; }
-}
-
-@media (prefers-reduced-motion: reduce) {
-    html:has(body.on-home[data-feed-view="reader"]) {
-        scroll-snap-type: none;
-        scroll-behavior: auto;
-    }
-}
+body.on-archive[data-feed-view="grid"] .feed-grid { display: block; }
 
 /* -------- GRID VIEW TILES (Instagram-stijl) -------- */
@@ -2829,45 +2106,4 @@
     -webkit-tap-highlight-color: rgba(0,0,0,.1);
 }
-
-/* Transparent covers (PNG logos etc.) sit on white so they don't blend into a dark
-   theme. The white is on the IMG, so the tile's accent-gradient placeholder shows
-   while the image is still loading. */
-.post-cover img,
-.post-list-cover img,
-.grid-tile-img { background-color: #fff; }
-/* Accent-gradient placeholder shown while the cover image loads (the image fades in
-   over it), instead of a white flash on cover tiles. */
-.grid-tile:not(.grid-tile-gradient) {
-    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%);
-}
-/* Cover as a real <img> (not background-image): browsers downsample <img> with the
-   high-quality image pipeline, so high-res line-art covers don't go jagged when shrunk.
-   Fades in on load (.is-loading removed) so the placeholder shows meanwhile. */
-.grid-tile-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; transition: opacity .4s ease; }
-.grid-tile-img.is-loading { opacity: 0; }
-/* Audio tile (Shaer, Robins vraag 30-7): the waveform PNG is white on
-   transparent and floats OVER the tile's own hue gradient, so every audio
-   post keeps its color and gets its shape. Wave and title each get their
-   own band (wave upper, title lower): centering both stacked them right
-   through each other (Robins schermafdruk). */
-.grid-tile-img.grid-tile-wave {
-    background-color: transparent;
-    object-fit: contain;
-    /* explicit box, not auto: an absolutely positioned img falls back to its
-       intrinsic size when width/height are auto, collapsing the band */
-    top: 8%; left: 8%; right: auto; bottom: auto;
-    width: 84%; height: 46%;
-    opacity: .85;
-}
-.grid-tile-audio .grid-tile-title { position: absolute; left: 10%; right: 10%; top: 60%; }
-.grid-tile-audio .grid-tile-gradient-src { position: absolute; left: 10%; right: 10%; top: 76%; }
-/* Cover videos are decorative, not players: let clicks + right-click pass through to the
-   tile link so you get the normal link menu (not the browser's <video> menu/controls).
-   Autoplay is independent of pointer-events, so the video still plays. */
-video.grid-tile-img,
-.post-list-cover video,
-.post-cover video { pointer-events: none; }
 .grid-tile:hover { transform: scale(0.98); }
 .grid-tile-overlay {
@@ -2876,7 +2112,5 @@
     padding: .6rem;
     display: flex;
-    flex-direction: column;
-    justify-content: flex-end;
-    align-items: flex-start;
+    align-items: flex-end;
     background: linear-gradient(to top, rgba(0,0,0,.65) 0%, rgba(0,0,0,0) 50%);
     opacity: 0;
@@ -2884,19 +2118,4 @@
     pointer-events: none; /* klik moet naar de <a> eronder gaan, niet naar de overlay */
 }
-/* Bron-site (cirkel-feed): toont bij hover "van <site>" onder de titel. */
-/* Source label: small text that wraps to 2 lines if needed, with a big chain icon
-   beside it (the text was 1.44rem — bigger than the title — and overflowed on mobile). */
-.grid-tile-overlay-src {
-    color: #fff; opacity: .85;
-    font-size: .72rem; line-height: 1.2; margin-top: .25rem;
-    display: flex; align-items: center; gap: .3rem; width: 100%;
-}
-.grid-tile-overlay-src::before { content: "⛓"; font-size: 1.3rem; line-height: 1; flex: 0 0 auto; }
-.grid-tile-gradient-src {
-    margin-top: .35rem; font-size: .72rem; line-height: 1.2; opacity: .85;
-    color: var(--ink-soft);
-    display: flex; align-items: center; gap: .3rem; width: 100%;
-}
-.grid-tile-gradient-src::before { content: "⛓"; font-size: 1.3rem; line-height: 1; flex: 0 0 auto; }
 .grid-tile-overlay strong {
     color: #fff;
@@ -2923,5 +2142,4 @@
     background-image: linear-gradient(135deg, hsl(var(--tile-hue, 30), 45%, 88%), hsl(calc(var(--tile-hue, 30) + 30), 35%, 75%));
     display: flex;
-    flex-direction: column;
     align-items: center;
     justify-content: center;
@@ -2944,10 +2162,4 @@
     overflow: hidden;
     z-index: 1;
-    /* Federated link posts can have a long bare URL as their title — break it so it wraps
-       inside the tile and clamps cleanly instead of overflowing past both edges. */
-    max-width: 100%;
-    overflow-wrap: anywhere;
-    word-break: break-word;
-    hyphens: auto;
 }
 
@@ -2972,21 +2184,17 @@
     pointer-events: none;
 }
-.grid-tile-pin svg {
-    width: .95em;
-    height: .95em;
-    margin-top: -1px;
-}
-/* Pinned/boosted tile: accent ring. An outline on the tile is painted UNDER the absolute
-   cover <img>, so draw the ring as a pseudo-element overlay ABOVE the image (z-index above
-   the img, below the corner badges + nsfw veil). */
-.grid-tile.is-pinned { position: relative; z-index: 1; }
-.grid-tile.is-pinned::after {
-    content: '';
-    position: absolute;
-    inset: 0;
-    border: 3px solid var(--accent);
-    border-radius: inherit;
-    pointer-events: none;
+.grid-tile-pin::before {
+    content: "📌";
+    font-size: .9em;
+    line-height: 1;
+}
+/* Pinned-tile overall highlight: accent border + subtle inner glow */
+.grid-tile.is-pinned {
+    outline: 3px solid var(--accent);
+    outline-offset: -3px;
     z-index: 1;
+}
+.grid-tile.is-pinned:hover {
+    outline-offset: -2px;
 }
 .grid-tile-type {
@@ -3156,35 +2364,4 @@
 }
 
-/* Solo / Cirkel scope-switcher (alleen in circle-modus) — segmented toggle, centraal.
-   Hoge specificiteit (+ !important op tekst) zodat 'ie globale link-stijlen wint. */
-.view-switcher-wrap .feed-scope {
-    display: inline-flex;
-    background: var(--paper-2);
-    border: 1px solid var(--rule);
-    border-radius: 999px;
-    padding: 3px;
-}
-.view-switcher-wrap .feed-scope-btn {
-    display: inline-flex;
-    align-items: center;
-    padding: .32rem .95rem;
-    min-height: 30px;
-    border: none !important;
-    border-radius: 999px !important;
-    background: transparent !important;
-    color: var(--ink-muted) !important;
-    font-family: var(--font-ui);
-    font-size: .82rem;
-    font-weight: 500 !important;
-    text-decoration: none !important;
-    cursor: pointer;
-    transition: all .15s;
-}
-.view-switcher-wrap .feed-scope-btn:hover { color: var(--ink) !important; }
-.view-switcher-wrap .feed-scope-btn.is-active {
-    background: var(--ink) !important;
-    color: var(--paper) !important;
-}
-
 .grid-cols-picker {
     display: none; /* default hidden — desktop + grid-active maakt 'm zichtbaar */
@@ -3221,17 +2398,17 @@
 }
 
-/* Desktop + grid-view → toon cols-picker (op de home-feed; niet op archief) */
+/* Desktop + grid-view → toon cols-picker (op home + archive) */
 @media (min-width: 600px) {
-    body.on-home[data-feed-view="grid"] .grid-cols-picker {
+    body.on-home[data-feed-view="grid"] .grid-cols-picker,
+    body.on-archive[data-feed-view="grid"] .grid-cols-picker {
         display: inline-flex;
     }
 }
 
-/* Kolom-overrides op desktop. Mobiel negeert dit (staat vast op 2). Geldt op ELKE
-   feed met grid (home én cirkel/tag/type/user) — scope op .feed-grid .grid-tiles. */
+/* Kolom-overrides op desktop. Mobiel negeert dit (staat vast op 2). */
 @media (min-width: 600px) {
-    body[data-grid-cols="2"] .feed-grid .grid-tiles { grid-template-columns: repeat(2, 1fr); }
-    body[data-grid-cols="3"] .feed-grid .grid-tiles { grid-template-columns: repeat(3, 1fr); }
-    body[data-grid-cols="4"] .feed-grid .grid-tiles { grid-template-columns: repeat(4, 1fr); }
+    body.on-home[data-grid-cols="2"] .grid-tiles { grid-template-columns: repeat(2, 1fr); }
+    body.on-home[data-grid-cols="3"] .grid-tiles { grid-template-columns: repeat(3, 1fr); }
+    body.on-home[data-grid-cols="4"] .grid-tiles { grid-template-columns: repeat(4, 1fr); }
 }
 
@@ -3263,5 +2440,5 @@
 
 /* Masthead: compacter */
-.masthead { padding: calc(.55rem + var(--ios-safe-top, 0px)) 0 .55rem; }
+.masthead { padding: calc(.55rem + var(--safe-top)) 0 .55rem; }
 .masthead-inner { gap: 1rem; }
 .site-title { font-size: 1.25rem; }
@@ -3291,5 +2468,5 @@
 
 /* Site footer compacter */
-.site-footer { padding: 1.25rem 0 calc(1.25rem + env(safe-area-inset-bottom, 0px)); margin-top: 2rem; font-size: .78rem; }
+.site-footer { padding: 1.25rem 0 calc(1.25rem + var(--safe-bot)); margin-top: 2rem; font-size: .78rem; }
 .site-footer-inner { gap: .75rem; }
 
@@ -3301,6 +2478,5 @@
 .profile-bio { font-size: .85rem; margin-top: .15rem; }
 .profile-links { margin-top: .5rem; gap: .4rem; }
-.profile-links a,
-.profile-links .profile-fedi-link { width: 32px; height: 32px; }
+.profile-links a { width: 32px; height: 32px; }
 
 @media (min-width: 768px) {
@@ -3525,21 +2701,4 @@
     border-color: var(--accent, #c2410c);
     background: color-mix(in srgb, var(--accent, #c2410c) 5%, var(--paper-2, transparent));
-}
-/* Korte highlight wanneer je via de mini-speler naar deze track springt. */
-.post-audio-track.pat-flash,
-.post-album-track-compact.pat-flash {
-    animation: patFlash 1.6s ease-out;
-    border-radius: 8px;
-}
-/* Blijvende highlight op de track die NU actief is in de speler. */
-.post-audio-track.pat-playing,
-.post-album-track-compact.pat-playing {
-    background: color-mix(in srgb, var(--accent, #c2410c) 12%, var(--paper-2, transparent));
-    box-shadow: inset 3px 0 0 0 var(--accent, #c2410c);
-    border-radius: 8px;
-}
-@keyframes patFlash {
-    0%, 30% { border-color: var(--accent, #c2410c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #c2410c) 35%, transparent); }
-    100% { box-shadow: 0 0 0 0 transparent; }
 }
 .post-audio-track .pat-play {
@@ -4983,278 +4142,2 @@
     }
 }
-
-/* === Fediverse interactions — inherits the native .comment styling === */
-.post-fediverse .fedi-stats { display: flex; gap: .45rem; margin: .35rem 0 1.5rem; align-items: center; }
-.post-fediverse .fedi-stat {
-  display: inline-flex; align-items: center; gap: .4rem;
-  padding: .32rem .7rem; border-radius: 999px; font-size: .85rem; line-height: 1;
-  font-variant-numeric: tabular-nums;
-  color: var(--ink-muted); background: transparent;
-  border: 1px solid var(--rule);
-}
-.post-fediverse .fedi-stat svg { width: 15px; height: 15px; }
-.post-fediverse .fedi-stat-btn { cursor: pointer; font: inherit; line-height: 1; transition: color .15s, border-color .15s, background .15s; }
-.post-fediverse .fedi-stat-btn:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, var(--rule)); background: var(--accent-tint); }
-.post-fediverse .fedi-stat-btn:hover svg { fill: color-mix(in srgb, var(--accent) 22%, transparent); }
-.post-fediverse .fedi-handle { color: var(--ink-soft, #888); font-size: .82rem; }
-.post-fediverse .comment-content p { margin: .2rem 0; }
-.post-fediverse .comment-content p:first-child { margin-top: 0; }
-.post-fediverse .comment-content p:last-child { margin-bottom: 0; }
-/* reply toggle: a plain "Reageer" action like the comment buttons (no triangle) */
-.post-fediverse .fedi-reply-toggle { list-style: none; cursor: pointer; display: inline-block; }
-.post-fediverse .fedi-reply-toggle::-webkit-details-marker { display: none; }
-.post-fediverse details[open] .fedi-reply-toggle { margin-bottom: .5rem; }
-
-/* Reply-via-fediverse CTA on post pages */
-.post-fediverse-cta { margin: 1.75rem 0; }
-.fedi-remote-reply-btn { display: inline-flex; align-items: center; gap: .45rem; }
-.post-fediverse-cta .fedi-remote-reply-btn {
-  border: 1px solid color-mix(in srgb, var(--accent, #888) 55%, transparent);
-  background: color-mix(in srgb, var(--accent, #888) 10%, transparent);
-  color: var(--ink, inherit); border-radius: 999px; padding: .5rem 1.05rem;
-  font-weight: 600; cursor: pointer; transition: background .15s ease;
-}
-.post-fediverse-cta .fedi-remote-reply-btn:hover { background: color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-.post-fediverse-cta .fedi-remote-reply-btn svg { color: var(--accent, currentColor); }
-.post-fediverse .comment-actions .fedi-remote-reply-btn { gap: .3rem; }
-
-/* Inline "reply via fediverse" address form (replaces the browser prompt) */
-.fedi-remote-form {
-  position: absolute; z-index: 60; display: inline-flex; align-items: center; gap: .4rem;
-  padding: .4rem; border-radius: 999px;
-  background: var(--paper-2, var(--paper, #fff));
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-  box-shadow: 0 10px 30px rgba(0,0,0,.28);
-}
-.fedi-remote-form input {
-  height: 2.2rem; padding: 0 .9rem; border-radius: 999px; font: inherit; min-width: 230px;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000);
-}
-.fedi-remote-form input::placeholder { color: var(--ink-soft, #999); }
-.fedi-remote-form input:focus { outline: none; border-color: color-mix(in srgb, var(--accent, #888) 70%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-.fedi-remote-form .fedi-remote-go {
-  height: 2.2rem; min-width: 2.2rem; padding: 0 .85rem; border-radius: 999px;
-  display: inline-flex; align-items: center; justify-content: center; font-size: 1rem;
-}
-.fedi-remote-form .fedi-remote-cancel { background: none; border: none; color: var(--ink-soft, #888); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .25rem .35rem; }
-.fedi-remote-form .fedi-remote-cancel:hover { color: var(--ink, #000); }
-
-/* ── NSFW / sensitive content — blur + click to reveal ───────────────── */
-.nsfw-media { position: relative; }
-.nsfw-veil {
-  position: absolute; inset: 0; z-index: 4; cursor: pointer; border-radius: inherit;
-  display: flex; flex-direction: column; align-items: center; justify-content: center;
-  gap: .5rem; padding: .75rem; text-align: center;
-  background: color-mix(in srgb, var(--paper, #14141a) 50%, transparent);
-  backdrop-filter: blur(22px) saturate(.6); -webkit-backdrop-filter: blur(22px) saturate(.6);
-}
-.nsfw-media.is-shown > .nsfw-veil { display: none; }
-.nsfw-veil-label { font-weight: 700; font-size: .82rem; color: var(--ink, #f3f1ea); text-shadow: 0 1px 2px rgba(0,0,0,.5); }
-.nsfw-veil-btn {
-  font-size: .72rem; font-weight: 600; padding: .3rem .8rem; border-radius: 999px;
-  border: 1px solid color-mix(in srgb, var(--ink, #fff) 55%, transparent);
-  background: color-mix(in srgb, var(--paper, #000) 35%, transparent); color: var(--ink, #f3f1ea);
-}
-/* Post page: blur cover + body behind a content-warning banner until revealed */
-.nsfw-gate .post-cover,
-.nsfw-gate .post-content { filter: blur(26px); pointer-events: none; user-select: none; transition: filter .2s ease; }
-.nsfw-gate.is-shown .post-cover,
-.nsfw-gate.is-shown .post-content { filter: none; pointer-events: auto; user-select: auto; }
-.nsfw-banner {
-  display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: 0 0 1.5rem;
-  padding: .8rem 1rem; border-radius: 12px;
-  border: 1px solid color-mix(in srgb, var(--accent, #e8b04b) 45%, transparent);
-  background: color-mix(in srgb, var(--accent, #e8b04b) 9%, transparent);
-}
-.nsfw-gate.is-shown .nsfw-banner { display: none; }
-.nsfw-banner-icon { font-size: 1.2rem; }
-.nsfw-banner-text { font-weight: 600; font-size: .92rem; color: var(--ink); }
-.nsfw-banner-btn {
-  margin-left: auto; cursor: pointer; font: inherit; font-size: .82rem; font-weight: 600;
-  padding: .4rem 1rem; border-radius: 999px; border: 0;
-  background: var(--accent, #e8b04b); color: #1a1205;
-}
-
-/* ── Het bandje (mixtape) ──────────────────────────────────────────────
-   Een cassette, getekend als SVG op de echte maat: 100,4 x 63,8 mm, dus een
-   viewBox van 314x200. De vorige versie tekende twee schijfjes met CSS; dit is
-   een replica -- behuizing, labelvlak, venster, twee spoelen met tandjes, de
-   schroefjes in de hoeken en de openingen voor de kop en de capstans onderin.
-
-   Alle kleuren komen uit de bestaande variabelen, dus licht, donker en het
-   accent van de site volgen vanzelf. */
-.post-tape {
-    position: relative;
-    border: 1px solid var(--rule, rgba(0,0,0,.1));
-    border-radius: 14px;
-    overflow: hidden;
-    margin: 1.25rem 0;
-    background: var(--paper-2, color-mix(in srgb, var(--ink) 3%, transparent));
-}
-
-.tape-shell { position: relative; padding: .8rem .8rem .3rem; }
-.tape-svg { display: block; width: 100%; height: auto; }
-
-/* De behuizing. Twee lagen zodat er een randje omheen zit, zoals bij een echt
-   doosje waar de bovenschaal iets uitsteekt. */
-.tape-body { fill: color-mix(in srgb, var(--ink) 82%, var(--paper, #fff)); }
-.tape-body-inner { fill: color-mix(in srgb, var(--ink) 70%, var(--paper, #fff)); }
-.tape-labelplate { fill: color-mix(in srgb, var(--paper, #fff) 92%, var(--accent)); }
-.tape-glass {
-    fill: color-mix(in srgb, var(--ink) 88%, transparent);
-    stroke: color-mix(in srgb, var(--paper, #fff) 25%, transparent);
-    stroke-width: 1.5;
-}
-.tape-pack { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
-.tape-ribbon { fill: color-mix(in srgb, var(--ink) 45%, var(--paper, #fff)); }
-.tape-hub { fill: var(--paper, #fff); }
-.tape-tooth { fill: color-mix(in srgb, var(--ink) 60%, var(--paper, #fff)); }
-.tape-screw { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
-.tape-slot { fill: color-mix(in srgb, var(--ink) 92%, transparent); }
-.tape-slot--capstan { fill: color-mix(in srgb, var(--ink) 96%, transparent); }
-
-/* Draaien. De tandjes zitten in dezelfde groep als de naaf, dus de hele spoel
-   draait als een geheel -- daar gaat het om, want aan die tandjes zie je pas
-   DAT hij draait. Het label blijft stil, anders wordt de titel onleesbaar. */
-.post-tape.is-playing .tape-reel { animation: tape-spin 3s linear infinite; }
-.post-tape.is-playing .tape-reel--right { animation-duration: 2.2s; }
-/* Spoelen: veel sneller, en achteruit ook echt achteruit. */
-.post-tape.is-winding .tape-reel { animation-duration: .45s; }
-.post-tape.is-winding-back .tape-reel { animation-direction: reverse; }
-@keyframes tape-spin { to { transform: rotate(360deg); } }
-@media (prefers-reduced-motion: reduce) {
-    .post-tape.is-playing .tape-reel,
-    .post-tape.is-winding .tape-reel { animation: none; }
-}
-
-/* De tekst staat OP het labelvlak van de cassette. Percentages volgen de
-   viewBox, dus het blijft kloppen op elke breedte. */
-.tape-label {
-    position: absolute;
-    left: 8.4%; right: 8.4%;
-    top: calc(.8rem + 11%);
-    height: 31%;
-    padding: .1rem .4rem;
-    color: color-mix(in srgb, var(--ink) 92%, transparent);
-    overflow: hidden;
-}
-.tape-kind {
-    margin: 0;
-    font-size: clamp(.55rem, 1.6vw, .68rem);
-    letter-spacing: .14em;
-    text-transform: uppercase;
-    opacity: .7;
-}
-.tape-title {
-    margin: 0;
-    font-size: clamp(.85rem, 2.6vw, 1.1rem);
-    line-height: 1.15;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-}
-.tape-artist { margin: 0; font-size: clamp(.65rem, 1.9vw, .8rem); opacity: .8; }
-.tape-meta { margin: 0; font-size: clamp(.6rem, 1.7vw, .72rem); opacity: .6; }
-
-/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een cassette
-   niet, en dat is de bedoeling -- er is geen zoekbalk omdat je op een bandje
-   niet kunt zoeken, alleen spoelen. */
-.tape-controls {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: .6rem;
-    padding: .5rem 0 .6rem;
-}
-.tape-btn {
-    display: grid;
-    place-items: center;
-    width: 40px; height: 40px;
-    border-radius: 50%;
-    border: 1px solid var(--rule, rgba(0,0,0,.15));
-    background: var(--paper, #fff);
-    color: var(--ink);
-    cursor: pointer;
-    padding: 0;
-    touch-action: manipulation;   /* vasthouden mag geen zoom of scroll worden */
-    user-select: none;
-}
-.tape-btn svg { width: 18px; height: 18px; }
-.tape-btn:hover { border-color: var(--accent); color: var(--accent); }
-.tape-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
-.tape-btn--play {
-    width: 52px; height: 52px;
-    background: var(--accent);
-    border-color: var(--accent);
-    color: var(--paper, #fff);
-}
-.tape-btn--play svg { width: 22px; height: 22px; }
-.post-tape.is-winding .tape-btn--back,
-.post-tape.is-winding .tape-btn--fwd { border-color: var(--accent); color: var(--accent); }
-
-.tape-now {
-    margin: 0;
-    padding: 0 .8rem .4rem;
-    text-align: center;
-    font-size: .8rem;
-    opacity: .8;
-    min-height: 1.2em;
-}
-
-/* De nummers staan er als INHOUD, niet als knoppen. Geen cursor, geen hover,
-   geen aanwijzing dat je erop kunt drukken: op een cassette kun je dat niet. */
-.tape-tracks {
-    margin: 0;
-    padding: 0 .8rem .8rem;
-    list-style: none;
-    counter-reset: tape;
-    display: grid;
-    gap: .1rem;
-}
-.tape-track {
-    counter-increment: tape;
-    display: flex;
-    gap: .5rem;
-    align-items: baseline;
-    font-size: .85rem;
-    padding: .18rem .3rem;
-    border-radius: 6px;
-    opacity: .75;
-}
-.tape-track::before {
-    content: counter(tape) ".";
-    opacity: .55;
-    min-width: 1.4em;
-    font-variant-numeric: tabular-nums;
-}
-.tape-track-title { flex: 1; }
-.tape-track-dur { opacity: .6; font-variant-numeric: tabular-nums; }
-.tape-track.is-current {
-    opacity: 1;
-    background: color-mix(in srgb, var(--accent) 12%, transparent);
-}
-
-
-/* ── De speler in bandmodus ────────────────────────────────────────────
-   Zelfde speler, ander gedrag: op een cassette kies je geen nummer. De lijst
-   blijft staan zodat je ziet wat erop staat, maar zonder de opmaak die belooft
-   dat je erin kunt prikken. */
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item {
-    cursor: default;
-    pointer-events: none;
-}
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item:hover {
-    background: none;
-}
-/* Het lopende nummer blijft wel gemarkeerd -- dat is de enige plek waar je op
-   een bandje leest waar je bent. */
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item.is-current {
-    opacity: 1;
-}
-/* Spoelknoppen mogen niet als "volgende" voelen: vasthouden hoort geen tekst te
-   selecteren of de pagina te laten zoomen. */
-.audio-btn.is-wind {
-    touch-action: manipulation;
-    user-select: none;
-}
Index: src/assets/fonts/OFL.txt
===================================================================
--- src/assets/fonts/OFL.txt	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,107 +1,0 @@
-The fonts bundled in this directory are licensed under the SIL Open Font
-License, Version 1.1. The license is reproduced in full below.
-
-Bundled fonts and their copyright holders:
-
-  Fraunces
-  Copyright 2019 The Fraunces Project Authors
-  (https://github.com/undercasetype/Fraunces)
-
-  Plus Jakarta Sans
-  Copyright 2020 The Plus Jakarta Sans Project Authors
-  (https://github.com/tokotype/PlusJakartaSans)
-
-  Literata
-  Copyright 2018 The Literata Project Authors
-  (https://github.com/googlefonts/literata)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://openfontlicense.org
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply to any
-document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
Index: src/assets/img/cirkel-logo.svg
===================================================================
--- src/assets/img/cirkel-logo.svg	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,13 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round">
-  <defs>
-    <marker id="cf-a" viewBox="0 0 10 10" refX="6.5" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
-      <path d="M1.5 1.5 L8.5 5 L1.5 8.5 Z" fill="black" stroke="none"/>
-    </marker>
-  </defs>
-  <line x1="28.95" y1="43.5" x2="63.05" y2="26.5" stroke-width="2.6" marker-start="url(#cf-a)" marker-end="url(#cf-a)"/>
-  <line x1="72" y1="32" x2="72" y2="66" stroke-width="2.6" marker-end="url(#cf-a)"/>
-  <line x1="28.8" y1="52.7" x2="63.2" y2="71.3" stroke-width="2.6" marker-end="url(#cf-a)"/>
-  <circle cx="20" cy="48" r="7" stroke-width="2.4"/>
-  <circle cx="72" cy="22" r="7" stroke-width="2.4"/>
-  <circle cx="72" cy="76" r="7" stroke-width="2.4"/>
-</svg>
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/js/audio-player.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,4 +1,4 @@
 /**
- * Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
+ * PrutCMS v10 Audio Player — v9 mini-player + Spotify-style sheet.
  *
  * Two surfaces:
@@ -13,5 +13,5 @@
  *  - body.has-audio-player adds bottom padding when player visible
  *  - body.audio-sheet-locked prevents body scroll when sheet open
- *  - Survives HTMX swaps + history-restores via event delegation on document.body
+ *  - Survives HTMX swaps via htmx:afterSettle re-attach
  *
  * Singleton — guards against double-init.
@@ -27,7 +27,4 @@
     vol:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 8a5 5 0 010 8M19 5a9 9 0 010 14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
     mute:  '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM17 9l5 5M22 9l-5 5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
-    // Dubbele driehoeken: het teken voor spoelen, niet voor overslaan.
-    rew:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 12l9-7v14zM2 12l9-7v14z" fill="currentColor"/></svg>',
-    ff:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 12L4 5v14zM22 12l-9-7v14z" fill="currentColor"/></svg>',
     musicNote: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 17V5l12-2v12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>',
   };
@@ -131,8 +128,4 @@
   const prevBtn = $('audio-prev');
   const nextBtn = $('audio-next');
-  // De vier knoppen die in bandmodus van betekenis veranderen: vorige/volgende
-  // worden terugspoelen/vooruitspoelen. Ze staan hier bij elkaar zodat het
-  // omzetten op EEN plek gebeurt.
-  const SPOELKNOPPEN = ['audio-prev', 'audio-next', 'audio-sheet-prev', 'audio-sheet-next'];
   const muteBtn = $('audio-mute');
   const volumeSlider = $('audio-volume');
@@ -165,41 +158,4 @@
   let isPlaying = false;
   let albumName = '';
-  // BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
-  // nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
-  // doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
-  // -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
-  // bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
-  // en spoelen gaat in seconden in plaats van per nummer.
-  let tapeMode = false;
-  // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
-  // gate; no plain media URL is ever exposed to the page) and feed them to the
-  // <audio> element through one of two engines:
-  //
-  //  1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
-  //     ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
-  //     The next track's bytes are APPENDED into the same buffer, so the whole
-  //     queue is one continuous playback session. That is what keeps a
-  //     backgrounded tab/PWA playing across track changes: Chrome's background
-  //     media policy pauses NEW playback sessions started in the background
-  //     (the old per-track src-swap + load() + play()), but never interrupts a
-  //     continuing one. A track change becomes a timeline position, not a swap.
-  //  2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
-  //     objectURL per track, the previous behaviour.
-  let currentObjectUrl = null;
-  // Monotonic load token: a fast prev/next can fire several loads before an
-  // earlier fetch resolves. Only the latest load may touch the audio pipeline.
-  let loadSeq = 0;
-  // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
-  // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
-  let preload = null;
-  // ── MSE chain state ──
-  const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
-  let mseFailed = false;               // runtime bail → blob engine for the rest of this session
-  const useMse = () => MSE_SUPPORTED && !mseFailed;
-  let ms = null;                       // MediaSource
-  let sb = null;                       // SourceBuffer
-  let chain = [];                      // appended segments: { qIndex, start, end } (timeline seconds)
-  let chainFetching = false;           // a fetch+append for the NEXT track is in flight
-  let sbOps = Promise.resolve();       // serializes SourceBuffer operations
 
   // Hide initially
@@ -221,379 +177,5 @@
   }
 
-  // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
-  // same-origin credentials get us past the stream route's access gate.
-  // Retries a few times with backoff: a single transient network blip used to
-  // bump the error counter and SKIP the song (auto-advance past it). Now one
-  // hiccup just costs a retry, and we only give up after genuinely failing.
-  async function fetchTrackBytes(url, attempts) {
-    attempts = attempts || 1;
-    let lastErr;
-    for (let i = 0; i < attempts; i++) {
-      try {
-        const r = await fetch(url, {
-          credentials: 'same-origin',
-          headers: { 'X-Audio-Player': '1' },
-        });
-        if (!r.ok) throw new Error('HTTP ' + r.status);
-        return await r.arrayBuffer();
-      } catch (e) {
-        lastErr = e;
-        if (i < attempts - 1) {
-          await new Promise((res) => setTimeout(res, 350 * (i + 1)));
-        }
-      }
-    }
-    throw lastErr;
-  }
-
-  // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
-  function applyBlobBytes(bytes, autoplay, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded
-    const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
-    root.classList.remove('audio-loading');
-    // Free the previously-playing track's blob — otherwise each track leaks a
-    // copy. Never the same handle as objUrl (createObjectURL is unique), so this
-    // can't revoke the source we're about to play.
-    if (currentObjectUrl && currentObjectUrl !== objUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = objUrl;
-    // Clean transition: pause + load forces a reset of internal state after
-    // multiple src changes (prevents state corruption of the audio element).
-    try { audio.pause(); } catch (e) {}
-    audio.src = objUrl;
-    try { audio.load(); } catch (e) {}
-    if (autoplay) play();
-  }
-
-  function onLoadError(err, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
-    root.classList.remove('audio-loading');
-    console.error('[pcms-audio] track load failed after retries', err);
-    // Genuine failure (after retries): bump the counter and auto-skip, but stop
-    // after 3 in a row so a fully-broken queue can't loop "next" forever.
-    consecutiveErrors++;
-    if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
-  }
-
-  // Discard any held/in-flight preload (plain bytes now — GC handles them).
-  function dropPreload() { preload = null; }
-
-  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
-  // while the same track is already cached / in flight is a no-op. Called from
-  // the `playing` event so the network is otherwise idle. Blob engine only —
-  // the MSE engine "preloads" by appending ahead (ensureNextAppended).
-  function preloadNext() {
-    if (queue.length < 2) return;
-    if (tapeMode && currentIndex >= queue.length - 1) return;   // einde band
-    const ni = (currentIndex + 1) % queue.length;
-    const t = queue[ni];
-    if (!t || !t.url) return;
-    if (preload && preload.url === t.url) return;  // already held or in flight
-    const marker = { url: t.url, bytes: null };
-    preload = marker;
-    fetchTrackBytes(t.url, 2).then((bytes) => {
-      // Only keep it if this is still the track we want next.
-      if (preload === marker) marker.bytes = bytes;
-    }).catch(() => { if (preload === marker) preload = null; });
-  }
-
-  // ============================================================
-  // 4a. MSE chain engine — one continuous playback session
-  // ============================================================
-  // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
-  // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
-  // byte-stream format generates continuous timestamps — sequence mode).
-  // Auto-advance = playback simply flowing into the next track's region.
-
-  // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
-  // appended tracks would glitch the MPEG frame parser.
-  function stripId3(buf) {
-    const u8 = new Uint8Array(buf);
-    let start = 0, end = u8.length;
-    if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) {  // "ID3"
-      const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
-      const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0);  // +10 when a footer is flagged
-      if (skip < end) start = skip;
-    }
-    if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128;  // "TAG"
-    return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
-  }
-
-  function teardownChain() {
-    chain = [];
-    chainFetching = false;
-    sbOps = Promise.resolve();
-    sb = null;
-    ms = null;
-  }
-
-  // Serialize a SourceBuffer operation (append/remove): they throw if issued
-  // while the buffer is still updating, so everything funnels through a queue.
-  function sbRun(fn) {
-    const run = () => new Promise((resolve, reject) => {
-      if (!sb || !ms || ms.readyState !== 'open') return resolve();
-      const ok  = () => { cleanup(); resolve(); };
-      const err = (e) => { cleanup(); reject(e); };
-      function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
-      sb.addEventListener('updateend', ok);
-      sb.addEventListener('error', err);
-      try { fn(); } catch (e) { cleanup(); reject(e); }
-    });
-    const p = sbOps.then(run, run);
-    sbOps = p.catch(() => {});
-    return p;
-  }
-
-  // Append one track's bytes as the next segment of the chain.
-  async function appendSegment(qIndex, bytes, mySeq) {
-    const clean = stripId3(bytes);
-    try {
-      await sbRun(() => sb.appendBuffer(clean));
-    } catch (e) {
-      if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
-        // Evict already-played data and retry once.
-        const seg = currentSegment();
-        if (seg && seg.start > 1) {
-          await sbRun(() => sb.remove(0, seg.start - 0.5));
-          chain = chain.filter((s) => s.end > seg.start - 0.5);
-        }
-        await sbRun(() => sb.appendBuffer(clean));
-      } else {
-        throw e;
-      }
-    }
-    if (mySeq !== loadSeq || !sb) return;
-    const buffered = sb.buffered;
-    const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
-    const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
-    chain.push({ qIndex, start, end: chainEnd });
-    // DE STREAM MOET DICHT ALS ER NIETS MEER KOMT, anders vuurt `ended` nooit.
-    // Bij een wachtrij van een was dat al zo. Een BANDJE heeft nu hetzelfde
-    // nodig: sinds hij niet meer rondloopt haakt de keten na het laatste nummer
-    // niets meer aan, en dan bleef de band aan het eind hangen -- de teller
-    // stilstaand op de laatste seconde, isPlaying() waar, en de spoelen
-    // draaiend. Gemeten op dev (22-8): 125,6 van 125,7 en daar bleef hij.
-    const laatsteVanDeBand = tapeMode && qIndex >= queue.length - 1;
-    if ((queue.length === 1 || laatsteVanDeBand) && ms && ms.readyState === 'open') {
-      // Single-track queue: close the stream so `ended` fires (which replays
-      // it, matching the old engine's behaviour). Bij een bandje stopt `ended`
-      // hem juist, want next() pauzeert daar aan het eind.
-      try { ms.endOfStream(); } catch (e) {}
-    }
-  }
-
-  // Keep exactly one full track appended ahead of the one playing.
-  function ensureNextAppended() {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
-    if (queue.length < 2 || !chain.length) return;
-    const seg = currentSegment();
-    if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return;  // already one ahead
-    // EEN BANDJE LOOPT NIET ROND (Robins eis, 22-8). Deze modulo is precies wat
-    // een cassette eindeloos maakte: na het laatste nummer hing hij nummer een
-    // er weer achter, en omdat het een doorlopende keten is merk je dat niet
-    // eens als een trackwissel -- de band gaat gewoon door.
-    const volgendeInRij = chain[chain.length - 1].qIndex + 1;
-    if (tapeMode && volgendeInRij > queue.length - 1) return;   // einde band
-    const nextIdx = volgendeInRij % queue.length;
-    const t = queue[nextIdx];
-    if (!t || !t.url) return;
-    chainFetching = true;
-    const mySeq = loadSeq;
-    const bytesP = (preload && preload.url === t.url && preload.bytes)
-      ? Promise.resolve(preload.bytes)
-      : fetchTrackBytes(t.url, 2);
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      if (preload && preload.url === t.url) preload = null;
-      return appendSegment(nextIdx, bytes, mySeq);
-    }).catch((e) => {
-      console.warn('[pcms-audio] next-track append failed', e);
-    }).finally(() => { chainFetching = false; });
-  }
-
-  // Drop played-out data so the buffer holds ~2 tracks at most.
-  function pruneBuffer(curSeg) {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
-    const cut = curSeg.start - 0.5;
-    if (cut <= 1) return;
-    sbRun(() => sb.remove(0, cut)).catch(() => {});
-    chain = chain.filter((s) => s.end > cut);
-  }
-
-  function currentSegment() {
-    const t = audio.currentTime || 0;
-    for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
-    return chain[chain.length - 1] || null;
-  }
-
-  // Playback flowed across a track boundary (the gapless auto-advance):
-  // update chrome/metadata, top the buffer up, evict what's been played.
-  function maybeCrossBoundary() {
-    const seg = currentSegment();
-    if (!seg || seg.qIndex === currentIndex) return;
-    currentIndex = seg.qIndex;
-    const t = queue[currentIndex];
-    if (t) {
-      console.log('[pcms-audio] gapless auto-advance →', t.title);
-      updateTrackChrome(t);
-    }
-    ensureNextAppended();
-    pruneBuffer(seg);
-    updatePositionState();
-    savePlayerState();
-  }
-
-  // Start a fresh chain at queue[index]. Manual actions only (start/jump/
-  // prev/next/restore) — those happen in the foreground, where starting a
-  // new playback session is allowed.
-  function chainStart(index, autoplay, mySeq, bytes) {
-    teardownChain();
-    ms = new MediaSource();
-    const msUrl = URL.createObjectURL(ms);
-    if (currentObjectUrl && currentObjectUrl !== msUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = msUrl;
-    try { audio.pause(); } catch (e) {}
-    audio.src = msUrl;
-    try { audio.load(); } catch (e) {}
-    const bailToBlob = (e) => {
-      console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
-      mseFailed = true;
-      teardownChain();
-      if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
-    };
-    ms.addEventListener('sourceopen', () => {
-      if (mySeq !== loadSeq || !ms) return;
-      try {
-        sb = ms.addSourceBuffer('audio/mpeg');
-      } catch (e) { return bailToBlob(e); }
-      appendSegment(index, bytes, mySeq).then(() => {
-        if (mySeq !== loadSeq) return;
-        // Session-restore: land at the saved in-track position.
-        if (pendingSeek > 0 && chain.length) {
-          const seg = chain[0];
-          try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
-          pendingSeek = 0;
-        }
-        ensureNextAppended();
-      }).catch(bailToBlob);
-    }, { once: true });
-    if (autoplay) play();
-  }
-
-  // Current position/duration in TRACK coordinates (the MSE timeline is the
-  // whole chain; the UI always shows the single playing track).
-  // De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
-  // geeft in bandmodus de teller over de hele band -- en het opslaan van de
-  // sessie heeft juist de trackpositie nodig: bij het herstellen begint de
-  // keten opnieuw en start dit nummer weer op nul.
-  function trackTijd() {
-    if (useMse() && chain.length) {
-      const seg = currentSegment();
-      if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
-    }
-    return { cur: audio.currentTime || 0, dur: audio.duration };
-  }
-
-  // De lengte van de hele band, uit de bekende trackduren. Null zodra er van
-  // een nummer geen duur bekend is: een som met gaten is een verzonnen getal,
-  // en dan valt de teller liever terug op wat hij wel zeker weet.
-  function bandDuur() {
-    if (!queue.length) return null;
-    let som = 0;
-    for (const t of queue) {
-      const d = Number(t && t.duration) || 0;
-      if (d <= 0) return null;
-      som += d;
-    }
-    return som;
-  }
-
-  /** Hoeveel band ligt er voor nummer `i`. */
-  function bandOffset(i) {
-    let som = 0;
-    for (let n = 0; n < i && n < queue.length; n++) som += Number(queue[n].duration) || 0;
-    return som;
-  }
-
-  function displayTimes() {
-    // EEN BANDJE HEEFT EEN TELLER, geen nummerpositie. Hij telt door over de
-    // kant heen.
-    //
-    // Uit de trackduren en niet uit de keten, en dat verschil is zichtbaar: de
-    // keten bevat alleen wat gebufferd is, dus het totaal groeide mee tijdens
-    // het luisteren (2:05 met een nummer geladen, 4:07 met drie). En sprong je
-    // naar nummer drie, dan begon de keten daar opnieuw op nul en stond de
-    // teller weer aan het begin van de band.
-    if (tapeMode) {
-      const totaal = bandDuur();
-      if (totaal) return { cur: bandOffset(currentIndex) + (trackTijd().cur || 0), dur: totaal };
-      if (useMse() && chain.length) return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
-    }
-    return trackTijd();
-  }
-
-  // metaOnly: show the track in the UI but DON'T download its bytes yet.
-  // Used by the site pre-seed so opening a page doesn't auto-download audio;
-  // the blob is fetched lazily on the first play().
-  // Persistently mark the current track (stays highlighted as long as it's active).
-  function markPlaying(trackId) {
-    document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
-    if (!trackId) return;
-    const el = document.getElementById('track-' + trackId);
-    if (el) el.classList.add('pat-playing');
-  }
-  // After an htmx navigation the post DOM is replaced → reapply the highlight.
-  document.body.addEventListener('htmx:afterSettle', () => {
-    const t = queue[currentIndex];
-    if (t) markPlaying(t.id);
-  });
-
-  // Media Session metadata (lock-screen / notification info + artwork). Set per
-  // track; the action handlers are wired once below. Keeping a live media session
-  // is what lets iOS continue a programmatic auto-advance play() instead of
-  // pausing it immediately.
-  function updateMediaMetadata(t) {
-    if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
-    try {
-      const art = [];
-      if (t && t.cover) {
-        let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
-        art.push({ src: u, sizes: '512x512', type: '' });
-      }
-      navigator.mediaSession.metadata = new MediaMetadata({
-        title: (t && t.title) || 'Untitled',
-        artist: (t && t.artist) || '',
-        album: albumName || '',
-        artwork: art,
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-
-  // All the visible per-track chrome: titles, covers, queue highlight, media
-  // session metadata. Called from loadTrack AND from the gapless boundary-cross.
-  function updateTrackChrome(t) {
-    // In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
-    // staat eronder, zoals een cassette een titel op het label heeft en de
-    // nummers op het doosje.
-    const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
-    const onder = tapeMode ? (t.title || '') : (t.artist || '');
-    titleEl.textContent  = hoofd;
-    artistEl.textContent = onder;
-    sheetTitle.textContent  = hoofd;
-    sheetArtist.textContent = onder;
-    sheetAlbum.textContent  = albumName || '';
-    setCoverImage(cover,      t.cover);
-    setCoverImage(sheetCover, t.cover);
-    root.classList.remove('audio-player-hidden');
-    document.body.classList.add('has-audio-player');
-    renderQueue();
-    markPlaying(t.id);
-    updateMediaMetadata(t);
-  }
-
-  function loadTrack(index, autoplay, metaOnly) {
+  function loadTrack(index) {
     if (!queue[index]) {
       console.warn('[pcms-audio] loadTrack: no track at index', index);
@@ -606,85 +188,31 @@
       return;
     }
-    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
-    // Metadata + chrome update synchronously so the UI reacts instantly while
-    // the bytes download.
-    updateTrackChrome(t);
-
-    if (metaOnly) return;
-
-    const mySeq = ++loadSeq;
-
-    // Fast path: the bytes for this exact track were already prefetched while
-    // the previous track played → no gap, no fetch window.
-    let bytesP;
-    if (preload && preload.url === t.url && preload.bytes) {
-      bytesP = Promise.resolve(preload.bytes);
-      preload = null;
-    } else {
-      // Not preloaded (or still in flight) → drop any stale preload and fetch
-      // fresh, retrying transient failures before giving up.
-      dropPreload();
-      root.classList.add('audio-loading');
-      bytesP = fetchTrackBytes(t.url, 3);
-    }
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      root.classList.remove('audio-loading');
-      if (useMse()) chainStart(index, autoplay, mySeq, bytes);
-      else applyBlobBytes(bytes, autoplay, mySeq);
-    }).catch((err) => onLoadError(err, mySeq));
-  }
-
-  // True when the viewport is in the mobile sheet-layout — matches the CSS
-  // breakpoint where .audio-sheet slides up full-width from the bottom
-  // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
-  // panel, so we do NOT auto-open it there.
-  // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
-  // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
-  // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
-  // exactly follows the CSS breakpoints.
-  function playerTier() {
-    if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
-    if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
-    return 'phone';
-  }
-  function hasFullPlayer() { return playerTier() !== 'desktop'; }
-  function isMobileView() { return playerTier() === 'phone'; }
+    console.log('[pcms-audio] loading', t.title, t.url);
+    audio.src = t.url;
+    titleEl.textContent  = t.title  || 'Untitled';
+    artistEl.textContent = t.artist || '';
+    sheetTitle.textContent  = t.title  || 'Untitled';
+    sheetArtist.textContent = t.artist || '';
+    sheetAlbum.textContent  = albumName || '';
+    setCoverImage(cover,      t.cover);
+    setCoverImage(sheetCover, t.cover);
+    root.classList.remove('audio-player-hidden');
+    document.body.classList.add('has-audio-player');
+    renderQueue();
+  }
 
   function setQueue(tracks, startIdx, opts) {
     queue = Array.isArray(tracks) ? tracks.slice() : [];
     albumName = (opts && opts.albumName) || '';
-    tapeMode = !!(opts && opts.asTape);
-    zetKnopStanden();
     if (!queue.length) return;
-    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
-    // Mobile: a track press from an album/playlist auto-opens the full
-    // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
-    // Only here (setQueue = a fresh, user-initiated queue) —
-    // not on next/prev or the site pre-seed — so a sheet the user
-    // deliberately closed doesn't reappear by itself.
-    if (hasFullPlayer()) openSheet();
+    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
+    play();
   }
 
   function play() {
-    if (!audio.src) {
-      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
-      // in flight). Kick off the blob load for the current track and autoplay.
-      if (queue[currentIndex]) loadTrack(currentIndex, true);
-      return;
-    }
+    if (!audio.src) return;
     const p = audio.play();
     if (p && typeof p.catch === 'function') {
-      p.catch((err) => {
-        console.warn('[pcms-audio] play() rejected:', err.name, err.message);
-        // Browser autoplay policy blocked it (typically after 3-4
-        // auto-plays on iOS Safari, or when the tab was temporarily inactive).
-        // Show a visual hint for the user to tap play.
-        if (err && err.name === 'NotAllowedError') {
-          root.classList.add('audio-needs-tap');
-          isPlaying = false;
-          root.classList.remove('is-playing');
-        }
-      });
+      p.catch((err) => console.warn('[pcms-audio] play() rejected', err));
     }
   }
@@ -693,22 +221,14 @@
   function next() {
     if (!queue.length) return;
-    // Aan het eind van een bandje: stoppen. `ended` roept deze functie aan, dus
-    // zonder deze tak begint de band na het laatste nummer weer vooraan.
-    if (tapeMode && currentIndex >= queue.length - 1) { pause(); return; }
-    loadTrack((currentIndex + 1) % queue.length, true);
+    loadTrack((currentIndex + 1) % queue.length);
+    play();
   }
   function prev() {
     if (!queue.length) return;
-    // En aan het begin ook niet omlopen. Terugspoelen voorbij het begin levert
-    // de kop van de band op, niet het laatste nummer.
-    if (tapeMode && currentIndex === 0) {
-      try { audio.currentTime = 0; } catch (e) { /* nog niets geladen */ }
-      return;
-    }
-    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
+    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
+    play();
   }
   function close() {
     pause();
-    mediaRegistry().release(registrySelf);
     root.classList.add('audio-player-hidden');
     document.body.classList.remove('has-audio-player');
@@ -716,12 +236,4 @@
     queue = [];
     albumName = '';
-    tapeMode = false;
-    zetKnopStanden();
-    loadSeq++;  // cancel any in-flight load
-    dropPreload();
-    teardownChain();
-    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
-    currentObjectUrl = null;
-    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
   }
 
@@ -737,14 +249,10 @@
       sheetQueueList.appendChild(li);
     });
-    // OP EEN BANDJE KIES JE NIET. De lijst blijft staan -- je mag zien wat
-    // erop staat -- maar zonder klik en zonder de opmaak die belooft dat het
-    // kan. Dat is hetzelfde onderscheid als bij de cassette in de post.
-    sheetQueueList.classList.toggle('is-tape', tapeMode);
-    if (tapeMode) return;
     sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
       li.addEventListener('click', () => {
         const idx = parseInt(li.dataset.idx, 10);
         if (!isNaN(idx) && idx !== currentIndex) {
-          loadTrack(idx, true);
+          loadTrack(idx);
+          play();
         }
       });
@@ -758,123 +266,24 @@
 
   // ============================================================
-  // 4b. Mutual exclusion — shared media registry (see embed-player.js).
-  // ============================================================
-  // All players (this site player + YouTube/SoundCloud/Spotify embeds)
-  // register themselves in window.pcmsMediaRegistry. Starting one pauses
-  // the previous. This is the precise replacement for the old focus/blur
-  // heuristic for embeds with a real JS API. (The blur fallback below stays
-  // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
-  function mediaRegistry() {
-    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
-    const r = {
-      _active: null,
-      setActive(player) {
-        if (this._active && this._active !== player && this._active.pause) {
-          try { this._active.pause(); } catch (e) {}
-        }
-        this._active = player;
-      },
-      release(player) { if (this._active === player) this._active = null; },
-    };
-    window.pcmsMediaRegistry = r;
-    return r;
-  }
-  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
-
-  // ============================================================
   // 5. Audio element events → UI sync
   // ============================================================
-  // Error counter prevents an infinite loop when ALL tracks are broken.
-  let consecutiveErrors = 0;
-
-  audio.addEventListener('play',  () => {
-    isPlaying = true;
-    root.classList.add('is-playing');
-    root.classList.remove('audio-needs-tap');  // hide tap hint
-    mediaRegistry().setActive(registrySelf);   // pause any currently playing embeds
-    if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
-  });
-
-  // Media Session action handlers (wired once): lock-screen / headset / car
-  // controls, and — crucially — an active session so iOS keeps a programmatic
-  // auto-advance playing instead of pausing it the instant it starts.
-  if ('mediaSession' in navigator) {
-    const ms = navigator.mediaSession;
-    const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
-    wire('play', () => play());
-    wire('pause', () => pause());
-    wire('previoustrack', () => prev());
-    wire('nexttrack', () => next());
-    wire('seekto', (e) => {
-      if (!e || e.seekTime == null) return;
-      // Lock-screen scrubber works in TRACK coordinates (positionState below).
-      if (useMse() && chain.length) {
-        const seg = currentSegment();
-        if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
-        return;
-      }
-      if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
-    });
-  }
-  // Lock-screen / notification scrubber: report per-track position, not the
-  // whole-chain timeline.
-  function updatePositionState() {
-    if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
-    try {
-      const dt = displayTimes();
-      if (!isFinite(dt.dur) || !dt.dur) return;
-      navigator.mediaSession.setPositionState({
-        duration: dt.dur,
-        playbackRate: audio.playbackRate || 1,
-        position: Math.min(dt.cur, dt.dur),
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-  // Reset the error counter only on a REAL playback start (`playing`), not the
-  // eager `play` event. `play` fires before any network/decode error, so resetting
-  // there would prevent the 3-strikes stop from ever triggering on a broken
-  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
-  audio.addEventListener('playing', () => {
-    consecutiveErrors = 0;
-    if (useMse()) ensureNextAppended(); else preloadNext();
-    updatePositionState();
-  });
-  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
+  audio.addEventListener('play',  () => { isPlaying = true;  root.classList.add('is-playing'); });
+  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
   audio.addEventListener('ended', next);
   audio.addEventListener('error', (e) => {
     const code = audio.error ? audio.error.code : '?';
     console.error('[pcms-audio] playback error', code, audio.src, e);
-    if (useMse() && ms) {
-      // The MSE pipeline failed (decode/append) → permanently fall back to the
-      // blob engine for this session and retry the SAME track.
-      console.warn('[pcms-audio] MSE failed, falling back to blob playback');
-      mseFailed = true;
-      teardownChain();
-      if (queue[currentIndex]) loadTrack(currentIndex, true);
-      return;
-    }
-    consecutiveErrors++;
-    // On network/decode error: skip to next track instead of stalling.
-    // Max 3 consecutive errors before giving up (otherwise infinite loop).
-    if (consecutiveErrors < 3 && queue.length > 1) {
-      console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
-      setTimeout(next, 400);
-    }
   });
-  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
+  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled'));
   audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
   audio.addEventListener('timeupdate', () => {
-    // Gapless boundary: in MSE mode a track change is just the timeline
-    // flowing past a segment edge — detect it here and update the chrome.
-    if (useMse() && chain.length) maybeCrossBoundary();
-    const dt = displayTimes();
-    if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
-    const pct = (dt.cur / dt.dur) * 100;
+    if (!audio.duration || isNaN(audio.duration)) return;
+    const pct = (audio.currentTime / audio.duration) * 100;
     seekFill.style.width = pct + '%';
     sheetSeekFill.style.width = pct + '%';
-    currentEl.textContent  = formatTime(dt.cur);
-    totalEl.textContent    = formatTime(dt.dur);
-    sheetCurrent.textContent = formatTime(dt.cur);
-    sheetTotal.textContent   = formatTime(dt.dur);
+    currentEl.textContent  = formatTime(audio.currentTime);
+    totalEl.textContent    = formatTime(audio.duration);
+    sheetCurrent.textContent = formatTime(audio.currentTime);
+    sheetTotal.textContent   = formatTime(audio.duration);
   });
 
@@ -885,71 +294,9 @@
   }
 
-  /**
-   * Waar op de BAND ligt deze verhouding? Geeft het nummer en de positie erin.
-   *
-   * Dit is de hele truc van hele-band-seek. De balk toont al de hele band (de
-   * teller rekent met de trackduren), maar aanklikken werkte binnen het lopende
-   * nummer -- en de keten houdt maar een nummer vooruit, dus een balk die de
-   * hele band belooft reikte in werkelijkheid tot nummer twee. Dat is erger dan
-   * geen balk, want het ziet eruit alsof het werkt.
-   *
-   * Omrekenen kan alleen met de echte duren, en die hebben we sinds de teller
-   * uit bandDuur()/bandOffset() komt. Dezelfde omrekening werkt op BEIDE
-   * motoren: de blob-motor heeft helemaal geen doorlopende tijdlijn, dus daar
-   * is dit niet alleen de beste maar de enige manier.
-   */
-  function bandPositie(ratio) {
-    const totaal = bandDuur();
-    if (!totaal) return null;
-    const doel = Math.max(0, Math.min(ratio, 1)) * totaal;
-    for (let i = 0; i < queue.length; i++) {
-      const start = bandOffset(i);
-      const eind = start + (Number(queue[i].duration) || 0);
-      if (doel < eind || i === queue.length - 1) {
-        return { index: i, binnen: Math.max(0, Math.min(doel - start, (Number(queue[i].duration) || 0) - 0.25)) };
-      }
-    }
-    return null;
-  }
-
   function attachSeek(seekEl) {
     seekEl.addEventListener('click', (e) => {
+      if (!audio.duration) return;
       const rect = seekEl.getBoundingClientRect();
       const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-
-      // BANDMODUS: de balk is de hele band, dus de klik ook.
-      const plek = tapeMode ? bandPositie(ratio) : null;
-      if (plek) {
-        if (plek.index === currentIndex) {
-          // Binnen het lopende nummer: gewoon verzetten, geen herlaadsprong.
-          if (useMse() && chain.length) {
-            const seg = currentSegment();
-            if (seg) { try { audio.currentTime = seg.start + plek.binnen; } catch (er) {} }
-          } else {
-            try { audio.currentTime = plek.binnen; } catch (er) {}
-          }
-          updatePositionState();
-          return;
-        }
-        // Een ander nummer: laden en er meteen in springen. pendingSeek is de
-        // bestaande weg daarvoor -- het sessieherstel doet precies dit -- en
-        // wordt toegepast zodra de metadata er is. Er valt een hoorbaar gat bij
-        // de sprong; dat hoort bij spoelen naar een plek die nog niet in de
-        // buffer zit, en is eerlijker dan een balk die daar niet komt.
-        pendingSeek = plek.binnen;
-        loadTrack(plek.index, !audio.paused);
-        return;
-      }
-
-      if (useMse() && chain.length) {
-        // Seek within the CURRENT track's segment of the chain timeline.
-        const seg = currentSegment();
-        if (seg) {
-          try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
-          updatePositionState();
-        }
-        return;
-      }
-      if (!audio.duration) return;
       audio.currentTime = ratio * audio.duration;
     });
@@ -958,19 +305,10 @@
   attachSeek(sheetSeek);
 
-  // Volume + mute persist across sessions/pages via localStorage.
-  const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
-  const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
-  let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
-  if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
-  audio.volume = _initVol;
-  volumeSlider.value = Math.round(_initVol * 100);
-  if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
-  root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
+  audio.volume = 0.8;
   volumeSlider.addEventListener('input', () => {
     audio.volume = volumeSlider.value / 100;
     if (volumeSlider.value > 0) audio.muted = false;
-    saveVol();
   });
-  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
+  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
 
   // ============================================================
@@ -978,61 +316,19 @@
   // ============================================================
   playBtn.addEventListener('click', togglePlay);
-  /**
-   * De vier knoppen in de juiste stand zetten. In bandmodus zijn het
-   * spoelknoppen: ander teken, ander woord, en vasthouden spoelt door.
-   */
-  function zetKnopStanden() {
-    for (const id of SPOELKNOPPEN) {
-      const b = $(id);
-      if (!b) continue;
-      const vooruit = id.endsWith('next');
-      b.innerHTML = tapeMode ? (vooruit ? SVG.ff : SVG.rew) : (vooruit ? SVG.next : SVG.prev);
-      const label = tapeMode ? (vooruit ? 'Vooruitspoelen' : 'Terugspoelen') : (vooruit ? 'Volgende' : 'Vorige');
-      b.setAttribute('aria-label', label);
-      b.setAttribute('title', label);
-      b.classList.toggle('is-wind', tapeMode);
-    }
-  }
-
-  // Een KLIK: een nummer verder, of in bandmodus een stukje spoelen. Dat laatste
-  // is er voor toetsenbord en schermlezer, want vasthouden is met een
-  // spatiebalk geen gebaar.
-  const TIK_SPOEL_S = 5;
-  prevBtn.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  nextBtn.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
+  prevBtn.addEventListener('click', prev);
+  nextBtn.addEventListener('click', next);
   sheetPlay.addEventListener('click', togglePlay);
-  sheetPrev.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  sheetNext.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
-
-  // VASTHOUDEN spoelt door. Op de knop zelf indrukken, maar loslaten op
-  // document: glijdt je vinger van de knop af, dan hoort de band te stoppen en
-  // niet door te blijven spoelen.
-  for (const id of SPOELKNOPPEN) {
-    const b = $(id);
-    if (!b) continue;
-    b.addEventListener('pointerdown', () => { if (tapeMode) startWind(id.endsWith('next') ? 1 : -1); });
-  }
-  document.addEventListener('pointerup', stopWind);
-  document.addEventListener('pointercancel', stopWind);
+  sheetPrev.addEventListener('click', prev);
+  sheetNext.addEventListener('click', next);
 
   // ============================================================
   // 7. Sheet expand/close + drag-down-to-close
   // ============================================================
-  // Back button closes the sheet on mobile: on open we push a history entry
-  // so the phone back button (popstate) closes the sheet first instead of
-  // leaving the page. We balance it on a UI-initiated close via history.back().
-  let sheetHistoryPushed = false;
-
   function openSheet() {
-    if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
-    if (sheet.classList.contains('is-open')) return;
     sheet.classList.add('is-open');
     sheet.setAttribute('aria-hidden', 'false');
     document.body.classList.add('audio-sheet-locked');
-    // Phone + tablet: push a history entry so the back button closes the sheet first.
-    try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
-  }
-  function closeSheet(fromPopstate) {
-    if (!sheet.classList.contains('is-open')) return;
+  }
+  function closeSheet() {
     sheet.classList.remove('is-open');
     sheet.setAttribute('aria-hidden', 'true');
@@ -1040,87 +336,10 @@
     sheetPanel.style.removeProperty('--pcms-drag-y');
     sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
-    // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
-    // next back button navigates normally. On a popstate close (back button itself)
-    // the entry is already popped.
-    const wasPushed = sheetHistoryPushed;
-    sheetHistoryPushed = false;
-    if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
-  }
-  window.addEventListener('popstate', () => {
-    if (sheet.classList.contains('is-open')) closeSheet(true);
-  });
-  // Clicking the mini-player track info:
-  //  - DESKTOP (≥1200px): jump to the post the track came from (if known),
-  //    via htmx so audio keeps playing. No post known → fall back to the sheet.
-  //  - MOBILE/TABLET: always open the full now-playing sheet.
-  function scrollToTrack(trackId) {
-    if (!trackId) { window.scrollTo(0, 0); return; }
-    const el = document.getElementById('track-' + trackId);
-    if (!el) { window.scrollTo(0, 0); return; }
-    el.scrollIntoView({ block: 'center', behavior: 'smooth' });
-    el.classList.add('pat-flash');
-    setTimeout(() => el.classList.remove('pat-flash'), 1600);
-  }
-  function goToPost(url, trackId) {
-    const hash = trackId ? ('#track-' + trackId) : '';
-    if (window.htmx && url.charAt(0) === '/') {
-      try {
-        const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
-        history.pushState({}, '', url + hash);
-        // Scroll to the track after the swap (small delay so the global
-        // afterSwap scroll-to-top runs first); fall back to top if not found.
-        const go = () => setTimeout(() => scrollToTrack(trackId), 60);
-        if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
-        return;
-      } catch (e) { /* fall back to full navigation */ }
-    }
-    location.href = url + hash;
-  }
-  expandTrigger.addEventListener('click', () => {
-    const t = queue[currentIndex];
-    // Only on true desktop (≥1200, no full-player) do we jump to the post.
-    // Tablet + phone have a full-player → open it (same as mobile behaviour).
-    const jumpToPost = !hasFullPlayer();
-    if (jumpToPost && t) {
-      // 1) Track played from a post → we already know that URL.
-      if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
-      // 2) Site-wide track (no postUrl) → look up the post via the track id.
-      if (t.id) {
-        fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
-          .then((r) => (r.ok ? r.json() : null))
-          .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
-          .catch(() => openSheet());
-        return;
-      }
-    }
-    openSheet();
-  });
-  sheetClose.addEventListener('click', () => closeSheet());
-  sheetBackdrop.addEventListener('click', () => closeSheet());
+  }
+  expandTrigger.addEventListener('click', openSheet);
+  sheetClose.addEventListener('click', closeSheet);
+  sheetBackdrop.addEventListener('click', closeSheet);
   document.addEventListener('keydown', (e) => {
     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-  // Resized to desktop width (≥1200) while the full player is open? Close it —
-  // the full player doesn't exist on desktop.
-  window.addEventListener('resize', () => {
-    if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
-  // registry already handles this precisely (real play events). But for
-  // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
-  // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
-  // play event: we catch those via focus. User clicks such an iframe →
-  // window 'blur' → pause our player. (For API embeds this is at worst a
-  // harmless double-pause.)
-  window.addEventListener('blur', () => {
-    setTimeout(() => {
-      const el = document.activeElement;
-      // Only embed iframes (inside .folio-embed) pause the player — not a
-      // random iframe (captcha/ad/map) that happens to receive focus.
-      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
-        pause();
-      }
-    }, 0);
   });
 
@@ -1184,64 +403,55 @@
   // wrapper. For the other three the data is on the button itself. The
   // handler reads from button-first, falls back to wrapper.
-  // Event delegation on document.body instead of per-button listeners. This
-  // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
-  // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
-  // would leave dead buttons (flag baked into the snapshot, listener gone). One
-  // delegated listener works regardless of how many times the DOM is (re)swapped.
-  // WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
-  // regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
-  // en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
-  // Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
-  // gegevens klopten, en er gebeurde niets.
-  const PLAY_SELECTOR =
-    '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
-  document.body.addEventListener('click', (e) => {
-    const btn = e.target.closest(PLAY_SELECTOR);
-    if (!btn) return;
-    e.preventDefault();
-    e.stopPropagation();
-    // The post you're playing FROM = the current page (embeds live in post content).
-    // Store it on the track(s) so the desktop mini-player can jump back to it —
-    // survives the sessionStorage resume as well.
-    const postUrl = location.pathname + location.search;
-    // Resolve metadata: button-first, then closest .post-audio-track wrapper
-    // (only inline single-track widgets put the data on the wrapper).
-    const wrapper = btn.closest('.post-audio-track');
-    const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
-    const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
-    const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
-    console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
-
-    if (albumId) {
-      const album = document.getElementById(albumId);
-      if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
-      try {
-        const tracks = JSON.parse(album.dataset.pcmsAlbum);
-        tracks.forEach((t) => { t.postUrl = postUrl; });
-        // Start at the clicked track if we know its URL, else start at 0
-        // (cover-btn and playall both want to start from the beginning).
-        const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
-        // Een mixtape gaat als EEN object de speler in: de titel van het bandje
-        // op de speler, de teller over de hele band, en spoelen in seconden.
-        // De soort staat al op het blok (data-pcms-album-kind), dus hier is het
-        // een doorgeefje en geen tweede plek die iets afleidt.
-        setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
-          albumName: album.dataset.pcmsAlbumTitle || '',
-          asTape: album.dataset.pcmsAlbumKind === 'mixtape',
-        });
-      } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
-    } else if (trackData) {
-      try {
-        const t = JSON.parse(trackData);
-        t.postUrl = postUrl;
-        setQueue([t], 0);
-      } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
-    } else if (trackUrl) {
-      // Fallback: at minimum we have the signed URL
-      setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
-    } else {
-      console.error('[pcms-audio] no track data or url on button or wrapper', btn);
-    }
-  });
+  function attachListeners() {
+    const playBtns = document.querySelectorAll(
+      '.post-audio-track .pat-play, ' +
+      '.post-album-tracks .pat-row, ' +
+      '.post-album-cover-btn, ' +
+      '.post-album-playall'
+    );
+    if (playBtns.length) console.log('[pcms-audio] attaching to', playBtns.length, 'play buttons');
+
+    playBtns.forEach((btn) => {
+      if (btn.dataset.pcmsAttached) return;
+      btn.dataset.pcmsAttached = '1';
+
+      btn.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+        // Resolve metadata: button-first, then closest .post-audio-track wrapper
+        // (only inline single-track widgets put the data on the wrapper).
+        const wrapper = btn.closest('.post-audio-track');
+        const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
+        const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
+        const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
+        console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
+
+        if (albumId) {
+          const album = document.getElementById(albumId);
+          if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
+          try {
+            const tracks = JSON.parse(album.dataset.pcmsAlbum);
+            // Start at the clicked track if we know its URL, else start at 0
+            // (cover-btn and playall both want to start from the beginning).
+            const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
+            setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
+          } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
+        } else if (trackData) {
+          try {
+            const t = JSON.parse(trackData);
+            setQueue([t], 0);
+          } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
+        } else if (trackUrl) {
+          // Fallback: at minimum we have the signed URL
+          setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '' }], 0);
+        } else {
+          console.error('[pcms-audio] no track data or url on button or wrapper', btn);
+        }
+      });
+    });
+  }
+
+  attachListeners();
+  document.body.addEventListener('htmx:afterSettle', attachListeners);
 
   // ============================================================
@@ -1283,94 +493,6 @@
   // 9. Public API
   // ============================================================
-  /**
-   * SPOELEN, in seconden over de hele band.
-   *
-   * Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
-   * IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
-   * gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
-   *
-   * Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
-   * niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
-   * van het huidige nummer en stapt dan naar de buur. Grover, maar het is
-   * eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
-   */
-  function seekBy(seconden) {
-    const d = Number(seconden) || 0;
-    if (!d || !audio) return;
-    if (useMse() && chain.length) {
-      const eind = chain[chain.length - 1].end;
-      const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
-      try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
-      updatePositionState();
-      return;
-    }
-    // Blob-motor: binnen het nummer blijven, en anders naar de buur.
-    const duur = audio.duration || 0;
-    const nu = audio.currentTime || 0;
-    if (duur && nu + d >= duur) {
-      // Vooruit voorbij het eind. In bandmodus stopt next() aan het eind van de
-      // band; daarbuiten loopt hij door naar het volgende nummer.
-      next();
-      return;
-    }
-    if (nu + d < 0) {
-      // TERUGSPOELEN VOORBIJ HET BEGIN moet in het vorige nummer landen aan het
-      // EIND, niet aan het begin -- dat is wat terugspoelen doet. Zonder dit
-      // sprong je bij elke druk naar de kop van het vorige nummer en kwam je
-      // nooit ergens in het midden uit.
-      if (!(tapeMode && currentIndex === 0)) pendingSeek = Number.MAX_SAFE_INTEGER;
-      prev();
-      return;
-    }
-    try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
-  }
-
-  /**
-   * SPOELEN, met de knop ingedrukt.
-   *
-   * Stond eerst in assets/js/mod/tape.js, want daar zat de cassette. Nu de
-   * speler zelf ook spoelknoppen krijgt zou dezelfde lus op twee plekken staan,
-   * en dan lopen ze uit elkaar zodra iemand aan de versnelling draait. Hier is
-   * de enige plek; de cassette op de pagina roept deze aan.
-   */
-  const WIND_START = 4;        // maal de normale snelheid bij het indrukken
-  const WIND_MAX = 16;
-  const WIND_VERSNELLING = 1.35;
-  const WIND_STAP_MS = 120;
-  let winder = null;
-
-  function stopWind() {
-    if (winder) { clearInterval(winder.timer); winder = null; }
-  }
-
-  function startWind(richting) {
-    stopWind();
-    const r = richting < 0 ? -1 : 1;
-    let snelheid = WIND_START;
-    let vorige = null;
-    let stil = 0;
-    const timer = setInterval(() => {
-      snelheid = Math.min(WIND_MAX, snelheid * WIND_VERSNELLING);
-      seekBy(r * snelheid * (WIND_STAP_MS / 1000));
-      // AAN DE KOP EN DE STAART STOPPEN. De positie wordt afgeklemd, dus daar
-      // gebeurt niets meer -- maar zonder deze controle blijft de knop malen en
-      // draaien de spoelen door alsof er nog band is.
-      const nu = displayTimes().cur;
-      if (vorige !== null && Math.abs(nu - vorige) < 0.05) {
-        if (++stil >= 2) { stopWind(); return; }
-      } else stil = 0;
-      vorige = nu;
-    }, WIND_STAP_MS);
-    winder = { timer, richting: r };
-  }
-
-  /** Waar staat de teller, over het hele bandje. */
-  function tapeTijden() { return displayTimes(); }
-
   window.pcmsAudioPlayer = {
-    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
-    startWind, stopWind,
-    isTape: () => tapeMode,
-    isWinding: () => !!winder,
+    setQueue, play, pause, next, prev, close, openSheet, closeSheet,
     isPlaying: () => isPlaying,
     currentTrack: () => queue[currentIndex] || null,
@@ -1378,73 +500,14 @@
 
   // ============================================================
-  // 10. Session persistence — player "survives" across page navigations
-  // ============================================================
-  // An <audio> element doesn't survive a full page load (and cross-context
-  // navigation — e.g. to the headerless hub overview — is intentionally a
-  // full-nav). We save the session to sessionStorage and restore + resume it
-  // on the next page: the player comes back with the same track at the same
-  // position. In Chrome (high media engagement) it resumes immediately;
-  // if the browser blocks autoplay it waits at that position (one tap = play).
-  const PLAYER_STATE_KEY = 'pcms-player-state';
-  let pendingSeek = 0;
-  function savePlayerState() {
-    try {
-      if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
-      sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
-        queue, currentIndex, albumName, tapeMode,
-        // In-TRACK position (the MSE timeline spans the whole chain; a restore
-        // starts a fresh chain where this track begins at 0).
-        time: trackTijd().cur || 0,
-        playing: !!audio.src && !audio.paused,
-      }));
-    } catch (e) {}
-  }
-  window.addEventListener('pagehide', savePlayerState);
-  window.addEventListener('beforeunload', savePlayerState);
-  audio.addEventListener('play',  savePlayerState);
-  audio.addEventListener('pause', savePlayerState);
-  audio.addEventListener('ended', savePlayerState);
-  setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
-  // Apply the restored position once track metadata is available.
-  audio.addEventListener('loadedmetadata', () => {
-    if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
-      try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
-      pendingSeek = 0;
-    }
-  });
-  function restorePlayerState() {
-    let s = null;
-    try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
-    if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
-    queue = s.queue;
-    albumName = s.albumName || '';
-    tapeMode = !!s.tapeMode;
-    zetKnopStanden();
-    pendingSeek = s.time || 0;
-    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
-    // playing → fetch + (attempt to) resume; paused → meta-only.
-    loadTrack(idx, !!s.playing, !s.playing);
-    return true;
-  }
-
-  // ============================================================
-  // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
-  // ============================================================
-  // An active session (restore) wins over the page seed, so music that is
-  // already playing continues instead of being replaced by the new page's tracks.
-  if (!restorePlayerState()) {
-    if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
-      queue = window.PCMS_SITE_TRACKS.map(t => ({
-        id:     t.id || null,
-        url:    t.media_url || t.url,
-        title:  t.title  || 'Untitled',
-        artist: t.artist || '',
-        cover:  t.cover_url || t.cover || '',
-      }));
-      // Only prime the queue — the player bar appears only on the first audio click
-      // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
-      // which shows the bar). No more pre-seed bar on page load.
-      currentIndex = 0;
-    }
+  // 10. Site-level pre-seed (window.PCMS_SITE_TRACKS)
+  // ============================================================
+  if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
+    queue = window.PCMS_SITE_TRACKS.map(t => ({
+      url:    t.media_url || t.url,
+      title:  t.title  || 'Untitled',
+      artist: t.artist || '',
+      cover:  t.cover_url || t.cover || '',
+    }));
+    if (queue.length) loadTrack(0);
   }
 })();
Index: src/assets/js/embed-player.js
===================================================================
--- src/assets/js/embed-player.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,611 +1,0 @@
-/**
- * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL
- * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
- *
- * The server (AudioEmbedService) renders a placeholder per embed:
- *   <div class="folio-embed folio-embed--<provider> pcms-embed-card"
- *        data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
- * This script wraps it in our own card (cover/poster + our play button +
- * progress bar in brand style) and controls the underlying player via the
- * platform API, so play/pause/progress are in OUR hands.
- *
- * Capability honesty:
- *  - youtube/soundcloud  → fully custom controls (native chrome hidden).
- *  - spotify             → controls + our frame around it; Spotify's own UI
- *                          stays inside (no alternative without Premium+OAuth).
- *
- * Mutual exclusion: every player (incl. the site audio player in audio-player.js)
- * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous.
- * This replaces the old focus/blur heuristic with real play events.
- *
- * Singleton — guard against double-init (HTMX can reload scripts).
- */
-(function () {
-  if (window.pcmsEmbedPlayer) return;
-
-  // ============================================================
-  // 0. Shared playback registry (also used by audio-player.js)
-  // ============================================================
-  function registry() {
-    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
-    const r = {
-      _active: null,
-      // Mark `player` as the sole active one; pause the previous.
-      setActive(player) {
-        if (this._active && this._active !== player && this._active.pause) {
-          try { this._active.pause(); } catch (e) {}
-        }
-        this._active = player;
-      },
-      release(player) { if (this._active === player) this._active = null; },
-    };
-    window.pcmsMediaRegistry = r;
-    return r;
-  }
-
-  // ============================================================
-  // 1. Lazy script loaders — 1 promise per platform, shared across N embeds.
-  //    A platform script is only loaded when an embed from that platform
-  //    is actually started on the page.
-  // ============================================================
-  const scripts = {};
-  function loadScript(src) {
-    return new Promise((resolve, reject) => {
-      const s = document.createElement('script');
-      s.src = src; s.async = true;
-      s.onload = () => resolve();
-      s.onerror = () => reject(new Error('embed script failed: ' + src));
-      document.head.appendChild(s);
-    });
-  }
-  // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking
-  // the API script) and on timeout — so the caller can gracefully fall back to
-  // the plain platform iframe instead of hanging indefinitely.
-  const API_TIMEOUT = 8000;
-
-  // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise.
-  function ytApi() {
-    if (scripts.yt) return scripts.yt;
-    scripts.yt = new Promise((resolve, reject) => {
-      if (window.YT && window.YT.Player) return resolve(window.YT);
-      const prev = window.onYouTubeIframeAPIReady;
-      window.onYouTubeIframeAPIReady = function () {
-        if (typeof prev === 'function') { try { prev(); } catch (e) {} }
-        resolve(window.YT);
-      };
-      loadScript('https://www.youtube.com/iframe_api').catch(reject);
-      setTimeout(() => reject(new Error('YT API timeout')), API_TIMEOUT);
-    });
-    return scripts.yt;
-  }
-  // SoundCloud: no global ready callback; resolve on script onload, then
-  // each widget waits for its own SC.Widget.Events.READY.
-  function scApi() {
-    if (scripts.sc) return scripts.sc;
-    scripts.sc = new Promise((resolve, reject) => {
-      if (window.SC && window.SC.Widget) return resolve(window.SC);
-      loadScript('https://w.soundcloud.com/player/api.js')
-        .then(() => resolve(window.SC)).catch(reject);
-      setTimeout(() => { (window.SC && window.SC.Widget) ? resolve(window.SC) : reject(new Error('SC API timeout')); }, API_TIMEOUT);
-    });
-    return scripts.sc;
-  }
-  // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise.
-  function spotifyApi() {
-    if (scripts.sp) return scripts.sp;
-    scripts.sp = new Promise((resolve, reject) => {
-      if (window.__spotifyIframeApi) return resolve(window.__spotifyIframeApi);
-      window.onSpotifyIframeApiReady = function (IFrameAPI) {
-        window.__spotifyIframeApi = IFrameAPI;
-        resolve(IFrameAPI);
-      };
-      loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject);
-      // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all
-      // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback.
-      setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
-    });
-    return scripts.sp;
-  }
-
-  // Plain platform iframe as fallback when the JS API is blocked/unreachable.
-  // Ad-blockers generally let embed iframes through. Autoplay parameter
-  // is safe here because we're always in a user-gesture context (play click).
-  function fallbackIframe(provider, ref, url) {
-    if (provider === 'youtube') {
-      const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0');
-      if (src) return { src, ratio: true, fs: true };
-    }
-    if (provider === 'soundcloud') {
-      return { src: 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&auto_play=true&visual=true&hide_related=true', h: '300px' };
-    }
-    if (provider === 'spotify') {
-      const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
-      return { src: m ? `https://open.spotify.com/embed/${m[1]}/${m[2]}` : (url || ''), h: '152px' };
-    }
-    return { src: url || '' };
-  }
-
-  // ============================================================
-  // 2. Helpers
-  // ============================================================
-  function fmt(sec) {
-    if (!sec || isNaN(sec) || sec < 0) return '0:00';
-    const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
-    return m + ':' + (s < 10 ? '0' : '') + s;
-  }
-  // Three ref shapes, the same ones AudioEmbedService.detectProvider produces
-  // and the same ones the Klonkt hub uses, so a ref travels between them
-  // unchanged:  "<video>" | "<video>?list=<L>" | "list:<L>"
-  //
-  // ytId answers only "which VIDEO", because that is what a poster thumbnail
-  // needs; for a bare playlist there is no video and it returns null.
-  function ytId(ref, url) {
-    const r = String(ref || '');
-    if (r.indexOf('list:') === 0) return null;              // a playlist has no single video
-    const bare = r.split('?list=')[0];
-    if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare;
-    const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
-    if (m) return m[1];
-    return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
-  }
-  /** The playlist id of a ref (or of the URL it came from), else null. */
-  function ytList(ref, url) {
-    const r = String(ref || '');
-    if (r.indexOf('list:') === 0) return r.slice(5) || null;
-    const i = r.indexOf('?list=');
-    if (i > 0) return r.slice(i + 6) || null;
-    const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/);
-    return m ? m[1] : null;
-  }
-  /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */
-  function ytEmbedSrc(ref, url, query) {
-    const base = 'https://www.youtube-nocookie.com/embed/';
-    const id = ytId(ref, url);
-    const list = ytList(ref, url);
-    let src;
-    if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list);
-    else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list);
-    else if (id) src = base + encodeURIComponent(id);
-    else return null;
-    return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || '');
-  }
-  // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
-  function safeHref(u) {
-    try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
-    catch (e) { return '#'; }
-  }
-  const ICON = {
-    play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
-    pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
-    volume: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 8a5 5 0 010 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
-    muted: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
-  };
-  const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
-
-  // Touch / coarse-pointer (phones, most tablets): the custom JS-mounted card is
-  // fragile — Safari tracking-protection blocks the platform API scripts AND the
-  // poster thumbnails (i.ytimg.com) → a black/blank box. Render the plain, reliable
-  // platform iframe directly instead (same approach the News feed uses). The
-  // padding-ratio wrapper reserves height on every browser incl. old iOS Safari
-  // (no `aspect-ratio` dependency). Desktop keeps the rich custom card.
-  const IS_TOUCH = !!(window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches);
-
-  function mountPlain(el, provider, ref, url) {
-    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider, 'pcms-embed-plain');
-    el.classList.remove('pcms-embed-loading');
-    let html = '';
-    if (provider === 'youtube') {
-      const src = ytEmbedSrc(ref, url, 'rel=0');
-      html = src
-        ? '<div class="pcms-embed-ratio"><iframe src="' + escAttr(src) + '" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
-        : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
-    } else if (provider === 'soundcloud') {
-      html = '<iframe class="pcms-embed-plain-frame" style="height:166px" src="https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>';
-    } else if (provider === 'spotify') {
-      const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
-      const src = m ? 'https://open.spotify.com/embed/' + m[1] + '/' + m[2] : (url || '');
-      html = '<iframe class="pcms-embed-plain-frame" style="height:152px" src="' + escAttr(src) + '" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>';
-    } else {
-      html = '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">' + (LABEL[provider] || provider) + '</a>';
-    }
-    el.innerHTML = html;
-  }
-
-  // ============================================================
-  // 3. Card controller — builds the on-brand card + delegates to an adapter
-  // ============================================================
-  function buildCard(el) {
-    const provider = el.dataset.embedProvider;
-    const ref = el.dataset.embedRef || '';
-    const url = el.dataset.embedUrl || '';
-    if (!provider) return;
-    if (IS_TOUCH) { mountPlain(el, provider, ref, url); return; }
-
-    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
-    el.classList.remove('pcms-embed-loading');
-
-    // Our player side (registry peer). pause() points to the adapter once
-    // mounted; before that it's a no-op.
-    let adapter = null;
-    const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
-
-    // Teardown hook: called by the MutationObserver when this card leaves the
-    // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
-    // so no poll timers or players keep leaking.
-    el._pcmsDestroy = function () {
-      try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
-      registry().release(self);
-    };
-
-    let playing = false;
-    let mounted = false;
-    let dur = 0;
-
-    // --- Mount the UI (differs per provider type) ---
-    const isVideo = provider === 'youtube';
-    const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
-    // A bare playlist has no video id, and interpolating null gave
-    // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster.
-    const posterId = provider === 'youtube' ? ytId(ref, url) : null;
-    const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : '';
-
-    el.innerHTML = ''
-      + (isVideo
-          ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
-            + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
-            + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
-          : `<div class="pcms-embed-audio">`
-            + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
-            + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
-            + `<div class="pcms-embed-sub"></div></div>`
-            + `<div class="pcms-embed-mount"></div></div>`)
-      + (custom
-          ? `<div class="pcms-embed-bar">`
-            + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
-            + `<span class="pcms-embed-cur mono">0:00</span>`
-            + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
-            + `<span class="pcms-embed-dur mono">0:00</span>`
-            + `<button type="button" class="pcms-embed-mute" aria-label="Dempen">${ICON.volume}</button>`
-            + `<input type="range" class="pcms-embed-vol" min="0" max="100" value="100" aria-label="Volume">`
-            + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
-            + `</div>`
-          : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
-
-    const mountEl = el.querySelector('.pcms-embed-mount');
-    const ppBtn = el.querySelector('.pcms-embed-pp');
-    const curEl = el.querySelector('.pcms-embed-cur');
-    const durEl = el.querySelector('.pcms-embed-dur');
-    const seekEl = el.querySelector('.pcms-embed-seek');
-    const seekFill = el.querySelector('.pcms-embed-seek-fill');
-    const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
-    const subEl = el.querySelector('.pcms-embed-sub');
-    const volEl = el.querySelector('.pcms-embed-vol');
-    const muteBtn = el.querySelector('.pcms-embed-mute');
-    let vol = 100;       // current volume 0-100 (applied to the adapter)
-    let preMuteVol = 100;
-
-    function setPlayingUI(on) {
-      playing = on;
-      el.classList.toggle('is-playing', on);
-      if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
-    }
-    function setProgress(cur, total) {
-      if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
-      if (curEl) curEl.textContent = fmt(cur);
-      if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
-    }
-
-    const hooks = {
-      onReady(meta) {
-        el.classList.add('is-ready');
-        el.classList.remove('pcms-embed-busy');
-        if (meta && meta.title && subEl) subEl.textContent = meta.title;
-        if (meta && meta.artwork) {
-          const art = el.querySelector('.pcms-embed-art');
-          if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
-        }
-        if (meta && meta.duration) setProgress(0, meta.duration);
-      },
-      onPlay() { setPlayingUI(true); registry().setActive(self); },
-      onPause() { setPlayingUI(false); },
-      onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
-      onProgress(cur, total) { setProgress(cur, total); },
-    };
-
-    // API blocked/unreachable → plain platform iframe (graceful degradation).
-    // Mutual exclusion for this fallback runs via the blur heuristic
-    // (audio-player.js), because the card never gets .is-mounted.
-    function renderFallback() {
-      const fb = fallbackIframe(provider, ref, url);
-      if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
-      const iframe = document.createElement('iframe');
-      iframe.src = fb.src;
-      iframe.loading = 'lazy';
-      iframe.title = LABEL[provider] || provider;
-      iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
-      if (fb.fs) iframe.allowFullscreen = true;
-      iframe.style.cssText = fb.ratio
-        ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
-        : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
-      el.classList.remove('is-mounted', 'pcms-embed-busy');
-      el.classList.add('pcms-embed-fallback');
-      el.innerHTML = '';
-      el.appendChild(iframe);
-
-      // Mutual exclusion also for the fallback iframe. A cross-origin iframe
-      // cannot be paused via an API, so 'pause' = reload WITHOUT
-      // autoplay (= stops the audio, player stays visible/restartable).
-      // We register it as active: now (= user starts the embed) the
-      // site player/other embeds pause; and when the site player starts later,
-      // the registry pauses this fallback.
-      self.pause = function () {
-        try {
-          const noAuto = fb.src
-            .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
-            .replace(/[?&]$/, '');
-          if (iframe.src === noAuto) {
-            // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
-            iframe.src = 'about:blank';
-            setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
-          } else {
-            iframe.src = noAuto;
-          }
-        } catch (e) {}
-      };
-      registry().setActive(self);
-    }
-
-    // First interaction → mount the adapter + play. The button toggles after that.
-    async function ensureMountedAndPlay() {
-      if (mounted) { if (adapter) adapter.play(); return; }
-      mounted = true;
-      // Spotify: their player cannot be skinned (controls-only) and the
-      // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
-      // → don't wait for the API, show the plain Spotify iframe immediately.
-      if (provider === 'spotify') { renderFallback(); return; }
-      el.classList.add('pcms-embed-busy');
-      try {
-        adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
-        if (el._pcmsApplyVol) el._pcmsApplyVol();  // onthouden volume toepassen
-        // is-mounted: CSS hides the poster (video) or Spotify facade and
-        // shows the real player. For SoundCloud our card+bar stays visible and
-        // the (functional) iframe remains hidden off-screen.
-        el.classList.add('is-mounted');
-        adapter.play();
-      } catch (err) {
-        console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
-        renderFallback();
-      }
-    }
-
-    if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
-    if (ppBtn) ppBtn.addEventListener('click', () => {
-      if (!mounted) return ensureMountedAndPlay();
-      if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
-    });
-    if (seekEl) seekEl.addEventListener('click', (e) => {
-      if (!adapter || !adapter.seek || !dur) return;
-      const rect = seekEl.getBoundingClientRect();
-      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-      adapter.seek(ratio * dur);
-    });
-    // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
-    // setVolume). The value is remembered and reapplied after mounting.
-    function applyVol() {
-      if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
-      if (muteBtn) muteBtn.innerHTML = vol === 0 ? ICON.muted : ICON.volume;
-      if (volEl && String(volEl.value) !== String(vol)) volEl.value = vol;
-      el.classList.toggle('is-muted', vol === 0);
-    }
-    if (volEl) volEl.addEventListener('input', () => { vol = parseInt(volEl.value, 10) || 0; if (vol > 0) preMuteVol = vol; applyVol(); });
-    if (muteBtn) muteBtn.addEventListener('click', () => {
-      if (vol > 0) { preMuteVol = vol; vol = 0; } else { vol = preMuteVol || 100; }
-      applyVol();
-    });
-    el._pcmsApplyVol = applyVol;  // called by ensureMountedAndPlay after mount
-  }
-
-  function escAttr(s) {
-    return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-  }
-
-  // ============================================================
-  // 4. Adapters — normalise the 3 very different APIs to one common shape.
-  //    Each mounter returns { play, pause, seek } and calls hooks with
-  //    seconds (units are normalised here).
-  // ============================================================
-  const MOUNTERS = {
-    // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
-    async youtube(mountEl, { ref, url }, hooks) {
-      const YT = await ytApi();
-      const id = ytId(ref, url);
-      const list = ytList(ref, url);
-      return new Promise((resolve, reject) => {
-        let pollTimer = null;
-        let endTimer = null;       // see onStateChange: a list "ends" between items
-        const player = new YT.Player(mountEl, {
-          // With a video AND a list, `list` makes the player continue into the
-          // playlist after this song. Without a video it is the playlist
-          // itself, and then listType says so -- videoId must stay absent, or
-          // the API loads that one video and forgets the list.
-          ...(id ? { videoId: id } : {}),
-          host: 'https://www.youtube-nocookie.com',
-          playerVars: {
-            controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
-            disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
-            ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}),
-          },
-          events: {
-            onReady() {
-              let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
-              hooks.onReady({ duration: d });
-              resolve({
-                play() { try { player.playVideo(); } catch (e) {} },
-                pause() { try { player.pauseVideo(); } catch (e) {} },
-                seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
-                setVolume(pct) { try { if (pct <= 0) player.mute(); else { player.unMute(); player.setVolume(pct); } } catch (e) {} },
-                destroy() {
-                  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-                  if (endTimer) { clearTimeout(endTimer); endTimer = null; }
-                  try { player.destroy(); } catch (e) {}
-                },
-              });
-            },
-            onStateChange(e) {
-              // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
-              if (e.data === 1) {
-                // A next playlist item started, so the pending "it is over" was
-                // false alarm.
-                if (endTimer) { clearTimeout(endTimer); endTimer = null; }
-                hooks.onPlay();
-                if (!pollTimer) pollTimer = setInterval(() => {
-                  try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
-                }, 250);
-              } else if (e.data === 2) {
-                hooks.onPause();
-                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-              } else if (e.data === 0) {
-                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-                // INSIDE a playlist, YouTube reports "ended" between every two
-                // songs as well. Firing onEnded there hands the queue on after
-                // song one and cuts the album off. So on a list we wait, and
-                // only a silence that is not interrupted by the next song
-                // counts as the end. Same rule as the hub (2.5s).
-                if (list) {
-                  if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500);
-                } else {
-                  hooks.onEnded();
-                }
-              }
-            },
-            onError() { reject(new Error('YT error')); },
-          },
-        });
-      });
-    },
-
-    // ---- SoundCloud: Widget API, visual=false, custom controls ----
-    async soundcloud(mountEl, { ref, url }, hooks) {
-      const SC = await scApi();
-      // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
-      const iframe = document.createElement('iframe');
-      iframe.allow = 'autoplay';
-      iframe.title = 'SoundCloud';
-      const params = new URLSearchParams({
-        url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
-        show_comments: 'false', show_user: 'false', show_teaser: 'false',
-        sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
-        single_active: 'true', color: 'ff5500',
-      });
-      iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
-      mountEl.appendChild(iframe);
-      const widget = SC.Widget(iframe);
-      const E = SC.Widget.Events;
-      return new Promise((resolve, reject) => {
-        let resolved = false;
-        widget.bind(E.READY, () => {
-          widget.getCurrentSound((sound) => {
-            const meta = sound ? {
-              title: sound.title || '',
-              artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
-            } : {};
-            widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
-          });
-          resolved = true;
-          resolve({
-            play() { widget.play(); },
-            pause() { widget.pause(); },
-            seek(sec) { widget.seekTo(sec * 1000); },
-            setVolume(pct) { try { widget.setVolume(pct); } catch (e) {} },
-            destroy() {
-              try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
-              try { iframe.remove(); } catch (e) {}
-            },
-          });
-        });
-        widget.bind(E.PLAY, () => hooks.onPlay());
-        widget.bind(E.PAUSE, () => hooks.onPause());
-        widget.bind(E.FINISH, () => hooks.onEnded());
-        widget.bind(E.PLAY_PROGRESS, (d) => {
-          hooks.onProgress((d.currentPosition || 0) / 1000, 0);
-        });
-        widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
-        setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
-      });
-    },
-
-    // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
-    async spotify(mountEl, { ref, url }, hooks) {
-      const IFrameAPI = await spotifyApi();
-      const uri = ref || url;
-      return new Promise((resolve, reject) => {
-        let lastPaused = true, resolved = false;
-        IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
-          controller.addListener('ready', () => {
-            hooks.onReady({});
-            resolved = true;
-            resolve({
-              play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
-              pause() { try { controller.pause(); } catch (e) {} },
-              seek(sec) { try { controller.seek(sec); } catch (e) {} },
-              destroy() { try { controller.destroy(); } catch (e) {} },
-            });
-          });
-          controller.addListener('playback_update', (e) => {
-            const d = e && e.data ? e.data : {};
-            hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
-            // No reliable 'ended' event from Spotify; we treat every
-            // isPaused transition as play/pause. End = just a pause (the bar
-            // stays at the end position rather than misleadingly jumping to 0).
-            if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
-            else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
-          });
-        });
-        setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
-      });
-    },
-  };
-
-  // ============================================================
-  // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
-  // ============================================================
-  function scan(root) {
-    (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
-      if (el.dataset.embedInit) return;
-      el.dataset.embedInit = '1';
-      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
-    });
-  }
-
-  if (document.readyState === 'loading') {
-    document.addEventListener('DOMContentLoaded', () => scan(document));
-  } else {
-    scan(document);
-  }
-  // HTMX replaces #pcms-main on internal navigation → rescan.
-  document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
-  document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
-
-  // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
-  // swap). Without this, YouTube poll timers + adapters/iframes linger when
-  // navigating away while an embed is playing → CPU/memory leak that accumulates
-  // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
-  const teardownObserver = new MutationObserver((muts) => {
-    for (const m of muts) {
-      m.removedNodes.forEach((node) => {
-        if (node.nodeType !== 1) return;
-        const cards = [];
-        if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
-        if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
-        cards.forEach((c) => {
-          if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
-            try { c._pcmsDestroy(); } catch (e) {}
-          }
-        });
-      });
-    }
-  });
-  try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
-
-  window.pcmsEmbedPlayer = { scan, registry };
-})();
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1214 +1,0 @@
-/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
-   manages the guardian push channel. No framework, no inline scripts (CSP).
-   All user-facing text comes from state.strings (server i18n). */
-(function () {
-  'use strict';
-  // A crash here used to fail silently (buttons just do nothing). Surface it on
-  // the page AND the console so the cause is visible instead of "everything hangs".
-  function fatal(msg) {
-    try {
-      var b = document.getElementById('g-fatal') || document.createElement('div');
-      b.id = 'g-fatal'; b.className = 'g-msg err';
-      b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
-      b.textContent = 'Guardian: ' + msg;
-      var root = document.querySelector('main') || document.body;
-      if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
-    } catch (e) { /* last resort */ }
-    try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
-  }
-  try {
-  var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
-  var T = S.strings || {};
-
-  function el(tag, cls, text) {
-    var n = document.createElement(tag);
-    if (cls) n.className = cls;
-    if (text != null) n.textContent = text;
-    return n;
-  }
-  function handleOf(uri, cached) {
-    if (cached && cached.charAt(0) === '@') return cached;   // trust only real @handles
-    try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
-    catch (e) { return uri; }
-  }
-  // The server hands over a timestamp already formatted in the site's timezone
-  // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
-  // slice is only a fallback for a row that predates that field: it shows raw
-  // UTC, which is what made a 20:20 call for help read 18:20.
-  function when(item, raw) {
-    if (item && item.when_text) return item.when_text;
-    return String(raw || '').slice(0, 16).replace('T', ' ');
-  }
-  function show(id, on) { document.getElementById(id).hidden = !on; }
-
-  // ── 1. Help requests ───────────────────────────────────────────────────
-  // A call for help is not an alarm: it may well be settled quietly between a
-  // guardian and the child. So the card carries no siren, it just has to be
-  // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
-  // ones across all children at the top, the full history of one child in that
-  // child's panel.
-  var HELP_TOP = 5;
-
-  /**
-   * Wacht deze hulpvraag nog op iemand?
-   *
-   * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
-   * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
-   * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
-   * hoort hij hier langs te komen.
-   *
-   * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
-   * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
-   * is hier de gevaarlijke.
-   */
-  function helpOpen(h) { return !h || !h.state || (h.state.open && !h.state.formerWard); }
-
-  function helpCard(h) {
-    var card = el('div', 'g-card help');
-    var row = el('div', 'row');
-    var who = el('span', 'who grow');
-    // name_html carries the custom emojis (FEP-9098) of the display name, the
-    // same way de Krant renders a byline. Falls back to the plain name.
-    if (h.name_html) who.innerHTML = h.name_html;
-    else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
-    row.appendChild(who);
-    row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
-    card.appendChild(row);
-    var body = el('div', 'body g-note');
-    // body_html is the shared note-body partial, rendered server-side: the
-    // content with its emojis, the quote / link-preview card and the media.
-    // Falls back to the bare content for rows stored before that existed.
-    body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
-    card.appendChild(body);
-    if (h.note_url) {
-      var a = el('a', 'g-link', T.open || 'open');
-      a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
-      card.appendChild(a);
-    }
-    card.appendChild(helpState(h));
-    return card;
-  }
-
-  /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
-  function ago(ms) {
-    var u = Math.floor(ms / 3600000);
-    if (u < 1) return T.help_just_now || 'just now';
-    if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
-    return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
-  }
-
-  /**
-   * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
-   * is afgesloten, en de twee knoppen.
-   *
-   * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
-   * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
-   * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
-   * onaangeroerd uit terwijl er iemand mee bezig is.
-   */
-  function helpState(h) {
-    var st = h.state || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null };
-    var box = el('div', 'g-help-state');
-
-    // Niet meer jouw ward: geen knoppen, want de route zou ze weigeren. En geen
-    // "afgehandeld", want dat zou een claim zijn over een kind waar je niets meer
-    // over te zeggen hebt.
-    if (st.formerWard) {
-      box.appendChild(el('div', 'g-help-former', T.help_former_ward || ''));
-      return box;
-    }
-
-    if (st.handled) {
-      var wie = st.handled.handle || handleOf(st.handled.uri);
-      box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
-      // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
-      box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
-      return box;
-    }
-
-    (st.pickedUpBy || []).forEach(function (p) {
-      var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
-      box.appendChild(el('div', 'g-help-pick', line));
-    });
-    // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
-    // 'er was ooit iemand mee bezig'.
-    // De leeftijd wordt HIER uitgerekend, niet op de server: een verschil met
-    // `now` in het antwoord maakt dat antwoord elke milliseconde anders, en dan
-    // kan de ETag nooit gelijk zijn.
-    var oud = st.oldestPickupAt ? (Date.now() - Date.parse(st.oldestPickupAt)) : null;
-    if (oud != null && !isNaN(oud) && oud > 3600000) {
-      box.appendChild(el('div', 'g-help-age small', ago(oud)));
-    }
-
-    var row = el('div', 'row');
-    var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
-    if (!mij) {
-      var pick = el('button', 'small', T.help_pick || 'I am on it');
-      pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
-      row.appendChild(pick);
-    }
-    var done = el('button', 'quiet small', T.help_close || 'Mark handled');
-    // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
-    // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
-    // van een ward.
-    done.addEventListener('click', function () {
-      done.hidden = true;
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', T.help_close_ask || ''));
-      var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
-      ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
-      var nee = el('button', 'quiet small', T.release_no || 'No');
-      nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
-      ask.appendChild(ja); ask.appendChild(nee);
-      box.appendChild(ask);
-    });
-    row.appendChild(done);
-    box.appendChild(row);
-    return box;
-  }
-
-  function markHelp(h, kind, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian/api/help/' + kind, {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
-    }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
-  }
-
-  function renderHelp() {
-    var list = document.getElementById('help-list');
-    list.textContent = '';
-    var alle = S.help || [];
-    // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
-    // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
-    // is, en neemt een gesloten vraag de ruimte in van een open vraag.
-    var open = alle.filter(helpOpen);
-    var klaar = alle.filter(function (h) { return !helpOpen(h); });
-
-    open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
-    if (open.length > HELP_TOP) {
-      list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
-    }
-
-    // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
-    // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
-    // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
-    // in het paneel van dat kind.
-    if (klaar.length) {
-      var doos = el('div', 'g-help-archive');
-      doos.hidden = true;
-      klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
-      var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
-      knop.addEventListener('click', function () {
-        doos.hidden = !doos.hidden;
-        knop.textContent = doos.hidden
-          ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
-          : (T.help_archive_hide || 'hide');
-      });
-      list.appendChild(knop);
-      list.appendChild(doos);
-    }
-
-    var badge = document.getElementById('help-count');
-    badge.textContent = open.length; badge.hidden = open.length === 0;
-    // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
-    // er wacht dan immers niets.
-    show('help-empty', open.length === 0);
-  }
-
-  // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
-  function answer(offerId, decision, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian/offer', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
-    }).then(refresh);
-  }
-  function offerCard(o) {
-    var card = el('div', 'g-card');
-    var row = el('div', 'row');
-    var subject = o['shaer:iAmCandidate']
-      ? handleOf(o['shaer:ward'], o['shaer:wardHandle'])            // my sent offer: about the ward
-      : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
-    row.appendChild(el('span', 'who grow', subject));
-    if (o['shaer:iAmCandidate']) {
-      // My own offer, waiting for the others to accept.
-      row.appendChild(el('span', 'tag wait', T.pending));
-      var rt = el('button', 'quiet small', T.retract);
-      rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
-      row.appendChild(rt);
-    } else if (o['shaer:needsMyAccept']) {
-      // A co-guardianship offer for a ward I already guard: my call.
-      row.appendChild(el('span', 'tag co', T.coguard));
-      var ac = el('button', 'small', T.accept);
-      ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
-      var rj = el('button', 'quiet small', T.reject);
-      rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
-      row.appendChild(ac); row.appendChild(rj);
-    } else {
-      row.appendChild(el('span', 'tag wait', T.awaiting_others));
-    }
-    card.appendChild(row);
-    return card;
-  }
-  /** A running lapse (FEP-633c 3.6.3): the available co-guardians deciding
-   *  to release a dormant one. Votes ride the same Accept/Reject wire as the
-   *  offers; buttons appear only for set members (the ward watches, it does
-   *  not vote). */
-  function lapseCard(l) {
-    var card = el('div', 'g-card lapse');
-    card.appendChild(el('div', 'who', (T.lapse_line || '{who} has stopped answering as a guardian of {ward}.')
-      .replace('{who}', handleOf(l.object.object)).replace('{ward}', handleOf(l.object['shaer:ward']))));
-    card.appendChild(el('div', 'g-avlabel', (T.lapse_tally || '{n} of {need} agreed; closes {date}.')
-      .replace('{n}', l['shaer:accepts']).replace('{need}', l['shaer:threshold'])
-      .replace('{date}', new Date(l['shaer:closesAt']).toLocaleDateString())));
-    var row = el('div', 'row');
-    var inSet = (l['shaer:set'] || []).indexOf(S.me) >= 0;
-    if (inSet && !l['shaer:myVote']) {
-      var yes = el('button', 'small', T.lapse_agree || 'Agree');
-      yes.addEventListener('click', function () { answer(l.id, 'accept', yes); });
-      var no = el('button', 'quiet small', T.lapse_disagree || 'Disagree');
-      no.addEventListener('click', function () { answer(l.id, 'reject', no); });
-      row.appendChild(yes); row.appendChild(no);
-    } else if (inSet) {
-      row.appendChild(el('span', 'g-avlabel', T.voted || 'You voted'));
-    }
-    card.appendChild(row);
-    card.appendChild(el('p', 'g-empty small', T.lapse_note || ''));
-    return card;
-  }
-
-  /** A gated-setting proposal (FEP-633c 5.6) a fellow guardian opened on a
-   *  ward we share, forwarded here by the ward's server. Answering is the
-   *  whole point: without a second voice the threshold is never met and the
-   *  proposal quietly expires. */
-  function gatedCard(g) {
-    var card = el('div', 'g-card gated');
-    var line = g.value ? (T.gated_line_on || '') : (T.gated_line_off || '');
-    card.appendChild(el('div', 'who', line
-      .replace('{who}', handleOf(g.proposer || ''))
-      .replace('{ward}', handleOf(g.ward))));
-    // Wat het betekent als dit doorgaat, VOOR de knoppen (shaer-nf9). Alleen bij
-    // openzetten -- dichtzetten laat niets nieuws door.
-    //
-    // En eerlijk over wat wij niet zien: de stemmen worden geteld op de server
-    // van het kind, niet hier. Er zijn geen lokale wards, dus gatedProgress komt
-    // voor deze kaart altijd op null uit. Een verzonnen "1 van 2" zou hier het
-    // gevaarlijkste getal op het scherm zijn -- het leest als "er kan nog iemand
-    // na mij". Wat we wel weten en wat de waarschuwing draagt: jouw ja kan de
-    // doorslag geven, want de telling settelt op het moment dat de drempel
-    // gehaald is.
-    if (g.consequence) {
-      var warn = el('div', 'g-warn');
-      warn.appendChild(el('p', 'small', warnText(g.consequence)));
-      // Wat we WEL weten sinds shaer-8vt: de server van het kind heeft
-      // meegestuurd of jouw antwoord het afmaakt. Zo niet -- een oudere server
-      // -- dan blijft de algemene regel staan, want bij twijfel waarschuwen.
-      warn.appendChild(el('p', g.decisive === false ? 'small g-dim' : 'small g-decisive',
-        g.decisive === false ? (T.warn_not_last || '') : (T.warn_decides || T.warn_tally_elsewhere || '')));
-      card.appendChild(warn);
-    }
-
-    var row = el('div', 'row');
-    var yes = el('button', 'small', T.gated_agree || 'Agree');
-    var no = el('button', 'quiet small', T.gated_disagree || 'Disagree');
-    function answerGated(decision, btn) {
-      btn.disabled = true;
-      fetch('/guardian/api/gated/' + encodeURIComponent(g.id), {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ answer: decision, site: S.site }),
-      }).then(refresh).catch(function () { btn.disabled = false; });
-    }
-    yes.addEventListener('click', function () { answerGated('accept', yes); });
-    no.addEventListener('click', function () { answerGated('reject', no); });
-    row.appendChild(yes); row.appendChild(no);
-    card.appendChild(row);
-    return card;
-  }
-
-  function renderPending() {
-    var list = document.getElementById('pending-list');
-    list.textContent = '';
-    var offers = S.offers || [];
-    var gated = S.gatedReviews || [];
-    // The offers state carries the adoption offers; the lapse proposals ride
-    // separately so a lapse never renders as an adoption.
-    var lapses = (S.lapses || []).filter(function (l) { return l['shaer:outcome'] === 'open'; });
-    offers.forEach(function (o) {
-      if (o.object && o.object.type === 'shaer:Lapse') return;   // rendered below
-      list.appendChild(offerCard(o));
-    });
-    lapses.forEach(function (l) { list.appendChild(lapseCard(l)); });
-    gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
-    show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
-  }
-
-  // ── 4. Accepted wards: one panel per child ─────────────────────────────
-  // A guardian thinks per child, not per function, so everything about one
-  // child sits behind that child's row: the gated settings, the follow requests
-  // waiting on them, their calls for help, their recent posts. The row itself
-  // carries counts, so nothing that needs an answer hides inside a closed
-  // panel.
-  var openPanels = {};   // ward uri -> open, so a refresh does not close it
-  var openGates = {};    // idem voor de poortenlijst: uitklappen mag niet elke 45s dichtvallen
-  // The follow requests and the wards' posts arrive from their own endpoints
-  // and are grouped into the panels by ward, so they are cached here rather
-  // than rendered into a section of their own.
-  var FEED = [], FOLLOWS = [], EVENTS = [];
-
-  function sectionInto(panel, title, items, empty, build) {
-    var h = el('div', 'g-panel-sec');
-    h.appendChild(el('h3', null, title));
-    if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
-    else items.forEach(function (it) { h.appendChild(build(it)); });
-    panel.appendChild(h);
-    return h;
-  }
-
-  function gateButton(w, feature, current, proposeLabel, onLabel, offLabel, consequence) {
-    var known = current === true || current === false;
-    var btn = el('button', 'quiet small', (known ? (current ? onLabel : offLabel) : proposeLabel) || feature);
-    var allow = known ? !current : true;
-
-    // ALLEEN BIJ OPENZETTEN waarschuwen (shaer-nf9). Dichtzetten laat niets
-    // nieuws door, en een waarschuwing die overal staat wordt nergens gelezen.
-    // Geen window.confirm: dat verstopt de uitleg achter een OK die mensen
-    // wegklikken. Zelfde vorm als het loslaten van een ward.
-    btn.addEventListener('click', function () {
-      if (!allow) return doPropose();
-      btn.hidden = true;
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', warnText(consequence)));
-      var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
-      ja.addEventListener('click', function () { ask.remove(); btn.hidden = false; doPropose(); });
-      var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
-      nee.addEventListener('click', function () { ask.remove(); btn.hidden = false; });
-      ask.appendChild(ja); ask.appendChild(nee);
-      btn.parentNode.appendChild(ask);
-    });
-
-    function doPropose() {
-      btn.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, feature: feature, allow: allow }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (j && j.state === 'open') {
-            btn.textContent = (T.embeds_waiting || 'waiting for the other guardians');
-            btn.disabled = true;
-            return;
-          }
-          refresh();
-        })
-        .catch(function () { btn.disabled = false; });
-    }
-    return btn;
-  }
-
-  /** De juiste waarschuwing bij een gevolg. Onbekend krijgt de zwaarste: een
-   *  gate die wij niet kennen kunnen we ook niet geruststellend beschrijven. */
-  function warnText(consequence) {
-    if (consequence === 'irreversible') return T.warn_irreversible || '';
-    if (consequence === 'unknown') return T.warn_unknown || '';
-    return T.warn_reversible || '';
-  }
-
-  function embedsButton(w) {
-    // Gated feature: external (non-fediverse) embeds. Off by default for a
-    // ward; only a guardian can open it, and the gate is enforced server-side
-    // when the feed is built, so this button is the only thing that moves it.
-    // Shown for EVERY ward, including one on another server. There the value is
-    // unknown (it lives on the ward's server), but proposing is exactly as
-    // possible: the proposal travels, the ward's server tallies the guardians
-    // and enforces. A guardian next door must not have more say than one far
-    // away.
-    var known = w.embeds === true || w.embeds === false;
-    var emb = el('button', 'quiet small',
-      (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
-    emb.addEventListener('click', function () {
-      emb.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          // Not settled yet: the other guardians still have to answer.
-          if (j && j.state === 'open') {
-            emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
-            emb.disabled = true;
-            return;
-          }
-          refresh();
-        })
-        .catch(function () { emb.disabled = false; });
-    });
-    return emb;
-  }
-
-  /**
-   * The second step of releasing a ward: what it does, then yes or no.
-   *
-   * The warning is assembled from what the server found, not from a fixed
-   * sentence, because releasing means two different things (FEP-633c): stepping
-   * down while other guardians remain (§3.3), or being the last one, which is
-   * emancipation and explicitly not one guardian's call (§3.4). And as long as
-   * the Undo does not federate, the ward's server keeps listing you either way
-   * — a guardian has to know that before pressing, not after.
-   */
-  function releaseStep(w, check, host, relBtn) {
-    var uri = w.other_uri;
-    var who = handleOf(uri, w.other_handle);
-    var box = el('div', 'g-warn');
-    box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
-    box.appendChild(el('p', null, T.release_effect || ''));
-    if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
-    else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
-    else box.appendChild(el('p', 'grave', T.release_unknown || ''));
-    box.appendChild(el('p', null, T.release_local || ''));
-
-    var row = el('div', 'row');
-    var no = el('button', 'small', T.release_no || 'No');
-    no.addEventListener('click', function () {
-      host.removeChild(box);
-      relBtn.hidden = false; relBtn.disabled = false;
-    });
-    // No first: the way out should be the easy one to hit.
-    row.appendChild(no);
-    // Being the last guardian is not a warning but a wall: the server refuses
-    // it (§3.4), so offering a yes here would only produce an error. The text
-    // above already says what has to happen instead.
-    if (check.last !== true) {
-      var yes = el('button', 'danger small', T.release_yes || 'Yes');
-      yes.addEventListener('click', function () {
-        yes.disabled = true;
-        remove(uri, yes, function (err) {
-          // The guardian set can change between the check and the click.
-          yes.disabled = false;
-          box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
-          if (err === 'would_emancipate') yes.remove();
-        });
-      });
-      row.appendChild(yes);
-    }
-    box.appendChild(row);
-    return box;
-  }
-
-  /** The availability dot (FEP-633c 3.6): buddy-list language on the
-   *  responsibility axis. Green available, yellow declared away with an end,
-   *  grey observed dormant (one answer restores). */
-  function availLabel(g) {
-    if (g.availability === 'away') {
-      var date = g.awayUntil ? new Date(g.awayUntil).toLocaleDateString() : '?';
-      return (T.avail_away || 'Unavailable till {date}').replace('{date}', date);
-    }
-    if (g.availability === 'dormant') return T.avail_dormant || 'Offline';
-    return T.avail_available || 'Available';
-  }
-  function availRow(g, wardUri) {
-    var row = el('div', 'row g-guard');
-    var dot = el('span', 'g-avdot ' + (g.availability === 'away' ? 'is-away' : g.availability === 'dormant' ? 'is-dormant' : 'is-active'));
-    row.appendChild(dot);
-    row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
-    row.appendChild(el('span', 'g-avlabel', availLabel(g)));
-    // A dormant fellow guardian without a running lapse: the deliberate,
-    // rare next step (3.6.3). Never shown for anyone still answering.
-    if (g.availability === 'dormant' && !g.lapse && g.uri !== S.me) {
-      var btn = el('button', 'quiet small', T.lapse_propose || 'Propose release');
-      btn.addEventListener('click', function () {
-        btn.disabled = true;
-        fetch('/guardian/api/lapse', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ ward: wardUri, target: g.uri, site: S.site }),
-        }).then(refresh).catch(function () { btn.disabled = false; });
-      });
-      row.appendChild(btn);
-    }
-    return row;
-  }
-
-  /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
-  function gateLabel(feature) {
-    return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
-  }
-
-  /**
-   * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
-   * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
-   * verzetten valt.
-   *
-   * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
-   * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
-   * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
-   * zouden dat verschil wegpoetsen.
-   */
-  /**
-   * Een gate als cockpit-schakelaar (Barts richting, 8-8): een knop zoals de
-   * airco-toets in een dashboard -- lampje links, de naam erop, en een klein
-   * statusveld dat de stand toont. De KNOP is de toggle: aanraken stelt de
-   * andere stand voor, met de nf9-uitleg ervoor. Het lampje en het veld dragen
-   * dezelfde stand in twee vormen, want een lampje alleen is voor kleurenblind
-   * en onwennig volk geen antwoord.
-   *
-   * Drie standen, niet twee. "Uit" en "voor zover wij weten uit" zijn niet
-   * hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat
-   * hoort niet te lezen als een genomen besluit. Wat er nog niet bestaat is
-   * een lege fitting, geen dichte deur. En een lopend voorstel knippert --
-   * wachten is de enige stand die vanzelf verandert, dus de enige die om je
-   * blik mag vragen (en bij prefers-reduced-motion staat hij stil).
-   */
-  function gateRow(w, g) {
-    var stand = g.value === null || g.value === undefined
-      ? (T.gate_unknown || 'unknown')
-      : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
-    if (!g.decided && g.value === false) stand = T.gate_default_off || stand;
-    // Een VASTE poort is geen stand die nog kan draaien: hij staat zoals de spec
-    // hem zet. "aan" of "uit" leest als iets waar nog over te praten valt, en
-    // dat is hier niet zo -- §5.3 laat er geen ruimte voor.
-    if (g.fixed) stand = T.gate_always || 'always';
-    if (!g.available) stand = T.gate_unavailable || 'not available yet';
-
-    var lamp = !g.available ? 'planned'
-      : (g.waiting || (g.proposal && g.proposal.status === 'open')) ? 'waiting'
-      : (g.value === true ? 'on' : (g.decided ? 'off' : 'undecided'));
-
-    var wrap = el('div', 'g-sw-wrap');
-    var btn = el('button', 'g-sw');
-    btn.type = 'button';
-    if (g.value === true || g.value === false) btn.setAttribute('aria-pressed', String(g.value));
-    var head = el('span', 'g-sw-head');
-    var lampEl = el('span', 'g-sw-lamp g-lamp-' + lamp);
-    lampEl.setAttribute('aria-hidden', 'true');
-    head.appendChild(lampEl);
-    head.appendChild(el('span', 'g-sw-name', gateLabel(g.feature)));
-    btn.appendChild(head);
-    // Het statusveld: de stand als tekst, in een verzonken venstertje zoals de
-    // temperatuur op het aircopaneel. Bij een lopend voorstel telt het veld wat
-    // er wacht -- dat is op dat moment de waarheid over deze poort.
-    var veld = (g.waiting ? (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting) : stand);
-    btn.appendChild(el('span', 'g-sw-state', veld));
-
-    var meta = el('span', 'g-sw-meta small');
-    meta.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
-    if (!g.available) meta.appendChild(el('span', 'g-dim', T.gate_planned_note || ''));
-    if (g.threshold && g.available) {
-      meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
-        .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
-    } else if (g.available) {
-      // Geen drempel verzinnen die we niet kennen.
-      meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
-    }
-    if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
-    btn.appendChild(meta);
-    wrap.appendChild(btn);
-
-    // Het kind vroeg hier ZELF om (shaer-8ru). Eigen regel onder de schakelaar,
-    // niet in het statusveld: dit is het enige op deze rij dat van het kind komt
-    // en het hoort niet weg te vallen tussen de drempels en de soorten. Ook
-    // bewust niet als "n waiting" -- drie onbekenden die je kind willen volgen is
-    // iets heel anders dan je kind dat een keer vraagt of muziek aan mag.
-    if (g.requested) wrap.appendChild(el('p', 'small g-gate-asked', T.gate_asked || ''));
-
-    if (g.proposal) {
-      wrap.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
-        (T.prop_line || 'Proposal {what} {value}: {status}')
-          .replace('{what}', gateLabel(g.feature))
-          .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-          .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
-    }
-    if (g.blockedBy) {
-      wrap.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
-        .replace('{what}', gateLabel(g.blockedBy))));
-    }
-
-    if (!g.adjustable) {
-      // Niet verstelbaar is niet hetzelfde als kapot: het lampje en het veld
-      // blijven de stand tonen, alleen de toets drukt niet in.
-      btn.disabled = true;
-      return wrap;
-    }
-
-    // De toets stelt de ANDERE stand voor; de richting volgt de stand die we
-    // uit de besluiten kennen, dus ook voor een ward elders kun je dichtzetten
-    // voorstellen. Bij openzetten eerst de nf9-uitleg (uitleg staat er, dan pas
-    // de knop); dichtzetten laat niets nieuws door en vraagt niet.
-    var allow = (g.value === true || g.value === false) ? !g.value : true;
-    var consequence = g.reversible === false ? 'irreversible' : 'reversible';
-    btn.addEventListener('click', function () {
-      if (!allow) return doPropose();
-      if (wrap.querySelector('.g-confirm')) return;   // uitleg staat er al
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', warnText(consequence)));
-      var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
-      ja.addEventListener('click', function () { ask.remove(); doPropose(); });
-      var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
-      nee.addEventListener('click', function () { ask.remove(); });
-      ask.appendChild(ja); ask.appendChild(nee);
-      wrap.appendChild(ask);
-    });
-
-    function doPropose() {
-      btn.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, feature: g.feature, allow: allow }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (j && j.state === 'open') {
-            wrap.appendChild(el('p', 'small g-prop g-prop-open',
-              (T.prop_line || 'Proposal {what} {value}: {status}')
-                .replace('{what}', gateLabel(g.feature))
-                .replace('{value}', allow ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-                .replace('{status}', T.prop_st_open || 'open')));
-          }
-          refresh();
-        })
-        .catch(function () { btn.disabled = false; });
-    }
-    return wrap;
-  }
-
-  function wardPanel(w) {
-    var uri = w.other_uri;
-    var panel = el('div', 'g-panel');
-    panel.hidden = !openPanels[uri];
-
-    var set = el('div', 'g-panel-sec');
-    set.appendChild(el('h3', null, T.settings_title || 'Settings'));
-    // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
-    // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
-    // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
-    // is op "wat mag dit kind".
-    //
-    // INGEKLAPT met een samenvatting erboven. Met twaalf poorten duwt een open
-    // lijst alles wat eronder staat -- guardians, volgverzoeken, hulpvragen --
-    // van het scherm. Wat je meestal wilt weten is "staat er iets open en wacht
-    // er iets op mij", en dat past op een regel.
-    if ((w.gates || []).length) {
-      var lijst = el('div', 'g-gates');
-      lijst.hidden = !openGates[uri];
-      w.gates.forEach(function (g) { lijst.appendChild(gateRow(w, g)); });
-
-      var aan = w.gates.filter(function (g) { return g.available && g.value === true; }).length;
-      var wacht = w.gates.reduce(function (n, g) { return n + (g.waiting || 0) + (g.proposal && g.proposal.status === 'open' ? 1 : 0); }, 0);
-      var kop = el('button', 'quiet small g-gates-toggle');
-      var zetKop = function () {
-        kop.textContent = (lijst.hidden ? (T.gates_show || 'Show gates') : (T.gates_hide || 'Hide gates'))
-          + ' - ' + (T.gates_summary || '{n} gates - {on} on, {wait} waiting')
-            .replace('{n}', w.gates.length).replace('{on}', aan).replace('{wait}', wacht);
-      };
-      zetKop();
-      kop.addEventListener('click', function () {
-        lijst.hidden = !lijst.hidden;
-        openGates[uri] = !lijst.hidden;   // blijft open over een verversing heen
-        zetKop();
-      });
-      set.appendChild(kop);
-      set.appendChild(lijst);
-    }
-    // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
-    // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
-    if (!(w.gates || []).length) {
-      var setRow = el('div', 'row');
-      setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
-      if (w.embeds !== false) {
-        setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
-      }
-      set.appendChild(setRow);
-      (w.proposals || []).forEach(function (p) {
-        var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
-        set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
-          .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-          .replace('{status}', T['prop_st_' + p.status] || p.status)));
-      });
-    }
-    panel.appendChild(set);
-
-    // The fellow guardians of this child, with availability (3.6). For a
-    // ward on another server the states live there, and saying so honestly
-    // beats guessing.
-    var gsec = el('div', 'g-panel-sec');
-    gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
-    if (w.guardians && w.guardians.length) {
-      w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
-    } else {
-      // A ward on another server: WHO guards it is public on its actor
-      // (shaer:guardians, 2.1), so list the seats; availability is the ward
-      // server's private ledger (3.6.1) and is not shown, only named.
-      var placeholder = el('p', 'g-empty small', '…');
-      gsec.appendChild(placeholder);
-      fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
-        .then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (!j || !j.guardians || !j.guardians.length) {
-            placeholder.textContent = T.panel_guards_remote || '';
-            return;
-          }
-          placeholder.remove();
-          j.guardians.forEach(function (g) {
-            var row = el('div', 'row g-guard');
-            row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
-            gsec.appendChild(row);
-          });
-          gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
-        })
-        .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
-    }
-    panel.appendChild(gsec);
-
-    sectionInto(panel, T.panel_follow || 'Follow requests',
-      FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
-      T.panel_follow_empty || '', followCard);
-
-    // Alleen wat nog WACHT. De afgehandelde vragen staan onderaan het paneel als
-    // eigen sectie: dooreen gezet lees je niet meer of er iets van je gevraagd
-    // wordt, en dat is de enige vraag die deze sectie hoort te beantwoorden.
-    sectionInto(panel, T.panel_help || 'Calls for help',
-      (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }),
-      T.panel_help_empty || '', helpCard);
-
-    sectionInto(panel, T.panel_posts || 'Recent posts',
-      FEED.filter(function (p) { return p.authorUri === uri; }),
-      T.panel_posts_empty || '', feedCard);
-
-    // De geschiedenis: apart, en helemaal onderaan. Wat af is hoort niet tussen
-    // wat nog wacht -- daar wordt "moet ik iets doen" onleesbaar van. Weg gaat
-    // hij niet: er wordt in dit systeem niets herschreven, er wordt toegevoegd.
-    // Geen lege sectie als er nog niets is afgehandeld; dat is geen informatie.
-    var gedaan = (S.help || []).filter(function (h) { return h.actor_uri === uri && !helpOpen(h); });
-    if (gedaan.length) {
-      sectionInto(panel, (T.panel_history || 'Handled ({n})').replace('{n}', gedaan.length),
-        gedaan, '', helpCard).classList.add('g-panel-history');
-    }
-
-    var act = el('div', 'g-panel-sec');
-    act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
-    var actRow = el('div', 'row');
-    var wave = el('button', 'small', T.wave || '👋 Wave');
-    wave.addEventListener('click', function () { sendWave(uri, wave); });
-    actRow.appendChild(wave);
-    var rel = el('button', 'quiet small', T.release);
-    // Letting a child go is a decision, not a click. It opens a step that first
-    // asks the server what releasing this particular ward actually does, then
-    // says it plainly and asks yes or no. Never window.confirm: that hides a
-    // long explanation behind an OK button people press to make it go away.
-    rel.addEventListener('click', function () {
-      rel.disabled = true;
-      // site matters: with several of your own sites the server would otherwise
-      // check this ward against the wrong one and answer "not my ward".
-      fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
-        .then(function (r) { return r.json(); })
-        .then(function (c) {
-          rel.hidden = true;
-          act.appendChild(releaseStep(w, c || {}, act, rel));
-        })
-        .catch(function () { rel.disabled = false; });
-    });
-    actRow.appendChild(rel);
-    act.appendChild(actRow);
-    // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
-    // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
-    // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
-    // vijf secties te scrollen.
-    panel.insertBefore(act, panel.firstChild);
-    return panel;
-  }
-
-  function renderWards() {
-    var list = document.getElementById('wards-list');
-    list.textContent = '';
-    var wards = S.wards || [];
-    wards.forEach(function (w) {
-      var uri = w.other_uri;
-      var card = el('div', 'g-card ward');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
-      // Counts on the row: whatever is waiting must be visible with the panel shut.
-      // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
-      // afgehandelde vraag hoorde de boei niet te laten staan.
-      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
-      var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
-      if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
-      if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
-      row.appendChild(el('span', 'tag ok', T.active));
-      var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
-      row.appendChild(toggle);
-      card.appendChild(row);
-      var panel = wardPanel(w);
-      card.appendChild(panel);
-      toggle.addEventListener('click', function () {
-        openPanels[uri] = !openPanels[uri];
-        panel.hidden = !openPanels[uri];
-        toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
-      });
-      list.appendChild(card);
-    });
-    show('wards-empty', wards.length === 0);
-    // Step away (3.6.1) only means something with wards to tell.
-    show('away-section', wards.length > 0);
-  }
-
-  // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
-  function declareAway(days, btn) {
-    btn.disabled = true;
-    fetch('/guardian/api/away', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ days: days, site: S.site }),
-    }).then(function (r) { return r.json(); })
-      .then(function (j) {
-        btn.disabled = false;
-        var msg = document.getElementById('away-msg');
-        msg.hidden = false;
-        if (j && j.ok) {
-          msg.className = 'g-msg';
-          msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
-            .replace('{date}', new Date(j.until).toLocaleDateString());
-        } else {
-          msg.className = 'g-msg err';
-          msg.textContent = (j && j.error) || (T.failed || 'failed');
-        }
-      })
-      .catch(function () { btn.disabled = false; });
-  }
-  var awayWeek = document.getElementById('away-week');
-  var awayMonth = document.getElementById('away-month');
-  if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
-  if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
-
-  function sendWave(uri, btn) {
-    btn.disabled = true;
-    fetch('/guardian/api/wave', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ ward: uri, site: S.site }),
-    }).then(function (r) { return r.json(); })
-      .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
-      .catch(function () { btn.disabled = false; });
-  }
-
-  function remove(uri, btn, onError) {
-    btn.disabled = true;
-    fetch('/guardian/wards/remove', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ uri: uri, site: S.site }),
-    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
-      .then(function (res) {
-        // The server can refuse: emptying shaer:guardians is emancipation and
-        // not one guardian's call (§3.4). Say so instead of silently redrawing.
-        if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
-        refresh();
-      })
-      .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
-  }
-
-
-  // De scrollpositie overleeft een verversing (Barts melding, 9-8). renderAll
-  // bouwt lijsten opnieuw op; staat de pagina daarbij even korter, dan springt
-  // de browser omhoog en ben je kwijt waar je was. Meten en terugzetten is
-  // genoeg -- en het is het verschil tussen een scherm dat bijwerkt en een
-  // scherm dat onder je handen wegschuift.
-  function metBehoudVanScroll(fn) {
-    var y = window.scrollY || document.documentElement.scrollTop || 0;
-    fn();
-    if (y) window.scrollTo(0, y);
-  }
-
-  function renderAll() { renderHelp(); renderPending(); renderWards(); }
-
-  // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
-  // Lives inside each child's panel now, so the fetches only fill a cache and
-  // ask the ward list to redraw. A guardian watches, it does not publish.
-  function feedCard(p) {
-    var card = el('div', 'g-card feed');
-    var head = el('div', 'row');
-    head.appendChild(el('span', 'who grow', p.author));
-    if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
-    card.appendChild(head);
-    var body = el('div', 'feed-body');
-    // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
-    // media, quote-kaart en embed, precies als in de Krant en in Berichten.
-    // Valt terug op de kale content voor een client uit de cache.
-    var html = p.body_html || p.content || '';
-    if (p.cw) {
-      // De content warning blijft van de PWA zelf: note-body versluiert alleen
-      // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
-      var d = document.createElement('details');
-      var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
-      var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
-      body.appendChild(d);
-    } else {
-      body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
-    }
-    card.appendChild(body);
-    return card;
-  }
-
-  function loadFeed() {
-    return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
-      .catch(function () { /* panels just show "nothing yet" */ });
-  }
-
-  // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
-  /**
-   * Twee richtingen, twee eindpunten. Ze deelden er een, en dat kon niet goed
-   * gaan: /api/follow zoekt in ap_pending_follows en vindt een uitgaand verzoek
-   * daar nooit. De knop deed dus niets en zei niets -- erger dan geen knop.
-   */
-  function answerFollow(id, decision, btn, uitgaand) {
-    if (btn) btn.disabled = true;
-    var pad = uitgaand ? '/guardian/api/outgoing-follow/' : '/guardian/api/follow/';
-    fetch(pad + encodeURIComponent(id), {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ decision: decision, site: S.site }),
-    }).then(loadFollowReqs);
-  }
-  function followCard(f) {
-    var uitgaand = f.direction === 'outgoing';
-    var card = el('div', 'g-card');
-    var row = el('div', 'row');
-    // Binnen het paneel van het kind is de ward een gegeven, dus alleen de
-    // ANDER wordt genoemd -- en wie dat is hangt van de richting af. Zonder dat
-    // onderscheid stond bij een uitgaand verzoek de ward zelf op de kaart
-    // ("je kind wil je kind volgen") en verdween degene om wie het ging.
-    row.appendChild(el('span', 'who grow',
-      uitgaand ? ((T.follow_out_line || 'wants to follow {who}').replace('{who}', f.target || '?'))
-               : f.follower));
-    var ok = el('button', 'small', T.accept || 'Accept');
-    ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok, uitgaand); });
-    var no = el('button', 'quiet small', T.reject || 'Deny');
-    no.addEventListener('click', function () { answerFollow(f.id, 'reject', no, uitgaand); });
-    row.appendChild(ok); row.appendChild(no);
-    card.appendChild(row);
-    return card;
-  }
-  /**
-   * Het logboek. Apart geladen en apart getoond: dit is geschiedenis, geen
-   * wachtrij. Faalt hij, dan blijft de sectie gewoon weg -- een guardian mist
-   * er niets door dat om hem vraagt.
-   */
-  function loadEvents() {
-    return fetch('/guardian/api/events?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (d) { if (d && !d.error) { EVENTS = d.items || []; renderLog(); } })
-      .catch(function () { /* geen logboek is geen storing */ });
-  }
-
-  /** Een regel per gebeurtenis: wat er gebeurde, om wie, en waarom. */
-  function eventLine(e) {
-    var wie = e.candidate || e.guardian || e.ward || e.follower || e.target || '';
-    var stuk = (T['ev_' + e.kind] || e.kind);
-    if (wie) stuk += ' - ' + handleOf(wie, wie);
-    // De REDEN is waarvoor dit logboek bestaat: zonder haar merkte een ward een
-    // weigering alleen doordat er iets uit een lijst verdween.
-    if (e.reason) stuk += ' (' + (T['evr_' + e.reason] || e.reason) + ')';
-    var row = el('div', 'g-card');
-    row.appendChild(el('span', 'who grow', stuk));
-    row.appendChild(el('span', 'when', when(e, e.created)));
-    return row;
-  }
-
-  function renderLog() {
-    var host = document.getElementById('g-log');
-    if (!host) return;
-    host.textContent = '';
-    if (!EVENTS.length) return;
-    var lijst = el('div', 'g-gates');
-    lijst.hidden = !S._logOpen;
-    EVENTS.forEach(function (e) { lijst.appendChild(eventLine(e)); });
-    var kop = el('button', 'quiet small g-gates-toggle');
-    var zet = function () {
-      kop.textContent = (lijst.hidden ? (T.log_show || 'Show history') : (T.log_hide || 'Hide history'))
-        + ' (' + EVENTS.length + ')';
-    };
-    zet();
-    kop.addEventListener('click', function () {
-      lijst.hidden = !lijst.hidden; S._logOpen = !lijst.hidden; zet();
-    });
-    host.appendChild(kop); host.appendChild(lijst);
-  }
-
-  function loadFollowReqs() {
-    return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
-      .catch(function () { /* panels just show "none waiting" */ });
-  }
-
-  function refresh() {
-    return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); } })
-      .then(loadFeed).then(loadFollowReqs);
-  }
-
-  // ── De lange poll (Barts opdracht, 9-8) ──────────────────────────────
-  //
-  // Het paneel tikte elke 45 seconden, ongeacht of er iets gebeurd was. Nu hangt
-  // er een verzoek open tot er iets is dat je moet verwerken -- een aanbod, een
-  // volgverzoek, een gate-voorstel, een hulpvraag. Dat is niet alleen zuiniger,
-  // het is ook SNELLER: een hulproep stond eerst tot drie kwartier van een
-  // minuut op het scherm te wachten.
-  //
-  // 304 = stilte, en dan meteen opnieuw wachten. Alleen bij 200 wordt er iets
-  // opnieuw getekend.
-  var pollBezig = false;
-  var laatsteState = null;   // de vorige body, om onnodig hertekenen te herkennen
-
-  function longPoll() {
-    if (pollBezig || document.hidden) return;
-    pollBezig = true;
-    var t0 = Date.now();
-    fetch('/guardian/api/state?wait=25&site=' + encodeURIComponent(S.site))
-      .then(function (r) {
-        pollBezig = false;
-        if (r.status === 304) return null;          // stilte
-        return r.text().then(function (tekst) {
-          // ZELFDE INHOUD, NIET HERTEKENEN. De 304 zou dit al moeten afvangen,
-          // maar een enkel veld dat per verzoek verandert (er stond een levende
-          // klok in) maakt elke ETag anders, en dan tekent het paneel zichzelf
-          // stuk terwijl je scrollt. Dit is de tweede grendel, aan de kant die
-          // de gebruiker merkt.
-          if (tekst === laatsteState) return null;
-          laatsteState = tekst;
-          var s; try { s = JSON.parse(tekst); } catch (e) { return null; }
-          if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); }
-          return loadFeed().then(loadFollowReqs);
-        });
-      })
-      .then(function () {
-        // EEN LANGE POLL DIE METEEN TERUGKEERT MAG GEEN LUS WORDEN. Wachtte de
-        // server niet (een fout, een tussenliggende proxy, iets wat we nog niet
-        // weten), dan houden we zelf de rem erop. Anders vervangt een defect de
-        // ene tik van 45 seconden door duizend per minuut.
-        var snel = Date.now() - t0 < 2000;
-        setTimeout(longPoll, snel ? 3000 : 200);
-      })
-      .catch(function () {
-        // Netwerk weg of server herstart: niet meteen opnieuw beuken.
-        pollBezig = false;
-        setTimeout(longPoll, 5000);
-      });
-  }
-
-  // ── 2. Adopt ───────────────────────────────────────────────────────────
-  var form = document.getElementById('adopt-form');
-  var input = document.getElementById('adopt-handle');
-  var adoptBtn = document.getElementById('adopt-btn');
-  var msg = document.getElementById('adopt-msg');
-  function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
-
-  form.addEventListener('submit', function (ev) {
-    ev.preventDefault();
-    var handle = input.value.trim();
-    if (!handle) return;
-    adoptBtn.disabled = true;
-    setMsg(T.sending || '…', false);
-    fetch('/guardian/adopt', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ handle: handle, site: S.site }),
-    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
-      .then(function (res) {
-        adoptBtn.disabled = false;
-        if (res.ok) {
-          input.value = '';
-          // Always refresh: the offer is recorded even if delivery is still
-          // in flight. Show it under "Verzonden aanvragen".
-          setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
-          refresh();
-        } else {
-          setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
-        }
-      })
-      .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
-  });
-
-  // ── Site picker ────────────────────────────────────────────────────────
-  var picker = document.getElementById('site-picker');
-  if (picker) picker.addEventListener('change', function () {
-    location.href = '/guardian?site=' + encodeURIComponent(picker.value);
-  });
-
-  // ── 5. Push ────────────────────────────────────────────────────────────
-  var toggle = document.getElementById('push-toggle');
-  var pmsg = document.getElementById('push-msg');
-  function pushState() {
-    if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
-    navigator.serviceWorker.register('/sw.js').catch(function () {});
-    navigator.serviceWorker.ready
-      .then(function (reg) { return reg.pushManager.getSubscription(); })
-      .then(function (sub) {
-        toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
-        toggle.dataset.subscribed = sub ? '1' : '';
-        toggle.classList.toggle('is-on', !!sub);
-      });
-  }
-  function urlB64(base64) {
-    var pad = '='.repeat((4 - (base64.length % 4)) % 4);
-    var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
-    var raw = atob(b); var arr = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
-    return arr;
-  }
-  toggle.addEventListener('click', function () {
-    pmsg.hidden = true;
-    navigator.serviceWorker.ready.then(function (reg) {
-      if (toggle.dataset.subscribed) {
-        reg.pushManager.getSubscription().then(function (sub) {
-          if (!sub) return;
-          fetch('/push/unsubscribe', {
-            method: 'POST', headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ endpoint: sub.endpoint }),
-          }).then(function () { return sub.unsubscribe(); }).then(pushState);
-        });
-        return;
-      }
-      fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
-        if (!v.publicKey) throw new Error('no key');
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
-      }).then(function (sub) {
-        return fetch('/push/subscribe', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            subscription: sub.toJSON(),
-            alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
-            uaLabel: 'Guardian PWA',
-          }),
-        });
-      }).then(pushState).catch(function (e) {
-        pmsg.hidden = false; pmsg.className = 'g-msg err';
-        pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
-      });
-    });
-  });
-
-  renderAll(); pushState(); loadFeed(); loadFollowReqs(); loadEvents();
-
-  // De push die de melding brengt is meteen het teken dat de staat veranderd is.
-  // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
-  // werk, en het werkt ook als de app dicht is.
-  if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.addEventListener('message', function (e) {
-      if (e.data && e.data.klonkt === 'push') refresh();
-    });
-  }
-  // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
-  // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
-  // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
-  // meteen een keer, want dan is de kans op nieuws het grootst.
-  // De lange poll doet het werk. Het tikje blijft als vangnet, maar veel trager:
-  // valt er een wekker weg (een pad dat niet wekt, een herstart midden in een
-  // verzoek), dan mag het scherm niet voorgoed stilstaan. Vijf minuten in plaats
-  // van vijfenveertig seconden, en met de ETag kost zo'n tik meestal een lege 304.
-  longPoll();
-  setInterval(function () { if (!document.hidden) refresh(); }, 300000);
-  document.addEventListener('visibilitychange', function () {
-    if (document.hidden) return;
-    refresh();
-    longPoll();       // bij terugkomst weer een wachter openzetten
-  });
-  } catch (e) {
-    fatal((e && e.message) || String(e));
-  }
-})();
Index: src/assets/js/install-app.js
===================================================================
--- src/assets/js/install-app.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/js/install-app.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -7,10 +7,10 @@
  *  - On click, opens a modal with platform-specific instructions:
  *      iOS     → "Voeg toe aan beginscherm" via Safari share sheet
- *      Android → PWA install (1-tap if available, else Chrome ⋮-menu instructions)
+ *      Android → choice between PWA install (1-tap if available) and APK download
  *      Desktop → instructions for Chrome/Edge install icon, or native prompt if available
  *  - Listens for `beforeinstallprompt` (Android Chrome / desktop Chromium) and
  *    deferreds it so the modal's "Install as PWA" button can fire it.
  *
- * Markup contract: <button data-pcms-install-app>
+ * Markup contract: <button data-pcms-install-app data-apk-url="/path/to.apk">
  *
  * CSS: .install-app-btn / .install-modal* / .install-choice* — defined in
@@ -30,4 +30,5 @@
   const isIOS     = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
   const isAndroid = /Android/.test(ua);
+  const apkUrl    = btn.dataset.apkUrl || '';
 
   // Capture Chrome/Edge native install prompt so we can fire it from our modal.
@@ -99,14 +100,71 @@
       : `<p class="install-choice-note">Open deze site in <strong>Chrome</strong> en kies in het <strong>⋮-menu → "App installeren"</strong> (of "Aan beginscherm toevoegen").</p>`;
 
+    const apkBlock = apkUrl ? `
+      <div class="install-choice install-choice-apk">
+        <h4>🤖 Optie B — APK (native Android-app)</h4>
+        <div class="install-pros-cons">
+          <div class="install-pros">
+            <strong>Voordelen</strong>
+            <ul>
+              <li>Echte app in app-lade, naast WhatsApp/Gmail</li>
+              <li>Beste achtergrond-audio ondersteuning</li>
+              <li>Lock-screen controls werken optimaal</li>
+              <li>Blijft geïnstalleerd ongeacht browser-cache</li>
+            </ul>
+          </div>
+          <div class="install-cons">
+            <strong>Nadelen</strong>
+            <ul>
+              <li>Moet eenmalig "onbekende bronnen" toestaan</li>
+              <li>Geen auto-updates — nieuwe versie = APK opnieuw downloaden</li>
+              <li>Browser toont beveiligings-waarschuwing bij install</li>
+              <li>Alleen Android (Android 5+ / API 21+)</li>
+            </ul>
+          </div>
+        </div>
+        <a href="${apkUrl}" class="install-choice-cta" download>
+          <svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:middle;margin-right:.35rem"><path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z" fill="currentColor"/></svg>
+          Download APK
+        </a>
+        <ol class="install-steps" style="margin-top:.75rem">
+          <li>Tik op de gedownloade APK</li>
+          <li>Sta eenmalig <strong>"installeren van onbekende bronnen"</strong> toe voor je browser</li>
+          <li>Tik <strong>Installeer</strong> in de popup</li>
+          <li>Open de app vanuit je app-lade</li>
+        </ol>
+      </div>
+    ` : '';
+
     showModal(`
       <h3 id="install-title" style="margin:0 0 .5rem">🤖 Installeer op Android</h3>
-      <p style="margin:0 0 1rem;opacity:.8">Voeg deze site als app toe aan je beginscherm:</p>
-      <ul class="install-steps" style="opacity:.85">
-        <li>Instant — geen download of toestemmingen</li>
-        <li>Updatet automatisch bij elke wijziging</li>
-        <li>Minder opslag dan een losse app</li>
-      </ul>
-      ${nativeLine}
-      <p style="margin:1rem 0 0;opacity:.7;font-size:.85rem">⚠️ Werkt het best in Chrome.</p>
+      <p style="margin:0 0 1.25rem;opacity:.8">Twee manieren. Kies wat bij je past:</p>
+
+      <div class="install-choice install-choice-pwa">
+        <h4>📱 Optie A — Home-screen shortcut (PWA)</h4>
+        <div class="install-pros-cons">
+          <div class="install-pros">
+            <strong>Voordelen</strong>
+            <ul>
+              <li>Instant — geen download of toestemmingen</li>
+              <li>Updates automatisch bij elke site-wijziging</li>
+              <li>Geen beveiligings-waarschuwing</li>
+              <li>Minder opslagruimte op je telefoon</li>
+            </ul>
+          </div>
+          <div class="install-cons">
+            <strong>Nadelen</strong>
+            <ul>
+              <li>Vereist Chrome als browser</li>
+              <li>Audio-in-achtergrond iets minder robuust dan native</li>
+              <li>Kan verdwijnen als je browser-data wist</li>
+            </ul>
+          </div>
+        </div>
+        ${nativeLine}
+      </div>
+
+      ${apkBlock}
+
+      <p style="margin:1rem 0 0;opacity:.65;font-size:.82rem">💡 Weet je niet wat te kiezen? Begin met <strong>Optie A (shortcut)</strong>. Te beperkt? Ga dan voor de APK.</p>
     `);
   }
Index: src/assets/js/mod/admin-audio.js
===================================================================
--- src/assets/js/mod/admin-audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,454 +1,0 @@
-// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
-// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
-// link BINNEN de site binnenkomt (shaer-0i6).
-
-import { pageData, makeSweeper } from './lib.js';
-
-// Zie post-edit.js: init() per paginawissel, de veger haalt de window-
-// listeners van de vorige lichting weg (shaer-5s1).
-const doc = makeSweeper();
-let T = {};
-
-export function init() {
-  doc.sweep();
-  T = pageData();
-  run();
-}
-
-function run() {
-
-(function() {
-
-  // ── Live filename display for custom file inputs ──────────────
-  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
-    const nameEl = input.parentElement.querySelector('.ax-file-name');
-    if (!nameEl) return;
-    input.addEventListener('change', () => {
-      if (input.files && input.files[0]) {
-        nameEl.textContent = input.files[0].name;
-        nameEl.removeAttribute('data-empty');
-      } else {
-        nameEl.textContent = (T.no_file || '');
-        nameEl.setAttribute('data-empty', '');
-      }
-    });
-  });
-
-  // ── Inline track preview (routed through the global mini-player) ──
-  // Each .ax-track-play button is a thin wrapper around the global
-  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
-  // .is-playing) is synced from the player's own audio element so it
-  // stays accurate even when the user uses the mini-player's controls.
-  (function setupTrackPreview() {
-    const buttons = document.querySelectorAll('.ax-track-play');
-    if (!buttons.length) return;
-
-    function setIcon(btn, playing) {
-      const icon = btn.querySelector('.ax-track-play-icon');
-      if (icon) icon.textContent = playing ? '⏸' : '▶';
-      btn.classList.toggle('is-playing', playing);
-      btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
-    }
-
-    // Resync ALL preview buttons against the current audio element state.
-    // Called on every play/pause/ended event so admins always see the right
-    // icon — including the case where they hit pause on the mini-player
-    // bar instead of the row's button.
-    function resyncAll() {
-      const audio = document.getElementById('audio-element');
-      const player = window.pcmsAudioPlayer;
-      const playing = audio && !audio.paused && !audio.ended;
-      // audio.src is now a blob: URL (Spotify-style playback), so compare
-      // against the player's logical track URL, not the element src.
-      const cur = player && player.currentTrack();
-      const curUrl = cur ? cur.url : '';
-      buttons.forEach(b => {
-        const isThisOne = playing && curUrl === b.dataset.streamUrl;
-        setIcon(b, isThisOne);
-      });
-    }
-
-    buttons.forEach(btn => {
-      btn.addEventListener('click', e => {
-        e.preventDefault();
-        const player = window.pcmsAudioPlayer;
-        if (!player) {
-          console.warn('[admin-audio] miniplayer not available');
-          return;
-        }
-        const url = btn.dataset.streamUrl;
-        if (!url) return;
-
-        // Build track metadata from the row's DOM so the mini-player shows
-        // useful info (title/artist/album/cover) without an extra API call.
-        const row = btn.closest('.ax-track');
-        const titleEl  = row && row.querySelector('[data-cell="title"]');
-        const artistEl = row && row.querySelector('[data-cell="artist"]');
-        const albumEl  = row && row.querySelector('[data-cell="album"]');
-        const coverImg = row && row.querySelector('img[data-cover-thumb]');
-        const track = {
-          url,
-          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
-          artist: artistEl ? artistEl.textContent.trim() : '',
-          album:  albumEl  ? albumEl.textContent.trim()  : '',
-          cover:  coverImg ? coverImg.src                : '',
-        };
-
-        // If this exact track is already current, toggle pause/play instead
-        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
-        const cur = player.currentTrack();
-        if (cur && cur.url === url) {
-          if (player.isPlaying()) player.pause();
-          else                    player.play();
-          return;
-        }
-
-        player.setQueue([track], 0);
-      });
-    });
-
-    // Hook the global audio element's events to keep the row buttons synced.
-    // We attach lazily after the player has built its DOM. The script in
-    // shell.ejs runs at page-load so #audio-element exists by the time
-    // this IIFE fires (script tag is below the body content).
-    const audio = document.getElementById('audio-element');
-    if (audio) {
-      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
-        audio.addEventListener(ev, resyncAll);
-      });
-      // Initial state on page load (e.g. user navigated back while a track
-      // was already playing — buttons should reflect that).
-      resyncAll();
-    }
-  })();
-
-
-  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
-  // Files dropped or picked are queued (not uploaded immediately) so the
-  // user can review the list, set shared metadata, then hit "Start upload".
-  // The loop POSTs one file at a time to /admin/audio/upload with
-  // Accept: application/json so the server returns structured per-file
-  // results instead of redirecting.
-  const dropzone   = document.getElementById('audio-dropzone');
-  const fileInput  = document.getElementById('audio-files');
-  const queueEl    = document.getElementById('upload-queue');
-  const actionsEl  = document.getElementById('upload-actions');
-  const startBtn   = document.getElementById('start-upload-btn');
-  const clearBtn   = document.getElementById('clear-queue-btn');
-  const artistInput= document.getElementById('batch-artist');
-  const albumInput = document.getElementById('batch-album');
-  const coverInput = document.getElementById('batch-cover');
-
-  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
-  const queue = [];
-
-  function fmtBytes(n) {
-    if (n < 1024) return n + ' B';
-    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
-    return (n / 1024 / 1024).toFixed(1) + ' MB';
-  }
-
-  function setItemStatus(item, status, label) {
-    const labels = {
-      queued:      (T.st_queued || ''),
-      uploading:   (T.st_uploading || ''),
-      transcoding: (T.st_transcoding || ''),
-      done:        '✓ ' + (T.st_done || ''),
-      error:       '✗ ' + (T.st_error || ''),
-    };
-    item.status = status;
-    const badge = item.el.querySelector('.ax-queue-status');
-    badge.className = 'ax-queue-status ax-queue-status--' + status;
-    badge.textContent = label || labels[status] || status;
-    // Restyle the row
-    item.el.classList.remove(
-      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
-    );
-    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
-    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
-    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
-  }
-
-  function addFiles(files) {
-    let added = 0;
-    for (const file of files) {
-      if (!file.type.startsWith('audio/') &&
-          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
-        // Silently skip non-audio drops; keeps the UX uncluttered.
-        continue;
-      }
-      const li = document.createElement('li');
-      li.className = 'ax-queue-item';
-      li.innerHTML =
-        '<div class="ax-queue-name"></div>' +
-        '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
-      // Use textContent to avoid HTML-injection if a filename contains markup.
-      li.querySelector('.ax-queue-name').textContent = file.name;
-      // Append size hint inline
-      const size = document.createElement('span');
-      size.className = 'ax-queue-size';
-      size.textContent = fmtBytes(file.size);
-      li.querySelector('.ax-queue-name').appendChild(size);
-      queueEl.appendChild(li);
-      queue.push({ file, el: li, status: 'queued' });
-      added++;
-    }
-    if (added) {
-      queueEl.hidden = false;
-      actionsEl.hidden = false;
-    }
-  }
-
-  // ── Drop-zone events ─────────────────────────────────────────
-  // Page-level guard: a dropped file outside the zone would otherwise
-  // make the browser navigate to it (e.g. opening the audio inline), which
-  // discards typed metadata. We swallow drops anywhere unless the dropzone
-  // explicitly handles them.
-  ['dragover', 'drop'].forEach(ev => {
-    doc.on(window, ev, e => {
-      // Allow drops INSIDE the dropzone — its own listener handles those.
-      if (dropzone.contains(e.target)) return;
-      e.preventDefault();
-    });
-  });
-
-  ['dragenter', 'dragover'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.add('is-dragover');
-    });
-  });
-  ['dragleave', 'drop'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.remove('is-dragover');
-    });
-  });
-  dropzone.addEventListener('drop', e => {
-    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
-  });
-  fileInput.addEventListener('change', () => {
-    addFiles(fileInput.files);
-    // Reset so the same file can be picked again later if user wants
-    fileInput.value = '';
-  });
-
-  // ── Clear queue button ───────────────────────────────────────
-  clearBtn.addEventListener('click', () => {
-    // Only remove items that aren't currently uploading (anything queued
-    // or already finished). An in-progress upload finishes, then its row
-    // would also disappear once we re-render — but we keep it simple and
-    // just refuse to clear during an active run.
-    if (startBtn.disabled) return;
-    queue.length = 0;
-    queueEl.innerHTML = '';
-    queueEl.hidden = true;
-    actionsEl.hidden = true;
-  });
-
-  // ── Sequential upload loop ───────────────────────────────────
-  startBtn.addEventListener('click', async () => {
-    if (startBtn.disabled) return;
-    startBtn.disabled = true;
-    clearBtn.disabled = true;
-    dropzone.style.pointerEvents = 'none';
-    dropzone.style.opacity = '0.5';
-
-    const sharedArtist = artistInput.value.trim();
-    const sharedAlbum  = albumInput.value.trim();
-    const sharedCover  = coverInput.files && coverInput.files[0];
-
-    // Process queued items one at a time. We iterate via index so that
-    // if more files get dropped during the run they ALSO get processed
-    // (queue.push above mutates the same array we're iterating).
-    for (let i = 0; i < queue.length; i++) {
-      const item = queue[i];
-      if (item.status !== 'queued') continue;
-      try {
-        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
-      } catch (err) {
-        console.error('upload failed for', item.file.name, err);
-        setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
-      }
-    }
-
-    startBtn.disabled = false;
-    clearBtn.disabled = false;
-    dropzone.style.pointerEvents = '';
-    dropzone.style.opacity = '';
-
-    // Reload the page so the new tracks appear in the list below.
-    // Could also fetch them and inject, but a full reload is simpler and
-    // ensures position indexes / album-grouping are correct.
-    const anyDone = queue.some(q => q.status === 'done');
-    if (anyDone) {
-      setTimeout(() => location.reload(), 700);
-    }
-  });
-
-  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
-    setItemStatus(item, 'uploading');
-
-    const fd = new FormData();
-    fd.append('audio', item.file);
-    if (sharedArtist) fd.append('artist', sharedArtist);
-    if (sharedAlbum)  fd.append('album',  sharedAlbum);
-    if (sharedCover)  fd.append('cover',  sharedCover);
-    // Title is intentionally omitted — server uses filename fallback.
-
-    // We can't reliably distinguish "still uploading bytes" from
-    // "uploading done, ffmpeg running" without progress events, but the
-    // status flips to "Converteren…" once the request is past upload phase.
-    // We approximate this by waiting until the response arrives — by then
-    // both phases are complete on the server side. For a smoother feel we
-    // briefly show "transcoding" near the end of the request lifecycle.
-    const transcodeHint = setTimeout(() => {
-      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
-    }, 1500);
-
-    try {
-      const res = await fetch('/admin/audio/upload', {
-        method: 'POST',
-        headers: { 'Accept': 'application/json' },
-        body: fd,
-        credentials: 'same-origin',
-      });
-      clearTimeout(transcodeHint);
-
-      // Server responds with JSON for our Accept header. If it didn't
-      // (e.g. session expired and got an HTML login page), surface that.
-      let data;
-      try { data = await res.json(); }
-      catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
-
-      if (!res.ok || !data.ok) {
-        throw new Error(data.error || ('HTTP ' + res.status));
-      }
-
-      setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
-    } catch (err) {
-      clearTimeout(transcodeHint);
-      throw err;
-    }
-  }
-
-  // ── Click-to-copy embed codes ─────────────────────────────────
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      const text = el.dataset.copy;
-      try {
-        await navigator.clipboard.writeText(text);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ ' + (T.copied || '');
-        setTimeout(() => {
-          el.classList.remove('is-copied');
-          el.textContent = original;
-        }, 1200);
-      } catch (_) { /* fall through — selection still works */ }
-    });
-  });
-
-  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
-  const addLinkBtn = document.getElementById('add-link-track-btn');
-  if (addLinkBtn) {
-    addLinkBtn.addEventListener('click', async () => {
-      addLinkBtn.disabled = true;
-      try {
-        const r = await fetch('/admin/audio/create-link', {
-          method: 'POST', credentials: 'same-origin',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ title: (T.new_track || '') }),
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
-        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
-        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
-      } catch (err) {
-        alert((T.create_failed || '') + ': ' + err.message);
-      } finally {
-        addLinkBtn.disabled = false;
-      }
-    });
-  }
-
-  // ── Wire all "Edit" buttons to the track-editor modal ─────────
-  // After save we patch the row in-place rather than reloading,
-  // so the user keeps their scroll position on long lists.
-  document.querySelectorAll('[data-track-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openTrackEditor !== 'function') {
-        alert((T.editor_not_loaded || ''));
-        return;
-      }
-      const id = btn.dataset.id;
-      window.openTrackEditor({
-        id,
-        onSaved: (track) => {
-          const row = document.querySelector('li[data-track-id="' + id + '"]');
-          if (!row) return;
-          // Update visible cells
-          const titleEl  = row.querySelector('[data-cell="title"]');
-          const artistEl = row.querySelector('[data-cell="artist"]');
-          const albumEl  = row.querySelector('[data-cell="album"]');
-          if (titleEl)  titleEl.textContent  = track.title  || (T.untitled || '');
-          if (artistEl) artistEl.textContent = track.artist || '—';
-          if (albumEl)  {
-            albumEl.textContent = track.album || '';
-            if (track.album) albumEl.removeAttribute('hidden');
-            else albumEl.setAttribute('hidden', '');
-          }
-          // Update cover thumb (replace element if type changed)
-          const oldThumb = row.querySelector('[data-cover-thumb]');
-          if (oldThumb) {
-            const parent = oldThumb.parentElement;
-            if (track.cover_url) {
-              const img = document.createElement('img');
-              img.className = 'ax-track-cover';
-              img.src = track.cover_url;
-              img.alt = '';
-              img.dataset.coverThumb = '';
-              parent.replaceChild(img, oldThumb);
-            } else {
-              const sp = document.createElement('span');
-              sp.className = 'ax-track-cover ax-track-cover-empty';
-              sp.textContent = '♫';
-              sp.dataset.coverThumb = '';
-              parent.replaceChild(sp, oldThumb);
-            }
-          }
-        },
-      });
-    });
-  });
-
-  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
-  document.querySelectorAll('[data-track-dl]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      if (btn.disabled) return;
-      const want = btn.dataset.on === '1' ? 0 : 1;
-      btn.disabled = true;
-      try {
-        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ downloadable: !!want }),
-        });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        btn.dataset.on = String(want);
-        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
-        btn.style.opacity = want ? '1' : '.5';
-        btn.title = want
-          ? (T.dl_on || '')
-          : (T.dl_off || '');
-      } catch (e) {
-        alert((T.change_failed || '') + ': ' + (e.message || e));
-      } finally {
-        btn.disabled = false;
-      }
-    });
-  });
-})();
-}
Index: src/assets/js/mod/admin-epk.js
===================================================================
--- src/assets/js/mod/admin-epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,27 +1,0 @@
-// Het EPK-scherm in beheer (pages/admin-epk.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var box = document.querySelector('.epk-track-pick');
-  if (!box) return;
-  var max = parseInt(box.dataset.max, 10) || 5;
-  function sync() {
-    var checks = box.querySelectorAll('input[type=checkbox]');
-    var n = box.querySelectorAll('input[type=checkbox]:checked').length;
-    checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
-  }
-  box.addEventListener('change', sync);
-  sync();
-})();
-}
Index: src/assets/js/mod/admin-help.js
===================================================================
--- src/assets/js/mod/admin-help.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,36 +1,0 @@
-// Het hulpscherm in beheer (pages/admin-help.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var q = document.getElementById('hb-q');
-  var items = [].slice.call(document.querySelectorAll('.hb-item'));
-  var empty = document.getElementById('hb-empty');
-  var count = document.getElementById('hb-count');
-  if (!q) return;
-  // Bewaar de originele tekst per item (voor highlight-reset).
-  items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
-  function run() {
-    var term = q.value.trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (it) {
-      var hit = !term || it._txt.indexOf(term) >= 0;
-      it.classList.toggle('hb-hidden', !hit);
-      if (hit) shown++;
-    });
-    empty.hidden = shown !== 0;
-    count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
-  }
-  q.addEventListener('input', run);
-})();
-}
Index: src/assets/js/mod/admin-media.js
===================================================================
--- src/assets/js/mod/admin-media.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-// Media in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-(function () {
-  if (window.__mediaWired) return; window.__mediaWired = true;
-  // Gedelegeerd op document, dus dit overleeft elke paginawissel -- ook op
-  // pagina's waar [data-copy] iets ANDERS betekent (audio en playlists: een
-  // shortcode, geen pad). Zonder deze wacht kaapt dit daar de kopieerklik en
-  // plakt er een origin voor (shaer-5s1). T per klik gelezen, want pageData
-  // wisselt mee met de pagina.
-  function active() { return (document.body.getAttribute('data-js') || '').split(/\s+/).indexOf('admin-media') !== -1; }
-  document.addEventListener('click', function (e) {
-    if (!active()) return;
-    var T = pageData();
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); })
-        .catch(function () { window.alert('Error'); });
-      return;
-    }
-    if (e.target.closest('#media-cleanup')) {
-      if (!window.confirm(T.cleanC)) return;
-      fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); })
-        .catch(function () { window.alert('Error'); });
-    }
-  });
-})();
Index: src/assets/js/mod/admin-paid.js
===================================================================
--- src/assets/js/mod/admin-paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,27 +1,0 @@
-// Betaalde posts in beheer (pages/admin-paid.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var field = document.getElementById('pd-redirect');
-  var btn = document.getElementById('pd-copy');
-  if (!field || !btn) return;
-  field.addEventListener('focus', function () { field.select(); });
-  btn.addEventListener('click', function () {
-    field.select();
-    var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
-    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
-    else { try { document.execCommand('copy'); done(); } catch (e) {} }
-  });
-})();
-}
Index: src/assets/js/mod/admin-playlists.js
===================================================================
--- src/assets/js/mod/admin-playlists.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-// Afspeellijsten in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens (het csrf-token en drie teksten) komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// Element-bedrading per render, dus init() per paginawissel (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function() {
-  const _d = pageData();
-  const csrf = _d.csrf || '';
-
-  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
-    if (typeof window.openPlaylistEditor === 'function') {
-      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
-    }
-  });
-
-  // Click-to-copy on shortcodes
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      try {
-        await navigator.clipboard.writeText(el.dataset.copy);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ ' + (_d.copied || '');
-        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
-      } catch (_) { /* fall back to selection */ }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openPlaylistEditor === 'function') {
-        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
-      }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      const id = btn.dataset.id;
-      const title = btn.dataset.title || id;
-      if (!confirm((_d.delConfirm || '').replace('{title}', title))) return;
-      try {
-        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
-          method: 'POST',
-          headers: { 'X-CSRF-Token': csrf },
-          credentials: 'same-origin',
-        });
-        const j = await r.json();
-        if (j.ok) location.reload();
-        else alert((_d.delFailed || '') + ': ' + (j.error || ''));
-      } catch (err) {
-        alert((_d.delFailed || '') + ': ' + err.message);
-      }
-    });
-  });
-
-  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
-  // openPlaylistEditor is defined synchronously by the included partial, so
-  // it's available by the time this IIFE runs.
-  (function deepLinkEdit() {
-    const params = new URLSearchParams(location.search);
-    const editId = params.get('edit');
-    if (!editId) return;
-    if (typeof window.openPlaylistEditor !== 'function') return;
-    // Strip the query param immediately so reload after save doesn't re-open.
-    history.replaceState({}, '', location.pathname);
-    window.openPlaylistEditor({
-      mode: 'edit',
-      id: editId,
-      onSaved: () => location.reload(),
-    });
-  })();
-})();
-}
Index: src/assets/js/mod/admin-push.js
===================================================================
--- src/assets/js/mod/admin-push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,116 +1,0 @@
-// Pushmeldingen in beheer (pages/admin-push.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var cfg = JSON.parse(document.getElementById('np-data').textContent);
-  var elState = document.getElementById('np-state');
-  var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
-  var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
-  var currentEndpoint = null;
-
-  var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
-  var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
-  if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
-
-  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
-    document.getElementById('np-unsupported').hidden = false;
-    elState.textContent = cfg.i18n.unsupported;
-    return;
-  }
-
-  function b64ToU8(s) {
-    var pad = '='.repeat((4 - (s.length % 4)) % 4);
-    var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
-    var out = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
-    return out;
-  }
-  function post(url, body) {
-    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
-  }
-  function deviceLabel() {
-    var ua = navigator.userAgent;
-    var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
-    var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
-    return (browser + (os ? ' op ' + os : ''));
-  }
-  function readAlertBoxes() {
-    var out = {};
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
-    return out;
-  }
-  function setAlertBoxes(alerts) {
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
-      cb.checked = !!alerts[cb.getAttribute('data-alert')];
-    });
-  }
-
-  function render(sub) {
-    currentEndpoint = sub ? sub.endpoint : null;
-    elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
-    btnOn.hidden = !!sub || Notification.permission === 'denied';
-    btnOff.hidden = !sub;
-    btnTest.hidden = !sub;
-    alertsBox.hidden = !sub;
-  }
-
-  navigator.serviceWorker.ready.then(function (reg) {
-    return reg.pushManager.getSubscription();
-  }).then(function (sub) {
-    // Show this device's SAVED prefs when we know them, defaults otherwise.
-    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
-    render(sub);
-  }).catch(function () { elState.textContent = cfg.i18n.unknown; });
-
-  btnOn.addEventListener('click', function () {
-    btnOn.disabled = true;
-    Notification.requestPermission().then(function (perm) {
-      if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
-      navigator.serviceWorker.ready.then(function (reg) {
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
-      }).then(function (sub) {
-        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
-          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
-      }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
-    });
-  });
-
-  btnOff.addEventListener('click', function () {
-    navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
-      if (!sub) { render(null); return; }
-      var ep = sub.endpoint;
-      sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
-        .then(function () { location.reload(); });
-    });
-  });
-
-  btnTest.addEventListener('click', function () {
-    btnTest.disabled = true;
-    post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
-  });
-
-  alertsBox.addEventListener('change', function () {
-    if (!currentEndpoint) return;
-    post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
-      if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
-    });
-  });
-
-  document.querySelectorAll('.np-remove').forEach(function (btn) {
-    btn.addEventListener('click', function () {
-      post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
-    });
-  });
-})();
-}
Index: src/assets/js/mod/admin-seo.js
===================================================================
--- src/assets/js/mod/admin-seo.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,158 +1,0 @@
-// De SEO-pagina: op dit moment alleen het MusicBrainz-paneel (shaer-mbz).
-//
-// De rest van dit scherm is gewone formuliervelden en heeft geen JavaScript
-// nodig; deze module bestaat omdat opzoeken dat wel doet.
-
-import { pageData, makeSweeper } from './lib.js';
-
-const doc = makeSweeper();
-let T = {};
-
-export function init() {
-  doc.sweep();
-  T = pageData();
-  run();
-}
-
-function run() {
-  // Binnen run() en niet op moduleniveau: deze modules krijgen bij elke
-  // paginawissel opnieuw init(), en een blok dat maar een keer per sessie
-  // draait is precies wat shaer-5s1 opleverde.
-  wireMusicBrainz();
-}
-
-/**
- * "Zoek jezelf op" in MusicBrainz.
- *
- * De kandidaten komen van de server, want MusicBrainz eist een verzoek per
- * seconde per APPLICATIE en een User-Agent met contact -- allebei niet vanuit
- * een browser af te dwingen.
- *
- * WIJ KIEZEN NIET. Ook niet als er precies een treffer is: een verkeerd geraden
- * MBID zet jouw naam onder andermans werk. De knop staat er, de klik is van de
- * artiest. En de keuze gaat mee met de Opslaan van de pagina -- een eigen
- * formulier kan hier niet, want deze pagina IS er een.
- */
-function wireMusicBrainz() {
-  const knop = document.getElementById('mb-zoek-btn');
-  const veld = document.getElementById('mb-q');
-  const uit = document.getElementById('mb-uit');
-  const idVeld = document.getElementById('mb-id');
-  const naamVeld = document.getElementById('mb-naam');
-  const huidig = document.getElementById('mb-huidig');
-  if (!knop || !veld || !uit || !idVeld || knop.__wired) return;
-  knop.__wired = true;
-
-  const el = (tag, cls, tekst) => {
-    const e = document.createElement(tag);
-    if (cls) e.className = cls;
-    // textContent en nooit innerHTML: dit is tekst uit een vreemd register.
-    if (tekst != null) e.textContent = tekst;
-    return e;
-  };
-
-  function zet(mbid, naam) {
-    idVeld.value = mbid || '';
-    if (naamVeld) naamVeld.value = naam || '';
-    if (huidig) {
-      huidig.hidden = !mbid;
-      const n = document.getElementById('mb-huidig-naam');
-      const a = document.getElementById('mb-huidig-link');
-      if (n) n.textContent = naam || mbid || '';
-      if (a) a.href = mbid ? `https://musicbrainz.org/artist/${mbid}` : '#';
-    }
-    uit.hidden = true;
-    uit.replaceChildren();
-    checkTerugweg(mbid);
-  }
-
-  const wis = document.getElementById('mb-wis');
-  if (wis) wis.addEventListener('click', () => zet('', ''));
-
-  // DE TERUG-WEG. Wij zetten hem niet -- dat kan niet via hun API en hoort ook
-  // niet -- we kijken alleen of de artiestenpagina ons domein noemt. Zolang dat
-  // niet zo is staat er "nog eenzijdig", want een halve koppeling die als
-  // bevestigd leest is het soort stilte waar we vandaag al genoeg van zagen.
-  const terugweg = document.getElementById('mb-terugweg');
-  async function checkTerugweg(mbid) {
-    if (!terugweg) return;
-    if (!mbid) { terugweg.hidden = true; terugweg.textContent = ''; return; }
-    terugweg.hidden = false;
-    terugweg.className = 'mb-terugweg';
-    terugweg.textContent = T.mb_checking || 'Terug-weg controleren…';
-    try {
-      const r = await fetch(`/admin/seo/api/musicbrainz/terugweg?mbid=${encodeURIComponent(mbid)}`, { credentials: 'same-origin' });
-      const j = await r.json();
-      terugweg.className = 'mb-terugweg ' + (j.verified ? 'is-ok' : 'is-eenzijdig');
-      terugweg.textContent = j.verified
-        ? (T.mb_verified || 'Wederzijds.')
-        : (T.mb_unverified || 'Nog eenzijdig.');
-    } catch {
-      terugweg.hidden = true;
-    }
-  }
-  // Meteen bij het openen, want de artiest kan hem intussen op MusicBrainz
-  // hebben gezet en hoort dat hier te zien zonder opnieuw te koppelen.
-  if (idVeld.value) checkTerugweg(idVeld.value);
-
-  async function zoek() {
-    const q = (veld.value || '').trim();
-    if (!q) return;
-    uit.hidden = false;
-    uit.replaceChildren(el('p', 'form-hint', T.mb_busy || 'Zoeken…'));
-    knop.disabled = true;
-    try {
-      const r = await fetch(`/admin/seo/api/musicbrainz?q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
-      const j = await r.json();
-      toon((j && j.kandidaten) || []);
-    } catch {
-      uit.replaceChildren(el('p', 'form-hint', T.mb_fail || 'MusicBrainz is even niet bereikbaar.'));
-    } finally {
-      knop.disabled = false;
-    }
-  }
-
-  function toon(kandidaten) {
-    if (!kandidaten.length) {
-      uit.replaceChildren(el('p', 'form-hint', T.mb_none || 'Niets gevonden.'));
-      return;
-    }
-    const lijst = el('ul', 'mb-lijst');
-    for (const k of kandidaten) {
-      const li = el('li', 'mb-kandidaat');
-
-      // Regel voor regel, want dit is een KEUZE en geen opsomming. Alles op een
-      // rij duwen maakt de toelichting -- juist het veld dat de bands uit
-      // elkaar houdt -- tot bijzin achter de naam.
-      li.appendChild(el('span', 'mb-naam', k.naam));
-
-      // De toelichting is het hele punt: er zijn drie bands die Nirvana heten,
-      // en zonder dit veld kiest iemand de verkeerde. Eigen regel dus.
-      if (k.toelichting) li.appendChild(el('span', 'mb-toelichting', k.toelichting));
-
-      // De feiten eronder, en alleen wat er IS. Een lege bullet tussen twee
-      // punten leest als ontbrekende informatie in plaats van als afwezige.
-      const feiten = [k.soort, k.land, k.jaren].filter(Boolean).join(' · ');
-      if (feiten) li.appendChild(el('span', 'mb-feiten', feiten));
-
-      const voet = el('div', 'mb-voet');
-      const open = el('a', 'mb-open', T.mb_open || 'Bekijk op MusicBrainz');
-      open.href = k.url; open.target = '_blank'; open.rel = 'noopener';
-      voet.appendChild(open);
-      // Hun eigen score. Alleen tonen als hij ZWAK is: bij een goede treffer is
-      // een getal ruis, bij een zwakke is het een waarschuwing.
-      if (k.score && k.score < 70) voet.appendChild(el('span', 'mb-zwak', `${k.score}%`));
-      const b = el('button', 'btn mb-kies', T.mb_pick || 'Dit ben ik');
-      b.type = 'button';
-      b.addEventListener('click', () => zet(k.mbid, k.naam));
-      voet.appendChild(b);
-      li.appendChild(voet);
-
-      lijst.appendChild(li);
-    }
-    uit.replaceChildren(lijst);
-  }
-
-  knop.addEventListener('click', zoek);
-  veld.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); zoek(); } });
-}
Index: src/assets/js/mod/admin-settings.js
===================================================================
--- src/assets/js/mod/admin-settings.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,25 +1,0 @@
-// Instellingen in beheer (pages/admin-settings.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function () {
-      var f = document.getElementById('mode-form');
-      if (!f || f.__age18wired) return; f.__age18wired = true;
-      f.addEventListener('submit', function (e) {
-        var sel = f.querySelector('input[name=mode]:checked');
-        if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
-          if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
-        }
-      });
-    })();
-}
Index: src/assets/js/mod/admin-site-edit.js
===================================================================
--- src/assets/js/mod/admin-site-edit.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,136 +1,0 @@
-// Site bewerken in beheer (pages/admin-site-edit.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function(){
-      if (window.__themePreviewWired) return; window.__themePreviewWired = true;
-      var html = document.documentElement;
-      function applyAccent(hex){
-        if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
-        var sa = document.getElementById('pcms-site-accent');
-        if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
-        sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
-      }
-      document.addEventListener('change', function(e){
-        var t = e.target; if(!t || !t.name) return;
-        if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
-        else if(t.name === 'accent'){ applyAccent(t.value); }
-        else if(t.name === 'theme_override'){
-          if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
-          else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
-        }
-      });
-    })();
-    
-
-// ── volgend blok ──
-
-(function() {
-  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
-  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
-  var sh = document.getElementById('slug-host');
-  if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
-})();
-(function() {
-  // Profile-links repeater: add row from <template>, remove on click.
-  var rows = document.getElementById('profile-links-rows');
-  var tpl  = document.getElementById('profile-link-template');
-  var add  = document.getElementById('profile-link-add');
-  if (!rows || !tpl || !add) return;
-
-  add.addEventListener('click', function() {
-    var clone = tpl.content.cloneNode(true);
-    rows.appendChild(clone);
-  });
-  rows.addEventListener('click', function(e) {
-    if (e.target && e.target.classList.contains('pl-remove')) {
-      var row = e.target.closest('.profile-link-row');
-      if (row) row.remove();
-    }
-  });
-})();
-
-// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
-// then write the returned URL into the visible input. Live thumb preview.
-(function() {
-  var picker  = document.getElementById('photo-picker');
-  if (!picker) return;
-  var thumb   = document.getElementById('photo-thumb');
-  var preview = document.getElementById('photo-preview');
-  var urlEl   = document.getElementById('photo-url');
-  var trigger = document.getElementById('photo-upload-trigger');
-  var clear   = document.getElementById('photo-clear');
-  var field   = document.getElementById('photo-upload-field');
-  var status  = document.getElementById('photo-status');
-
-  function showPreview(url) {
-    if (url) {
-      preview.src = url;
-      preview.hidden = false;
-      thumb.removeAttribute('data-empty');
-      clear.hidden = false;
-    } else {
-      preview.src = '';
-      preview.hidden = true;
-      thumb.setAttribute('data-empty', '');
-      clear.hidden = true;
-    }
-  }
-
-  if (urlEl) {
-    urlEl.addEventListener('input', function() {
-      showPreview(urlEl.value.trim());
-    });
-  }
-
-  if (trigger && field) {
-    trigger.addEventListener('click', function() { field.click(); });
-    field.addEventListener('change', async function() {
-      var file = field.files && field.files[0];
-      if (!file) return;
-      status.classList.remove('is-error');
-      status.textContent = 'Uploaden…';
-      try {
-        var fd = new FormData();
-        fd.append('photo', file);
-        var r = await fetch('/admin/sites/upload-photo', {
-          method: 'POST',
-          body: fd,
-          credentials: 'same-origin',
-        });
-        var j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
-        urlEl.value = j.url;
-        showPreview(j.url);
-        status.textContent = 'Geüpload ✓';
-        setTimeout(function() { status.textContent = ''; }, 2000);
-      } catch (e) {
-        status.classList.add('is-error');
-        status.textContent = 'Mislukt: ' + e.message;
-      } finally {
-        field.value = '';
-      }
-    });
-  }
-
-  if (clear) {
-    clear.addEventListener('click', function() {
-      urlEl.value = '';
-      showPreview('');
-      status.textContent = '';
-      // Note: doesn't delete the file from disk — saving the form with empty
-      // URL leaves the file orphaned on the server. Acceptable for now.
-    });
-  }
-})();
-}
Index: src/assets/js/mod/admin-videos.js
===================================================================
--- src/assets/js/mod/admin-videos.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,37 +1,0 @@
-// Video's in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-(function () {
-  if (window.__videosWired) return; window.__videosWired = true;
-  // Gedelegeerd op document, dus dit overleeft elke paginawissel -- ook op
-  // pagina's waar [data-copy] iets ANDERS betekent (audio en playlists: een
-  // shortcode, geen pad). Zonder deze wacht kaapt dit daar de kopieerklik en
-  // plakt er een origin voor (shaer-5s1). T per klik gelezen, want pageData
-  // wisselt mee met de pagina.
-  function active() { return (document.body.getAttribute('data-js') || '').split(/\s+/).indexOf('admin-videos') !== -1; }
-  document.addEventListener('click', function (e) {
-    if (!active()) return;
-    var T = pageData();
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } })
-        .catch(function () {});
-    }
-  });
-})();
Index: src/assets/js/mod/auth-register.js
===================================================================
--- src/assets/js/mod/auth-register.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,25 +1,0 @@
-// Het registratieformulier (pages/auth-register.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
-  if (!u || !out) return;
-  function upd() {
-    var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
-    out.textContent = '@' + v + '@' + location.host;
-  }
-  u.addEventListener('input', upd);
-  upd();
-})();
-}
Index: src/assets/js/mod/authorize-interaction.js
===================================================================
--- src/assets/js/mod/authorize-interaction.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,68 +1,0 @@
-// Interactie autoriseren (pages/authorize-interaction.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function () {
-      // Element-vlag, geen window-vlag: na een swap is de knop een NIEUW element
-      // en moet hij opnieuw bedraad; een window-vlag zou dat voorgoed blokkeren.
-      var a = document.getElementById('fedi-bm-btn'); if (!a || a.__wired) return; a.__wired = true;
-      a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-      a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
-    })();
-    
-
-// ── volgend blok ──
-
-      (function () {
-        if (window.__fediEditWired) return; window.__fediEditWired = true;
-        document.addEventListener('click', function (e) {
-          var b = e.target.closest && e.target.closest('.fedi-edit-btn');
-          if (!b) return;
-          var li = b.closest('.fedi-manage-item'); if (!li) return;
-          var form = li.querySelector('.fedi-edit-form'); if (!form) return;
-          var open = form.classList.toggle('is-open');
-          b.classList.toggle('is-open', open);
-          b.setAttribute('aria-expanded', open ? 'true' : 'false');
-          if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
-        });
-      })();
-      
-
-// ── volgend blok ──
-
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
-(function(){
-  if (window.__fediReactWired) return; window.__fediReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.fedi-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){
-        if (j) {
-          var on = !!j.on;
-          btn.classList.toggle('is-on', on);
-          var lbl = btn.querySelector('.fedi-bigact-label');
-          if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
-        }
-      })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-}
Index: src/assets/js/mod/chrome.js
===================================================================
--- src/assets/js/mod/chrome.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,284 +1,0 @@
-// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
-//
-// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
-// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
-// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
-// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
-// klik binnen de site al weg (shaer-0i6).
-//
-// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
-// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
-// een vertrouwd script is precies waar 'strict-dynamic' voor is.
-//
-// TWEE REGELS voor alles wat hier bij komt:
-//
-//   GEDELEGEERD  luister op document, nooit op een element dat er nu staat. De
-//                chrome wordt vervangen, dus een vastgehouden verwijzing is na
-//                een navigatie een verwijzing naar iets dat weg is.
-//   IDEMPOTENT   de module kan een tweede keer geladen worden. Een slot op
-//                window voorkomt dat er een tweede stel luisteraars bij komt --
-//                dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
-//
-// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
-// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
-
-(function () {
-  'use strict';
-  if (window.__chromeMod) return;
-  window.__chromeMod = true;
-
-  // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
-  // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
-  // site niets meer opleveren: elke keer opnieuw opzoeken.
-  document.addEventListener('click', function (e) {
-    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
-    var o = document.getElementById('search-overlay');
-    if (o) {
-      o.hidden = false;
-      var i = o.querySelector('input');
-      if (i) i.focus();
-    }
-  });
-})();
-
-// ── de profielkop ──────────────────────────────────────────────
-// Zat inline in partials/profile-header.ejs, en die partial zit IN de
-// OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
-(function () {
-  if (window.__pcmsFediFollowWired) return;
-  window.__pcmsFediFollowWired = true;
-  function modal() { return document.querySelector('.pf-follow'); }
-  function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-  function go() {
-    var m = modal(); if (!m) return;
-    var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
-    // Strip a full @user@host handle (and any scheme/path) down to the server host.
-    var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-    if (!server) { if (inp) inp.focus(); return; }
-    try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
-    var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
-    location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
-  }
-  document.addEventListener('click', function (e) {
-    var b = e.target.closest && e.target.closest('.profile-fedi-link');
-    if (b) { e.preventDefault();
-      var m = modal(); if (!m) return;
-      m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
-      var inp = m.querySelector('.pf-follow-input');
-      try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
-      m.classList.add('is-open');
-      setTimeout(function () { if (inp) inp.focus(); }, 30);
-      return;
-    }
-    if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
-    if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
-    var open = document.querySelector('.pf-follow.is-open');
-    if (open && e.target === open) closeM(); /* click on backdrop */
-  });
-  document.addEventListener('keydown', function (e) {
-    var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
-    if (e.key === 'Escape') { closeM(); }
-    else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
-  });
-})();
-
-/* Profile summary: click the avatar to open a modal with photo + details. */
-(function () {
-  if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
-  function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
-  document.addEventListener('click', function (e) {
-    if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
-    var open = document.querySelector('.pf-summary.is-open');
-    if (open && e.target === open) { close(); return; } /* click on backdrop */
-    var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
-    if (!t) return;
-    e.preventDefault();
-    var root = (t.closest && t.closest('#pcms-chrome')) || document;
-    var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
-    if (modal) modal.classList.add('is-open');
-  });
-  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
-})();
-
-// ── de topnav ──────────────────────────────────────────────────
-// Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het
-// overlay-element (data-i18n) in plaats van uit interpolatie.
-(function() {
-  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
-  // → without this guard the script would stack EXTRA listeners on every navigation,
-  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
-  // Everything below uses event delegation on body/document, so it also works for
-  // buttons that appear after this run (OOB).
-  if (window.__pcmsChromeWired) return;
-  window.__pcmsChromeWired = true;
-
-  function toggleTheme() {
-    var cur = document.documentElement.getAttribute('data-theme') || 'dark';
-    var next = cur === 'dark' ? 'light' : 'dark';
-    document.documentElement.setAttribute('data-theme', next);
-    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-  }
-  function overlay() { return document.getElementById('search-overlay'); }
-  function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
-  function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
-
-  document.body.addEventListener('click', function(e) {
-    if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
-    if (e.target.closest('#search-toggle')) { openSearch(); return; }
-    if (e.target.closest('#search-close')) { closeSearch(); return; }
-    // Close open dropdowns (user menu + language picker) on click outside.
-    document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
-      if (!d.contains(e.target)) d.removeAttribute('open');
-    });
-  });
-
-  document.addEventListener('keydown', function(e) {
-    if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
-  });
-
-  // ── Live results while typing ───────────────────────────────────
-  // De teksten komen van de overlay zelf, elke keer opnieuw: bij een
-  // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn.
-  function _st() {
-    var o = document.getElementById('search-overlay');
-    try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; }
-  }
-  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
-  function renderSuggest(box, d, q, ov) {
-    var html = '';
-    function grp(title, items, fmt) {
-      if (!items || !items.length) return;
-      html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
-    }
-    grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
-    grp(_st().tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
-    grp(_st().events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
-    grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
-    var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
-    if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; }
-    var action = (ov && ov.getAttribute('data-action')) || '/search';
-    html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
-    box.innerHTML = html;
-  }
-  var _sTimer;
-  document.addEventListener('input', function(e) {
-    var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
-    if (!inp) return;
-    var box = document.getElementById('search-suggest');
-    if (!box) return;
-    var q = inp.value.trim();
-    clearTimeout(_sTimer);
-    if (q.length < 2) { box.innerHTML = ''; return; }
-    _sTimer = setTimeout(function() {
-      var ov = document.getElementById('search-overlay');
-      var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
-      fetch(url + '?q=' + encodeURIComponent(q))
-        .then(function(r){ return r.ok ? r.json() : null; })
-        .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
-        .catch(function(){});
-    }, 200);
-  });
-  // Click on a suggestion → close the overlay (the link/boost handles navigation).
-  document.addEventListener('click', function(e) {
-    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
-  });
-})();
-
-// ── het profielblad ────────────────────────────────────────────
-// Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
-(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';
-    // De twee labels staan op het element zelf: een module kan geen vertaling
-    // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
-    themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
-  }
-  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();
-    });
-  }
-})();
Index: src/assets/js/mod/download.js
===================================================================
--- src/assets/js/mod/download.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-// De downloadpagina -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
-// pageData(); interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-        // Auto-start de download (zelfde-origin attachment-link).
-        setTimeout(function(){ try { var u = pageData().fileUrl; if (u) window.location.href = u; } catch(e){} }, 600);
-}
Index: src/assets/js/mod/lib.js
===================================================================
--- src/assets/js/mod/lib.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,106 +1,0 @@
-// Gedeeld gereedschap voor de pagina-modules (shaer-bqr).
-
-/**
- * De servergegevens van deze pagina.
- *
- * Een module is een statisch bestand, dus er kan geen EJS in. Wat het script van
- * de server nodig heeft -- vertalingen, ids, instellingen -- zet de pagina in
- * een <script type="application/json"> via partials/page-data.ejs, en dit leest
- * het terug.
- *
- * ELKE KEER OPNIEUW LEZEN en niet eenmalig onthouden: bij een htmx-navigatie
- * wisselt de inhoud terwijl de module blijft leven, dus een gegevensblok dat je
- * bij het laden vasthoudt is een pagina later verouderd.
- *
- * Nooit een uitzondering: een module die valt over ontbrekende gegevens neemt de
- * hele pagina mee. Wat er niet is, is een leeg object.
- */
-export function pageData() {
-  try {
-    var el = document.querySelector('script[type="application/json"][data-page-data]');
-    return el ? (JSON.parse(el.textContent) || {}) : {};
-  } catch (e) {
-    return {};
-  }
-}
-
-/**
- * Een enkele waarde, met een terugval. Scheelt in elke module hetzelfde
- * gedoe met ontbrekende sleutels.
- */
-export function pageValue(key, fallback) {
-  var d = pageData();
-  return (d && Object.prototype.hasOwnProperty.call(d, key)) ? d[key] : fallback;
-}
-
-/**
- * Listeners op document en window overleven een paginawissel; de pagina zelf
- * niet. Een module die per navigatie opnieuw start (export init) registreert
- * zulke listeners hier, en veegt bij elke start de vorige lichting weg --
- * anders stapelen ze, elk met een closure naar elementen die al weg zijn.
- *
- * Listeners op ELEMENTEN hoeven hier niet doorheen: die sterven met hun
- * element bij de swap. Alleen wat het document overleeft, moet geveegd.
- */
-export function makeSweeper() {
-  let list = [];
-  return {
-    on(target, ev, fn, opts) { target.addEventListener(ev, fn, opts); list.push([target, ev, fn, opts]); },
-    sweep() { for (const [t, ev, fn, opts] of list) t.removeEventListener(ev, fn, opts); list = []; },
-  };
-}
-
-/**
- * Een tekst veilig in HTML zetten.
- *
- * De modules bouwen op sommige plekken HTML met stringplakwerk. Vroeger ging een
- * vertaling daar door de escapende EJS-tag heen; nu niet meer. Zonder deze functie
- * breekt een apostrof in een vertaling het attribuut af waar hij in staat -- het
- * soort fout dat pas in een andere taal opvalt.
- */
-export function esc(s) {
-  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
-    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
-  });
-}
-
-/**
- * De WebAuthn-bibliotheek erbij halen, vanuit JavaScript (shaer-0i6).
- *
- * Hij stond als `<script src>` in paid-gate.ejs en paid-passkey.ejs, en dat
- * werkt alleen bij een volledige laadbeurt. Kom je op zo'n pagina via een link
- * BINNEN de site, dan is het een htmx-fragment met de nonce van een ANDER
- * verzoek, en weigert de CSP hem. Het gevolg was stil: de module laadde wel,
- * vond `window.SimpleWebAuthnBrowser` niet, en de knop deed niets -- op het
- * passkey- en betaalpad.
- *
- * Een dynamische import vanuit deze module mag wel: de shell startte hem met
- * een geldige nonce, en `strict-dynamic` bestaat er juist voor dat vertrouwde
- * scripts verder mogen laden. Het bestand is een UMD-bundel; zonder CommonJS
- * of AMD zet die zichzelf op globalThis, dus na afloop staat de global er.
- *
- * Faalt het laden, dan geeft dit undefined terug en niet een fout: beide
- * aanroepers hebben al een nette terugval voor "geen WebAuthn hier".
- */
-/**
- * De cache-buster voor alles in /assets/vendor (shaer-724).
- *
- * Die map valt onder dezelfde max-age van een jaar als de rest van /assets, en
- * een vendorbestand heeft geen versie in zijn naam. Zonder query houdt een
- * browser die hem ooit zag hem dus een jaar vast, ook na een update van de
- * bibliotheek.
- *
- * EEN nummer voor de hele map, hier, en niet drie losse per module: read.js had
- * er al een en lib.js en post-edit.js hadden er geen, en dan is "ophogen als de
- * bibliotheek wijzigt" een opdracht die je op drie plekken moet onthouden. Dat
- * kost read.js wel een import van dit bestand op de leespagina; die is klein en
- * staat na de eerste pagina in de cache.
- */
-export const VENDOR_V = 1;
-
-export async function loadWebAuthn() {
-  if (window.SimpleWebAuthnBrowser) return window.SimpleWebAuthnBrowser;
-  try { await import(`/assets/vendor/simplewebauthn-browser.umd.min.js?v=${VENDOR_V}`); }
-  catch (e) { console.warn('[paid] WebAuthn-bibliotheek laadt niet:', e && e.message); }
-  return window.SimpleWebAuthnBrowser;
-}
Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,180 +1,0 @@
-// Berichten: filteren, zoeken, en gesprekken in- en uitklappen (shaer-bqr).
-//
-// Dit stond inline in pages/messages.ejs. Dat werkte alleen na een volledige
-// laadbeurt: kwam je hier via een link BINNEN de site, dan arriveerde het script
-// via htmx met een nonce die het document niet kent, en weigerde de CSP het
-// (shaer-0i6). Chips, zoeken en het inklappen deden dan niets, en de reply-editor
-// laadde niet -- dat was Barts melding.
-//
-// TWEE DINGEN VERANDERD bij de verhuizing, en ze zijn allebei nodig omdat een
-// module ANDERS leeft dan een inline script:
-//
-//   NIETS VASTHOUDEN   het inline script pakte .msg-list, #msg-q en .msg-nomatch
-//                      een keer bij het inladen. Een module wordt per document
-//                      maar EEN keer geimporteerd, dus als je Berichten verlaat
-//                      en terugkomt wijzen die naar elementen die er niet meer
-//                      zijn. Nu wordt er per keer opgezocht.
-//   OPNIEUW INDEXEREN  bij binnenkomst op deze pagina, niet alleen bij "meer
-//                      laden". Anders is de lijst na een navigatie niet
-//                      geindexeerd en filtert het zoeken op niets.
-//
-// Alles gedelegeerd op document, met een slot: de module kan een tweede keer
-// geladen worden en mag dan geen tweede stel luisteraars neerzetten.
-
-(function () {
-  'use strict';
-  if (window.__msgWired) return;
-  window.__msgWired = true;
-
-  var kind = 'all';
-  var items = [];
-
-  // Per keer opzoeken. Zie de kop: vasthouden overleeft een navigatie niet.
-  function list() { return document.querySelector('.msg-list'); }
-  function noMatch() { return document.querySelector('.msg-nomatch'); }
-  function query() { return document.getElementById('msg-q'); }
-
-  function indexItem(li) {
-    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
-    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
-    if (li.classList.contains('msg-thread')) {
-      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
-      return;
-    }
-    var body = li.querySelector('.msg-content');
-    var post = li.querySelector('.msg-post');
-    var poll = li.querySelector('.msg-poll');
-    li._search = ((li.getAttribute('data-who') || '') + ' ' +
-      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
-      (poll ? poll.textContent : '')).toLowerCase();
-  }
-
-  function reindex() {
-    var l = list();
-    items = l ? Array.prototype.slice.call(l.querySelectorAll(':scope > .msg-item')) : [];
-    items.forEach(function (li) { if (!li._search) indexItem(li); });
-  }
-
-  function apply() {
-    if (!list()) return;
-    var qEl = query();
-    var term = (qEl && qEl.value || '').trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (li) {
-      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
-        (!term || li._search.indexOf(term) !== -1);
-      li.style.display = ok ? '' : 'none';
-      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
-      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
-      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
-      // stand die de lezer zelf koos.
-      if (li.classList.contains('msg-thread')) {
-        li.classList.toggle('is-search-open', !!term && ok);
-      }
-      if (ok) shown++;
-    });
-    var nm = noMatch();
-    if (nm) nm.hidden = shown !== 0;
-  }
-
-  // In- en uitklappen door op de tegenpartij te tikken. De stand leeft alleen in
-  // deze pagina: standaard uitgeklapt, en na een herlading weer. Dat is bewust --
-  // een gesprek dat je gisteren dichtklapte stilhouden is niet hetzelfde als het
-  // opruimen van je scherm van nu.
-  document.addEventListener('click', function (e) {
-    var who = e.target.closest && e.target.closest('.msg-thread-who'); if (!who) return;
-    var li = who.closest('.msg-thread'); if (!li) return;
-    var collapsed = li.classList.toggle('is-collapsed');
-    who.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
-  });
-
-  document.addEventListener('click', function (e) {
-    var chip = e.target.closest && e.target.closest('.msg-chip'); if (!chip) return;
-    kind = chip.getAttribute('data-show');
-    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
-    apply();
-  });
-
-  // Gedelegeerd in plaats van op het invoerveld zelf: dat veld wordt bij een
-  // navigatie vervangen.
-  document.addEventListener('input', function (e) {
-    if (e.target && e.target.id === 'msg-q') apply();
-  });
-
-  document.body.addEventListener('htmx:afterSettle', function (e) {
-    // "Meer laden" voegt rijen toe aan de bestaande lijst; een navigatie brengt
-    // een hele nieuwe lijst. In beide gevallen opnieuw indexeren en de actieve
-    // chip toepassen.
-    if (e.target && (e.target.id === 'msg-list' || e.target.querySelector && e.target.querySelector('.msg-list'))) {
-      reindex();
-      apply();
-    }
-  });
-
-  // De bookmarklet-knop: bouwt zijn href pas in de browser, want hij heeft de
-  // origin nodig.
-  function wireBookmarklet() {
-    var a = document.getElementById('fedi-bm-btn');
-    if (!a || a.__wired) return;
-    a.__wired = true;
-    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-    a.addEventListener('click', function (e) {
-      e.preventDefault();
-      a.classList.add('nudge');
-      setTimeout(function () { a.classList.remove('nudge'); }, 600);
-    });
-  }
-
-  // Zwaaien ter plekke: een seintje is geen paginawissel.
-  //
-  // Het formulier blijft een echt formulier -- zonder JS post het en leidt de
-  // route om, precies als vroeger. Hier onderscheppen we het en sturen het met
-  // fetch, zoals like/boost in de Krant het al deden.
-  //
-  // LET OP DE VERZENDKNOP. Deze formulieren hebben er MEERDERE ("Wat leuk!",
-  // "Hoe gaat het?", "Terug"), elk met een eigen name="text". FormData(f) neemt
-  // de aangeklikte knop NIET mee -- dat doet de browser alleen bij een echte
-  // verzending. Zonder submitter vertrekt er dus een zwaai zonder tekst, en die
-  // weigert de route. Vandaar submitter, met een terugval op de eerste knop.
-  document.addEventListener('submit', function (e) {
-    var f = e.target.closest && e.target.closest('.msg-wave, .msg-quickreply');
-    if (!f || f.dataset.sending) return;
-    e.preventDefault();
-    var knoppen = [].slice.call(f.querySelectorAll('button'));
-    var knop = e.submitter || knoppen[0];
-    if (!knop) return;
-    f.dataset.sending = '1';
-    knoppen.forEach(function (b) { b.disabled = true; });
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function (v, k) { body.append(k, v); });
-    if (knop.name) body.set(knop.name, knop.value);
-    fetch(f.action, {
-      method: 'POST', body: body,
-      headers: { 'X-Requested-With': 'fetch' },
-      credentials: 'same-origin',
-    })
-      .then(function (r) { return r.ok ? r.json() : null; })
-      .then(function (j) {
-        if (!j || !j.ok) throw new Error('geweigerd');
-        // Gelukt: de knoppen maken plaats voor de bevestiging. Zwaaien is
-        // eenmalig -- anders dan een like valt het niet terug te draaien, dus
-        // een aan/uit-knop zou liegen over wat er kan.
-        var klaar = document.createElement('span');
-        klaar.className = 'msg-wave-done';
-        klaar.textContent = '👋 ' + (f.dataset.sent || '');
-        f.replaceChildren(klaar);
-      })
-      .catch(function () {
-        // Mislukt: alles terug zoals het was, zodat een tweede poging kan.
-        delete f.dataset.sending;
-        knoppen.forEach(function (b) { b.disabled = false; });
-        f.classList.add('is-failed');
-        setTimeout(function () { f.classList.remove('is-failed'); }, 600);
-      });
-  });
-
-  function start() { reindex(); apply(); wireBookmarklet(); }
-  start();
-  // Kom je hier via een link binnen de site, dan is de lijst er pas na de swap.
-  document.body.addEventListener('pcmsNav', start);
-})();
Index: src/assets/js/mod/news.js
===================================================================
--- src/assets/js/mod/news.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,148 +1,0 @@
-// De Krant -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
-// pageData(); interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// De twee gedelegeerde blokken dragen een window-vlag en zijn bij een tweede
-// init een no-op; de twee SCANNERS (gif-video's, lees-meer) moeten juist wel
-// elke render opnieuw over de verse elementen lopen (shaer-5s1). Element-
-// vlaggen (data-gif-wired, data-rm) houden dubbel bedraden tegen.
-export function init() { run(); }
-
-function run() {
-
-  (function () {
-    if (window.__tlPasteWired) return; window.__tlPasteWired = true;
-    function modal() { return document.getElementById('tl-paste'); }
-    function openM() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input');
-      m.classList.add('is-open');
-      if (inp) {
-        inp.value = ''; inp.focus();
-        if (navigator.clipboard && navigator.clipboard.readText) {
-          navigator.clipboard.readText().then(function (t) {
-            t = (t || '').trim();
-            if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
-          }).catch(function () {});
-        }
-      }
-    }
-    function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-    function go() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
-      if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
-      location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
-    }
-    document.addEventListener('click', function (e) {
-      if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
-      if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
-      if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
-      var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
-    });
-    document.addEventListener('keydown', function (e) {
-      var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
-      if (e.key === 'Escape') closeM();
-      else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
-    });
-  })();
-  
-
-// ── volgend blok ──
-
-(function(){
-  if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-  var current = null, currentBtn = null;
-  function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-  function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
-  function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
-  document.addEventListener('click', function(e){
-    if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-    if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
-    var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-    if (b) {
-      e.preventDefault();
-      if (currentBtn === b) { close(); return; }
-      close();
-      var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
-      f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-      var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-      try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
-      document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
-      return;
-    }
-    if (current) close();
-  });
-  document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
-  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-  window.addEventListener('scroll', close, true);
-})();
-
-/* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
-   longer real videos keep their controls. Decided on the actual duration once metadata loads. */
-wireGifVideos();
-
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
-(function(){
-  if (window.__newsReactWired) return; window.__newsReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.tl-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-
-// ── volgend blok ──
-
-// Collapse long post bodies to a max height with a "read more" toggle — only when the content
-// actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
-// prevents double-wiring.
-wireReadMore();
-}
-
-/* Short feed videos: zie de opmerking bij de aanroep in run(). */
-function wireGifVideos(){
-  document.querySelectorAll('.tl-media-video').forEach(function(v){
-    if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
-    var decide = function(){
-      if (v.duration && v.duration <= 30) {
-        v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
-      }
-    };
-    if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
-  });
-}
-
-function wireReadMore(){
-  var _d = pageData(); var RM = _d.readMore || 'Lees meer', SL = _d.showLess || 'Toon minder';
-  document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
-    c.setAttribute('data-rm', '1');
-    if (c.scrollHeight > 360) {
-      c.classList.add('tl-clamp');
-      var b = document.createElement('button');
-      b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
-      b.addEventListener('click', function(){
-        b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
-      });
-      c.insertAdjacentElement('afterend', b);
-    }
-  });
-}
-
-// Paginering: items die de feed later binnenhaalt zouden de scanners missen --
-// init() draait per NAVIGATIE, en een pagineer-swap is er geen. Een keer,
-// gedelegeerd; op andere pagina's vinden de scanners niets en doen ze niets.
-document.body.addEventListener('htmx:afterSettle', function () { wireGifVideos(); wireReadMore(); });
Index: src/assets/js/mod/paid-gate.js
===================================================================
--- src/assets/js/mod/paid-gate.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,72 +1,0 @@
-// De betaalmuur -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData, loadWebAuthn } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-//
-// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
-// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
-// run() daar niet op, dan valt hij terug op "geen WebAuthn" en verbergt hij
-// de ontgrendelknop op een pagina waar ontgrendelen gewoon kan.
-export async function init() { await loadWebAuthn(); run(); }
-
-function run() {
-(function () {
-  var _d = pageData();
-  var base = _d.base || "";
-  var slug = _d.slug || "";
-  var hasPatron = !!_d.hasPatron;
-  var I = _d.i18n || {};
-  var btn = document.getElementById('pg-unlock');
-  var status = document.getElementById('pg-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
-
-  // No WebAuthn here: an assertion is impossible. With a Patreon page there is
-  // already a "Word supporter" button, so hide the (dead) unlock button rather
-  // than turn it into a second "Word supporter". Without one, this IS the button.
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    if (hasPatron) { btn.style.display = 'none'; }
-    else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
-    return;
-  }
-
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.confirm);
-    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
-      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
-      .then(function (data) {
-        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
-          .then(function (response) {
-            return fetch(base + '/paid/unlock', {
-              method: 'POST', headers: { 'Content-Type': 'application/json' },
-              body: JSON.stringify({ response: response, blob: data.blob }),
-            });
-          });
-      })
-      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
-      .then(function (res) {
-        if (res.j && res.j.ok && res.j.redirect) {
-          // Reload the real post page via the one-shot unlock capability, so it
-          // renders through its normal template (layout, styles, audio).
-          location.href = res.j.redirect;
-        } else if (res.status === 403) {
-          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
-        } else {
-          btn.disabled = false; say(I.failed, true);
-        }
-      })
-      .catch(function (e) {
-        if (e && e.link) { toLink(); return; }
-        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
-        btn.disabled = false; say(I.error, true);
-      });
-  });
-})();
-}
Index: src/assets/js/mod/paid-passkey.js
===================================================================
--- src/assets/js/mod/paid-passkey.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,52 +1,0 @@
-// De passkey-ontgrendeling -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData, loadWebAuthn } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-//
-// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
-// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
-// run() daar niet op, dan ziet hij de global nog niet staan en zet hij de
-// knop uit terwijl er niets aan de hand is.
-export async function init() { await loadWebAuthn(); run(); }
-
-function run() {
-(function () {
-  var _d = pageData();
-  var options = _d.options || {};
-  var blob = _d.blob || "";
-  var I = _d.i18n || {};
-  var postUrl = _d.postUrl || "";
-  var btn = document.getElementById('pk-go');
-  var status = document.getElementById('pk-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    btn.disabled = true;
-    say(I.unsupported, true);
-    return;
-  }
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.follow);
-    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
-      .then(function (response) {
-        return fetch('/paid/register', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ response: response, blob: blob }),
-        });
-      })
-      .then(function (r) { return r.json(); })
-      .then(function (j) {
-        if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); }
-        else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); }
-      })
-      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
-  });
-})();
-}
Index: src/assets/js/mod/playlist-editor.js
===================================================================
--- src/assets/js/mod/playlist-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,467 +1,0 @@
-// De afspeellijst-editor -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
-// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
-// link BINNEN de site binnenkomt (shaer-0i6).
-
-import { pageData } from './lib.js';
-
-/**
- * De teksten van deze modal, uit het gegevensblok van partials/playlist-editor.ejs.
- *
- * EIGEN BLOK en niet pageData(): die pakt met querySelector er EEN, en deze
- * modal hangt onder pagina's die er zelf al een hebben. Een tweede blok zou
- * daar genegeerd worden.
- *
- * De terugval is bewust de sleutelnaam en niet de Nederlandse tekst: ontbreekt
- * er iets, dan zie je DAT er iets ontbreekt in plaats van een pagina die er
- * half vertaald uitziet en waarvan niemand merkt welke helft.
- */
-let _T = null;
-function T(sleutel) {
-  if (_T === null) {
-    try {
-      const el = document.querySelector('script[type="application/json"][data-playlist-editor-i18n]');
-      _T = el ? (JSON.parse(el.textContent) || {}) : {};
-    } catch (e) { _T = {}; }
-  }
-  return _T[sleutel] != null ? _T[sleutel] : sleutel;
-}
-
-(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 = pageData().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(T('e_tracks'));
-      return;
-    }
-    if (!tracks.length) {
-      alert(T('e_geen_tracks'));
-      return;
-    }
-
-    // Existing playlist data when editing
-    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', release_date: '', mb_release_id: '', 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="${T('dialoog')}">
-        <div class="pl-modal-header">
-          <h3>${isEdit ? '✎ ' + T('t_edit') : '+ ' + T('t_new')}</h3>
-          <button type="button" class="pl-modal-close" aria-label="${T('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>${T('titel')}</span>
-                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('artiest')}</span>
-                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('jaar')}</span>
-                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('type')}</span>
-                  <select id="pli-kind">
-                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 ${T('k_album')}</option>
-                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 ${T('k_playlist')}</option>
-                    <option value="mixtape"  ${initial.kind === 'mixtape' ? 'selected' : ''}>📼 ${T('k_mixtape')}</option>
-                  </select>
-                </label>
-                <label class="pl-field pl-uitgave">
-                  <span>${T('uitgave')}</span>
-                  <input type="date" id="pli-release-date" value="${esc(initial.release_date || '')}">
-                </label>
-                <label class="pl-field pl-uitgave">
-                  <span>${T('mb_release')}</span>
-                  <input type="text" id="pli-mb-release" maxlength="36" spellcheck="false"
-                         placeholder="00000000-0000-0000-0000-000000000000"
-                         value="${esc(initial.mb_release_id || '')}">
-                </label>
-                <div class="pl-field pl-field-full">
-                  <span>${T('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="${T('cover_url')}" value="${esc(initial.cover)}" style="flex:1">
-                  </div>
-                  <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">📷 ${T('cover_kies')}</button>
-                    <span class="pl-cover-status" id="pli-cover-status"></span>
-                  </div>
-                </div>
-              </div>
-              <div class="pl-section-title">
-                ${T('tracks_in')} <span class="pl-track-count" id="pli-count">0</span>
-                <small>${T('sleep_hint')}</small>
-              </div>
-              <div id="pli-selected" class="pl-selected-list"></div>
-            </div>
-            <div class="pl-editor-right">
-              <div class="pl-section-title">${T('beschikbaar')}</div>
-              <input type="search" id="pli-search" placeholder="${T('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">${T('annuleren')}</button>
-          <button type="button" class="btn btn-primary" id="pli-save" disabled>
-            ${isEdit ? T('opslaan') : T('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 releaseEl = $('#pli-release-date');
-    const mbReleaseEl = $('#pli-mb-release');
-    const coverEl = $('#pli-cover');
-
-    // Uitgavedatum en release-id horen bij een ALBUM, niet bij een
-    // afspeellijst -- dat is wat de keuze album/playlist betekent. Ze
-    // verdwijnen dus als je omschakelt, en de server maakt ze dan ook leeg;
-    // dit scherm is de uitleg, niet de bewaking.
-    // ALLEEN een album, en niet "alles behalve playlist". Dat stond er eerst, en
-    // met een derde soort erbij zou een mixtape ineens uitgavevelden tonen die
-    // de server toch weggooit -- een scherm dat iets anders belooft dan het doet.
-    const toonUitgave = () => {
-      const album = kindEl.value === 'album';
-      for (const el of backdrop.querySelectorAll('.pl-uitgave')) el.hidden = !album;
-    };
-    kindEl.addEventListener('change', toonUitgave);
-    toonUitgave();
-    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="" data-fallback>`
-        : `<span class="pl-cover-empty">🎨</span>`;
-    });
-
-    // ── Cover file upload (werkt in create ÉN edit) ───────────
-    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
-    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
-    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
-    // cover-veld en wordt bij het opslaan met de playlist meegestuurd.
-    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 = T('e_alleen_afb');
-          coverStatus.className = 'pl-cover-status is-error';
-          return;
-        }
-        coverStatus.textContent = T('bezig');
-        coverStatus.className = 'pl-cover-status';
-        const fd = new FormData();
-        fd.append('image', file);
-        try {
-          const r = await fetch('/posts/upload-image',
-            { method: 'POST', body: fd, credentials: 'same-origin' }
-          );
-          const j = await r.json();
-          if (!r.ok || !j.url) throw new Error(j.error || T('e_upload'));
-          const 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 = T('e_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">' + T('leeg_sel') + '</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="${T('versleep')}">⠿</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="${T('verwijder')}">×</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">' + T('geen_res') + '</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="${T('geen_audio')}"`}>
-          ${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 = T('bezig_opslaan');
-
-      const payload = {
-        title:  titleEl.value.trim(),
-        artist: artistEl.value.trim(),
-        year:   parseInt(yearEl.value, 10) || 0,
-        cover:  coverEl.value.trim(),
-        // De waarde zelf. Deze regel was `=== 'playlist' ? 'playlist' : 'album'`,
-        // en dat is precies de vorm die een nieuwe soort niet afwijst maar
-        // opslokt: een mixtape werd hier stil een album. De server heeft het
-        // laatste woord via normKind().
-        kind:   kindEl.value,
-        release_date:  releaseEl.value.trim(),
-        mb_release_id: mbReleaseEl.value.trim(),
-        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(T('e_opslaan') + (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(T('e_opslaan') + 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);
-      }
-    });
-  };
-})();
Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1215 +1,0 @@
-// De posteditor -- verplaatst uit inline script, shaer-bqr.
-//
-// Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
-// door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
-// het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
-
-import { pageData, esc, makeSweeper, VENDOR_V } from './lib.js';
-// Dezelfde regel die de server gebruikt -- zie shared/post-music-type.js voor
-// waarom hij daar staat en niet twee keer.
-import { afleidenUitInsluitingen, SOORTEN } from '../shared/post-music-type.js';
-
-// De oude inline scripts draaiden bij ELKE render; een module draait zijn
-// top-level een keer per sessie. Vandaar init(): de bootstrap roept hem aan
-// bij elke paginawissel waarop deze module actief is, en de veger haalt eerst
-// de document/window-listeners van de vorige pagina weg -- die overleven de
-// swap, met closures naar elementen die al verdwenen zijn (shaer-5s1).
-const doc = makeSweeper();
-let T = {};
-let _barObserver = null;
-
-export function init() {
-  doc.sweep();
-  if (_barObserver) { _barObserver.disconnect(); _barObserver = null; }
-  T = pageData();
-  run();
-}
-
-function run() {
-
-          (function () {
-            var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
-            if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
-          })();
-          
-
-// ── volgend blok ──
-
-        (function () {
-          var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
-          // Typing a warning text implies the post is sensitive → auto-tick NSFW.
-          if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
-            cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
-          }
-        })();
-        
-
-// ── volgend blok ──
-
-        (function () {
-          var box = document.getElementById('pe-poll-fields');
-          var tog = document.getElementById('pe-poll-toggle');
-          var opts = document.getElementById('pe-poll-opts');
-          var add = document.getElementById('pe-poll-add');
-          if (!box || !opts) return;
-          if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
-          var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
-          function rows() { return opts.querySelectorAll('.pe-poll-row'); }
-          // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
-          function refresh() {
-            var n = rows().length;
-            opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
-            if (add) add.disabled = n >= 8;
-          }
-          function makeRow() {
-            var row = document.createElement('div'); row.className = 'pe-poll-row';
-            var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
-            var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
-            row.appendChild(i); row.appendChild(d); return row;
-          }
-          if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
-          if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
-          refresh();
-        })();
-        
-
-// ── volgend blok ──
-
-            (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
-              if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
-          
-
-// ── volgend blok ──
-
-            (function () {
-              var cb = document.getElementById('pe-sched-toggle');
-              var box = document.getElementById('pe-sched-fields');
-              if (!cb || !box) return;
-              var inp = document.getElementById('pe-publish-at');
-              var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local
-              var pad = function (n) { return String(n).padStart(2, '0'); };
-              // Offset (ms) between a timezone and UTC at a given moment.
-              function tzOffset(date, tz) {
-                var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
-                var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
-                return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
-              }
-              // datetime-local "wall time" (in the site zone) → UTC Date.
-              function wallToUtc(wall) {
-                if (!SITE_TZ) return new Date(wall);
-                var guess = new Date(wall + ':00Z').getTime();
-                return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
-              }
-              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
-              function utcToWall(iso) {
-                var d = new Date(iso); if (isNaN(d)) return '';
-                if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
-                var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
-                var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
-                return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
-              }
-              // Prefill: stored UTC → wall time in the site zone.
-              if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
-              // "Scheduled for" in human-readable time in the site zone.
-              var when = document.getElementById('pe-sched-when');
-              if (when && when.dataset.iso) {
-                var dw = new Date(when.dataset.iso);
-                if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
-              }
-              function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
-              cb.addEventListener('change', sync); sync();
-              // On save: wall time in the site zone → UTC-ISO via a hidden field.
-              var form = cb.closest('form');
-              if (form) {
-                form.addEventListener('submit', function () {
-                  if (inp) inp.removeAttribute('name');
-                  var old = form.querySelector('input[data-pa-utc]');
-                  if (old) old.remove();
-                  if (cb.checked && inp && inp.value) {
-                    var d2 = wallToUtc(inp.value);
-                    if (!isNaN(d2)) {
-                      var h = document.createElement('input');
-                      h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
-                      h.value = d2.toISOString();
-                      form.appendChild(h);
-                    }
-                  }
-                });
-              }
-            })();
-          
-
-// ── volgend blok ──
-
-(function() {
-
-  // ── Cover upload ────────────────────────────────────────────────
-  const coverField   = document.getElementById('cover-upload-field');
-  const coverTrigger = document.getElementById('cover-upload-trigger');
-  const coverUrl     = document.getElementById('cover-url-field');
-  const coverVideo   = document.getElementById('cover-video-field');
-  const coverStatus  = document.getElementById('cover-upload-status');
-  const coverWrap    = document.getElementById('cover-preview-wrap');
-  const coverImg     = document.getElementById('cover-preview-img');
-
-  async function uploadImage(file) {
-    const fd = new FormData();
-    fd.append('image', file);
-    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
-    if (!res.ok) {
-      const j = await res.json().catch(() => ({}));
-      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
-    }
-    return await res.json();   // {url, size, mime}
-  }
-
-  // ── Image editor (rotate / crop / mirror) ──────────
-  // Lazy-load Cropper.js (locally vendored) on first use.
-  let _cropperReady = null;
-  function ensureCropper() {
-    if (window.Cropper) return Promise.resolve();
-    if (_cropperReady) return _cropperReady;
-    _cropperReady = new Promise((resolve, reject) => {
-      if (!document.querySelector('link[data-cropper-css]')) {
-        const l = document.createElement('link');
-        // Met versie, net als het script eronder (shaer-724): /assets wordt een
-        // jaar gecachet en deze twee bestanden dragen geen versie in hun naam.
-        l.rel = 'stylesheet'; l.href = `/assets/vendor/cropper.min.css?v=${VENDOR_V}`; l.setAttribute('data-cropper-css', '');
-        document.head.appendChild(l);
-      }
-      const s = document.createElement('script');
-      s.src = `/assets/vendor/cropper.min.js?v=${VENDOR_V}`;
-      s.onload = () => resolve();
-      s.onerror = () => reject(new Error('cropper load failed'));
-      document.head.appendChild(s);
-    });
-    return _cropperReady;
-  }
-
-  // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
-  // the canvas editor, otherwise it'd be flattened to a single static frame.
-  async function isAnimatedWebpFile(file) {
-    if (!file || file.type !== 'image/webp') return false;
-    try {
-      const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
-      return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
-    } catch (_) { return false; }
-  }
-
-  // Opens the editor for a chosen file; resolves with an edited File,
-  // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
-  // canvas editor (they would become static) — those upload directly.
-  async function openImageEditor(file) {
-    if (!file || !file.type || !file.type.startsWith('image/')) return file;
-    if (file.type === 'image/gif') return file;               // preserve animation
-    if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
-    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
-
-    return new Promise((resolve) => {
-      const back = document.createElement('div');
-      back.className = 'imed-backdrop';
-      back.innerHTML =
-        '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="' + esc(T.title) + '">' +
-          '<div class="imed-stage"><img alt=""></div>' +
-          '<div class="imed-tools">' +
-            '<button type="button" data-act="rl" title="' + esc(T.rotate_left) + '">⟲</button>' +
-            '<button type="button" data-act="rr" title="' + esc(T.rotate_right) + '">⟳</button>' +
-            '<button type="button" data-act="fh" title="' + esc(T.flip_h) + '">⇆</button>' +
-            '<button type="button" data-act="fv" title="' + esc(T.flip_v) + '">⇅</button>' +
-            '<button type="button" data-act="zi" title="' + esc(T.zoom_in) + '">＋</button>' +
-            '<button type="button" data-act="zo" title="' + esc(T.zoom_out) + '">－</button>' +
-            '<button type="button" data-act="reset" title="' + esc(T.reset) + '">↺</button>' +
-          '</div>' +
-          '<div class="imed-actions">' +
-            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary">' + esc(T.cancel) + '</button>' +
-            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary">' + esc(T.apply) + '</button>' +
-          '</div>' +
-        '</div>';
-      document.body.appendChild(back);
-      const img = back.querySelector('img');
-      const url = URL.createObjectURL(file);
-      let cropper = null, sx = 1, sy = 1;
-
-      function cleanup() {
-        try { if (cropper) cropper.destroy(); } catch (_) {}
-        URL.revokeObjectURL(url);
-        back.remove();
-        document.removeEventListener('keydown', onKey);
-      }
-      function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
-      document.addEventListener('keydown', onKey);
-
-      img.onload = () => {
-        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
-      };
-      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
-      img.src = url;
-
-      back.addEventListener('click', (e) => {
-        const btn = e.target.closest('[data-act]');
-        if (e.target === back) { cleanup(); resolve(null); return; }
-        if (!btn || !cropper) return;
-        const a = btn.getAttribute('data-act');
-        if (a === 'rl') cropper.rotate(-90);
-        else if (a === 'rr') cropper.rotate(90);
-        else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
-        else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
-        else if (a === 'zi') cropper.zoom(0.1);
-        else if (a === 'zo') cropper.zoom(-0.1);
-        else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
-        else if (a === 'cancel') { cleanup(); resolve(null); }
-        else if (a === 'apply') {
-          const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
-          const png = (file.type === 'image/png' || file.type === 'image/webp');
-          const mime = png ? 'image/png' : 'image/jpeg';
-          const ext = png ? '.png' : '.jpg';
-          canvas.toBlob((blob) => {
-            cleanup();
-            if (!blob) { resolve(file); return; }
-            const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
-            resolve(new File([blob], base + ext, { type: mime }));
-          }, mime, 0.92);
-        }
-      });
-    });
-  }
-
-  function showCoverPreview(url) {
-    if (!coverWrap || !coverImg) return;
-    if (url) {
-      coverImg.src = url;
-      coverImg.hidden = false;
-      coverWrap.removeAttribute('data-empty');
-      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
-      if (emptyIcon) emptyIcon.remove();
-    } else {
-      coverImg.hidden = true;
-      coverImg.src = '';
-      coverWrap.setAttribute('data-empty', '');
-      if (!coverWrap.querySelector('.pe-cover-empty')) {
-        const span = document.createElement('span');
-        span.className = 'pe-cover-empty';
-        span.textContent = '🖼';
-        coverWrap.appendChild(span);
-      }
-    }
-  }
-
-  if (coverTrigger && coverField) {
-    coverTrigger.addEventListener('click', () => coverField.click());
-  }
-  if (coverField) {
-    coverField.addEventListener('change', async () => {
-      if (!coverField.files[0]) return;
-      const edited = await openImageEditor(coverField.files[0]);
-      coverField.value = '';
-      if (!edited) return; // cancelled
-      coverStatus.classList.remove('is-error');
-      coverStatus.textContent = '' + esc(T.js_uploading) + '';
-      try {
-        const j = await uploadImage(edited);
-        coverUrl.value = j.url;
-        if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
-        showCoverPreview(j.url);
-        coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓';
-        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
-      } catch (e) {
-        coverStatus.classList.add('is-error');
-        coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
-      }
-    });
-  }
-  // Live-update preview when user pastes a URL manually
-  if (coverUrl) {
-    coverUrl.addEventListener('input', () => {
-      const v = coverUrl.value.trim();
-      if (v) showCoverPreview(v); else showCoverPreview('');
-    });
-  }
-
-  // ── WYSIWYG editor (P58) ────────────────────────────────────────
-  // Architecture:
-  //   - Visible <div contenteditable> (`#content-editor`) is what the user
-  //     types in; it shows real HTML (formatted, not raw markup).
-  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
-  //     On submit we serialize the editor's HTML into it, with shortcode
-  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
-  //   - Initial content comes from a <script type="application/json"> tag
-  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
-  //     and walk text nodes to render shortcode tokens as chips.
-  const contentField  = document.getElementById('content-upload-field');
-  const contentBtn    = document.getElementById('insert-image-btn');
-  const contentStatus = document.getElementById('content-upload-status');
-  const editor        = document.getElementById('content-editor');
-  const hiddenField   = document.getElementById('content-hidden');
-  const charCountEl   = document.getElementById('char-count');
-  const initialEl     = document.getElementById('initial-content');
-  const toolbar       = document.getElementById('pe-toolbar');
-  const form          = editor && editor.closest('form');
-
-  if (!editor) return;
-
-  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
-  // immediately open the keyboard when the editor opens — not desired.
-  try {
-    const titleInput = form && form.querySelector('input[name="title"]');
-    if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
-      titleInput.focus({ preventScroll: true });
-    }
-  } catch (_) {}
-
-  // ── Shortcode chip rendering / serialization ────────────────────
-  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
-  // — but we DON'T want to chipify text the user is mid-typing inside an
-  // HTML attribute; since chipify only walks text nodes (never attribute
-  // values) that's already safe.
-  const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
-
-  const SC_ICONS = {
-    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
-    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
-    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
-    embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
-  };
-
-  function chipLabel(kind, value) {
-    if (kind === 'track') {
-      // UUIDs are noisy — show a 6-char prefix for visual hint
-      const v = String(value || '');
-      return '' + esc(T.chip_track) + ' ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
-    }
-    if (kind === 'album')    return '' + esc(T.chip_album) + ' ' + value;
-    if (kind === 'playlist') return '' + esc(T.chip_playlist) + ' ' + value;
-    if (kind === 'embed') {
-      const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
-      return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
-    }
-    return value;
-  }
-
-  function makeChip(kind, value) {
-    const span = document.createElement('span');
-    span.className = 'sc-chip';
-    span.contentEditable = 'false';
-    span.setAttribute('data-sc', kind + ':' + value);
-    span.innerHTML =
-      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
-      '<span class="sc-chip-label"></span>';
-    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
-    return span;
-  }
-
-  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
-  function chipifyShortcodes(root) {
-    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
-    const targets = [];
-    while (walker.nextNode()) {
-      const n = walker.currentNode;
-      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
-      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
-      if (SC_RE.test(n.nodeValue)) targets.push(n);
-      SC_RE.lastIndex = 0;
-    }
-    for (const node of targets) {
-      const txt = node.nodeValue;
-      const frag = document.createDocumentFragment();
-      let last = 0;
-      let m;
-      SC_RE.lastIndex = 0;
-      while ((m = SC_RE.exec(txt)) !== null) {
-        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
-        frag.appendChild(makeChip(m[1], m[2].trim()));
-        last = m.index + m[0].length;
-      }
-      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
-      node.parentNode.replaceChild(frag, node);
-    }
-  }
-
-  // Inverse of chipify: clone the editor, replace every chip with its text.
-  function serializeChips(rootClone) {
-    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
-    for (const c of chips) {
-      const txt = '[[' + c.getAttribute('data-sc') + ']]';
-      c.replaceWith(document.createTextNode(txt));
-    }
-  }
-
-  // ── Boot: load initial content as HTML, then render shortcodes as chips
-  try {
-    const initial = JSON.parse(initialEl.textContent || '""');
-    editor.innerHTML = initial || '';
-    chipifyShortcodes(editor);
-  } catch (e) {
-    console.error('[editor] could not parse initial content', e);
-    editor.innerHTML = '';
-  }
-
-  // ── Char counter
-  function updateCharCount() {
-    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
-    if (charCountEl) charCountEl.textContent = String(text.length);
-  }
-  updateCharCount();
-  editor.addEventListener('input', updateCharCount);
-
-  // ── Toolbar wiring
-  // Lock the scroll position around an edit command. execCommand/insert scrolls
-  // the caret into view by default → the view "jumps" when clicking a formatting
-  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
-  // + the page and restore them — sync and over a few frames, because Chrome
-  // sometimes scrolls a frame later. The user scrolls themselves.
-  function scrollableAncestors(el) {
-    const list = [];
-    let node = el;
-    while (node && node !== document.body && node !== document.documentElement) {
-      const oy = getComputedStyle(node).overflowY;
-      if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
-      node = node.parentElement;
-    }
-    return list;
-  }
-  function keepScroll(fn) {
-    // In fullscreen the page is locked (body overflow:hidden) and the field may
-    // scroll to the caret freely — no page jump possible, so nothing to fix.
-    const frame = document.querySelector('.pe-editor-frame');
-    if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
-    const wx = window.scrollX, wy = window.scrollY;
-    const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
-    const restore = function () {
-      window.scrollTo(wx, wy);
-      anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
-    };
-    fn();
-    restore();
-    requestAnimationFrame(restore);
-  }
-  function execCmd(cmd, arg) {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      document.execCommand(cmd, false, arg);
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-  function wrapCode() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
-    keepScroll(function () {
-      const range = sel.getRangeAt(0);
-      const code = document.createElement('code');
-      code.textContent = sel.toString();
-      range.deleteContents();
-      range.insertNode(code);
-      // Move caret after the new node
-      range.setStartAfter(code);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-      editor.focus({ preventScroll: true });
-    });
-  }
-  function linkPrompt() {
-    const url = window.prompt('' + esc(T.js_link_prompt) + '');
-    if (!url) return;
-    execCmd('createLink', url);
-  }
-  // Is the current selection inside a <blockquote> within the editor? Return it.
-  function blockquoteAncestor() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0) return null;
-    let node = sel.anchorNode;
-    while (node && node !== editor) {
-      if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
-      node = node.parentNode;
-    }
-    return null;
-  }
-  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
-  // can never turn it OFF (browser quirk). If the caret is already in a quote →
-  // unwrap it; otherwise apply blockquote.
-  function toggleBlockquote() {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      const bq = blockquoteAncestor();
-      if (bq) {
-        const parent = bq.parentNode;
-        // Extract content from the quote in place, then remove the empty wrapper.
-        const ref = bq;
-        let firstMoved = null;
-        while (bq.firstChild) {
-          const child = bq.firstChild;
-          if (!firstMoved) firstMoved = child;
-          parent.insertBefore(child, ref);
-        }
-        parent.removeChild(bq);
-        // Restore the caret inside the unwrapped content.
-        if (firstMoved) {
-          const sel = window.getSelection();
-          const range = document.createRange();
-          range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
-          range.collapse(false);
-          sel.removeAllRanges();
-          sel.addRange(range);
-        }
-      } else {
-        document.execCommand('formatBlock', false, 'blockquote');
-      }
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-
-  if (toolbar) {
-    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
-    // from the editor field. Without this the selection is lost on tap
-    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
-    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
-    // on mousedown keeps focus in the editor; the click still fires normally.
-    toolbar.addEventListener('mousedown', (e) => {
-      if (e.target.closest('button')) e.preventDefault();
-    });
-    toolbar.addEventListener('click', (e) => {
-      const btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      const cmd = btn.dataset.cmd;
-      const arg = btn.dataset.arg || null;
-      if (cmd === 'link-prompt') linkPrompt();
-      else if (cmd === 'code-wrap') wrapCode();
-      else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
-      else execCmd(cmd, arg);
-    });
-  }
-
-  // ── Full-screen writing mode: the writing field fills the whole page.
-  const fsBtn = document.getElementById('pe-fullscreen-btn');
-  const editorFrame = document.querySelector('.pe-editor-frame');
-  const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
-  // OP TOUCH IS DIT GEEN LUXE MAAR DE ENIGE INGANG (shaer-kd1). Buiten
-  // fullscreen is de toolbar daar verborgen (@media (pointer: coarse) in
-  // pages/post-edit.ejs), en het veld staat op contenteditable=false. Ontbreekt
-  // een van deze twee elementen, dan kun je op een telefoon NIET TYPEN -- en tot
-  // nu toe gebeurde dat zonder één spoor: applyFs deed een kale `return`.
-  if (isTouch && (!editorFrame || !editor)) {
-    console.warn('[post-edit] fullscreen onbereikbaar op touch:',
-      'frame=' + !!editorFrame, 'editor=' + !!editor,
-      '-- de toolbar is hier verborgen, dus dit betekent: niet kunnen typen');
-  }
-
-  // On mobile the keyboard pushes the visible (visual) viewport up while
-  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
-  // slides out of view. Keep the fullscreen frame aligned to the visual
-  // viewport (top + height) so the toolbar stays visible at the top.
-  function syncFsViewport() {
-    if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
-    const vv = window.visualViewport;
-    if (!vv) return;
-    editorFrame.style.top = vv.offsetTop + 'px';
-    editorFrame.style.height = vv.height + 'px';
-  }
-  function clearFsViewport() {
-    if (!editorFrame) return;
-    editorFrame.style.top = '';
-    editorFrame.style.height = '';
-  }
-  function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
-  function applyFs(on) {
-    if (!editorFrame) {
-      // Was een kale `return`. Op touch is dit het verschil tussen "fullscreen
-      // werkt niet" en "je kunt niet typen", en het gebeurde zonder spoor.
-      console.warn('[post-edit] fullscreen kan niet: .pe-editor-frame ontbreekt');
-      return;
-    }
-    editorFrame.classList.toggle('pe-fs', on);
-    document.body.classList.toggle('pe-fs-open', on);
-    document.documentElement.classList.toggle('pe-fs-open', on);
-    if (fsBtn) {
-      fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
-      fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
-    }
-    if (window.visualViewport) {
-      if (on) {
-        window.visualViewport.addEventListener('resize', syncFsViewport);
-        window.visualViewport.addEventListener('scroll', syncFsViewport);
-        syncFsViewport();
-      } else {
-        window.visualViewport.removeEventListener('resize', syncFsViewport);
-        window.visualViewport.removeEventListener('scroll', syncFsViewport);
-        clearFsViewport();
-      }
-    }
-    // On touch the field is NOT editable inline; only in fullscreen.
-    if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
-    if (on) {
-      editor.focus({ preventScroll: true });
-    } else {
-      if (isTouch) editor.blur();
-      // On close: scroll to the TOP of the content instead of staying
-      // somewhere at the bottom (footer).
-      requestAnimationFrame(function () {
-        try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
-      });
-    }
-  }
-  // The fullscreen writing "page": opening pushes a history state so the browser
-  // back button (and the Done button) closes it and returns you to the form — feels
-  // like a separate page, but all form fields remain intact (same DOM).
-  function openFs() {
-    if (isFs()) return;
-    try { history.pushState({ peFs: true }, ''); } catch (_) {}
-    applyFs(true);
-  }
-  function closeFs() {
-    if (!isFs()) return;
-    if (history.state && history.state.peFs) history.back(); // → popstate closes it
-    else applyFs(false);
-  }
-  function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
-  doc.on(window, 'popstate', function () { if (isFs()) applyFs(false); });
-  // __wired zoals overal in run(): init() draait bij ELKE paginawissel, en op
-  // dezelfde DOM zou een kale addEventListener stapelen. Na een htmx-wissel is
-  // het element nieuw en dus de vlag weg -- precies de bedoeling.
-  if (fsBtn && !fsBtn.__fsWired) { fsBtn.__fsWired = true; fsBtn.addEventListener('click', toggleFullscreen); }
-  var fsDoneBtn = document.getElementById('pe-fs-done');
-  if (fsDoneBtn && !fsDoneBtn.__fsWired) { fsDoneBtn.__fsWired = true; fsDoneBtn.addEventListener('click', closeFs); }
-  doc.on(document, 'keydown', (e) => {
-    if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
-  });
-
-  // On mobile/tablet (touch): the content field is NOT editable inline — it is
-  // not a text field there. One tap → fullscreen, where it becomes editable
-  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
-  if (isTouch && editor && !editor.__fsTapWired) {
-    editor.__fsTapWired = true;
-    editor.setAttribute('contenteditable', 'false');
-    editor.classList.add('pe-tap-to-edit');
-    editor.addEventListener('click', function () {
-      if (!isFs()) openFs();
-    });
-  }
-
-  // Reflect bold/italic/list state on the toolbar buttons
-  function updateToolbarState() {
-    if (!toolbar) return;
-    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
-    for (const cmd of cmds) {
-      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
-      if (!btn) continue;
-      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
-    }
-    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
-    const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
-    if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
-  }
-  doc.on(document, 'selectionchange', () => {
-    if (document.activeElement === editor) updateToolbarState();
-  });
-
-  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
-  editor.addEventListener('keydown', (e) => {
-    const mod = e.ctrlKey || e.metaKey;
-    if (!mod) return;
-    const k = e.key.toLowerCase();
-    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
-    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
-    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
-    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
-  });
-
-  // Paste: keep it simple — strip formatting unless user wants it. Default
-  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
-  // styles from clipboard only when shift is held — otherwise plain text.
-  editor.addEventListener('paste', (e) => {
-    if (e.shiftKey) return; // user wants formatted paste
-    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
-    if (text == null) return;
-    e.preventDefault();
-    document.execCommand('insertText', false, text);
-  });
-
-  // ── Image upload (button + drag-drop into the editor)
-  async function uploadAndInsertImage(file) {
-    const edited = await openImageEditor(file);
-    if (!edited) return; // cancelled
-    contentStatus.classList.remove('is-error');
-    contentStatus.textContent = '' + esc(T.js_uploading) + '';
-    try {
-      const j = await uploadImage(edited);
-      const img = '<img src="' + j.url + '" alt="">';
-      editor.focus({ preventScroll: true });
-      document.execCommand('insertHTML', false, img);
-      contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
-      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
-      updateCharCount();
-    } catch (e) {
-      contentStatus.classList.add('is-error');
-      contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
-    }
-  }
-
-  if (contentBtn && contentField) {
-    contentBtn.addEventListener('click', () => contentField.click());
-    contentField.addEventListener('change', () => {
-      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
-      contentField.value = '';
-    });
-
-    editor.addEventListener('dragover', (e) => {
-      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
-        e.preventDefault();
-        editor.classList.add('is-dragover');
-      }
-    });
-    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
-    editor.addEventListener('drop', async (e) => {
-      editor.classList.remove('is-dragover');
-      const files = e.dataTransfer && e.dataTransfer.files;
-      if (!files || !files.length) return;
-      e.preventDefault();
-      for (const f of files) {
-        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
-      }
-    });
-  }
-
-  // ── Insert chip helpers (track / playlist)
-  function insertChip(kind, value) {
-    editor.focus({ preventScroll: true });
-    const chip = makeChip(kind, value);
-    // Insert at caret using the Selection API (execCommand insertNode)
-    const sel = window.getSelection();
-    if (sel && sel.rangeCount > 0) {
-      const range = sel.getRangeAt(0);
-      range.deleteContents();
-      range.insertNode(chip);
-      // Insert a trailing space so the user can keep typing after the chip
-      const space = document.createTextNode('\u00A0');
-      chip.after(space);
-      range.setStartAfter(space);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-    } else {
-      editor.appendChild(chip);
-      editor.appendChild(document.createTextNode('\u00A0'));
-    }
-    updateCharCount();
-    volgMuziek();
-  }
-
-  // Wordt hieronder gevuld door het typeblok. Zolang dat er niet is (of de
-  // gebruiker het type zelf koos) gebeurt er niets -- insertChip mag daar niet
-  // op stuklopen.
-  let volgMuziek = () => {};
-
-  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
-  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
-  const embedBtn = document.getElementById('insert-embed-btn');
-  if (embedBtn) {
-    embedBtn.addEventListener('click', () => {
-      const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
-      if (!raw) return;
-      const url = raw.trim();
-      if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
-      insertChip('embed', url);
-    });
-  }
-
-  // ── Track insert: opens the track-picker modal (P59)
-  const trackBtn = document.getElementById('insert-track-btn');
-  const trackPicker = document.getElementById('track-picker');
-  if (trackBtn && trackPicker) {
-    const tpList   = document.getElementById('tp-list');
-    const tpEmpty  = document.getElementById('tp-empty');
-    const tpSearch = document.getElementById('tp-search');
-    let tpCache = null;       // cached track list (fetched once per page load)
-    let tpLastFocus = null;   // element to restore focus to on close
-
-    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
-
-    function fmtDur(sec) {
-      sec = Math.max(0, Math.floor(sec || 0));
-      const m = Math.floor(sec / 60), s = sec % 60;
-      return m + ':' + String(s).padStart(2, '0');
-    }
-    function escAttr(s) {
-      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-      }[c]));
-    }
-
-    function renderList(filter) {
-      if (!Array.isArray(tpCache)) return;
-      const q = (filter || '').trim().toLowerCase();
-      const filtered = q
-        ? tpCache.filter(t =>
-            (t.title  || '').toLowerCase().includes(q) ||
-            (t.artist || '').toLowerCase().includes(q))
-        : tpCache;
-
-      if (!filtered.length) {
-        tpList.innerHTML = '';
-        tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
-        tpList.appendChild(tpEmpty);
-        return;
-      }
-
-      tpList.innerHTML = filtered.map(t => {
-        const cov = t.cover
-          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
-          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
-        const dis = t.playable ? '' : ' aria-disabled="true"';
-        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
-        return (
-          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
-            cov +
-            '<span class="tp-meta">' +
-              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
-              sub +
-            '</span>' +
-            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
-          '</button>'
-        );
-      }).join('');
-    }
-
-    async function loadTracks() {
-      if (Array.isArray(tpCache)) return tpCache;
-      tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
-      try {
-        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
-        const j = await r.json();
-        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
-      } catch (e) {
-        tpCache = [];
-        tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
-      }
-      return tpCache;
-    }
-
-    function openPicker() {
-      tpLastFocus = document.activeElement;
-      trackPicker.hidden = false;
-      trackPicker.setAttribute('aria-hidden', 'false');
-      document.body.classList.add('tp-locked');
-      tpSearch.value = '';
-      renderList('');
-      // Defer focus so the open animation doesn't get jumped
-      setTimeout(() => tpSearch.focus(), 30);
-    }
-    function closePicker() {
-      trackPicker.hidden = true;
-      trackPicker.setAttribute('aria-hidden', 'true');
-      document.body.classList.remove('tp-locked');
-      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
-        try { tpLastFocus.focus(); } catch(_) {}
-      }
-    }
-
-    trackBtn.addEventListener('click', async () => {
-      openPicker();
-      await loadTracks();
-      renderList(tpSearch.value);
-    });
-
-    // Close: backdrop click, [data-tp-close], or Escape
-    trackPicker.addEventListener('click', (e) => {
-      if (e.target.closest('[data-tp-close]')) {
-        closePicker();
-        return;
-      }
-      const row = e.target.closest('.tp-row[data-track-id]');
-      if (row) {
-        if (row.getAttribute('aria-disabled') === 'true') return;
-        const id = row.dataset.trackId;
-        if (id) {
-          insertChip('track', id);
-          closePicker();
-        }
-      }
-    });
-    doc.on(document, 'keydown', (e) => {
-      if (!trackPicker.hidden && e.key === 'Escape') {
-        e.preventDefault();
-        closePicker();
-      }
-    });
-
-    // Live filter
-    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
-  }
-
-  // ── Playlist insert (open existing or create new via modal)
-  const playlistBtn = document.getElementById('insert-playlist-btn');
-  if (playlistBtn) {
-    playlistBtn.addEventListener('click', async () => {
-      if (typeof window.openPlaylistEditor !== 'function') {
-        alert('' + esc(T.js_playlist_editor_missing) + '');
-        return;
-      }
-      try {
-        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
-        const j = await r.json();
-        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
-          const choice = prompt(
-            '' + esc(T.js_playlist_existing) + '\n\n' +
-            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
-            '\n\n' + esc(T.js_playlist_choose) + ''
-          );
-          if (choice && /^\d+$/.test(choice.trim())) {
-            const idx = parseInt(choice.trim(), 10) - 1;
-            if (idx >= 0 && idx < j.playlists.length) {
-              insertChip('playlist', j.playlists[idx].id);
-              return;
-            }
-          }
-          if (choice === null) return;
-        }
-      } catch (_) { /* fall through to create */ }
-
-      window.openPlaylistEditor({
-        mode: 'create',
-        onSaved: ({ id }) => insertChip('playlist', id),
-      });
-    });
-  }
-
-  // ── Post type: segmented control + type-aware panels ──────────
-  (function () {
-    const typeInput = document.getElementById('pe-type-input');
-    const card = document.querySelector('.pe-type-card');
-    if (!typeInput || !card) return;
-    const seg = card.querySelector('.pe-typeseg');
-    const panels = card.querySelectorAll('.pe-type-panel');
-    let handmatig = false;   // heeft de gebruiker het type zelf aangeklikt?
-
-    function applyType(tt) {
-      typeInput.value = tt;
-      seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
-        const on = b.dataset.type === tt;
-        b.classList.toggle('is-active', on);
-        b.setAttribute('aria-checked', on ? 'true' : 'false');
-      });
-      // data-panel mag meerdere types noemen: Album en Playlist delen het
-      // muziekpaneel, want het verschil zit in de playlist en niet in de upload.
-      panels.forEach(p => {
-        const voor = String(p.dataset.panel || '').trim().split(/\s+/);
-        p.hidden = !voor.includes(tt);
-      });
-    }
-    seg.addEventListener('click', (e) => {
-      const btn = e.target.closest('.pe-typeseg-btn');
-      if (!btn) return;
-      handmatig = true;          // jouw klik wint vanaf nu van de afleiding
-      applyType(btn.dataset.type);
-    });
-    applyType(typeInput.value || 'post');
-
-    // ── Het type volgt de muziek (shaer-cyg) ──────────────────────
-    //
-    // Robins regel: de post neemt de soort van zijn muziek over als hij precies
-    // EEN muzikale eenheid bevat. Dus zodra je een playlist invoegt verspringt
-    // de balk mee, en zie je wat je aan het maken bent in plaats van het zelf te
-    // moeten bijhouden.
-    //
-    // TWEE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
-    // keuze -- daarna wordt er niets meer voor je omgezet. En Foto en Video zijn
-    // een bewuste andere keuze over dezelfde post, dus die overschrijft hij
-    // nooit, ook niet als er muziek in staat.
-    //
-    // Bij het OPENEN van een post gebeurt er niets: de eerste afleiding wordt
-    // alleen als ijkpunt onthouden. Anders zou een oude post van type veranderen
-    // door hem te bekijken.
-    const VOLGBAAR = new Set(['post', 'album', 'playlist', 'mixtape', 'audio']);
-    const kindVan = new Map();
-    let vorigeAfleiding = null;
-
-    const soortenGeladen = fetch('/admin/playlists/api/list', { credentials: 'same-origin' })
-      .then((r) => r.json())
-      .then((j) => {
-        if (j && j.ok && Array.isArray(j.playlists)) {
-          // De soort ZOALS HIJ IS. Dit was `=== 'playlist' ? 'playlist' : 'album'`,
-          // en dan ziet de editor een mixtape als album terwijl de server hem
-          // als mixtape opslaat: het type verspringt onder je handen bij het
-          // bewaren. SOORTEN is dezelfde lijst die de server gebruikt.
-          for (const p of j.playlists) kindVan.set(p.id, SOORTEN.includes(p.kind) ? p.kind : 'album');
-        }
-      })
-      // Zonder de soorten leidt de regel niets af (elke playlist is dan
-      // 'onbekend'), en dat is beter dan een gok die als keuze oogt.
-      .catch(() => {})
-      .then(() => { vorigeAfleiding = afleidenNu(); });
-
-    function afleidenNu() {
-      // Precies de tekst die straks wordt opgeslagen: chips terug naar
-      // shortcodes. Zo leidt de editor af uit wat de server ook zal zien.
-      const clone = editor.cloneNode(true);
-      serializeChips(clone);
-      const r = afleidenUitInsluitingen(clone.innerHTML, (id) => kindVan.get(id) || null);
-      return r ? r.type : null;
-    }
-
-    volgMuziek = async () => {
-      await soortenGeladen;
-      const nu = afleidenNu();
-      if (nu === vorigeAfleiding) return;    // de muziek is niet van soort veranderd
-      vorigeAfleiding = nu;
-      if (handmatig || !nu) return;
-      if (!VOLGBAAR.has(typeInput.value)) return;
-      applyType(nu);
-    };
-
-    // Ook getypte of geplakte shortcodes tellen, niet alleen de knoppen.
-    let tik = null;
-    editor.addEventListener('input', () => {
-      clearTimeout(tik);
-      tik = setTimeout(volgMuziek, 300);
-    });
-
-    // Video URL → [[embed:url]] chip
-    const vBtn = document.getElementById('pe-video-insert');
-    const vUrl = document.getElementById('pe-video-url');
-    if (vBtn && vUrl) {
-      const doInsert = () => {
-        const url = (vUrl.value || '').trim();
-        if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
-        insertChip('embed', url);
-        vUrl.value = '';
-      };
-      vBtn.addEventListener('click', doInsert);
-      vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
-    }
-
-    // Audio: inline upload → transcodes server-side → [[track:id]] chip
-    const drop = document.getElementById('pe-audio-drop');
-    const fileInput = document.getElementById('pe-audio-file');
-    const list = document.getElementById('pe-audio-list');
-    if (drop && fileInput && list) {
-      const pick = () => fileInput.click();
-      drop.addEventListener('click', pick);
-      drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
-      ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
-      ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
-      drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
-      fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
-
-      function clientDuration(f) {
-        return new Promise((resolve) => {
-          try {
-            const u = URL.createObjectURL(f);
-            const a = document.createElement('audio');
-            a.preload = 'metadata';
-            a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
-            a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
-            a.src = u;
-          } catch (_) { resolve(null); }
-        });
-      }
-      async function handleFiles(files) {
-        for (const f of Array.from(files || [])) await uploadOne(f);
-      }
-      async function uploadOne(f) {
-        const li = document.createElement('li');
-        li.className = 'pe-audio-item';
-        const nameEl = document.createElement('span');
-        nameEl.className = 'pe-audio-item-name';
-        nameEl.textContent = f.name;
-        const stateEl = document.createElement('span');
-        stateEl.className = 'pe-audio-item-state';
-        stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
-        li.appendChild(nameEl); li.appendChild(stateEl);
-        list.appendChild(li);
-        try {
-          const dur = await clientDuration(f);
-          const fd = new FormData();
-          fd.append('audio', f);
-          if (dur) fd.append('duration', String(dur));
-          const res = await fetch('/admin/audio/upload', {
-            method: 'POST', body: fd,
-            headers: { 'Accept': 'application/json' },
-            credentials: 'same-origin',
-          });
-          const j = await res.json().catch(() => ({}));
-          if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
-          insertChip('track', j.id);
-          stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
-          li.classList.add('is-done');
-        } catch (err) {
-          stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
-          li.classList.add('is-fail');
-        }
-      }
-    }
-  })();
-
-  // ── Submit: serialize editor contents into the hidden field
-  if (form && hiddenField) {
-    form.addEventListener('submit', () => {
-      const clone = editor.cloneNode(true);
-      serializeChips(clone);
-      hiddenField.value = clone.innerHTML;
-    });
-  }
-})();
-
-// ── volgend blok ──
-
-(function () {
-  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
-  // ▲▼ shifts the position, with a readable description instead of a raw number.
-  var toggle = document.getElementById('pin-toggle');
-  var rank   = document.getElementById('pin-rank');
-  var pos    = document.getElementById('pin-pos');
-  var label  = document.getElementById('pin-label');
-  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
-  var down   = document.getElementById('pin-down');
-  if (!toggle || !rank || !pos) return;
-
-  function descr(n) {
-    n = Number(n) || 0;
-    if (n <= 1) return '' + esc(T.pin_top) + '';
-    return n + '' + esc(T.pin_nth_suffix) + '';
-  }
-  function render() {
-    var on = toggle.checked;
-    pos.hidden = !on;
-    if (on && Number(rank.value) < 1) rank.value = 1;
-    if (!on) rank.value = 0;
-    if (label) label.textContent = on ? descr(rank.value) : '';
-    if (up) up.disabled = Number(rank.value) <= 1;
-  }
-  toggle.addEventListener('change', render);
-  if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
-  if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
-  render();
-})();
-
-(function () {
-  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
-  // obstacles by setting a dynamic bottom offset = the greater of:
-  //  1) the height of the keyboard area NOT covered by the layout viewport
-  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
-  //     due to interactive-widget=resizes-content → offset ≈ 0);
-  //  2) the height of the playing audio player (fixed, z-index 1000).
-  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
-  var bar = document.querySelector('.pe-actions');
-  if (!bar) return;
-  var vv = window.visualViewport;
-  function position() {
-    var ap = document.querySelector('.audio-player');
-    var playing = document.body.classList.contains('has-audio-player') &&
-                  ap && getComputedStyle(ap).display !== 'none';
-    var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
-    var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
-    var offset = Math.max(audioOffset, kbCovered);
-    bar.style.bottom = offset ? offset + 'px' : '';
-  }
-  position();
-  doc.on(window, 'resize', position);
-  if (vv) { doc.on(vv, 'resize', position); doc.on(vv, 'scroll', position); }
-  // has-audio-player is toggled via a body class → observe it. De observer
-  // overleeft de swap net als de listeners; init() disconnect de vorige.
-  try { _barObserver = new MutationObserver(position); _barObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
-})();
-}
Index: src/assets/js/mod/post.js
===================================================================
--- src/assets/js/mod/post.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-// De post-pagina: het "beantwoorden vanaf je eigen server"-veld (shaer-bqr).
-//
-// Stond inline in pages/post.ejs. Kwam je op een post via een link BINNEN de
-// site, dan arriveerde dat script via htmx met een nonce die het document niet
-// kent en weigerde de CSP het (shaer-0i6) -- dus deed de knop niets, precies op
-// de plek waar de meeste bezoekers binnenkomen.
-//
-// Kon ongewijzigd mee: alles is al gedelegeerd op document, er wordt geen enkel
-// pagina-element vastgehouden, en er staat een slot op. Dat is niet toevallig --
-// het is geschreven voor een popover die na een htmx-swap moet blijven werken.
-
-  (function(){
-    if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-    var current = null, currentBtn = null;
-    function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-    function go(raw, uri){
-      var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-      if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); }
-    }
-    function place(f, b){
-      var r = b.getBoundingClientRect();
-      f.style.top = (r.bottom + window.scrollY + 6) + 'px';
-      f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px';
-    }
-    document.addEventListener('click', function(e){
-      if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-      if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep
-      var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-      if (b) {
-        e.preventDefault();
-        if (currentBtn === b) { close(); return; }   // toggle off
-        close();
-        var f = document.createElement('form');
-        f.className = 'fedi-remote-form';
-        f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
-        f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
-          + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
-          + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-        var _inp = f.querySelector('input');
-        _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-        try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){}
-        document.body.appendChild(f);            // floating popover → no layout reflow
-        place(f, b); current = f; currentBtn = b;
-        _inp.focus(); _inp.select();
-        return;
-      }
-      if (current) close(); // click anywhere else closes it
-    });
-    document.addEventListener('submit', function(e){
-      var f = e.target.closest && e.target.closest('.fedi-remote-form');
-      if (!f) return; e.preventDefault();
-      go(f.querySelector('input').value, f.dataset.uri);
-    });
-    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-    window.addEventListener('scroll', close, true);
-  })();
-  
Index: src/assets/js/mod/read.js
===================================================================
--- src/assets/js/mod/read.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,663 +1,0 @@
-/**
- * De leesweergave: tikken op een bericht opent dat bericht, en een knop om terug
- * naar boven te gaan.
- *
- * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
- * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
- * weg, en dat is winst: Lezen is nu een WEERGAVE van de feed
- * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
- * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
- * doet CSS.
- *
- * HET SNAPPEN STAAT HIER MET OPZET NIET IN. Ik heb dat een ronde lang wel
- * geprobeerd -- richting bijhouden, een vangzone uitrekenen, per scroll-event
- * het snappunt verzetten -- en dat is de verkeerde laag. Robins bezwaar (20-8):
- * "scroll-snap op het element is iets anders dan touch events,
- * requestAnimationFrame etc. dan gaan we te veel van de view doen." Klopt, en
- * het vocht ook met de browser: de scroll-events bevatten OOK de bewegingen van
- * zijn eigen snap-animatie, dus de richting die je eruit afleidt is niet die van
- * de gebruiker.
- *
- * Wat er nodig was, was een regel minder in de CSS en niet honderd erbij hier:
- * zie style.css bij .feed-reader .read-post.
- *
- * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
- * voor toetsenbord en schermlezer; de tik hieronder is er voor een duim.
- *
- * Vier uitzonderingen op die tik, want een tik die je niet bedoelde is erger dan
- * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
- * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
- * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
- */
-
-// VENDOR_V komt uit lib.js: een nummer voor de hele vendormap (shaer-724),
-// zodat 'ophogen als de bibliotheek wijzigt' op een plek staat.
-import { VENDOR_V } from './lib.js';
-
-// ── Paginamodus ─────────────────────────────────────────────────────────────
-/**
- * Elk bericht een paneel dat zelf scrollt; tussen berichten ga je met knoppen.
- *
- * Robins voorstel (20-8) nadat het namaken van iOS-momentum niet lukte, en dat
- * is de winst: in dit model DOET het scrollgevoel er niet toe. Er is geen
- * momentum om na te bouwen en geen snap die vangt.
- *
- * Hoe het werkt:
- *  - lenis.stop() zet het scrollen van de STROOM stil. Lenis vangt dan wiel en
- *    vinger af (hij preventDefault't in onVirtualScroll), dus je kunt niet meer
- *    tussen berichten door scrollen.
- *  - data-lenis-prevent op elk paneel houdt de scroll BINNEN een bericht
- *    ongemoeid. Dat werkt ook terwijl Lenis gestopt is, want die controle staat
- *    in zijn bron vóór de gestopt-controle. Nagekeken in de dist, niet gehoopt.
- *  - Bewegen tussen panelen gaat met lenis.scrollTo(..., { force: true }), want
- *    force is precies de uitzondering die een gestopte Lenis toch laat scrollen.
- *
- * Geldt waar een bericht een eigen scherm heeft: op mobiel altijd, op desktop
- * als de site dat instelt (reader_full_page). Buiten die modus verandert er
- * niets -- dan is Lenis gewoon aan en scrol je vrij.
- */
-/**
- * PAGINAMODUS STAAT UIT -- maar de code blijft staan (Robin, 20-8: "weghalen
- * maar bewaren, voor als ik het later weer wil").
- *
- * Wat het is: elk bericht een paneel van een scherm dat zelf scrolt, met twee
- * balken om ertussen te navigeren en de stroomscroll vastgezet. Gebouwd omdat
- * het namaken van iOS-momentum niet lukte; in dit model doet het scrollgevoel er
- * namelijk niet toe. Mobiel is nu terug op systeemscroll met snap, en dat is wat
- * er ook stond voordat we dit probeerden.
- *
- * Aanzetten: deze constante op true. Dan komen de balken terug (CSS hangt aan
- * body.is-paged), stopt Lenis de stroomscroll en scrollen de panelen zelf.
- */
-const PAGINAMODUS = false;
-
-function paginaModus() {
-  return PAGINAMODUS && OP_TOUCH.matches && document.body.dataset.feedView === 'reader';
-}
-
-function panelen() {
-  return [...document.querySelectorAll('.feed-reader .read-post')];
-}
-
-/** Welk paneel vult nu het scherm? Het eerste waarvan de bovenkant niet voorbij is. */
-function huidigIndex() {
-  const P = panelen();
-  for (let i = 0; i < P.length; i++) {
-    if (P[i].getBoundingClientRect().top > 8) return Math.max(0, i - 1);
-  }
-  return Math.max(0, P.length - 1);
-}
-
-function gaNaar(i) {
-  const P = panelen();
-  const doel = P[Math.max(0, Math.min(P.length - 1, i))];
-  if (!doel) return;
-  if (lenis) lenis.scrollTo(doel, { force: true });
-  else doel.scrollIntoView({ behavior: 'smooth', block: 'start' });
-  setTimeout(zetBalken, 80);
-}
-
-/**
- * Omhoog vanaf het EERSTE bericht brengt je naar de header.
- *
- * In paginamodus staat Lenis stil, dus de pagina scrolt niet meer met je vinger
- * -- en dan is alles boven het eerste bericht onbereikbaar. Robin liep daar
- * tegenaan (20-8): "ik kan niet meer terug scrollen naar de header". De
- * omhoog-knop is daar de enige weg naartoe, dus die krijgt er een trede bij.
- */
-function gaOmhoog() {
-  const i = huidigIndex();
-  if (i > 0) { gaNaar(i - 1); return; }
-  if (lenis) lenis.scrollTo(0, { force: true });
-  else window.scrollTo({ top: 0, behavior: 'smooth' });
-}
-
-/**
- * Welke balk hoort er te staan? Bovenaan de pagina is er niets boven je, dus dan
- * geen bovenbalk. Buiten paginamodus staan ze allebei niet -- de CSS verbergt ze
- * daar al, maar hidden houdt ze ook uit de toetsenbordvolgorde.
- */
-function zetBalken() {
-  const boven = document.getElementById('read-prev');
-  const onder = document.getElementById('read-next-nav');
-  const aan = paginaModus();
-  // De bovenbalk hoort NOOIT over de header te liggen. Hij verscheen al zodra je
-  // een paar pixels scrolde, en dekte dan de avatar, de omschrijving en de
-  // weergaveknoppen af (Robins schermafbeelding, 20-8). Nu komt hij pas als het
-  // eerste bericht de bovenrand van het scherm heeft bereikt -- dan is de header
-  // voorbij en is er ook echt iets om naar terug te gaan.
-  const eerste = panelen()[0];
-  const headerNogInBeeld = eerste ? eerste.getBoundingClientRect().top > 4 : true;
-  if (boven) boven.hidden = !aan || headerNogInBeeld;
-  if (onder) onder.hidden = !aan;
-  if (onder && aan) onder.style.bottom = onderChroom() + 'px';
-}
-
-/**
- * Hoe hoog staat de onderrand van het scherm werkelijk vol?
- *
- * De onderbalk stond op een geraden 4.75rem boven de onderkant, en dan zweeft
- * hij: soms een kier boven de speler, soms er half achter. De tabbalk en de
- * mini-speler hebben allebei een eigen hoogte, ze stapelen op mobiel, en de
- * speler komt en gaat. Dus meten in plaats van gokken: hoe ver ligt de BOVENKANT
- * van het hoogste vaste element boven de onderrand van het venster?
- */
-function onderChroom() {
-  // .bottom-tab-fab staat erbij omdat de Write-knop BOVEN de tabbalk uitsteekt:
-  // meet je alleen de balk, dan legt onze balk zich over die knop heen (Robins
-  // schermafbeelding, 20-8).
-  const kandidaten = ['.bottom-tab', '.bottom-tab-fab', '#pcms-audio-player'];
-  let hoogste = 0;
-  kandidaten.forEach((sel) => {
-    const el = document.querySelector(sel);
-    if (!el) return;
-    const st = getComputedStyle(el);
-    if (st.display === 'none' || st.visibility === 'hidden') return;
-    const r = el.getBoundingClientRect();
-    if (r.height <= 0) return;
-    hoogste = Math.max(hoogste, window.innerHeight - r.top);
-  });
-  return Math.round(hoogste);
-}
-
-function pasPaginaModusToe() {
-  const aan = paginaModus();
-  document.body.classList.toggle('is-paged', aan);
-  panelen().forEach((a) => {
-    if (aan) a.setAttribute('data-lenis-prevent', '');
-    else a.removeAttribute('data-lenis-prevent');
-  });
-  if (lenis) { if (aan) lenis.stop(); else lenis.start(); }
-  zetBalken();
-}
-
-/**
- * Terug naar boven, en bewust NIET window.scrollTo(0).
- *
- * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
- * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
- * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
- */
-function naarBoven() {
-  const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-  const gedrag = zacht ? 'smooth' : 'auto';
-  const posts = [...document.querySelectorAll('.feed-reader .read-post')];
-  const huidig = posts.find((a) => {
-    const r = a.getBoundingClientRect();
-    return r.top <= 8 && r.bottom > 8;
-  });
-  // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
-  if (huidig && huidig.getBoundingClientRect().top < -8) {
-    huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
-    return;
-  }
-  window.scrollTo({ top: 0, behavior: gedrag });
-}
-
-/** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
-function toonKnop(knop) {
-  knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
-}
-
-let tapX = 0, tapY = 0;
-function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
-
-function onTap(e) {
-  if (e.defaultPrevented || e.button !== 0) return;
-  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-  const t = e.target;
-  if (!t || typeof t.closest !== 'function') return;
-  const art = t.closest('.read-post');
-  if (!art) return;
-  if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
-  if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
-  const sel = window.getSelection && window.getSelection();
-  if (sel && String(sel).trim()) return;
-
-  const slug = art.dataset.slug;
-  if (!slug) return;
-  location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
-}
-
-/**
- * Omhoog scrollen blijft VRIJ (Robin, 20-8).
- *
- * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
- * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
- * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
- * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
- * scrollpositie-boekhouding.
- *
- * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
- * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
- * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
- * leest de browser en niet de gebruiker.
- *
- * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
- * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
- * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
- * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
- * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
- * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
- * het doorscrollen zijn"):
- *
- *     snappen UIT, beweging naar 1459 loopt
- *     -> halverwege snappen AAN gezet
- *     -> eindigt op 1459, NIET op een berichtgrens
- *
- * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
- * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
- * aanraken tot de scroll echt stil is.
- *
- * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
- * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
- * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
- */
-const RUST_MS = 120;
-
-let bezig = false;          // loopt er een gebaar of een uitloop?
-let ooitGescrold = false;   // snappen begint UIT, zie zetSnappen()
-let rustTimer = null;
-
-/**
- * LENIS, en alleen op desktop.
- *
- * Op touch doet Lenis van zichzelf niets (syncTouch staat standaard uit) en is
- * het systeem-scrollen al soepel -- daar blijft de native CSS-snap staan die
- * hierboven beschreven is. Robins keuze (20-8): "enkel voor desktop, dat is
- * prima, logisch dat het niet op mobiel kan".
- *
- * WAAROM LENIS HIER STAAT, en dat is niet het vloeiende scrollen: de DUUR van
- * een snap is met native scroll-snap niet in te stellen -- die zit in de browser.
- * Lenis' snap-pakket wel: duration, easing, distanceThreshold en debounce zijn
- * allemaal van ons. Dat was de aanleiding.
- *
- * Het vloeiende scrollen (smoothWheel) kwam er daarna bij, en dat is de kant die
- * OPPASSEN vraagt. Een Mac-trackpad heeft zijn EIGEN momentum, en Lenis'
- * demping komt daar bovenop -- dubbel gedempt voelt drijverig. Vandaar lerp 0.2
- * in plaats van de standaard 0.1. Robin vond dat op 20-8 nog steeds te zweverig,
- * dus dat getal is nog niet uit; zie de opmerking bij de instellingen.
- *
- * De stand met `smoothWheel: false` werkte ook, en dan doet Lenis alleen de
- * snap. Dat is de terugvalpositie als het vloeiende scrollen niet bevalt.
- *
- * lenis/snap haakt alleen in op lenis.on('scroll') en roept lenis.scrollTo aan
- * (nagekeken in de dist), dus die opzet werkt.
- */
-const OP_DESKTOP = window.matchMedia('(hover: hover) and (pointer: fine)');
-// PROEF (20-8): Lenis ook op touch. Robin meldde op mobiel drie dingen -- de
-// header niet zichtbaar bij laden, snappen dat soms te ver gaat, en horizontaal
-// kunnen scrollen. De eerste twee komen van de NATIVE snap: die vangt al bij het
-// laden, en zijn uitloop vliegt bij een flick over een grens heen. Lenis zet de
-// native snap uit en doet het zelf, dus die twee kunnen ermee verdwijnen.
-//
-// De prijs is echt: syncTouch betekent dat Lenis het VINGERSCROLLEN overneemt
-// van iOS -- momentum, rubber-band en het wegschuiven van de adresbalk worden
-// dan een benadering in JavaScript. De makers waarschuwen daar zelf voor op
-// iOS < 16. Dit staat er dus als proef, niet als besluit: voelt het niet goed,
-// dan is OP_TOUCH weer false zetten de hele terugweg.
-const OP_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
-// VENDOR_V komt uit lib.js: een nummer voor de hele vendormap (shaer-724).
-
-let lenis = null;
-let snap = null;
-
-async function startLenis() {
-  // DESKTOP EN TOUCH ALLEBEI. Er heeft hier een tijd gestaan dat mobiel terug was
-  // op de systeemscroll; dat klopte niet meer met de regel hieronder, die
-  // syncTouch juist AANzet op touch. Reden dat Lenis ook op de telefoon meedraait:
-  // zijn snap heeft zijn scroll-gebeurtenissen nodig om te kunnen timen.
-  if (lenis || !(OP_DESKTOP.matches || OP_TOUCH.matches)) return;
-  const [L, S] = await Promise.all([
-    import(`/assets/vendor/lenis.mjs?v=${VENDOR_V}`),
-    import(`/assets/vendor/lenis-snap.mjs?v=${VENDOR_V}`),
-  ]);
-  lenis = new L.default({
-    /**
-     * Wat Lenis MET RUST LAAT.
-     *
-     * In paginamodus staat Lenis stil en scrolt alleen het paneel van het
-     * bericht zelf. Dat regelde ik eerst met een data-lenis-prevent-attribuut
-     * dat de module op elk paneel zette -- maar dan hangt het scrollen af van of
-     * dat attribuut op tijd en op elk (ook later bijgeladen) paneel staat, en
-     * Robin kon binnen een lang bericht niet scrollen.
-     *
-     * Deze functie stelt dezelfde vraag zonder die afhankelijkheid: is dit een
-     * bericht-paneel? Dan bemoeit Lenis zich er niet mee en scrolt de browser
-     * het zelf -- wat op een telefoon precies is wat je wilt, want dat is de
-     * systeemscroll.
-     */
-    prevent: (node) => !!(node && node.classList
-      && node.classList.contains('read-post')
-      && document.body.classList.contains('is-paged')),
-    // Lenis tekent de scrollbeweging zelf, maar STEVIG GEDEMPT (lerp 0.2 in
-    // plaats van de standaard 0.1). Reden: een muiswiel scrollt in schokken en
-    // heeft die demping nodig; een Mac-trackpad heeft zijn EIGEN momentum en
-    // krijgt er dan een tweede overheen -- dat is precies het drijverige gevoel
-    // waar Robin voor waarschuwde. Hoger betekent korter naijlen, dus dit is de
-    // middenweg: de schokjes weg, de nasleep kort.
-    // Staat het toch te zweven, dan is lerp omhoog (richting 1) of terug naar
-    // smoothWheel:false de knop -- die stand werkte ook, met alleen de snap.
-    smoothWheel: true,
-    lerp: 0.2,
-    // Alleen op touch overneemt hij het vingerscrollen; op desktop hoeft dat niet.
-    syncTouch: OP_TOUCH.matches,
-    // Twee touch-knoppen, en ze doen echt iets anders. Lenis rekent bij het
-    // LOSLATEN eenmalig een doel uit -- afstand = snelheid^touchInertiaExponent
-    // -- en kruipt daar dan naartoe met syncTouchLerp.
-    //
-    //   touchInertiaExponent  hoe VER de flick draagt   (standaard 1.7)
-    //   syncTouchLerp         hoe hard hij REMT         (standaard 0.075)
-    //
-    // Let op: `touchInertiaMultiplier` bestaat NIET in 1.3.26. Lenis slikt
-    // onbekende opties zonder fout, dus zo'n regel lijkt te werken en doet niets.
-    // Controleer een optienaam in src/assets/vendor/lenis.mjs voor je hem zet.
-    //
-    // Robin wilde langer en sneller uitrollen zonder remgevoel, dus de exponent
-    // gaat omhoog (een stevige flick draagt daarmee ruim vier tot zes keer zo
-    // ver) en de lerp gaat juist boven de standaard: hij legt die afstand vlot
-    // af in plaats van er stroperig naartoe te kruipen.
-    ...(OP_TOUCH.matches ? { syncTouchLerp: 0.09, touchInertiaExponent: 2.2 } : {}),
-    autoRaf: true,
-  });
-  snap = new S.default(lenis, {
-    type: 'proximity',
-    // DE VANGZONE, en dit is HET getal om aan te draaien. Ooit 12%, toen 55%
-    // (20-8: eerder vangen, boven de reacties), en 20-8 terug naar 12% -- want
-    // sinds het snappen alleen nog vooruit werkt, voelde die brede zone als
-    // trekken tijdens het lezen. 12% is ~110 pixels op een venster van 910: hij
-    // vangt pas vlak vóór de lijn.
-    // Houd dit gelijk aan vangZone() hieronder: deze drempel bepaalt of
-    // lenis/snap überhaupt aanklopt, die andere of wij het doorlaten. Zet je
-    // deze lager dan die, dan komt onze regel nooit aan bod.
-    distanceThreshold: '12%',
-    // Hoe lang na de laatste scrollbeweging hij mag vangen. De standaard is 500
-    // en dat voelt als te laat. Op touch nog korter dan op desktop, want daar
-    // eindigt een veeg in een lange, trage staart -- en juist dan wil je dat de
-    // snap er snel bij is in plaats van te wachten tot de laatste pixel stil ligt.
-    //
-    // LET OP DE SPANNING: een vloeiendere uitloop (lagere syncTouchLerp) maakt
-    // die staart langer, en stelt de snap dus uit. Deze twee getallen houden
-    // elkaar in evenwicht; draai je aan de een, kijk dan ook naar de ander.
-    // OP TOUCH JUIST LANG, en dat is het omgekeerde van wat ik vorige ronde deed.
-    // Tussen je vinger loslaten en het op gang komen van de uitloop zit een korte
-    // stilte in de scrollbeweging. Met 25ms viel de snap precies in dat gat: hij
-    // greep op het moment van loslaten en knipte de veeg af -- wat aanvoelt als
-    // "stroef bij het loslaten", en het werd er dan ook erger van. 200ms laat de
-    // uitloop eerst zijn werk doen.
-    // Met een iOS-achtige uitloop duurt de staart lang; te kort wachten laat de
-    // snap midden in de vlucht ingrijpen. 150ms laat hem uitrollen en vangt dan.
-    debounce: OP_TOUCH.matches ? 150 : 60,
-    // Op touch korter: een telefoon vraagt om directer antwoord dan een muis, en
-    // de snap komt daar aan het eind van een lange uitloop -- dan mag hij kort.
-    duration: OP_TOUCH.matches ? 0.18 : 0.4,
-    // Vlot weg, dan steeds langzamer aankomen (Robin, 20-8). easeOutQuart: op de
-    // helft van de tijd is 94% van de weg af, en de rest dempt zacht uit.
-    // Bewust NIET Lenis' standaard easeOutExpo -- die schiet weg en kruipt dan
-    // zo lang na dat het lijkt of hij niet afmaakt.
-    easing: (t) => 1 - Math.pow(1 - t, 4),
-  });
-  // Het snappunt is de bovenkant van elk bericht -- ook van het laatste. Dat
-  // deed eerder niet mee omdat zijn bovenkant onbereikbaar was; sinds het
-  // laatste bericht in CSS een volle schermhoogte krijgt, kan hij wel.
-  const berichten = [...document.querySelectorAll('.feed-reader .read-post')];
-  berichten.forEach((a) => snap.addElement(a, { align: 'start' }));
-
-  // ALLEEN VOORUIT VANGEN. Dit hoort bij de ruime zone hierboven en is niet
-  // optioneel: lenis/snap kiest het DICHTSTBIJZIJNDE punt en zijn drempel geldt
-  // naar twee kanten. Scroll je een lang bericht in en stop je 300 pixels onder
-  // de bovenkant, dan is die bovenkant het dichtstbij -- en met een zone van 400
-  // wordt je teruggetrokken. Dat is precies de klacht "ik kan niet scrollen
-  // binnen een lang bericht", en met de oude zone van 86 pixels viel het alleen
-  // niet op.
-  //
-  // Dus: een doel ACHTER je slaan we over. Ligt er een punt voor je binnen de
-  // zone, dan gaan we daarheen; anders gebeurt er niets en scrol je vrij door.
-  // Zo verruimt de zone alleen de kant waar hij bedoeld is.
-  //
-  // Waarom niet gewoon de drempel? Omdat lenis/snap alleen de dichtstbijzijnde
-  // kandidaat beoordeelt: zonder deze omleiding houdt de zone vooruit op bij de
-  // helft van de afstand tussen twee berichten, hoe groot je de drempel ook zet.
-  //
-  // DE REGEL, in Robins woorden (20-8): "enkel bij downscrollen, aan de
-  // onderkant van elke post, snappen naar de lijn tussen de posts" -- en geen
-  // snap op een bericht dat al voorbij is gescrold. Drie voorwaarden dus:
-  //   1. de laatste echte beweging ging omlaag,
-  //   2. het doel ligt VOOR je (een punt achter je slaan we over),
-  //   3. het ligt binnen de vangzone -- die begint rond de voet van het bericht.
-  // Het doel zelf blijft de bovenkant van het volgende bericht: dat IS de lijn
-  // ertussen, en dat is het essentiële.
-  //
-  // DE ZONE IS EEN GETAL, geen '55%'. Die string kwam ongewijzigd uit de opties
-  // en werd hier met een getal vergeleken -- altijd onwaar, dus deze terugval
-  // heeft nooit gewerkt en alleen het doel dat lenis zelf koos kwam erdoor.
-  const vangZone = () => 0.12 * window.innerHeight;   // gelijk aan distanceThreshold hierboven
-  // Lenis' eigen `direction` is het teken van de snelheid, en die is bij het
-  // afvuren van de (gedebouncede) snap alweer nul. Daarom onthouden we de
-  // laatste richting die er echt was.
-  let laatsteRichtingOmlaag = true;
-  lenis.on('scroll', () => {
-    if (Math.abs(lenis.velocity) > 0.05) laatsteRichtingOmlaag = lenis.velocity > 0;
-  });
-  const echtGaNaar = snap.goTo.bind(snap);
-  snap.goTo = (index) => {
-    if (!laatsteRichtingOmlaag) return;   // omhoog: nooit vangen
-    const punten = snap.computeSnaps();   // zelfde volgorde als goTo intern gebruikt
-    const nu = lenis.scroll;
-    let vooruit = -1;
-    punten.forEach((punt, i) => {
-      if (punt.value > nu + 2 && (vooruit < 0 || punt.value < punten[vooruit].value)) vooruit = i;
-    });
-    if (vooruit < 0) return;              // niets meer voor je: vrij uitscrollen
-    if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit);
-  };
-
-  // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de
-  // vendor-bron, geen vermoeden. Drie feiten op een rij:
-  //
-  //   1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij
-  //      de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).
-  //   2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per
-  //      gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je
-  //      vinger LOSLIET.
-  //   3. de uitloop draagt daarna nog honderden pixels verder.
-  //
-  // De snapbeslissing valt dus op een positie die de scroll meteen verlaat.
-  // Op desktop klopt dezelfde som wel: wieldelta's zijn klein en de
-  // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.
-  // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen
-  // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het
-  // exacte landingspunt van de uitloop.
-  if (OP_TOUCH.matches) {
-    // Hoe lang het richten naar de lijn duurt, in seconden. De ene knop voor
-    // "snapperiger": 0.18 is direct, 0.3 is landen, 0.5 is zweven.
-    const SNAP_DUUR = 0.22;
-    lenis.off('virtual-scroll', snap.onSnapDebounced);
-    let raakTimer = null;
-    let vertrek = 0;   // scrollpositie bij loslaten: punten daarachter zijn
-                       // de bovenkant van het HUIDIGE bericht, nooit grijpen
-    lenis.on('virtual-scroll', (e) => {
-      const soort = e.event && e.event.type;
-      if (soort === 'touchstart' || soort === 'touchmove') { clearTimeout(raakTimer); return; }
-      if (soort !== 'touchend') return;
-      vertrek = lenis.scroll;
-      clearTimeout(raakTimer);
-      // TIMEOUT 0, geen 150ms. Dit event komt binnen VOOR lenis zelf de
-      // touchend verwerkt (de emit staat in de bron boven de verwerking), dus
-      // targetScroll is hier nog het oude doel. Een tik later is de uitloop
-      // gestart en staat targetScroll op de echte landing -- en dan sturen we
-      // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er
-      // dan een aparte snap-animatie overheen te zetten.
-      raakTimer = setTimeout(() => {
-        if (!laatsteRichtingOmlaag || snap.isStopped) return;
-        const doel = lenis.targetScroll;   // de echte landing, niet de raakdelta
-        const punten = snap.computeSnaps();
-        // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij
-        // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,
-        // dan landen we alsnog OP het target. De zone werkt dus alleen aan de
-        // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon
-        // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver
-        // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies
-        // een bericht verder, nooit twee: dat is het bladeren.
-        let eerste = -1;
-        punten.forEach((punt, i) => {
-          if (punt.value > vertrek + 2 && (eerste < 0 || punt.value < punten[eerste].value)) eerste = i;
-        });
-        if (eerste < 0) return;            // niets meer voor je: vrij uitscrollen
-        // HET RICHTEN IS DE SNAP (Robins regel, 24-8): de lopende uitloop
-        // krijgt het nieuwe doel, er komt geen tweede animatie overheen.
-        //
-        // MET EEN VASTE DUUR, niet met de lerp van de uitloop. Die lerp-vorm
-        // (0.09) is boterzacht maar heeft een exponentiele staart van ruim een
-        // halve seconde -- Robin wilde het korter en beslister (24-8). Een
-        // duur eindigt ECHT, op een instelbaar moment, zoals iOS-paging: vlot
-        // weg, stevig landen. Zelfde easing-familie als de desktop-snap.
-        // SNAP_DUUR is de enige knop: korter is snapperiger, langer is zachter.
-        if (doel >= punten[eerste].value - vangZone()) {
-          lenis.scrollTo(punten[eerste].value, {
-            duration: SNAP_DUUR,
-            easing: (t) => 1 - Math.pow(1 - t, 4),
-            force: true,
-            userData: { initiator: 'snap' },
-          });
-        }
-      }, 0);
-    });
-  }
-
-  // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
-  document.documentElement.style.scrollSnapType = 'none';
-}
-
-function stopLenis() {
-  if (snap) { snap.destroy(); snap = null; }
-  if (lenis) { lenis.destroy(); lenis = null; }
-  document.documentElement.style.scrollSnapType = '';
-}
-
-/**
- * Omhoog niet snappen. Met Lenis is dat snap.stop()/start(); zonder Lenis (dus
- * op touch) zetten we de CSS-eigenschap om, precies zoals hiervoor.
- */
-function zetSnappen(aan) {
-  if (snap) { if (aan) snap.start(); else snap.stop(); return; }
-  // Zonder Lenis (mobiel) zetten we de CSS-eigenschap om. Hij staat bij het
-  // laden UIT -- dat is de standaardstand van deze variabele -- zodat de browser
-  // niet meteen naar het eerste bericht springt en de header wegvalt. Pas je
-  // eerste gebaar naar beneden zet hem aan.
-  const el = document.documentElement;
-  const wil = aan ? '' : 'none';
-  if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
-}
-
-/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
-function nieuwGebaar(naarBeneden) {
-  if (bezig) return;
-  bezig = true;
-  zetSnappen(naarBeneden);
-}
-
-/**
- * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
- * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
- * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
- */
-function rustNu() { bezig = false; }
-function planRust() {
-  clearTimeout(rustTimer);
-  rustTimer = setTimeout(rustNu, RUST_MS);
-}
-
-function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
-let raakY = 0;
-function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
-function opRaakBeweeg(e) {
-  if (!e.touches || !e.touches[0]) return;
-  const y = e.touches[0].clientY;
-  // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
-  // duim die stilstaat.
-  if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
-}
-function opToets(e) {
-  if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
-  else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
-}
-
-let knop = null;
-let opScroll = null;
-
-export function init() {
-  // EERST OPRUIMEN, en pas daarna terugvallen als er geen leesstroom is.
-  //
-  // Dit stond andersom, en dat brak het scrollen op de HELE site: navigeerde je
-  // van Lezen naar het beheer, dan viel init() bij de ontbrekende leesstroom
-  // meteen terug -- en bleef Lenis leven EN gestopt (lenis.stop() vangt wiel en
-  // vinger af). Daarna scrolde niets meer, ook niet op pagina's die met Lezen
-  // niets te maken hebben. Robins melding (20-8): "scrollen werkt nu nergens ook
-  // niet in admin panels".
-  //
-  // Opruimen hoort dus bij het VERLATEN van de weergave, niet bij het opzetten
-  // ervan. init() draait bij elke paginawissel, dus dit is de plek.
-  stopLenis();
-  document.body.classList.remove('is-paged');
-  document.querySelectorAll('[data-lenis-prevent].read-post')
-    .forEach((a) => a.removeAttribute('data-lenis-prevent'));
-
-  const s = document.getElementById('read-stream');
-  if (!s) return;
-  // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
-  // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
-  // dezelfde afhandelaar zich op en vuurt hij twee keer.
-  s.removeEventListener('pointerdown', onPointerDown);
-  s.removeEventListener('click', onTap);
-  s.addEventListener('pointerdown', onPointerDown, { passive: true });
-  s.addEventListener('click', onTap);
-
-  // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
-  // hij niets, maar hij springt niet in beeld bij het laden.
-  // De terug-naar-boven-knop staat er tijdelijk uit (Robin, 20-8) -- in
-  // paginamodus doet de bovenbalk dat werk al. De code blijft staan zodat hij
-  // met een regel terug is.
-  knop = null;
-  const oudeKnop = document.getElementById('read-top');
-  if (oudeKnop) oudeKnop.hidden = true;
-
-  if (opScroll) {
-    window.removeEventListener('scroll', opScroll);
-    window.removeEventListener('scrollend', rustNu);
-    window.removeEventListener('resize', opScroll);
-    window.removeEventListener('wheel', opWiel);
-    window.removeEventListener('touchstart', opRaakStart);
-    window.removeEventListener('touchmove', opRaakBeweeg);
-    window.removeEventListener('keydown', opToets);
-  }
-  // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
-  // levend; pas als het stil blijft mag een nieuwe richting gelden.
-  opScroll = () => {
-    if (knop) toonKnop(knop);
-    planRust();
-    if (document.body.classList.contains('is-paged')) zetBalken();
-  };
-  window.addEventListener('scroll', opScroll, { passive: true });
-  window.addEventListener('scrollend', rustNu);
-  window.addEventListener('resize', opScroll, { passive: true });
-  window.addEventListener('wheel', opWiel, { passive: true });
-  window.addEventListener('touchstart', opRaakStart, { passive: true });
-  window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
-  window.addEventListener('keydown', opToets);
-
-  const balkBoven = document.getElementById('read-prev');
-  const balkOnder = document.getElementById('read-next-nav');
-  if (balkBoven) balkBoven.onclick = gaOmhoog;
-  if (balkOnder) balkOnder.onclick = () => gaNaar(huidigIndex() + 1);
-
-  // Alleen in de leesweergave, en alleen op desktop. Bij elke init() opnieuw
-  // beoordelen: van Grid naar Lezen schakelen hoort hem aan te zetten, en
-  // wegnavigeren hoort hem op te ruimen.
-  if (document.body.dataset.feedView === 'reader') startLenis().then(pasPaginaModusToe);
-  else pasPaginaModusToe();
-
-
-}
-
-export default { init };
Index: src/assets/js/mod/reply-editor.js
===================================================================
--- src/assets/js/mod/reply-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,225 +1,0 @@
-// Rijke reply-editor -- MODULE sinds shaer-nh2.
-//
-// Stond als <script src> onderaan partials/reply-editor.ejs. Bij een volledige
-// laadbeurt ging dat goed (injectCspNonce zet er een nonce op), maar bij een
-// htmx-navigatie draagt de partial de nonce van DAT verzoek terwijl het
-// document die van zijn eigen laadbeurt heeft. De CSP is strict-dynamic, dus
-// alleen die laatste telt -- en het script draaide niet. Gevolg: geen toolbar,
-// geen .re-full, en op mobiel dus geen fullscreen-compose.
-//
-// Als module wordt hij door de bootstrap in de shell geladen, die WEL de juiste
-// nonce heeft. Dat is bovendien de voorwaarde om straks code te DELEN met de
-// posteditor: een los script kan niet importeren uit mod/lib.js.
-
-// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
-// from a plain textarea to a contenteditable with a small toolbar and, on
-// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
-// mobile). Progressive enhancement: without this file the textarea submits as
-// before. On submit: `content` = editor HTML (server sanitizes), `text` =
-// plain-text fallback.
-
-  var MOBILE = '(max-width: 700px)';
-
-function initForm(form) {
-    if (form.__re) return;
-    form.__re = true;
-
-    var ta = form.querySelector('textarea[name="text"]');
-    var ed = form.querySelector('.re-editor');
-    var bar = form.querySelector('.re-toolbar');
-    var head = form.querySelector('.re-head');
-    var foot = form.querySelector('.re-foot');
-    var lang = form.querySelector('.re-lang');
-    var hidden = form.querySelector('input[name="content"]');
-    if (!ta || !ed || !hidden) return;
-
-    // Upgrade: swap the textarea for the editor.
-    ta.hidden = true;
-    ta.required = false;
-    ed.hidden = false;
-    bar.hidden = false;
-    if (lang) lang.hidden = false;
-
-    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
-    var attWrap = form.querySelector('.re-attachments');
-    var attField = form.querySelector('input[name="attachments"]');
-    var fileInput = form.querySelector('.re-file');
-    var canAttach = !!(attWrap && attField && fileInput);   // edit mode renders without media
-    var attachments = [];
-
-    function syncAtt() {
-      if (!canAttach) return;
-      attField.value = attachments.length ? JSON.stringify(attachments) : '';
-      attWrap.hidden = attachments.length === 0;
-    }
-    function addChip(a) {
-      var chip = document.createElement('span');
-      chip.className = 're-att';
-      if (a.mediaType.indexOf('image/') === 0) {
-        var img = document.createElement('img');
-        img.src = a.url; img.alt = a.name || '';
-        chip.appendChild(img);
-      } else {
-        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
-      }
-      var del = document.createElement('button');
-      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
-      del.addEventListener('click', function () {
-        attachments = attachments.filter(function (x) { return x !== a; });
-        chip.remove(); syncAtt();
-      });
-      chip.appendChild(del);
-      attWrap.appendChild(chip);
-    }
-    function uploadFiles(files) {
-      if (!canAttach) return;
-      Array.prototype.forEach.call(files, function (file) {
-        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
-        var chip = document.createElement('span');
-        chip.className = 're-att re-att-busy';
-        chip.textContent = '⏳ ' + file.name;
-        attWrap.hidden = false;
-        attWrap.appendChild(chip);
-        var fd = new FormData();
-        fd.append('media', file);
-        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
-          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
-          .then(function (j) {
-            chip.remove();
-            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
-            attachments.push(a); addChip(a); syncAtt();
-          })
-          .catch(function (err) {
-            chip.className = 're-att re-att-err';
-            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
-            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
-          });
-      });
-    }
-
-    // Toolbar commands (execCommand is deprecated-but-universal; same approach
-    // as the post editor).
-    bar.addEventListener('click', function (e) {
-      var btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      var cmd = btn.getAttribute('data-cmd');
-      if (cmd === 'attach') { if (fileInput) fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
-      ed.focus();
-      if (cmd === 'bold') document.execCommand('bold');
-      else if (cmd === 'italic') document.execCommand('italic');
-      else if (cmd === 'list') document.execCommand('insertUnorderedList');
-      else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
-      else if (cmd === 'link') {
-        var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
-        if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
-      }
-    });
-
-    // Reflect the current formatting on the toolbar (bold/italic/list active),
-    // like the post editor, while the caret is inside this editor.
-    function syncToolbar() {
-      if (document.activeElement !== ed) return;
-      var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' };
-      bar.querySelectorAll('button[data-cmd]').forEach(function (b) {
-        var c = map[b.getAttribute('data-cmd')];
-        if (!c) return;
-        var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; }
-        b.classList.toggle('is-active', on);
-      });
-    }
-    document.addEventListener('selectionchange', syncToolbar);
-    ed.addEventListener('keyup', syncToolbar);
-    ed.addEventListener('mouseup', syncToolbar);
-
-    if (fileInput) fileInput.addEventListener('change', function () {
-      uploadFiles(fileInput.files);
-      fileInput.value = '';
-    });
-
-    // Paste: files become attachments; text pastes as plain text (rich paste
-    // becomes messy HTML; formatting is what the toolbar is for).
-    ed.addEventListener('paste', function (e) {
-      var cd = e.clipboardData || window.clipboardData;
-      if (cd.files && cd.files.length) {
-        e.preventDefault();            // never let the browser inline-paste a file as base64
-        uploadFiles(cd.files);         // no-op without the attach UI (edit mode)
-        return;
-      }
-      var txt = cd.getData('text/plain');
-      if (!txt) return;
-      e.preventDefault();
-      document.execCommand('insertText', false, txt);
-    });
-
-    // Drag/drop media onto the editor.
-    if (canAttach) {
-    ed.addEventListener('dragover', function (e) {
-      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
-        e.preventDefault();
-        ed.classList.add('re-drop');
-      }
-    });
-    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
-    ed.addEventListener('drop', function (e) {
-      ed.classList.remove('re-drop');
-      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
-        e.preventDefault();
-        uploadFiles(e.dataTransfer.files);
-      }
-    });
-    }
-
-    // Mentions bar (u02): removing a chip stops addressing that partner. The
-    // hidden field always mirrors the remaining chips.
-    var mField = form.querySelector('input[name="mentions"]');
-    if (mField) {
-      var parts = [];
-      try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
-      form.querySelectorAll('.re-mention-del').forEach(function (btn) {
-        btn.addEventListener('click', function () {
-          var chip = btn.closest('.re-mention');
-          var uri = chip.getAttribute('data-uri');
-          parts = parts.filter(function (p) { return p.uri !== uri; });
-          mField.value = JSON.stringify(parts);
-          chip.remove();
-        });
-      });
-    }
-
-    // Full-screen compose on mobile: enter on focus, leave via ×.
-    function setFull(on) {
-      form.classList.toggle('re-full', on);
-      head.hidden = !on;
-      document.documentElement.classList.toggle('re-lock', on);
-      if (on) ed.focus();
-    }
-    ed.addEventListener('focus', function () {
-      if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
-    });
-    var cancel = form.querySelector('.re-cancel');
-    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
-
-    // Serialize on submit; block truly empty replies (media-only is fine).
-    form.addEventListener('submit', function (e) {
-      var html = ed.innerHTML.trim();
-      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
-      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
-      hidden.value = plain ? html : '';
-      ta.value = plain;
-      document.documentElement.classList.remove('re-lock');
-    });
-
-    // Open <details> parents (fedi-node) keep working: nothing special needed.
-    void foot;
-  }
-
-/**
- * De bootstrap roept dit aan bij elke paginawissel waarop deze module actief
- * is. Opnieuw scannen is veilig: init(form) heeft een eigen `__re`-vlag per
- * FORMULIER, en na een htmx-wissel zijn de formulieren nieuw -- dus geen vlag,
- * dus opnieuw opgewaardeerd. Precies wat je wilt.
- */
-export function init() {
-  document.querySelectorAll('form[data-re]').forEach(initForm);
-}
Index: src/assets/js/mod/tape.js
===================================================================
--- src/assets/js/mod/tape.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,197 +1,0 @@
-/**
- * Het bandje: een cassette die draait, en alleen vooruit en achteruit speelt.
- *
- * WAT DEZE MODULE WEL EN NIET DOET. Hij speelt niets af. De site heeft al een
- * speler (audio-player.js) met een wachtrij, een mini-speler onderin en een
- * sessie die een paginawissel overleeft. Een tweede speler ernaast bouwen om
- * een bandgevoel na te doen zou twee dingen tegelijk laten klinken en die hele
- * sessie weggooien. Dus: de afspeelknop draagt dezelfde data-attributen als een
- * albumhoes en wordt door die speler opgepakt.
- *
- * SPOELEN GAAT IN SECONDEN, over de nummergrenzen heen. Dat kan omdat die
- * speler een wachtrij in EEN doorlopende MediaSource-tijdlijn giet: "een
- * trackwissel is een positie, geen omschakeling". Een bandje is dus letterlijk
- * die tijdlijn. Vandaar seekBy() en niet next()/prev() -- springen per nummer
- * is een playlistgebaar, en dan is dit een lijst met een cassetteplaatje.
- *
- * DE SPOELLUS STAAT IN DE SPELER, niet hier: sinds de speler onderin dezelfde
- * spoelknoppen heeft zou dezelfde versnelling op twee plekken staan. Deze
- * module bedient hem en tekent het jasje.
- *
- * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
- * is een gesloten module met een klein oppervlak (setQueue, play, pause, next,
- * prev, isPlaying, currentTrack). Liever hier vijf keer per seconde kijken dan
- * daar een gebeurtenissenlaag inbouwen die de rest van de site niet vraagt. Het
- * polsen stopt zodra er geen bandje op de pagina staat.
- */
-
-const TIK_MS = 200;
-let tikker = null;
-let gebonden = null;   // de container waarop de klikafhandelaar hangt
-
-/** De speler, of niets als hij (nog) niet geladen is. */
-const speler = () => window.pcmsAudioPlayer || null;
-
-/** De url's die op dit bandje staan, in volgorde. */
-function bandUrls(tape) {
-  try {
-    return JSON.parse(tape.dataset.pcmsAlbum || '[]').map((t) => t.url);
-  } catch { return []; }
-}
-
-/**
- * Speelt DIT bandje op dit moment? Vergelijken op url en niet op index: de
- * wachtrij kan ondertussen van een ander blok komen, en dan hoort deze cassette
- * gewoon stil te staan.
- */
-function huidigeIndex(tape) {
-  const p = speler();
-  if (!p) return -1;
-  const nu = p.currentTrack();
-  if (!nu || !nu.url) return -1;
-  return bandUrls(tape).indexOf(nu.url);
-}
-
-function tekenBand(tape) {
-  const i = huidigeIndex(tape);
-  const p = speler();
-  const draait = i >= 0 && !!p && p.isPlaying();
-
-  tape.classList.toggle('is-playing', draait);
-  tape.classList.toggle('is-loaded', i >= 0);
-  // Spoelt de SPELER (bijvoorbeeld via zijn eigen knoppen onderin), dan hoort
-  // deze cassette dat ook te laten zien.
-  const windt = i >= 0 && !!p && p.isWinding && p.isWinding();
-  if (!windt) tape.classList.remove('is-winding', 'is-winding-back');
-  else tape.classList.add('is-winding');
-
-  const knop = tape.querySelector('[data-tape-play]');
-  if (knop) {
-    knop.classList.toggle('is-pauze', draait);
-    knop.setAttribute('aria-label', draait ? 'Pauzeren' : 'Afspelen');
-  }
-
-  // Welk nummer loopt er. Bij een bandje is dat de enige plek waar je het leest,
-  // want de lijst is met opzet geen knoppenrij.
-  const items = [...tape.querySelectorAll('.tape-track')];
-  items.forEach((li, n) => li.classList.toggle('is-current', n === i));
-  const nu = tape.querySelector('[data-tape-now]');
-  if (nu) {
-    const titel = i >= 0 && items[i] ? items[i].querySelector('.tape-track-title').textContent : '';
-    nu.textContent = i >= 0 ? `${i + 1}. ${titel}` : '';
-  }
-}
-
-function tekenAlles() {
-  for (const tape of document.querySelectorAll('.post-tape')) tekenBand(tape);
-}
-
-/**
- * SPOELEN IS VASTHOUDEN, en dat is het verschil met een afspeellijst.
- *
- * Eerst deed vooruit en achteruit hier next()/prev(): een nummer verder. Dat is
- * een playlistgebaar. Op een cassette bestaat "volgend nummer" niet -- je houdt
- * de knop ingedrukt, de band loopt door, en je laat los waar je bent. Robin
- * wees daarop (21-8) en hij heeft gelijk: met knoppen die per nummer springen
- * is het een lijst met een cassetteplaatje erboven.
- *
- * Het versnelt terwijl je hem vasthoudt, zoals een echt bandje op gang komt.
- * Een korte TIK spoelt een klein stukje -- dat is er voor toetsenbord en
- * schermlezer, want vasthouden is met een spatiebalk geen gebaar.
- */
-const TIK_STAP_S = 5;        // een losse klik
-
-function stopWinden(tape) {
-  const p = speler();
-  if (p && p.stopWind) p.stopWind();
-  if (tape) tape.classList.remove('is-winding', 'is-winding-back');
-}
-
-function startWinden(tape, richting) {
-  const p = speler();
-  if (!p || !p.startWind || huidigeIndex(tape) < 0) return;
-  // DE LUS STAAT IN DE SPELER. Hij stond hier, en toen kreeg de speler zelf ook
-  // spoelknoppen -- twee kopieen van dezelfde versnelling die uit elkaar lopen
-  // zodra iemand aan een getal draait. Deze module zet alleen nog het jasje:
-  // welke spoelen draaien en hoe hard.
-  p.startWind(richting);
-  tape.classList.add('is-winding');
-  if (richting < 0) tape.classList.add('is-winding-back');
-}
-
-function opPointerDown(e) {
-  const knop = e.target.closest && e.target.closest('[data-tape-go]');
-  if (!knop) return;
-  const tape = knop.closest('.post-tape');
-  if (!tape) return;
-  startWinden(tape, knop.dataset.tapeGo === 'fwd' ? 1 : -1);
-}
-
-function opPointerUp() {
-  if (winder) stopWinden(winder.tape);
-}
-
-function opKlik(e) {
-  const tape = e.target.closest && e.target.closest('.post-tape');
-  if (!tape) return;
-  const p = speler();
-
-  const spoel = e.target.closest('[data-tape-go]');
-  if (spoel) {
-    // Een tik spoelt een stukje. Vasthouden gaat via de pointer-afhandelaars
-    // hierboven; die hebben dan al gespoeld en deze klik doet er nog een klein
-    // beetje bovenop, wat niet stoort.
-    if (!p || huidigeIndex(tape) < 0) return;
-    p.seekBy(spoel.dataset.tapeGo === 'fwd' ? TIK_STAP_S : -TIK_STAP_S);
-    setTimeout(() => tekenBand(tape), 60);
-    return;
-  }
-
-  const play = e.target.closest('[data-tape-play]');
-  if (play && p) {
-    // Loopt dit bandje al, dan is deze knop een pauzeknop. De globale
-    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf het begin
-    // starten, want die kent alleen "speel deze wachtrij".
-    const i = huidigeIndex(tape);
-    if (i >= 0) {
-      e.preventDefault();
-      e.stopPropagation();
-      if (p.isPlaying()) p.pause(); else p.play();
-      setTimeout(tekenAlles, 60);
-    }
-    // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
-    // data-attributen op en zet de band op.
-  }
-}
-
-export function init() {
-  const tapes = document.querySelectorAll('.post-tape');
-
-  // Altijd eerst opruimen, ook als er niets staat. Zonder dat blijft de tikker
-  // lopen na een paginawissel naar een pagina zonder bandje -- dezelfde fout
-  // die in de leesweergave een keer de hele site heeft vastgezet.
-  if (tikker) { clearInterval(tikker); tikker = null; }
-  stopWinden(null);
-  if (gebonden) {
-    gebonden.removeEventListener('click', opKlik, true);
-    gebonden.removeEventListener('pointerdown', opPointerDown, true);
-    gebonden.removeEventListener('pointerup', opPointerUp, true);
-    gebonden.removeEventListener('pointercancel', opPointerUp, true);
-    gebonden = null;
-  }
-  if (!tapes.length) return;
-
-  // Vangen in de CAPTURE-fase, want de speler luistert zelf op document in de
-  // bubbelfase. Alleen zo kan de pauzeknop zijn klik tegenhouden voordat de
-  // wachtrij opnieuw wordt gezet.
-  gebonden = document;
-  gebonden.addEventListener('click', opKlik, true);
-  gebonden.addEventListener('pointerdown', opPointerDown, true);
-  // Loslaten telt ook als je BUITEN de knop loslaat, anders blijft de band
-  // doorspoelen als je met je vinger wegglijdt.
-  gebonden.addEventListener('pointerup', opPointerUp, true);
-  gebonden.addEventListener('pointercancel', opPointerUp, true);
-
-  tikker = setInterval(tekenAlles, TIK_MS);
-  tekenAlles();
-}
Index: src/assets/js/mod/track-editor.js
===================================================================
--- src/assets/js/mod/track-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,482 +1,0 @@
-// De track-editor (beheer > audio) (partials/track-editor.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-(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 — automatisch bepaald, hier te overschrijven)</small></span>
-              <input type="number" id="te-duration" min="0" step="1"
-                     inputmode="numeric" pattern="[0-9]*"
-                     value="${track.duration || ''}" placeholder="auto">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Eigenaar / credit <small>(copyright-houder)</small></span>
-                <div class="te-credit-row">
-                  <input type="text" id="te-credit" maxlength="200"
-                         autocomplete="off" spellcheck="false"
-                         placeholder="bv. © 2025 Mara Vos"
-                         value="${esc(track.credit || '')}">
-                  <button type="button" class="te-sym-btn" id="te-credit-copyr"
-                          title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
-                </div>
-              </label>
-              <label class="te-field">
-                <span>Licentie</span>
-                <input type="text" id="te-license" maxlength="120"
-                       autocomplete="off" spellcheck="false" list="te-license-list"
-                       placeholder="Alle rechten voorbehouden"
-                       value="${esc(track.license || '')}">
-                <datalist id="te-license-list">
-                  <option value="Alle rechten voorbehouden"></option>
-                  <option value="CC BY 4.0"></option>
-                  <option value="CC BY-SA 4.0"></option>
-                  <option value="CC BY-NC 4.0"></option>
-                  <option value="CC BY-NC-SA 4.0"></option>
-                  <option value="CC BY-ND 4.0"></option>
-                  <option value="CC0 1.0 (publiek domein)"></option>
-                </datalist>
-              </label>
-            </div>
-
-            <div class="te-field">
-              <span>Open in <small>(links naar dezelfde track elders)</small></span>
-              <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
-              <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
-              <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
-            </div>
-
-            <div class="te-field">
-              <span>Audiobestand</span>
-              <div class="te-cover-btn-row">
-                <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
-                <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
-                <span class="te-cover-status" id="te-audio-status"></span>
-              </div>
-            </div>
-
-            <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="text" id="te-cover-url" inputmode="url"
-                         placeholder="/media/… of 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 = () => {
-        // audio.src is a blob: URL (Spotify-style playback) — compare against
-        // the player's logical current-track URL instead.
-        const player = window.pcmsAudioPlayer;
-        const cur = player && player.currentTrack();
-        return !!(cur && track.stream_url && cur.url === 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);
-
-    // A click on the backdrop does NOT close the editor — too easy to lose edits by
-    // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
-    $('.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();
-      });
-    }
-
-    // ── Replace the audio file of this track ──────────────────
-    const aPick = $('#te-audio-pick');
-    const aFile = $('#te-audio-file');
-    const aStatus = $('#te-audio-status');
-    if (aPick && aFile) {
-      aPick.addEventListener('click', () => aFile.click());
-      aFile.addEventListener('change', async (e) => {
-        const f = e.target.files && e.target.files[0];
-        aFile.value = '';
-        if (!f) return;
-        aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
-        aPick.disabled = true;
-        try {
-          const fd = new FormData();
-          fd.append('audio', f);
-          const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
-          if (!j.ok) throw new Error(j.error || 'mislukt');
-          aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
-          track.stream_url = j.stream_url || track.stream_url;
-          if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
-        } catch (err) {
-          aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
-        } finally { aPick.disabled = false; }
-      });
-    }
-
-    // ── Insert © symbol into the credit field ─────────────────
-    const copyrBtn = $('#te-credit-copyr');
-    if (copyrBtn) {
-      copyrBtn.addEventListener('click', () => {
-        const inp = $('#te-credit');
-        if (!inp) return;
-        const sym = '© ';
-        const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
-        const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
-        inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
-        inp.focus();
-        const pos = start + sym.length;
-        try { inp.setSelectionRange(pos, pos); } catch (e) {}
-      });
-    }
-
-    // ── 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,
-          credit:  $('#te-credit').value.trim() || null,
-          license: $('#te-license').value.trim() || null,
-          link_spotify:    $('#te-link-spotify').value.trim() || null,
-          link_youtube:    $('#te-link-youtube').value.trim() || null,
-          link_soundcloud: $('#te-link-soundcloud').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';
-      }
-    });
-
-    // ── Auto-duration ───────────────────────────────────────────
-    // The server already determines duration automatically on upload. This is the
-    // fallback/UX layer: if an admin opens an existing track without a duration,
-    // we read it from the audio metadata and fill the field — so you never need
-    // to type seconds manually. An existing value is never overwritten. We fetch
-    // the bytes via the same header gate as the player (X-Audio-Player).
-    (async function autoDuration() {
-      const durEl = $('#te-duration');
-      if (!durEl || !track.stream_url) return;
-      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
-      let objUrl = null;
-      try {
-        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
-        if (!r.ok) return;
-        objUrl = URL.createObjectURL(await r.blob());
-        const probe = new Audio();
-        probe.preload = 'metadata';
-        probe.addEventListener('loadedmetadata', () => {
-          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
-            durEl.value = Math.round(probe.duration);
-          }
-          if (objUrl) URL.revokeObjectURL(objUrl);
-        });
-        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
-        probe.src = objUrl;
-      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
-    })();
-
-    // Focus title for fast typing
-    setTimeout(() => $('#te-title').focus(), 60);
-  };
-})();
Index: src/assets/js/shared/post-music-type.js
===================================================================
--- src/assets/js/shared/post-music-type.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,125 +1,0 @@
-/**
- * De regel: welk soort uitgave is een post? (shaer-cyg)
- *
- * WAAROM DIT ONDER assets/ STAAT. De regel wordt op twee plaatsen gebruikt: de
- * server leidt er het type van bestaande posts mee af, en de editor laat het
- * type er live door volgen als je muziek invoegt. Dat MOET dezelfde regel zijn.
- * Twee kopieën zouden niet luidruchtig kapotgaan maar stil uit elkaar lopen --
- * de editor zegt album, de server zegt playlist, en niemand die het merkt. De
- * browser kan alleen bij /assets, de server kan overal bij; dus staat hij hier,
- * en importeert de server hem vanuit services/music.
- *
- * Deze module is bewust PUUR: geen database, geen DOM, geen fetch. Wie hem
- * gebruikt levert zelf een opzoeker voor de soort van een playlist.
- *
- * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
- * een post neemt het type van zijn muziek over als hij precies EEN muzikale
- * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
- * verwijst, en houden de collecties hun eigen identiteit.
- *
- *   losse track(s)                -> playlist   metadata van de post geleend
- *   een collectie                 -> die soort  metadata van de post geleend
- *   een collectie + losse tracks  -> album      de losse zijn BONUS-TRACKS
- *   een MIXTAPE + losse tracks    -> mixtape    de losse zijn BONUS-TRACKS
- *   twee of meer collecties       -> post       NIETS geleend
- *
- * Die vierde regel is de uitzondering op de derde en staat er met opzet naast:
- * bonustracks maken van een uitgave een album, maar een bandje is geen uitgave.
- *
- * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
- * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
- * drager van EEN identiteit, en vervalt de reden vanzelf.
- *
- * WIE KIEST ALBUM OF PLAYLIST: dat gebeurt wanneer de PLAYLIST wordt gemaakt.
- * Die keuze staat in playlists.kind en wordt hier OPGEZOCHT, niet afgeleid --
- * een post om een album is een album, ook al heet de shortcode [[playlist:...]].
- *
- * EN ALS WE HET NIET WETEN: een gewone post met insluitingen die de post als
- * context hebben. Geen noodgreep maar de rustende toestand; tracks wijzen met
- * `context` toch al terug naar hun post, dus er gaat niets verloren.
- */
-
-// Dezelfde patronen als de renderer in AudioEmbedService: wat daar niet
-// insluit, telt hier niet mee. Anders zou een shortcode die niets oplevert wel
-// het type van de post kunnen bepalen.
-// De soorten die een playlist kan zijn, en dit is de ENIGE lijst. PlaylistService
-// en music/index.js halen hem hier op, precies omdat het andersom niet kan:
-// deze module draait ook in de browser en mag niets van de database weten.
-//
-// Waarom dat uitmaakt: bij twee soorten stond de keuze vijf keer als
-// `x === 'playlist' ? 'playlist' : 'album'` verspreid over drie bestanden. Zo'n
-// vorm valt niet om bij een derde soort, hij slikt hem -- een mixtape werd
-// stilzwijgend een album en ging als Album de deur uit.
-export const SOORTEN = ['album', 'playlist', 'mixtape'];
-
-const RE_PLAYLIST = /\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi;
-const RE_ALBUM    = /\[\[album:([^\]]+)\]\]/g;
-const RE_TRACK    = /\[\[track:([A-Za-z0-9_-]+)\]\]/g;
-
-/**
- * @param {string} content    de HTML/tekst van de post
- * @param {(id: string) => ('album'|'playlist'|null)} kindVan
- *        de gekozen soort van een playlist, of null als hij onbekend is
- * @returns {null|{type, collectie?, collecties?, tracks, bonus, onbekend?, leentMetadata}}
- */
-export function afleidenUitInsluitingen(content, kindVan) {
-  const c = String(content || '');
-  const zoek = typeof kindVan === 'function' ? kindVan : () => null;
-  const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
-
-  const playlists = uniek(RE_PLAYLIST);
-  const albums    = uniek(RE_ALBUM);
-  const tracks    = uniek(RE_TRACK);
-
-  if (!playlists.length && !albums.length && !tracks.length) return null;
-
-  // De soort van een playlist is een gegeven, geen afleiding.
-  const onbekend = [];
-  const uitPlaylists = playlists.map((id) => {
-    const kind = zoek(id);
-    if (!SOORTEN.includes(kind)) { onbekend.push(id); return null; }
-    return { soort: kind, id };
-  }).filter(Boolean);
-
-  const collecties = [
-    ...uitPlaylists,
-    ...albums.map((naam) => ({ soort: 'album', naam })),
-  ];
-
-  // Onbekende situatie -> gewone post. De insluitingen blijven staan en houden
-  // de post als context; alleen het label wordt niet verzonnen.
-  if (onbekend.length) {
-    return { type: 'post', collecties, tracks, bonus: [], onbekend, leentMetadata: false };
-  }
-
-  if (!collecties.length) {
-    // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
-    // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
-    return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
-  }
-
-  if (collecties.length === 1) {
-    const c0 = collecties[0];
-    // EEN MIXTAPE BLIJFT EEN MIXTAPE, ook met losse tracks erbij. De regel
-    // hieronder maakt van collectie + losse tracks een album met bonustracks, en
-    // dat klopt voor een uitgave: extra nummers bij een plaat zijn bonus. Een
-    // bandje is geen uitgave. Er een album van maken omdat er een los nummer
-    // naast staat zou het ding hernoemen op grond van iets wat er niet bij
-    // hoort. Album en playlist houden bewust hun oude gedrag: dat zijn
-    // bestaande posts en die mogen hier niet stilletjes van soort wisselen.
-    if (c0.soort === 'mixtape') {
-      return { type: 'mixtape', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
-    }
-    // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
-    // en dat maakt het geheel een album.
-    if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
-    return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
-  }
-
-  return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
-}
-
-/** De playlist-ids die in een tekst worden ingesloten. */
-export function ingeslotenPlaylists(content) {
-  return [...new Set([...String(content || '').matchAll(RE_PLAYLIST)].map((m) => m[1].trim()))];
-}
Index: src/assets/spinner.svg
===================================================================
--- src/assets/spinner.svg	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="44 100"/></svg>
Index: src/assets/vendor/cropper.min.css
===================================================================
--- src/assets/vendor/cropper.min.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-/*!
- * Cropper.js v1.6.2
- * https://fengyuanchen.github.io/cropperjs
- *
- * Copyright 2015-present Chen Fengyuan
- * Released under the MIT license
- *
- * Date: 2024-04-21T07:43:02.731Z
- */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
Index: src/assets/vendor/cropper.min.js
===================================================================
--- src/assets/vendor/cropper.min.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,10 +1,0 @@
-/*!
- * Cropper.js v1.6.2
- * https://fengyuanchen.github.io/cropperjs
- *
- * Copyright 2015-present Chen Fengyuan
- * Released under the MIT license
- *
- * Date: 2024-04-21T07:43:05.335Z
- */
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?C(Object(n),!0).forEach(function(t){var e,i;e=a,i=n[t=t],(t=D(t))in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i}):Object.getOwnPropertyDescriptors?Object.defineProperties(a,Object.getOwnPropertyDescriptors(n)):C(Object(n)).forEach(function(t){Object.defineProperty(a,t,Object.getOwnPropertyDescriptor(n,t))})}return a}function D(t){t=function(t,e){if("object"!=typeof t||!t)return t;var i=t[Symbol.toPrimitive];if(void 0===i)return("string"===e?String:Number)(t);if("object"!=typeof(i=i.call(t,e||"default")))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}(t,"string");return"symbol"==typeof t?t:t+""}function j(t){return(j="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function A(t,e){for(var i=0;i<e.length;i++){var a=e[i];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),Object.defineProperty(t,D(a.key),a)}}function P(t){return function(t){if(Array.isArray(t))return a(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){var i;if(t)return"string"==typeof t?a(t,e):"Map"===(i="Object"===(i=Object.prototype.toString.call(t).slice(8,-1))&&t.constructor?t.constructor.name:i)||"Set"===i?Array.from(t):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?a(t,e):void 0}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(t,e){(null==e||e>t.length)&&(e=t.length);for(var i=0,a=new Array(e);i<e;i++)a[i]=t[i];return a}var t="undefined"!=typeof window&&void 0!==window.document,h=t?window:{},e=!(!t||!h.document.documentElement)&&"ontouchstart"in h.document.documentElement,i=t&&"PointerEvent"in h,c="cropper",I="all",U="crop",q="move",$="zoom",B="e",k="w",O="s",T="n",E="ne",W="nw",H="se",N="sw",Q="".concat(c,"-crop"),K="".concat(c,"-disabled"),L="".concat(c,"-hidden"),Z="".concat(c,"-hide"),G="".concat(c,"-invisible"),n="".concat(c,"-modal"),V="".concat(c,"-move"),d="".concat(c,"Action"),m="".concat(c,"Preview"),F="crop",J="move",_="none",tt="crop",et="cropend",it="cropmove",at="cropstart",nt="dblclick",ot=i?"pointerdown":e?"touchstart":"mousedown",ht=i?"pointermove":e?"touchmove":"mousemove",rt=i?"pointerup pointercancel":e?"touchend touchcancel":"mouseup",st="zoom",ct="image/jpeg",dt=/^e|w|s|n|se|sw|ne|nw|all|crop|move|zoom$/,lt=/^data:/,pt=/^data:image\/jpeg;base64,/,mt=/^img|canvas$/i,ut={viewMode:0,dragMode:F,initialAspectRatio:NaN,aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,ready:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},gt=Number.isNaN||h.isNaN;function p(t){return"number"==typeof t&&!gt(t)}function ft(t){return 0<t&&t<1/0}function vt(t){return void 0===t}function o(t){return"object"===j(t)&&null!==t}var wt=Object.prototype.hasOwnProperty;function u(t){if(!o(t))return!1;try{var e=t.constructor,i=e.prototype;return e&&i&&wt.call(i,"isPrototypeOf")}catch(t){return!1}}function l(t){return"function"==typeof t}var bt=Array.prototype.slice;function yt(t){return Array.from?Array.from(t):bt.call(t)}function z(i,a){return i&&l(a)&&(Array.isArray(i)||p(i.length)?yt(i).forEach(function(t,e){a.call(i,t,e,i)}):o(i)&&Object.keys(i).forEach(function(t){a.call(i,i[t],t,i)})),i}var g=Object.assign||function(i){for(var t=arguments.length,e=new Array(1<t?t-1:0),a=1;a<t;a++)e[a-1]=arguments[a];return o(i)&&0<e.length&&e.forEach(function(e){o(e)&&Object.keys(e).forEach(function(t){i[t]=e[t]})}),i},xt=/\.\d*(?:0|9){12}\d*$/;function Y(t,e){e=1<arguments.length&&void 0!==e?e:1e11;return xt.test(t)?Math.round(t*e)/e:t}var Mt=/^width|height|left|top|marginLeft|marginTop$/;function f(t,e){var i=t.style;z(e,function(t,e){Mt.test(e)&&p(t)&&(t="".concat(t,"px")),i[e]=t})}function v(t,e){var i;e&&(p(t.length)?z(t,function(t){v(t,e)}):t.classList?t.classList.add(e):(i=t.className.trim())?i.indexOf(e)<0&&(t.className="".concat(i," ").concat(e)):t.className=e)}function X(t,e){e&&(p(t.length)?z(t,function(t){X(t,e)}):t.classList?t.classList.remove(e):0<=t.className.indexOf(e)&&(t.className=t.className.replace(e,"")))}function r(t,e,i){e&&(p(t.length)?z(t,function(t){r(t,e,i)}):(i?v:X)(t,e))}var Ct=/([a-z\d])([A-Z])/g;function Dt(t){return t.replace(Ct,"$1-$2").toLowerCase()}function Bt(t,e){return o(t[e])?t[e]:t.dataset?t.dataset[e]:t.getAttribute("data-".concat(Dt(e)))}function w(t,e,i){o(i)?t[e]=i:t.dataset?t.dataset[e]=i:t.setAttribute("data-".concat(Dt(e)),i)}var kt,Ot,Tt=/\s\s*/,Et=(Ot=!1,t&&(kt=!1,i=function(){},e=Object.defineProperty({},"once",{get:function(){return Ot=!0,kt},set:function(t){kt=t}}),h.addEventListener("test",i,e),h.removeEventListener("test",i,e)),Ot);function s(i,t,a,e){var n=3<arguments.length&&void 0!==e?e:{},o=a;t.trim().split(Tt).forEach(function(t){var e;Et||(e=i.listeners)&&e[t]&&e[t][a]&&(o=e[t][a],delete e[t][a],0===Object.keys(e[t]).length&&delete e[t],0===Object.keys(e).length)&&delete i.listeners,i.removeEventListener(t,o,n)})}function b(o,t,h,e){var r=3<arguments.length&&void 0!==e?e:{},s=h;t.trim().split(Tt).forEach(function(a){var t,n;r.once&&!Et&&(t=o.listeners,s=function(){delete n[a][h],o.removeEventListener(a,s,r);for(var t=arguments.length,e=new Array(t),i=0;i<t;i++)e[i]=arguments[i];h.apply(o,e)},(n=void 0===t?{}:t)[a]||(n[a]={}),n[a][h]&&o.removeEventListener(a,n[a][h],r),n[a][h]=s,o.listeners=n),o.addEventListener(a,s,r)})}function y(t,e,i){var a;return l(Event)&&l(CustomEvent)?a=new CustomEvent(e,{detail:i,bubbles:!0,cancelable:!0}):(a=document.createEvent("CustomEvent")).initCustomEvent(e,!0,!0,i),t.dispatchEvent(a)}function Wt(t){t=t.getBoundingClientRect();return{left:t.left+(window.pageXOffset-document.documentElement.clientLeft),top:t.top+(window.pageYOffset-document.documentElement.clientTop)}}var Ht=h.location,Nt=/^(\w+:)\/\/([^:/?#]*):?(\d*)/i;function Lt(t){t=t.match(Nt);return null!==t&&(t[1]!==Ht.protocol||t[2]!==Ht.hostname||t[3]!==Ht.port)}function zt(t){var e="timestamp=".concat((new Date).getTime());return t+(-1===t.indexOf("?")?"?":"&")+e}function x(t){var e=t.rotate,i=t.scaleX,a=t.scaleY,n=t.translateX,t=t.translateY,o=[],n=(p(n)&&0!==n&&o.push("translateX(".concat(n,"px)")),p(t)&&0!==t&&o.push("translateY(".concat(t,"px)")),p(e)&&0!==e&&o.push("rotate(".concat(e,"deg)")),p(i)&&1!==i&&o.push("scaleX(".concat(i,")")),p(a)&&1!==a&&o.push("scaleY(".concat(a,")")),o.length?o.join(" "):"none");return{WebkitTransform:n,msTransform:n,transform:n}}function M(t,e){var i=t.pageX,t=t.pageY,a={endX:i,endY:t};return e?a:S({startX:i,startY:t},a)}function R(t,e){var i,a=t.aspectRatio,n=t.height,t=t.width,e=1<arguments.length&&void 0!==e?e:"contain",o=ft(t),h=ft(n);return o&&h?(i=n*a,"contain"===e&&t<i||"cover"===e&&i<t?n=t/a:t=n*a):o?n=t/a:h&&(t=n*a),{width:t,height:n}}var Yt=String.fromCharCode;var Xt=/^data:.*,/;function Rt(t){var e,i,a,n,o,h,r,s=new DataView(t);try{if(255===s.getUint8(0)&&216===s.getUint8(1))for(var c=s.byteLength,d=2;d+1<c;){if(255===s.getUint8(d)&&225===s.getUint8(d+1)){i=d;break}d+=1}if(a=i&&(n=i+10,"Exif"===function(t,e,i){var a="";i+=e;for(var n=e;n<i;n+=1)a+=Yt(t.getUint8(n));return a}(s,i+4,4))&&((r=18761===(o=s.getUint16(n)))||19789===o)&&42===s.getUint16(n+2,r)&&8<=(h=s.getUint32(n+4,r))?n+h:a)for(var l,p=s.getUint16(a,r),m=0;m<p;m+=1)if(l=a+12*m+2,274===s.getUint16(l,r)){l+=8,e=s.getUint16(l,r),s.setUint16(l,1,r);break}}catch(t){e=1}return e}var t={render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.cropped&&this.renderCropBox()},initContainer:function(){var t=this.element,e=this.options,i=this.container,a=this.cropper,n=Number(e.minContainerWidth),e=Number(e.minContainerHeight),n=(v(a,L),X(t,L),{width:Math.max(i.offsetWidth,0<=n?n:200),height:Math.max(i.offsetHeight,0<=e?e:100)});f(a,{width:(this.containerData=n).width,height:n.height}),v(t,L),X(a,L)},initCanvas:function(){var t=this.containerData,e=this.imageData,i=this.options.viewMode,a=Math.abs(e.rotate)%180==90,n=a?e.naturalHeight:e.naturalWidth,a=a?e.naturalWidth:e.naturalHeight,e=n/a,o=t.width,h=t.height,e=(t.height*e>t.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1<h?(t=Math.max(t,a.width),i=Math.max(i,a.height),3===h&&(t<i*r?t=i*r:i=t/r)):0<h&&(t?t=Math.max(t,s?o.width:0):i?i=Math.max(i,s?o.height:0):s&&((t=o.width)<(i=o.height)*r?t=i*r:i=t/r)),t=(r=R({aspectRatio:r,width:t,height:i})).width,i=r.height,n.minWidth=t,n.minHeight=i,n.maxWidth=1/0,n.maxHeight=1/0),e&&((s?0:1)<h?(r=a.width-n.width,t=a.height-n.height,n.minLeft=Math.min(0,r),n.minTop=Math.min(0,t),n.maxLeft=Math.max(0,r),n.maxTop=Math.max(0,t),s&&this.limited&&(n.minLeft=Math.min(o.left,o.left+(o.width-n.width)),n.minTop=Math.min(o.top,o.top+(o.height-n.height)),n.maxLeft=o.left,n.maxTop=o.top,2===h)&&(n.width>=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90<e?{width:r,height:n}:{width:n,height:r}),a=h.width*((o=i.width)/h.naturalWidth),n=h.height*((e=i.height)/h.naturalHeight),h.left-=(a-h.width)/2,h.top-=(n-h.height)/2,h.width=a,h.height=n,h.aspectRatio=o/e,h.naturalWidth=o,h.naturalHeight=e,this.limitCanvas(!0,!1)),(h.width>h.maxWidth||h.width<h.minWidth)&&(h.left=h.oldLeft),(h.height>h.maxHeight||h.height<h.minHeight)&&(h.top=h.oldTop),h.width=Math.min(Math.max(h.width,h.minWidth),h.maxWidth),h.height=Math.min(Math.max(h.height,h.minHeight),h.maxHeight),this.limitCanvas(!1,!0),h.left=Math.min(Math.max(h.left,h.minLeft),h.maxLeft),h.top=Math.min(Math.max(h.top,h.minTop),h.maxTop),h.oldLeft=h.left,h.oldTop=h.top,f(this.canvas,g({width:h.width,height:h.height},x({translateX:h.left,translateY:h.top}))),this.renderImage(t),this.cropped&&this.limited&&this.limitCropBox(!0,!0)},renderImage:function(t){var e=this.canvasData,i=this.imageData,a=i.naturalWidth*(e.width/e.naturalWidth),n=i.naturalHeight*(e.height/e.naturalHeight);g(i,{width:a,height:n,left:(e.width-a)/2,top:(e.height-n)/2}),f(this.image,g({width:i.width,height:i.height},x(g({translateX:i.left,translateY:i.top},i)))),t&&this.output()},initCropBox:function(){var t=this.options,e=this.canvasData,i=t.aspectRatio||t.initialAspectRatio,t=Number(t.autoCropArea)||.8,a={width:e.width,height:e.height};i&&(e.height*i>e.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?t<n*c?n=t/c:t=n*c:t?n=t/c:n&&(t=n*c),i<a*c?a=i/c:i=a*c),r.minWidth=Math.min(t,i),r.minHeight=Math.min(n,a),r.maxWidth=i,r.maxHeight=a),e&&(s?(r.minLeft=Math.max(0,h.left),r.minTop=Math.max(0,h.top),r.maxLeft=Math.min(o.width,h.left+h.width)-r.width,r.maxTop=Math.min(o.height,h.top+h.height)-r.height):(r.minLeft=0,r.minTop=0,r.maxLeft=o.width-r.width,r.maxTop=o.height-r.height))},renderCropBox:function(){var t=this.options,e=this.containerData,i=this.cropBoxData;(i.width>i.maxWidth||i.width<i.minWidth)&&(i.left=i.oldLeft),(i.height>i.maxHeight||i.height<i.minHeight)&&(i.top=i.oldTop),i.width=Math.min(Math.max(i.width,i.minWidth),i.maxWidth),i.height=Math.min(Math.max(i.height,i.minHeight),i.maxHeight),this.limitCropBox(!1,!0),i.left=Math.min(Math.max(i.left,i.minLeft),i.maxLeft),i.top=Math.min(Math.max(i.top,i.minTop),i.maxTop),i.oldLeft=i.left,i.oldTop=i.top,t.movable&&t.cropBoxMovable&&w(this.face,d,i.width>=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&e<n&&(a=r*(o=e/s),n=e),f(t,{width:a,height:n}),f(t.getElementsByTagName("img")[0],g({width:c*o,height:d*o},x(g({translateX:-l*o,translateY:-p*o},h))))}))}},e={bind:function(){var t=this.element,e=this.options,i=this.cropper;l(e.cropstart)&&b(t,at,e.cropstart),l(e.cropmove)&&b(t,it,e.cropmove),l(e.cropend)&&b(t,et,e.cropend),l(e.crop)&&b(t,tt,e.crop),l(e.zoom)&&b(t,st,e.zoom),b(i,ot,this.onCropStart=this.cropStart.bind(this)),e.zoomable&&e.zoomOnWheel&&b(i,"wheel",this.onWheel=this.wheel.bind(this),{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&b(i,nt,this.onDblclick=this.dblclick.bind(this)),b(t.ownerDocument,ht,this.onCropMove=this.cropMove.bind(this)),b(t.ownerDocument,rt,this.onCropEnd=this.cropEnd.bind(this)),e.responsive&&b(window,"resize",this.onResize=this.resize.bind(this))},unbind:function(){var t=this.element,e=this.options,i=this.cropper;l(e.cropstart)&&s(t,at,e.cropstart),l(e.cropmove)&&s(t,it,e.cropmove),l(e.cropend)&&s(t,et,e.cropend),l(e.crop)&&s(t,tt,e.crop),l(e.zoom)&&s(t,st,e.zoom),s(i,ot,this.onCropStart),e.zoomable&&e.zoomOnWheel&&s(i,"wheel",this.onWheel,{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&s(i,nt,this.onDblclick),s(t.ownerDocument,ht,this.onCropMove),s(t.ownerDocument,rt,this.onCropEnd),e.responsive&&s(window,"resize",this.onResize)}},St={resize:function(){var t,e,i,a,n,o,h;this.disabled||(t=this.options,a=this.container,e=this.containerData,i=a.offsetWidth/e.width,a=a.offsetHeight/e.height,1!=(n=Math.abs(i-1)>Math.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1<t.className.indexOf(e))?J:F))},wheel:function(t){var e=this,i=Number(this.options.wheelZoomRatio)||.1,a=1;this.disabled||(t.preventDefault(),this.wheeling)||(this.wheeling=!0,setTimeout(function(){e.wheeling=!1},50),t.deltaY?a=0<t.deltaY?1:-1:t.wheelDelta?a=-t.wheelDelta/120:t.detail&&(a=0<t.detail?1:-1),this.zoom(-a*i,t))},cropStart:function(t){var e,i=t.buttons,a=t.button;this.disabled||("mousedown"===t.type||"pointerdown"===t.type&&"mouse"===t.pointerType)&&(p(i)&&1!==i||p(a)&&0!==a||t.ctrlKey)||(i=this.options,e=this.pointers,t.changedTouches?z(t.changedTouches,function(t){e[t.identifier]=M(t)}):e[t.pointerId||0]=M(t),a=1<Object.keys(e).length&&i.zoomable&&i.zoomOnTouch?$:Bt(t.target,d),dt.test(a)&&!1!==y(this.element,at,{originalEvent:t,action:a})&&(t.preventDefault(),this.action=a,this.cropping=!1,a===U)&&(this.cropping=!0,v(this.dragBox,n)))},cropMove:function(t){var e,i=this.action;!this.disabled&&i&&(e=this.pointers,t.preventDefault(),!1!==y(this.element,it,{originalEvent:t,action:i}))&&(t.changedTouches?z(t.changedTouches,function(t){g(e[t.identifier]||{},M(t,!0))}):g(e[t.pointerId||0]||{},M(t,!0)),this.change(t))},cropEnd:function(t){var e,i;this.disabled||(e=this.action,i=this.pointers,t.changedTouches?z(t.changedTouches,function(t){delete i[t.identifier]}):delete i[t.pointerId||0],e&&(t.preventDefault(),Object.keys(i).length||(this.action=""),this.cropping&&(this.cropping=!1,r(this.dragBox,n,this.cropped&&this.options.modal)),y(this.element,et,{originalEvent:t,action:e})))}},jt={change:function(t){function e(t){switch(t){case B:f+D.x>y&&(D.x=y-f);break;case k:p+D.x<w&&(D.x=w-p);break;case T:m+D.y<b&&(D.y=b-m);break;case O:v+D.y>x&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||f<y?u+=D.x:D.y<=0&&m<=b&&(M=!1),(!(D.y<=0)||b<m)&&(g-=D.y,m+=D.y);u<0&&g<0?(d=N,m-=g=-g,p-=u=-u):u<0?(d=W,p-=u=-u):g<0&&(d=H,m-=g=-g);break;case W:if(l){if(D.y<=0&&(m<=b||p<=w)){M=!1;break}e(T),g-=D.y,m+=D.y,p+=s.width-(u=g*l)}else e(T),e(k),!(D.x<=0)||w<p?(u-=D.x,p+=D.x):D.y<=0&&m<=b&&(M=!1),(!(D.y<=0)||b<m)&&(g-=D.y,m+=D.y);u<0&&g<0?(d=H,m-=g=-g,p-=u=-u):u<0?(d=E,p-=u=-u):g<0&&(d=N,m-=g=-g);break;case N:if(l){if(D.x<=0&&(p<=w||x<=v)){M=!1;break}e(k),u-=D.x,p+=D.x,g=u/l}else e(O),e(k),!(D.x<=0)||w<p?(u-=D.x,p+=D.x):0<=D.y&&x<=v&&(M=!1),(!(0<=D.y)||v<x)&&(g+=D.y);u<0&&g<0?(d=E,m-=g=-g,p-=u=-u):u<0?(d=H,p-=u=-u):g<0&&(d=W,m-=g=-g);break;case H:if(l){if(0<=D.x&&(y<=f||x<=v)){M=!1;break}e(B),g=(u+=D.x)/l}else e(O),e(B),!(0<=D.x)||f<y?u+=D.x:0<=D.y&&x<=v&&(M=!1),(!(0<=D.y)||v<x)&&(g+=D.y);u<0&&g<0?(d=W,m-=g=-g,p-=u=-u):u<0?(d=N,p-=u=-u):g<0&&(d=E,m-=g=-g);break;case q:this.move(D.x,D.y),M=!1;break;case $:this.zoom((a=S({},i=c),o=0,z(i,function(n,t){delete a[t],z(a,function(t){var e=Math.abs(n.startX-t.startX),i=Math.abs(n.startY-t.startY),a=Math.abs(n.endX-t.endX),t=Math.abs(n.endY-t.endY),e=Math.sqrt(e*e+i*i),i=(Math.sqrt(a*a+t*t)-e)/e;Math.abs(i)>Math.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0<D.x?d=0<D.y?H:E:D.x<0&&(p-=u,d=0<D.y?N:W),D.y<0&&(m-=g),this.cropped||(X(this.cropBox,L),this.cropped=!0,this.limited&&this.limitCropBox(!0,!0))):M=!1}M&&(s.width=u,s.height=g,s.left=p,s.top=m,this.action=d,this.renderCropBox()),z(c,function(t){t.startX=t.endX,t.startY=t.endY})}},At={crop:function(){return!this.ready||this.cropped||this.disabled||(this.cropped=!0,this.limitCropBox(!0,!0),this.options.modal&&v(this.dragBox,n),X(this.cropBox,L),this.setCropBoxData(this.initialCropBoxData)),this},reset:function(){return this.ready&&!this.disabled&&(this.imageData=g({},this.initialImageData),this.canvasData=g({},this.initialCanvasData),this.cropBoxData=g({},this.initialCropBoxData),this.renderCanvas(),this.cropped)&&this.renderCropBox(),this},clear:function(){return this.cropped&&!this.disabled&&(g(this.cropBoxData,{left:0,top:0,width:0,height:0}),this.cropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),X(this.dragBox,n),v(this.cropBox,L)),this},replace:function(e){var t=1<arguments.length&&void 0!==arguments[1]&&arguments[1];return!this.disabled&&e&&(this.isImg&&(this.element.src=e),t?(this.url=e,this.image.src=e,this.ready&&(this.viewBoxImage.src=e,z(this.previews,function(t){t.getElementsByTagName("img")[0].src=e}))):(this.isImg&&(this.replaced=!0),this.options.data=null,this.uncreate(),this.load(e))),this},enable:function(){return this.ready&&this.disabled&&(this.disabled=!1,X(this.cropper,K)),this},disable:function(){return this.ready&&!this.disabled&&(this.disabled=!0,v(this.cropper,K)),this},destroy:function(){var t=this.element;return t[c]&&(t[c]=void 0,this.isImg&&this.replaced&&(t.src=this.originalUrl),this.uncreate()),this},move:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=i.left,i=i.top;return this.moveTo(vt(t)?t:a+Number(t),vt(e)?e:i+Number(e))},moveTo:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.movable&&(p(t)&&(i.left=t,a=!0),p(e)&&(i.top=e,a=!0),a)&&this.renderCanvas(!0),this},zoom:function(t,e){var i=this.canvasData;return t=Number(t),this.zoomTo(i.width*(t=t<0?1/(1-t):1+t)/i.naturalWidth,null,e)},zoomTo:function(t,e,i){var a,n,o,h=this.options,r=this.canvasData,s=r.width,c=r.height,d=r.naturalWidth,l=r.naturalHeight;if(0<=(t=Number(t))&&this.ready&&!this.disabled&&h.zoomable){h=d*t,l=l*t;if(!1===y(this.element,st,{ratio:t,oldRatio:s/d,originalEvent:i}))return this;i?(t=this.pointers,d=Wt(this.cropper),t=t&&Object.keys(t).length?(o=n=a=0,z(t,function(t){var e=t.startX,t=t.startY;a+=e,n+=t,o+=1}),{pageX:a/=o,pageY:n/=o}):{pageX:i.pageX,pageY:i.pageY},r.left-=(h-s)*((t.pageX-d.left-r.left)/s),r.top-=(l-c)*((t.pageY-d.top-r.top)/c)):u(e)&&p(e.x)&&p(e.y)?(r.left-=(h-s)*((e.x-r.left)/s),r.top-=(l-c)*((e.y-r.top)/c)):(r.left-=(h-s)/2,r.top-=(l-c)/2),r.width=h,r.height=l,this.renderCanvas(!0)}return this},rotate:function(t){return this.rotateTo((this.imageData.rotate||0)+Number(t))},rotateTo:function(t){return p(t=Number(t))&&this.ready&&!this.disabled&&this.options.rotatable&&(this.imageData.rotate=t%360,this.renderCanvas(!0,!0)),this},scaleX:function(t){var e=this.imageData.scaleY;return this.scale(t,p(e)?e:1)},scaleY:function(t){var e=this.imageData.scaleX;return this.scale(p(e)?e:1,t)},scale:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.imageData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.scalable&&(p(t)&&(i.scaleX=t,a=!0),p(e)&&(i.scaleY=e,a=!0),a)&&this.renderCanvas(!0,!0),this},getData:function(){var i,a,t=0<arguments.length&&void 0!==arguments[0]&&arguments[0],e=this.options,n=this.imageData,o=this.canvasData,h=this.cropBoxData;return this.ready&&this.cropped?(i={x:h.left-o.left,y:h.top-o.top,width:h.width,height:h.height},a=n.width/n.naturalWidth,z(i,function(t,e){i[e]=t/a}),t&&(o=Math.round(i.y+i.height),h=Math.round(i.x+i.width),i.x=Math.round(i.x),i.y=Math.round(i.y),i.width=h-i.x,i.height=o-i.y)):i={x:0,y:0,width:0,height:0},e.rotatable&&(i.rotate=n.rotate||0),e.scalable&&(i.scaleX=n.scaleX||1,i.scaleY=n.scaleY||1),i},setData:function(t){var e,i=this.options,a=this.imageData,n=this.canvasData,o={};return this.ready&&!this.disabled&&u(t)&&(e=!1,i.rotatable&&p(t.rotate)&&t.rotate!==a.rotate&&(a.rotate=t.rotate,e=!0),i.scalable&&(p(t.scaleX)&&t.scaleX!==a.scaleX&&(a.scaleX=t.scaleX,e=!0),p(t.scaleY))&&t.scaleY!==a.scaleY&&(a.scaleY=t.scaleY,e=!0),e&&this.renderCanvas(!0,!0),i=a.width/a.naturalWidth,p(t.x)&&(o.left=t.x*i+n.left),p(t.y)&&(o.top=t.y*i+n.top),p(t.width)&&(o.width=t.width*i),p(t.height)&&(o.height=t.height*i),this.setCropBoxData(o)),this},getContainerData:function(){return this.ready?g({},this.containerData):{}},getImageData:function(){return this.sized?g({},this.imageData):{}},getCanvasData:function(){var e=this.canvasData,i={};return this.ready&&z(["left","top","width","height","naturalWidth","naturalHeight"],function(t){i[t]=e[t]}),i},setCanvasData:function(t){var e=this.canvasData,i=e.aspectRatio;return this.ready&&!this.disabled&&u(t)&&(p(t.left)&&(e.left=t.left),p(t.top)&&(e.top=t.top),p(t.width)?(e.width=t.width,e.height=t.width/i):p(t.height)&&(e.height=t.height,e.width=t.height*i),this.renderCanvas(!0)),this},getCropBoxData:function(){var t,e=this.cropBoxData;return(t=this.ready&&this.cropped?{left:e.left,top:e.top,width:e.width,height:e.height}:t)||{}},setCropBoxData:function(t){var e,i,a=this.cropBoxData,n=this.options.aspectRatio;return this.ready&&this.cropped&&!this.disabled&&u(t)&&(p(t.left)&&(a.left=t.left),p(t.top)&&(a.top=t.top),p(t.width)&&t.width!==a.width&&(e=!0,a.width=t.width),p(t.height)&&t.height!==a.height&&(i=!0,a.height=t.height),n&&(e?a.height=a.width/n:i&&(a.width=a.height*n)),this.renderCropBox()),this},getCroppedCanvas:function(){var t,e,i,a,n,o,h,r,s,c,d,l,p,m,u,g,f,v,w,b,y,x,M,C,D,B,k,O=0<arguments.length&&void 0!==arguments[0]?arguments[0]:{};return this.ready&&window.HTMLCanvasElement?(B=this.canvasData,u=this.image,l=this.imageData,a=B,v=O,g=l.aspectRatio,e=l.naturalWidth,n=l.naturalHeight,c=void 0===(c=l.rotate)?0:c,d=void 0===(d=l.scaleX)?1:d,l=void 0===(l=l.scaleY)?1:l,i=a.aspectRatio,r=a.naturalWidth,a=a.naturalHeight,h=void 0===(h=v.fillColor)?"transparent":h,p=void 0===(p=v.imageSmoothingEnabled)||p,m=void 0===(m=v.imageSmoothingQuality)?"low":m,o=void 0===(o=v.maxWidth)?1/0:o,k=void 0===(k=v.maxHeight)?1/0:k,t=void 0===(t=v.minWidth)?0:t,v=void 0===(v=v.minHeight)?0:v,w=document.createElement("canvas"),f=w.getContext("2d"),s=R({aspectRatio:i,width:o,height:k}),i=R({aspectRatio:i,width:t,height:v},"cover"),r=Math.min(s.width,Math.max(i.width,r)),s=Math.min(s.height,Math.max(i.height,a)),i=R({aspectRatio:g,width:o,height:k}),a=R({aspectRatio:g,width:t,height:v},"cover"),o=Math.min(i.width,Math.max(a.width,e)),k=Math.min(i.height,Math.max(a.height,n)),g=[-o/2,-k/2,o,k],w.width=Y(r),w.height=Y(s),f.fillStyle=h,f.fillRect(0,0,r,s),f.save(),f.translate(r/2,s/2),f.rotate(c*Math.PI/180),f.scale(d,l),f.imageSmoothingEnabled=p,f.imageSmoothingQuality=m,f.drawImage.apply(f,[u].concat(P(g.map(function(t){return Math.floor(Y(t))})))),f.restore(),t=w,this.cropped?(e=(v=this.getData(O.rounded)).x,i=v.y,a=v.width,n=v.height,1!=(o=t.width/Math.floor(B.naturalWidth))&&(e*=o,i*=o,a*=o,n*=o),h=R({aspectRatio:k=a/n,width:O.maxWidth||1/0,height:O.maxHeight||1/0}),r=R({aspectRatio:k,width:O.minWidth||0,height:O.minHeight||0},"cover"),c=(s=R({aspectRatio:k,width:O.width||(1!=o?t.width:a),height:O.height||(1!=o?t.height:n)})).width,d=s.height,c=Math.min(h.width,Math.max(r.width,c)),d=Math.min(h.height,Math.max(r.height,d)),p=(l=document.createElement("canvas")).getContext("2d"),l.width=Y(c),l.height=Y(d),p.fillStyle=O.fillColor||"transparent",p.fillRect(0,0,c,d),m=O.imageSmoothingEnabled,u=O.imageSmoothingQuality,p.imageSmoothingEnabled=void 0===m||m,u&&(p.imageSmoothingQuality=u),g=t.width,f=t.height,w=i,(v=e)<=-a||g<v?C=x=b=v=0:v<=0?(x=-v,v=0,C=b=Math.min(g,a+v)):v<=g&&(x=0,C=b=Math.min(a,g-v)),b<=0||w<=-n||f<w?D=M=y=w=0:w<=0?(M=-w,w=0,D=y=Math.min(f,n+w)):w<=f&&(M=0,D=y=Math.min(n,f-w)),B=[v,w,b,y],0<C&&0<D&&B.push(x*(k=c/a),M*k,C*k,D*k),p.drawImage.apply(p,[t].concat(P(B.map(function(t){return Math.floor(Y(t))})))),l):t):null},setAspectRatio:function(t){var e=this.options;return this.disabled||vt(t)||(e.aspectRatio=Math.max(0,t)||NaN,this.ready&&(this.initCropBox(),this.cropped)&&this.renderCropBox()),this},setDragMode:function(t){var e,i,a=this.options,n=this.dragBox,o=this.face;return this.ready&&!this.disabled&&(i=a.movable&&t===J,a.dragMode=t=(e=t===F)||i?t:_,w(n,d,t),r(n,Q,e),r(n,V,i),a.cropBoxMovable||(w(o,d,t),r(o,Q,e),r(o,V,i))),this}},Pt=h.Cropper,It=function(){function n(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:{},i=this,a=n;if(!(i instanceof a))throw new TypeError("Cannot call a class as a function");if(!t||!mt.test(t.tagName))throw new Error("The first argument is required and must be an <img> or <canvas> element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1<a&&(this.url=function(t,e){for(var i=[],a=new Uint8Array(t);0<a.length;)i.push(Yt.apply(null,yt(a.subarray(0,8192)))),a=a.subarray(8192);return"data:".concat(e,";base64,").concat(btoa(i.join("")))}(t,ct),n=(t=function(t){var e=0,i=1,a=1;switch(t){case 2:i=-1;break;case 3:e=-180;break;case 4:a=-1;break;case 5:e=90,a=-1;break;case 6:e=90;break;case 7:e=90,i=-1;break;case 8:e=-90}return{rotate:e,scaleX:i,scaleY:a}}(a)).rotate,o=t.scaleX,h=t.scaleY),e.rotatable&&(i.rotate=n),e.scalable&&(i.scaleX=o,i.scaleY=h),this.clone()}},{key:"clone",value:function(){var t=this.element,e=this.url,i=t.crossOrigin,a=e,n=(this.options.checkCrossOrigin&&Lt(e)&&(i=i||"anonymous",a=zt(e)),this.crossOrigin=i,this.crossOriginUrl=a,document.createElement("img"));i&&(n.crossOrigin=i),n.src=a||e,n.alt=t.alt||"The image to crop",(this.image=n).onload=this.start.bind(this),n.onerror=this.stop.bind(this),v(n,Z),t.parentNode.insertBefore(n,t.nextSibling)}},{key:"start",value:function(){function t(t,e){g(a.imageData,{naturalWidth:t,naturalHeight:e,aspectRatio:t/e}),a.initialImageData=g({},a.imageData),a.sizing=!1,a.sized=!0,a.build()}var e,i,a=this,n=this.image,o=(n.onload=null,n.onerror=null,this.sizing=!0,h.navigator&&/(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(h.navigator.userAgent));n.naturalWidth&&!o?t(n.naturalWidth,n.naturalHeight):(e=document.createElement("img"),i=document.body||document.documentElement,(this.sizingImage=e).onload=function(){t(e.width,e.height),o||i.removeChild(e)},e.src=n.src,o||(e.style.cssText="left:0;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;opacity:0;position:absolute;top:0;z-index:-1;",i.appendChild(e)))}},{key:"stop",value:function(){var t=this.image;t.onload=null,t.onerror=null,t.parentNode.removeChild(t),this.image=null}},{key:"build",value:function(){var t,e,i,a,n,o,h,r,s;this.sized&&!this.ready&&(t=this.element,e=this.options,i=this.image,a=t.parentNode,(n=document.createElement("div")).innerHTML='<div class="cropper-container" touch-action="none"><div class="cropper-wrap-box"><div class="cropper-canvas"></div></div><div class="cropper-drag-box"></div><div class="cropper-crop-box"><span class="cropper-view-box"></span><span class="cropper-dashed dashed-h"></span><span class="cropper-dashed dashed-v"></span><span class="cropper-center"></span><span class="cropper-face"></span><span class="cropper-line line-e" data-cropper-action="e"></span><span class="cropper-line line-n" data-cropper-action="n"></span><span class="cropper-line line-w" data-cropper-action="w"></span><span class="cropper-line line-s" data-cropper-action="s"></span><span class="cropper-point point-e" data-cropper-action="e"></span><span class="cropper-point point-n" data-cropper-action="n"></span><span class="cropper-point point-w" data-cropper-action="w"></span><span class="cropper-point point-s" data-cropper-action="s"></span><span class="cropper-point point-ne" data-cropper-action="ne"></span><span class="cropper-point point-nw" data-cropper-action="nw"></span><span class="cropper-point point-sw" data-cropper-action="sw"></span><span class="cropper-point point-se" data-cropper-action="se"></span></div></div>',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It});
Index: src/assets/vendor/lenis-LICENSE.txt
===================================================================
--- src/assets/vendor/lenis-LICENSE.txt	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-The MIT License
-
-Copyright (c) 2024 darkroom.engineering
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Index: src/assets/vendor/lenis-snap.mjs
===================================================================
--- src/assets/vendor/lenis-snap.mjs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,334 +1,0 @@
-//#region packages/snap/src/debounce.ts
-function debounce(callback, delay) {
-	let timer;
-	return function(...args) {
-		clearTimeout(timer);
-		timer = setTimeout(() => {
-			timer = void 0;
-			callback.apply(this, args);
-		}, delay);
-	};
-}
-//#endregion
-//#region packages/snap/src/element.ts
-function removeParentSticky(element) {
-	if (getComputedStyle(element).position === "sticky") {
-		element.style.setProperty("position", "static");
-		element.dataset.sticky = "true";
-	}
-	if (element.offsetParent) removeParentSticky(element.offsetParent);
-}
-function addParentSticky(element) {
-	if (element?.dataset?.sticky === "true") {
-		element.style.removeProperty("position");
-		delete element.dataset.sticky;
-	}
-	if (element.offsetParent) addParentSticky(element.offsetParent);
-}
-function offsetTop(element, accumulator = 0) {
-	const top = accumulator + element.offsetTop;
-	if (element.offsetParent) return offsetTop(element.offsetParent, top);
-	return top;
-}
-function offsetLeft(element, accumulator = 0) {
-	const left = accumulator + element.offsetLeft;
-	if (element.offsetParent) return offsetLeft(element.offsetParent, left);
-	return left;
-}
-function scrollTop(element, accumulator = 0) {
-	const top = accumulator + element.scrollTop;
-	if (element.offsetParent) return scrollTop(element.offsetParent, top);
-	return top + window.scrollY;
-}
-function scrollLeft(element, accumulator = 0) {
-	const left = accumulator + element.scrollLeft;
-	if (element.offsetParent) return scrollLeft(element.offsetParent, left);
-	return left + window.scrollX;
-}
-var SnapElement = class {
-	element;
-	options;
-	align;
-	rect = {};
-	wrapperResizeObserver;
-	resizeObserver;
-	debouncedWrapperResize;
-	constructor(element, { align = ["start"], ignoreSticky = true, ignoreTransform = false } = {}) {
-		this.element = element;
-		this.options = {
-			align,
-			ignoreSticky,
-			ignoreTransform
-		};
-		this.align = [align].flat();
-		this.debouncedWrapperResize = debounce(this.onWrapperResize, 500);
-		this.wrapperResizeObserver = new ResizeObserver(this.debouncedWrapperResize);
-		this.wrapperResizeObserver.observe(document.body);
-		this.onWrapperResize();
-		this.resizeObserver = new ResizeObserver(this.onResize);
-		this.resizeObserver.observe(this.element);
-		this.setRect({
-			width: this.element.offsetWidth,
-			height: this.element.offsetHeight
-		});
-	}
-	destroy() {
-		this.wrapperResizeObserver.disconnect();
-		this.resizeObserver.disconnect();
-	}
-	setRect({ top, left, width, height, element } = {}) {
-		top = top ?? this.rect.top;
-		left = left ?? this.rect.left;
-		width = width ?? this.rect.width;
-		height = height ?? this.rect.height;
-		element = element ?? this.rect.element;
-		if (top === this.rect.top && left === this.rect.left && width === this.rect.width && height === this.rect.height && element === this.rect.element) return;
-		this.rect.top = top;
-		this.rect.y = top;
-		this.rect.width = width;
-		this.rect.height = height;
-		this.rect.left = left;
-		this.rect.x = left;
-		this.rect.bottom = top + height;
-		this.rect.right = left + width;
-	}
-	onWrapperResize = () => {
-		let top;
-		let left;
-		if (this.options.ignoreSticky) removeParentSticky(this.element);
-		if (this.options.ignoreTransform) {
-			top = offsetTop(this.element);
-			left = offsetLeft(this.element);
-		} else {
-			const rect = this.element.getBoundingClientRect();
-			top = rect.top + scrollTop(this.element);
-			left = rect.left + scrollLeft(this.element);
-		}
-		if (this.options.ignoreSticky) addParentSticky(this.element);
-		this.setRect({
-			top,
-			left
-		});
-	};
-	onResize = ([entry]) => {
-		if (!entry?.borderBoxSize[0]) return;
-		const width = entry.borderBoxSize[0].inlineSize;
-		const height = entry.borderBoxSize[0].blockSize;
-		this.setRect({
-			width,
-			height
-		});
-	};
-};
-//#endregion
-//#region packages/snap/src/uid.ts
-let index = 0;
-function uid() {
-	return index++;
-}
-//#endregion
-//#region packages/snap/src/snap.ts
-/**
-* Snap class to handle the snap functionality
-*
-* @example
-* const snap = new Snap(lenis, {
-*   type: 'mandatory', // 'mandatory', 'proximity' or 'lock'
-*   onSnapStart: (snap) => {
-*     console.log('onSnapStart', snap)
-*   },
-*   onSnapComplete: (snap) => {
-*     console.log('onSnapComplete', snap)
-*   },
-* })
-*
-* snap.add(500) // snap at 500px
-*
-* const removeSnap = snap.add(500)
-*
-* if (someCondition) {
-*   removeSnap()
-* }
-*/
-var Snap = class {
-	options;
-	elements = /* @__PURE__ */ new Map();
-	snaps = /* @__PURE__ */ new Map();
-	viewport = {
-		width: window.innerWidth,
-		height: window.innerHeight
-	};
-	isStopped = false;
-	onSnapDebounced;
-	currentSnapIndex;
-	constructor(lenis, { type = "proximity", lerp, easing, duration, distanceThreshold = "50%", debounce: debounceDelay = 500, onSnapStart, onSnapComplete } = {}) {
-		this.lenis = lenis;
-		if (!window.lenis) window.lenis = {};
-		window.lenis.snap = true;
-		this.options = {
-			type,
-			lerp,
-			easing,
-			duration,
-			distanceThreshold,
-			debounce: debounceDelay,
-			onSnapStart,
-			onSnapComplete
-		};
-		this.onWindowResize();
-		window.addEventListener("resize", this.onWindowResize);
-		this.onSnapDebounced = debounce(this.onSnap, this.options.debounce);
-		this.lenis.on("virtual-scroll", this.onSnapDebounced);
-	}
-	/**
-	* Destroy the snap instance
-	*/
-	destroy() {
-		this.lenis.off("virtual-scroll", this.onSnapDebounced);
-		window.removeEventListener("resize", this.onWindowResize);
-		this.elements.forEach((element) => {
-			element.destroy();
-		});
-	}
-	/**
-	* Start the snap after it has been stopped
-	*/
-	start() {
-		this.isStopped = false;
-	}
-	/**
-	* Stop the snap
-	*/
-	stop() {
-		this.isStopped = true;
-	}
-	/**
-	* Add a snap to the snap instance
-	*
-	* @param value The value to snap to
-	* @param userData User data that will be forwarded through the snap event
-	* @returns Unsubscribe function
-	*/
-	add(value) {
-		const id = uid();
-		this.snaps.set(id, { value });
-		return () => this.snaps.delete(id);
-	}
-	/**
-	* Add an element to the snap instance
-	*
-	* @param element The element to add
-	* @param options The options for the element
-	* @returns Unsubscribe function
-	*/
-	addElement(element, options = {}) {
-		const id = uid();
-		this.elements.set(id, new SnapElement(element, options));
-		return () => this.elements.delete(id);
-	}
-	addElements(elements, options = {}) {
-		const map = [...elements].map((element) => this.addElement(element, options));
-		return () => {
-			map.forEach((remove) => {
-				remove();
-			});
-		};
-	}
-	onWindowResize = () => {
-		this.viewport.width = window.innerWidth;
-		this.viewport.height = window.innerHeight;
-	};
-	computeSnaps = () => {
-		const { isHorizontal } = this.lenis;
-		let snaps = [...this.snaps.values()];
-		this.elements.forEach(({ rect, align }) => {
-			let value;
-			align.forEach((align) => {
-				if (align === "start") value = rect.top;
-				else if (align === "center") value = isHorizontal ? rect.left + rect.width / 2 - this.viewport.width / 2 : rect.top + rect.height / 2 - this.viewport.height / 2;
-				else if (align === "end") value = isHorizontal ? rect.left + rect.width - this.viewport.width : rect.top + rect.height - this.viewport.height;
-				if (typeof value === "number") snaps.push({ value: Math.ceil(value) });
-			});
-		});
-		snaps = snaps.sort((a, b) => Math.abs(a.value) - Math.abs(b.value));
-		return snaps;
-	};
-	previous() {
-		this.goTo((this.currentSnapIndex ?? 0) - 1);
-	}
-	next() {
-		this.goTo((this.currentSnapIndex ?? 0) + 1);
-	}
-	goTo(index) {
-		const snaps = this.computeSnaps();
-		if (snaps.length === 0) return;
-		this.currentSnapIndex = Math.max(0, Math.min(index, snaps.length - 1));
-		const currentSnap = snaps[this.currentSnapIndex];
-		if (currentSnap === void 0) return;
-		this.lenis.scrollTo(currentSnap.value, {
-			duration: this.options.duration,
-			easing: this.options.easing,
-			lerp: this.options.lerp,
-			lock: this.options.type === "lock",
-			userData: { initiator: "snap" },
-			onStart: () => {
-				this.options.onSnapStart?.({
-					index: this.currentSnapIndex,
-					...currentSnap
-				});
-			},
-			onComplete: () => {
-				this.options.onSnapComplete?.({
-					index: this.currentSnapIndex,
-					...currentSnap
-				});
-			}
-		});
-	}
-	get distanceThreshold() {
-		let distanceThreshold = Number.POSITIVE_INFINITY;
-		if (this.options.type === "mandatory") return Number.POSITIVE_INFINITY;
-		const { isHorizontal } = this.lenis;
-		const axis = isHorizontal ? "width" : "height";
-		if (typeof this.options.distanceThreshold === "string" && this.options.distanceThreshold.endsWith("%")) distanceThreshold = Number(this.options.distanceThreshold.replace("%", "")) / 100 * this.viewport[axis];
-		else if (typeof this.options.distanceThreshold === "number") distanceThreshold = this.options.distanceThreshold;
-		else distanceThreshold = this.viewport[axis];
-		return distanceThreshold;
-	}
-	onSnap = (e) => {
-		if (this.isStopped) return;
-		if (e.event.type === "touchmove") return;
-		if (this.options.type === "lock" && this.lenis.userData?.initiator === "snap") return;
-		let { scroll, isHorizontal } = this.lenis;
-		const delta = isHorizontal ? e.deltaX : e.deltaY;
-		scroll = Math.ceil(this.lenis.scroll + delta);
-		const snaps = this.computeSnaps();
-		if (snaps.length === 0) return;
-		let snapIndex;
-		const prevSnapIndex = snaps.findLastIndex(({ value }) => value < scroll);
-		const nextSnapIndex = snaps.findIndex(({ value }) => value > scroll);
-		if (this.options.type === "lock") {
-			if (delta > 0) snapIndex = nextSnapIndex;
-			else if (delta < 0) snapIndex = prevSnapIndex;
-		} else {
-			const prevSnap = snaps[prevSnapIndex];
-			const distanceToPrevSnap = prevSnap ? Math.abs(scroll - prevSnap.value) : Number.POSITIVE_INFINITY;
-			const nextSnap = snaps[nextSnapIndex];
-			snapIndex = distanceToPrevSnap < (nextSnap ? Math.abs(scroll - nextSnap.value) : Number.POSITIVE_INFINITY) ? prevSnapIndex : nextSnapIndex;
-		}
-		if (snapIndex === void 0) return;
-		if (snapIndex === -1) return;
-		snapIndex = Math.max(0, Math.min(snapIndex, snaps.length - 1));
-		const snap = snaps[snapIndex];
-		if (Math.abs(scroll - snap.value) <= this.distanceThreshold) this.goTo(snapIndex);
-	};
-	resize() {
-		this.elements.forEach((element) => {
-			element.onWrapperResize();
-		});
-	}
-};
-//#endregion
-export { Snap as default };
-
-//# sourceMappingURL=lenis-snap.mjs.map
Index: src/assets/vendor/lenis.mjs
===================================================================
--- src/assets/vendor/lenis.mjs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1057 +1,0 @@
-//#region package.json
-var version = "1.3.26";
-//#endregion
-//#region packages/core/src/maths.ts
-/**
-* Clamp a value between a minimum and maximum value
-*
-* @param min Minimum value
-* @param input Value to clamp
-* @param max Maximum value
-* @returns Clamped value
-*/
-function clamp(min, input, max) {
-	return Math.max(min, Math.min(input, max));
-}
-/**
-*  Linearly interpolate between two values using an amount (0 <= t <= 1)
-*
-* @param x First value
-* @param y Second value
-* @param t Amount to interpolate (0 <= t <= 1)
-* @returns Interpolated value
-*/
-function lerp(x, y, t) {
-	return (1 - t) * x + t * y;
-}
-/**
-* Damp a value over time using a damping factor
-* {@link http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/}
-*
-* @param x Initial value
-* @param y Target value
-* @param lambda Damping factor
-* @param dt Time elapsed since the last update
-* @returns Damped value
-*/
-function damp(x, y, lambda, deltaTime) {
-	return lerp(x, y, 1 - Math.exp(-lambda * deltaTime));
-}
-/**
-* Calculate the modulo of the dividend and divisor while keeping the result within the same sign as the divisor
-* {@link https://anguscroll.com/just/just-modulo}
-*
-* @param n Dividend
-* @param d Divisor
-* @returns Modulo
-*/
-function modulo(n, d) {
-	return (n % d + d) % d;
-}
-//#endregion
-//#region packages/core/src/animate.ts
-/**
-* Animate class to handle value animations with lerping or easing
-*
-* @example
-* const animate = new Animate()
-* animate.fromTo(0, 100, { duration: 1, easing: (t) => t })
-* animate.advance(0.5) // 50
-*/
-var Animate = class {
-	isRunning = false;
-	value = 0;
-	from = 0;
-	to = 0;
-	currentTime = 0;
-	lerp;
-	duration;
-	easing;
-	onUpdate;
-	/**
-	* Advance the animation by the given delta time
-	*
-	* @param deltaTime - The time in seconds to advance the animation
-	*/
-	advance(deltaTime) {
-		if (!this.isRunning) return;
-		let completed = false;
-		if (this.duration && this.easing) {
-			this.currentTime += deltaTime;
-			const linearProgress = clamp(0, this.currentTime / this.duration, 1);
-			completed = linearProgress >= 1;
-			const easedProgress = completed ? 1 : this.easing(linearProgress);
-			this.value = this.from + (this.to - this.from) * easedProgress;
-		} else if (this.lerp) {
-			this.value = damp(this.value, this.to, this.lerp * 60, deltaTime);
-			if (Math.round(this.value) === Math.round(this.to)) {
-				this.value = this.to;
-				completed = true;
-			}
-		} else {
-			this.value = this.to;
-			completed = true;
-		}
-		if (completed) this.stop();
-		this.onUpdate?.(this.value, completed);
-	}
-	/** Stop the animation */
-	stop() {
-		this.isRunning = false;
-	}
-	/**
-	* Set up the animation from a starting value to an ending value
-	* with optional parameters for lerping, duration, easing, and onUpdate callback
-	*
-	* @param from - The starting value
-	* @param to - The ending value
-	* @param options - Options for the animation
-	*/
-	fromTo(from, to, { lerp, duration, easing, onStart, onUpdate }) {
-		this.from = this.value = from;
-		this.to = to;
-		this.lerp = lerp;
-		this.duration = duration;
-		this.easing = easing;
-		this.currentTime = 0;
-		this.isRunning = true;
-		onStart?.();
-		this.onUpdate = onUpdate;
-	}
-};
-//#endregion
-//#region packages/core/src/debounce.ts
-function debounce(callback, delay) {
-	let timer;
-	return function(...args) {
-		clearTimeout(timer);
-		timer = setTimeout(() => {
-			timer = void 0;
-			callback.apply(this, args);
-		}, delay);
-	};
-}
-//#endregion
-//#region packages/core/src/dimensions.ts
-/**
-* Dimensions class to handle the size of the content and wrapper
-*
-* @example
-* const dimensions = new Dimensions(wrapper, content)
-* dimensions.on('resize', (e) => {
-*   console.log(e.width, e.height)
-* })
-*/
-var Dimensions = class {
-	width = 0;
-	height = 0;
-	scrollHeight = 0;
-	scrollWidth = 0;
-	debouncedResize;
-	wrapperResizeObserver;
-	contentResizeObserver;
-	constructor(wrapper, content, { autoResize = true, debounce: debounceValue = 250 } = {}) {
-		this.wrapper = wrapper;
-		this.content = content;
-		if (autoResize) {
-			this.debouncedResize = debounce(this.resize, debounceValue);
-			if (this.wrapper instanceof Window) window.addEventListener("resize", this.debouncedResize);
-			else {
-				this.wrapperResizeObserver = new ResizeObserver(this.debouncedResize);
-				this.wrapperResizeObserver.observe(this.wrapper);
-			}
-			this.contentResizeObserver = new ResizeObserver(this.debouncedResize);
-			this.contentResizeObserver.observe(this.content);
-		}
-		this.resize();
-	}
-	destroy() {
-		this.wrapperResizeObserver?.disconnect();
-		this.contentResizeObserver?.disconnect();
-		if (this.wrapper === window && this.debouncedResize) window.removeEventListener("resize", this.debouncedResize);
-	}
-	resize = () => {
-		this.onWrapperResize();
-		this.onContentResize();
-	};
-	onWrapperResize = () => {
-		if (this.wrapper instanceof Window) {
-			this.width = window.innerWidth;
-			this.height = window.innerHeight;
-		} else {
-			this.width = this.wrapper.clientWidth;
-			this.height = this.wrapper.clientHeight;
-		}
-	};
-	onContentResize = () => {
-		if (this.wrapper instanceof Window) {
-			this.scrollHeight = this.content.scrollHeight;
-			this.scrollWidth = this.content.scrollWidth;
-		} else {
-			this.scrollHeight = this.wrapper.scrollHeight;
-			this.scrollWidth = this.wrapper.scrollWidth;
-		}
-	};
-	get limit() {
-		return {
-			x: this.scrollWidth - this.width,
-			y: this.scrollHeight - this.height
-		};
-	}
-};
-//#endregion
-//#region packages/core/src/emitter.ts
-/**
-* Emitter class to handle events
-* @example
-* const emitter = new Emitter()
-* emitter.on('event', (data) => {
-*   console.log(data)
-* })
-* emitter.emit('event', 'data')
-*/
-var Emitter = class {
-	events = {};
-	/**
-	* Emit an event with the given data
-	* @param event Event name
-	* @param args Data to pass to the event handlers
-	*/
-	emit(event, ...args) {
-		const callbacks = this.events[event] || [];
-		for (let i = 0, length = callbacks.length; i < length; i++) callbacks[i]?.(...args);
-	}
-	/**
-	* Add a callback to the event
-	* @param event Event name
-	* @param cb Callback function
-	* @returns Unsubscribe function
-	*/
-	on(event, cb) {
-		if (this.events[event]) this.events[event].push(cb);
-		else this.events[event] = [cb];
-		return () => {
-			this.events[event] = this.events[event]?.filter((i) => cb !== i);
-		};
-	}
-	/**
-	* Remove a callback from the event
-	* @param event Event name
-	* @param callback Callback function
-	*/
-	off(event, callback) {
-		this.events[event] = this.events[event]?.filter((i) => callback !== i);
-	}
-	/**
-	* Remove all event listeners and clean up
-	*/
-	destroy() {
-		this.events = {};
-	}
-};
-//#endregion
-//#region packages/core/src/virtual-scroll.ts
-const LINE_HEIGHT = 100 / 6;
-const listenerOptions = { passive: false };
-function getDeltaMultiplier(deltaMode, size) {
-	if (deltaMode === 1) return LINE_HEIGHT;
-	if (deltaMode === 2) return size;
-	return 1;
-}
-var VirtualScroll = class {
-	touchStart = {
-		x: 0,
-		y: 0
-	};
-	lastDelta = {
-		x: 0,
-		y: 0
-	};
-	window = {
-		width: 0,
-		height: 0
-	};
-	emitter = new Emitter();
-	constructor(element, options = {
-		wheelMultiplier: 1,
-		touchMultiplier: 1
-	}) {
-		this.element = element;
-		this.options = options;
-		window.addEventListener("resize", this.onWindowResize);
-		this.onWindowResize();
-		this.element.addEventListener("wheel", this.onWheel, listenerOptions);
-		this.element.addEventListener("touchstart", this.onTouchStart, listenerOptions);
-		this.element.addEventListener("touchmove", this.onTouchMove, listenerOptions);
-		this.element.addEventListener("touchend", this.onTouchEnd, listenerOptions);
-	}
-	/**
-	* Add an event listener for the given event and callback
-	*
-	* @param event Event name
-	* @param callback Callback function
-	*/
-	on(event, callback) {
-		return this.emitter.on(event, callback);
-	}
-	/** Remove all event listeners and clean up */
-	destroy() {
-		this.emitter.destroy();
-		window.removeEventListener("resize", this.onWindowResize);
-		this.element.removeEventListener("wheel", this.onWheel, listenerOptions);
-		this.element.removeEventListener("touchstart", this.onTouchStart, listenerOptions);
-		this.element.removeEventListener("touchmove", this.onTouchMove, listenerOptions);
-		this.element.removeEventListener("touchend", this.onTouchEnd, listenerOptions);
-	}
-	/**
-	* Event handler for 'touchstart' event
-	*
-	* @param event Touch event
-	*/
-	onTouchStart = (event) => {
-		const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event;
-		this.touchStart.x = clientX;
-		this.touchStart.y = clientY;
-		this.lastDelta = {
-			x: 0,
-			y: 0
-		};
-		this.emitter.emit("scroll", {
-			deltaX: 0,
-			deltaY: 0,
-			event
-		});
-	};
-	/** Event handler for 'touchmove' event */
-	onTouchMove = (event) => {
-		const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event;
-		const deltaX = -(clientX - this.touchStart.x) * this.options.touchMultiplier;
-		const deltaY = -(clientY - this.touchStart.y) * this.options.touchMultiplier;
-		this.touchStart.x = clientX;
-		this.touchStart.y = clientY;
-		this.lastDelta = {
-			x: deltaX,
-			y: deltaY
-		};
-		this.emitter.emit("scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-	};
-	onTouchEnd = (event) => {
-		this.emitter.emit("scroll", {
-			deltaX: this.lastDelta.x,
-			deltaY: this.lastDelta.y,
-			event
-		});
-	};
-	/** Event handler for 'wheel' event */
-	onWheel = (event) => {
-		let { deltaX, deltaY, deltaMode } = event;
-		const multiplierX = getDeltaMultiplier(deltaMode, this.window.width);
-		const multiplierY = getDeltaMultiplier(deltaMode, this.window.height);
-		deltaX *= multiplierX;
-		deltaY *= multiplierY;
-		deltaX *= this.options.wheelMultiplier;
-		deltaY *= this.options.wheelMultiplier;
-		this.emitter.emit("scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-	};
-	onWindowResize = () => {
-		this.window = {
-			width: window.innerWidth,
-			height: window.innerHeight
-		};
-	};
-};
-//#endregion
-//#region packages/core/src/lenis.ts
-const defaultEasing = (t) => Math.min(1, 1.001 - 2 ** (-10 * t));
-var Lenis = class {
-	_isScrolling = false;
-	_isStopped = false;
-	_isLocked = false;
-	_preventNextNativeScrollEvent = false;
-	_resetVelocityTimeout = null;
-	_rafId = null;
-	_isDraggingSelection = false;
-	reducedMotionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
-	/**
-	* Whether or not the user is touching the screen
-	*/
-	isTouching;
-	/**
-	* Whether or not the device is running iOS
-	*/
-	isIos;
-	/**
-	* The time in ms since the lenis instance was created
-	*/
-	time = 0;
-	/**
-	* User data that will be forwarded through the scroll event
-	*
-	* @example
-	* lenis.scrollTo(100, {
-	*   userData: {
-	*     foo: 'bar'
-	*   }
-	* })
-	*/
-	userData = {};
-	/**
-	* The last velocity of the scroll
-	*/
-	lastVelocity = 0;
-	/**
-	* The current velocity of the scroll
-	*/
-	velocity = 0;
-	/**
-	* The direction of the scroll
-	*/
-	direction = 0;
-	/**
-	* The options passed to the lenis instance
-	*/
-	options;
-	/**
-	* The target scroll value
-	*/
-	targetScroll;
-	/**
-	* The animated scroll value
-	*/
-	animatedScroll;
-	animate = new Animate();
-	emitter = new Emitter();
-	dimensions;
-	virtualScroll;
-	constructor({ wrapper = window, content = document.documentElement, eventsTarget = wrapper, smoothWheel = true, syncTouch = false, syncTouchLerp = .075, touchInertiaExponent = 1.7, duration, easing, lerp = .1, infinite = false, orientation = "vertical", gestureOrientation = orientation === "horizontal" ? "both" : "vertical", touchMultiplier = 1, wheelMultiplier = 1, autoResize = true, prevent, virtualScroll, overscroll = true, autoRaf = false, anchors = false, autoToggle = false, allowNestedScroll = false, __experimental__naiveDimensions = false, naiveDimensions = __experimental__naiveDimensions, stopInertiaOnNavigate = false, respectReducedMotion = true } = {}) {
-		window.lenisVersion = version;
-		if (!window.lenis) window.lenis = {};
-		window.lenis.version = version;
-		if (orientation === "horizontal") window.lenis.horizontal = true;
-		if (syncTouch === true) window.lenis.touch = true;
-		this.isIos = /(iPad|iPhone|iPod)/g.test(navigator.userAgent);
-		if (!wrapper || wrapper === document.documentElement) wrapper = window;
-		if (typeof duration === "number" && typeof easing !== "function") easing = defaultEasing;
-		else if (typeof easing === "function" && typeof duration !== "number") duration = 1;
-		this.options = {
-			wrapper,
-			content,
-			eventsTarget,
-			smoothWheel,
-			syncTouch,
-			syncTouchLerp,
-			touchInertiaExponent,
-			duration,
-			easing,
-			lerp,
-			infinite,
-			gestureOrientation,
-			orientation,
-			touchMultiplier,
-			wheelMultiplier,
-			autoResize,
-			prevent,
-			virtualScroll,
-			overscroll,
-			autoRaf,
-			anchors,
-			autoToggle,
-			allowNestedScroll,
-			naiveDimensions,
-			stopInertiaOnNavigate,
-			respectReducedMotion
-		};
-		this.dimensions = new Dimensions(wrapper, content, { autoResize });
-		this.updateClassName();
-		this.targetScroll = this.animatedScroll = this.actualScroll;
-		this.options.wrapper.addEventListener("scroll", this.onNativeScroll);
-		this.options.wrapper.addEventListener("scrollend", this.onScrollEnd, { capture: true });
-		if (this.options.anchors || this.options.stopInertiaOnNavigate) this.options.wrapper.addEventListener("click", this.onClick);
-		this.options.wrapper.addEventListener("pointerdown", this.onPointerDown);
-		this.virtualScroll = new VirtualScroll(eventsTarget, {
-			touchMultiplier,
-			wheelMultiplier
-		});
-		this.virtualScroll.on("scroll", this.onVirtualScroll);
-		if (this.options.autoToggle) {
-			this.checkOverflow();
-			this.rootElement.addEventListener("transitionend", this.onTransitionEnd);
-		}
-		if (this.options.autoRaf) this._rafId = requestAnimationFrame(this.raf);
-	}
-	/**
-	* Destroy the lenis instance, remove all event listeners and clean up the class name
-	*/
-	destroy() {
-		this.emitter.destroy();
-		this.options.wrapper.removeEventListener("scroll", this.onNativeScroll);
-		this.options.wrapper.removeEventListener("scrollend", this.onScrollEnd, { capture: true });
-		this.options.wrapper.removeEventListener("pointerdown", this.onPointerDown);
-		if (this.options.anchors || this.options.stopInertiaOnNavigate) this.options.wrapper.removeEventListener("click", this.onClick);
-		this.virtualScroll.destroy();
-		this.dimensions.destroy();
-		this.cleanUpClassName();
-		if (this._rafId) cancelAnimationFrame(this._rafId);
-	}
-	on(event, callback) {
-		return this.emitter.on(event, callback);
-	}
-	off(event, callback) {
-		return this.emitter.off(event, callback);
-	}
-	onScrollEnd = (e) => {
-		if (!(e instanceof CustomEvent)) {
-			if (this.isScrolling === "smooth" || this.isScrolling === false) e.stopPropagation();
-		}
-	};
-	dispatchScrollendEvent = () => {
-		this.options.wrapper.dispatchEvent(new CustomEvent("scrollend", {
-			bubbles: this.options.wrapper === window,
-			detail: { lenisScrollEnd: true }
-		}));
-	};
-	get overflow() {
-		const property = this.isHorizontal ? "overflow-x" : "overflow-y";
-		return getComputedStyle(this.rootElement)[property];
-	}
-	checkOverflow() {
-		if (["hidden", "clip"].includes(this.overflow)) this.internalStop();
-		else this.internalStart();
-	}
-	onTransitionEnd = (event) => {
-		if (event.propertyName?.includes("overflow") && event.target === this.rootElement) this.checkOverflow();
-	};
-	setScroll(scroll) {
-		if (this.isHorizontal) this.options.wrapper.scrollTo({
-			left: scroll,
-			behavior: "instant"
-		});
-		else this.options.wrapper.scrollTo({
-			top: scroll,
-			behavior: "instant"
-		});
-	}
-	onClick = (event) => {
-		const linkElementsUrls = event.composedPath().filter((node) => node instanceof HTMLAnchorElement && node.href).map((element) => new URL(element.href));
-		const currentUrl = new URL(window.location.href);
-		if (this.options.anchors) {
-			const anchorElementUrl = linkElementsUrls.find((targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname && targetUrl.hash);
-			if (anchorElementUrl) {
-				const options = typeof this.options.anchors === "object" && this.options.anchors ? this.options.anchors : void 0;
-				const target = decodeURIComponent(anchorElementUrl.hash);
-				this.scrollTo(target, options);
-				return;
-			}
-		}
-		if (this.options.stopInertiaOnNavigate) {
-			if (linkElementsUrls.some((targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname !== targetUrl.pathname)) {
-				this.reset();
-				return;
-			}
-		}
-	};
-	onPointerDown = (event) => {
-		if (event.button === 1) this.reset();
-	};
-	isTouchOnSelectionHandle(event) {
-		const selection = window.getSelection();
-		if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false;
-		const touch = event.targetTouches[0] ?? event.changedTouches[0];
-		if (!touch) return false;
-		const rects = selection.getRangeAt(0).getClientRects();
-		if (rects.length === 0) return false;
-		const first = rects[0];
-		const last = rects[rects.length - 1];
-		const HANDLE_RADIUS = 40;
-		const nearStart = Math.hypot(touch.clientX - first.left, touch.clientY - first.top) <= HANDLE_RADIUS;
-		const nearEnd = Math.hypot(touch.clientX - last.right, touch.clientY - last.bottom) <= HANDLE_RADIUS;
-		return nearStart || nearEnd;
-	}
-	onVirtualScroll = (data) => {
-		if (typeof this.options.virtualScroll === "function" && this.options.virtualScroll(data) === false) return;
-		const { deltaX, deltaY, event } = data;
-		this.emitter.emit("virtual-scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-		if (event.ctrlKey) return;
-		if (event.lenisStopPropagation) return;
-		const isTouch = event.type.includes("touch");
-		const isWheel = event.type.includes("wheel");
-		if (isTouch && this.isIos) {
-			if (event.type === "touchstart") this._isDraggingSelection = this.isTouchOnSelectionHandle(event);
-			if (this._isDraggingSelection) {
-				if (event.type === "touchend") this._isDraggingSelection = false;
-				return;
-			}
-		}
-		this.isTouching = event.type === "touchstart" || event.type === "touchmove";
-		const isClickOrTap = deltaX === 0 && deltaY === 0;
-		if (this.options.syncTouch && isTouch && event.type === "touchstart" && isClickOrTap && !this.isStopped && !this.isLocked) {
-			this.reset();
-			return;
-		}
-		const isUnknownGesture = this.options.gestureOrientation === "vertical" && deltaY === 0 || this.options.gestureOrientation === "horizontal" && deltaX === 0;
-		if (isClickOrTap || isUnknownGesture) return;
-		let composedPath = event.composedPath();
-		composedPath = composedPath.slice(0, composedPath.indexOf(this.rootElement));
-		const prevent = this.options.prevent;
-		const gestureOrientation = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical";
-		if (composedPath.find((node) => node instanceof HTMLElement && (typeof prevent === "function" && prevent?.(node) || node.hasAttribute?.("data-lenis-prevent") || gestureOrientation === "vertical" && node.hasAttribute?.("data-lenis-prevent-vertical") || gestureOrientation === "horizontal" && node.hasAttribute?.("data-lenis-prevent-horizontal") || isTouch && node.hasAttribute?.("data-lenis-prevent-touch") || isWheel && node.hasAttribute?.("data-lenis-prevent-wheel") || this.options.allowNestedScroll && this.hasNestedScroll(node, {
-			deltaX,
-			deltaY
-		})))) return;
-		if (this.isStopped || this.isLocked) {
-			if (event.cancelable) event.preventDefault();
-			return;
-		}
-		if (!(this.options.syncTouch && isTouch || this.options.smoothWheel && isWheel)) {
-			this.isScrolling = "native";
-			this.animate.stop();
-			event.lenisStopPropagation = true;
-			return;
-		}
-		let delta = deltaY;
-		if (this.options.gestureOrientation === "both") delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX;
-		else if (this.options.gestureOrientation === "horizontal") delta = deltaX;
-		if (!this.options.overscroll || this.options.infinite || this.options.wrapper !== window && this.limit > 0 && (this.animatedScroll > 0 && this.animatedScroll < this.limit || this.animatedScroll === 0 && deltaY > 0 || this.animatedScroll === this.limit && deltaY < 0)) event.lenisStopPropagation = true;
-		if (event.cancelable) event.preventDefault();
-		const isSyncTouch = isTouch && this.options.syncTouch;
-		const hasTouchInertia = isTouch && event.type === "touchend";
-		if (hasTouchInertia) delta = Math.sign(delta) * Math.abs(this.velocity) ** this.options.touchInertiaExponent;
-		this.scrollTo(this.targetScroll + delta, {
-			programmatic: false,
-			...isSyncTouch ? { lerp: hasTouchInertia ? this.options.syncTouchLerp : 1 } : {
-				lerp: this.options.lerp,
-				duration: this.options.duration,
-				easing: this.options.easing
-			}
-		});
-	};
-	/**
-	* Force lenis to recalculate the dimensions
-	*/
-	resize() {
-		this.dimensions.resize();
-		this.animatedScroll = this.targetScroll = this.actualScroll;
-		this.emit();
-	}
-	emit() {
-		this.emitter.emit("scroll", this);
-	}
-	onNativeScroll = () => {
-		if (this._resetVelocityTimeout !== null) {
-			clearTimeout(this._resetVelocityTimeout);
-			this._resetVelocityTimeout = null;
-		}
-		if (this._preventNextNativeScrollEvent) {
-			this._preventNextNativeScrollEvent = false;
-			return;
-		}
-		if (this.isScrolling === false || this.isScrolling === "native") {
-			const lastScroll = this.animatedScroll;
-			this.animatedScroll = this.targetScroll = this.actualScroll;
-			this.lastVelocity = this.velocity;
-			this.velocity = this.animatedScroll - lastScroll;
-			this.direction = Math.sign(this.animatedScroll - lastScroll);
-			if (!this.isStopped) this.isScrolling = "native";
-			this.emit();
-			if (this.velocity !== 0) this._resetVelocityTimeout = setTimeout(() => {
-				this.lastVelocity = this.velocity;
-				this.velocity = 0;
-				this.isScrolling = false;
-				this.emit();
-			}, 400);
-		}
-	};
-	reset() {
-		this.isLocked = false;
-		this.isScrolling = false;
-		this.animatedScroll = this.targetScroll = this.actualScroll;
-		this.lastVelocity = this.velocity = 0;
-		this.animate.stop();
-	}
-	/**
-	* Start lenis scroll after it has been stopped
-	*/
-	start() {
-		if (!this.isStopped) return;
-		if (this.options.autoToggle) {
-			this.rootElement.style.removeProperty("overflow");
-			return;
-		}
-		this.internalStart();
-	}
-	internalStart() {
-		if (!this.isStopped) return;
-		this.reset();
-		this.isStopped = false;
-		this.emit();
-	}
-	/**
-	* Stop lenis scroll
-	*/
-	stop() {
-		if (this.isStopped) return;
-		if (this.options.autoToggle) {
-			this.rootElement.style.setProperty("overflow", "clip");
-			return;
-		}
-		this.internalStop();
-	}
-	internalStop() {
-		if (this.isStopped) return;
-		this.reset();
-		this.isStopped = true;
-		this.emit();
-	}
-	/**
-	* RequestAnimationFrame for lenis
-	*
-	* @param time The time in ms from an external clock like `requestAnimationFrame` or Tempus
-	*/
-	raf = (time) => {
-		const deltaTime = time - (this.time || time);
-		this.time = time;
-		this.animate.advance(deltaTime * .001);
-		if (this.options.autoRaf) this._rafId = requestAnimationFrame(this.raf);
-	};
-	/**
-	* Scroll to a target value
-	*
-	* @param target The target value to scroll to
-	* @param options The options for the scroll
-	*
-	* @example
-	* lenis.scrollTo(100, {
-	*   offset: 100,
-	*   duration: 1,
-	*   easing: (t) => 1 - Math.cos((t * Math.PI) / 2),
-	*   lerp: 0.1,
-	*   onStart: () => {
-	*     console.log('onStart')
-	*   },
-	*   onComplete: () => {
-	*     console.log('onComplete')
-	*   },
-	* })
-	*/
-	scrollTo(_target, { offset = 0, immediate = false, lock = false, programmatic = true, lerp = programmatic ? this.options.lerp : void 0, duration = programmatic ? this.options.duration : void 0, easing = programmatic ? this.options.easing : void 0, onStart, onComplete, force = false, userData } = {}) {
-		if (this.prefersReducedMotion) if (programmatic) immediate = true;
-		else {
-			lerp = 1;
-			duration = void 0;
-			easing = void 0;
-		}
-		if ((this.isStopped || this.isLocked) && !force) return;
-		let target = _target;
-		let adjustedOffset = offset;
-		if (typeof target === "string" && [
-			"top",
-			"left",
-			"start",
-			"#"
-		].includes(target)) target = 0;
-		else if (typeof target === "string" && [
-			"bottom",
-			"right",
-			"end"
-		].includes(target)) target = this.limit;
-		else {
-			let node = null;
-			if (typeof target === "string") {
-				node = target.startsWith("#") ? document.getElementById(target.slice(1)) : document.querySelector(target);
-				if (!node) if (target === "#top") target = 0;
-				else console.warn("Lenis: Target not found", target);
-			} else if (target instanceof HTMLElement && target?.nodeType) node = target;
-			if (node) {
-				if (this.options.wrapper !== window) {
-					const wrapperRect = this.rootElement.getBoundingClientRect();
-					adjustedOffset -= this.isHorizontal ? wrapperRect.left : wrapperRect.top;
-				}
-				const rect = node.getBoundingClientRect();
-				const targetStyle = getComputedStyle(node);
-				const scrollMargin = this.isHorizontal ? Number.parseFloat(targetStyle.scrollMarginLeft) : Number.parseFloat(targetStyle.scrollMarginTop);
-				const containerStyle = getComputedStyle(this.rootElement);
-				const scrollPadding = this.isHorizontal ? Number.parseFloat(containerStyle.scrollPaddingLeft) : Number.parseFloat(containerStyle.scrollPaddingTop);
-				target = (this.isHorizontal ? rect.left : rect.top) + this.animatedScroll - (Number.isNaN(scrollMargin) ? 0 : scrollMargin) - (Number.isNaN(scrollPadding) ? 0 : scrollPadding);
-			}
-		}
-		if (typeof target !== "number") return;
-		target += adjustedOffset;
-		if (this.options.infinite) {
-			if (programmatic) {
-				this.targetScroll = this.animatedScroll = this.scroll;
-				const distance = target - this.animatedScroll;
-				if (distance > this.limit / 2) target -= this.limit;
-				else if (distance < -this.limit / 2) target += this.limit;
-			}
-		} else target = clamp(0, target, this.limit);
-		if (target === this.targetScroll) {
-			onStart?.(this);
-			onComplete?.(this);
-			return;
-		}
-		this.userData = userData ?? {};
-		if (immediate) {
-			this.animatedScroll = this.targetScroll = target;
-			this.setScroll(this.scroll);
-			this.reset();
-			this.preventNextNativeScrollEvent();
-			this.emit();
-			onComplete?.(this);
-			this.userData = {};
-			requestAnimationFrame(() => {
-				this.dispatchScrollendEvent();
-			});
-			return;
-		}
-		if (!programmatic) this.targetScroll = target;
-		if (typeof duration === "number" && typeof easing !== "function") easing = defaultEasing;
-		else if (typeof easing === "function" && typeof duration !== "number") duration = 1;
-		this.animate.fromTo(this.animatedScroll, target, {
-			duration,
-			easing,
-			lerp,
-			onStart: () => {
-				if (lock) this.isLocked = true;
-				this.isScrolling = "smooth";
-				onStart?.(this);
-			},
-			onUpdate: (value, completed) => {
-				this.isScrolling = "smooth";
-				this.lastVelocity = this.velocity;
-				this.velocity = value - this.animatedScroll;
-				this.direction = Math.sign(this.velocity);
-				this.animatedScroll = value;
-				this.setScroll(this.scroll);
-				if (programmatic) this.targetScroll = value;
-				if (!completed) this.emit();
-				if (completed) {
-					this.reset();
-					this.emit();
-					onComplete?.(this);
-					this.userData = {};
-					requestAnimationFrame(() => {
-						this.dispatchScrollendEvent();
-					});
-					this.preventNextNativeScrollEvent();
-				}
-			}
-		});
-	}
-	preventNextNativeScrollEvent() {
-		this._preventNextNativeScrollEvent = true;
-		requestAnimationFrame(() => {
-			this._preventNextNativeScrollEvent = false;
-		});
-	}
-	hasNestedScroll(node, { deltaX, deltaY }) {
-		const time = Date.now();
-		if (!node._lenis) node._lenis = {};
-		const cache = node._lenis;
-		let hasOverflowX;
-		let hasOverflowY;
-		let isScrollableX;
-		let isScrollableY;
-		let hasOverscrollBehaviorX;
-		let hasOverscrollBehaviorY;
-		let scrollWidth;
-		let scrollHeight;
-		let clientWidth;
-		let clientHeight;
-		if (time - (cache.time ?? 0) > 2e3) {
-			cache.time = Date.now();
-			const computedStyle = window.getComputedStyle(node);
-			cache.computedStyle = computedStyle;
-			hasOverflowX = [
-				"auto",
-				"overlay",
-				"scroll"
-			].includes(computedStyle.overflowX);
-			hasOverflowY = [
-				"auto",
-				"overlay",
-				"scroll"
-			].includes(computedStyle.overflowY);
-			hasOverscrollBehaviorX = ["auto"].includes(computedStyle.overscrollBehaviorX);
-			hasOverscrollBehaviorY = ["auto"].includes(computedStyle.overscrollBehaviorY);
-			cache.hasOverflowX = hasOverflowX;
-			cache.hasOverflowY = hasOverflowY;
-			if (!(hasOverflowX || hasOverflowY)) return false;
-			scrollWidth = node.scrollWidth;
-			scrollHeight = node.scrollHeight;
-			clientWidth = node.clientWidth;
-			clientHeight = node.clientHeight;
-			isScrollableX = scrollWidth > clientWidth;
-			isScrollableY = scrollHeight > clientHeight;
-			cache.isScrollableX = isScrollableX;
-			cache.isScrollableY = isScrollableY;
-			cache.scrollWidth = scrollWidth;
-			cache.scrollHeight = scrollHeight;
-			cache.clientWidth = clientWidth;
-			cache.clientHeight = clientHeight;
-			cache.hasOverscrollBehaviorX = hasOverscrollBehaviorX;
-			cache.hasOverscrollBehaviorY = hasOverscrollBehaviorY;
-		} else {
-			isScrollableX = cache.isScrollableX;
-			isScrollableY = cache.isScrollableY;
-			hasOverflowX = cache.hasOverflowX;
-			hasOverflowY = cache.hasOverflowY;
-			scrollWidth = cache.scrollWidth;
-			scrollHeight = cache.scrollHeight;
-			clientWidth = cache.clientWidth;
-			clientHeight = cache.clientHeight;
-			hasOverscrollBehaviorX = cache.hasOverscrollBehaviorX;
-			hasOverscrollBehaviorY = cache.hasOverscrollBehaviorY;
-		}
-		if (!(hasOverflowX && isScrollableX || hasOverflowY && isScrollableY)) return false;
-		const orientation = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical";
-		let scroll;
-		let maxScroll;
-		let delta;
-		let hasOverflow;
-		let isScrollable;
-		let hasOverscrollBehavior;
-		if (orientation === "horizontal") {
-			scroll = Math.round(node.scrollLeft);
-			maxScroll = scrollWidth - clientWidth;
-			delta = deltaX;
-			hasOverflow = hasOverflowX;
-			isScrollable = isScrollableX;
-			hasOverscrollBehavior = hasOverscrollBehaviorX;
-		} else if (orientation === "vertical") {
-			scroll = Math.round(node.scrollTop);
-			maxScroll = scrollHeight - clientHeight;
-			delta = deltaY;
-			hasOverflow = hasOverflowY;
-			isScrollable = isScrollableY;
-			hasOverscrollBehavior = hasOverscrollBehaviorY;
-		} else return false;
-		if (!hasOverscrollBehavior && (scroll >= maxScroll || scroll <= 0)) return true;
-		return (delta > 0 ? scroll < maxScroll : scroll > 0) && hasOverflow && isScrollable;
-	}
-	/**
-	* The root element on which lenis is instanced
-	*/
-	get rootElement() {
-		return this.options.wrapper === window ? document.documentElement : this.options.wrapper;
-	}
-	/**
-	* The limit which is the maximum scroll value
-	*/
-	get limit() {
-		if (this.options.naiveDimensions) {
-			if (this.isHorizontal) return this.rootElement.scrollWidth - this.rootElement.clientWidth;
-			return this.rootElement.scrollHeight - this.rootElement.clientHeight;
-		}
-		return this.dimensions.limit[this.isHorizontal ? "x" : "y"];
-	}
-	/**
-	* Whether or not the scroll is horizontal
-	*/
-	get isHorizontal() {
-		return this.options.orientation === "horizontal";
-	}
-	/**
-	* The actual scroll value
-	*/
-	get actualScroll() {
-		const wrapper = this.options.wrapper;
-		return this.isHorizontal ? wrapper.scrollX ?? wrapper.scrollLeft : wrapper.scrollY ?? wrapper.scrollTop;
-	}
-	/**
-	* The current scroll value
-	*/
-	get scroll() {
-		return this.options.infinite ? modulo(this.animatedScroll, this.limit) : this.animatedScroll;
-	}
-	/**
-	* The progress of the scroll relative to the limit
-	*/
-	get progress() {
-		return this.limit === 0 ? 1 : this.scroll / this.limit;
-	}
-	/**
-	* Current scroll state
-	*/
-	get isScrolling() {
-		return this._isScrolling;
-	}
-	set isScrolling(value) {
-		if (this._isScrolling !== value) {
-			this._isScrolling = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is stopped
-	*/
-	get isStopped() {
-		return this._isStopped;
-	}
-	set isStopped(value) {
-		if (this._isStopped !== value) {
-			this._isStopped = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is locked
-	*/
-	get isLocked() {
-		return this._isLocked;
-	}
-	set isLocked(value) {
-		if (this._isLocked !== value) {
-			this._isLocked = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is smooth scrolling
-	*/
-	get isSmooth() {
-		return this.isScrolling === "smooth";
-	}
-	/**
-	* Whether the user prefers reduced motion and lenis is honoring it (see `respectReducedMotion` option)
-	*/
-	get prefersReducedMotion() {
-		return this.options.respectReducedMotion && this.reducedMotionMediaQuery.matches;
-	}
-	/**
-	* The class name applied to the wrapper element
-	*/
-	get className() {
-		let className = "lenis";
-		if (this.options.autoToggle) className += " lenis-autoToggle";
-		if (this.isStopped) className += " lenis-stopped";
-		if (this.isLocked) className += " lenis-locked";
-		if (this.isScrolling) className += " lenis-scrolling";
-		if (this.isScrolling === "smooth") className += " lenis-smooth";
-		return className;
-	}
-	updateClassName() {
-		this.cleanUpClassName();
-		this.className.split(" ").forEach((className) => {
-			this.rootElement.classList.add(className);
-		});
-	}
-	cleanUpClassName() {
-		for (const className of Array.from(this.rootElement.classList)) if (className === "lenis" || className.startsWith("lenis-")) this.rootElement.classList.remove(className);
-	}
-};
-//#endregion
-export { Lenis as default };
-
-//# sourceMappingURL=lenis.mjs.map
Index: src/assets/vendor/simplewebauthn-browser.umd.min.js
===================================================================
--- src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-/* [@simplewebauthn/browser@13.3.0] */
-!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;e<o.length;e++)a[e]=o.charCodeAt(e);return i}function n(){return o.stubThis(void 0!==globalThis?.PublicKeyCredential&&"function"==typeof globalThis.PublicKeyCredential)}const o={stubThis:e=>e};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}}));
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/config/database.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -17,35 +17,4 @@
 db.pragma('journal_mode = WAL');
 db.pragma('foreign_keys = ON');
-// With WAL + several concurrent writers (request handlers, the delivery worker, the
-// background thread-crawler) a short write-lock should retry rather than throw SQLITE_BUSY.
-db.pragma('busy_timeout = 5000');   // wait up to 5s for a lock instead of failing immediately
-db.pragma('synchronous = NORMAL');  // safe with WAL (no torn writes); fewer fsyncs = faster writes
-
-/**
- * Tijdstempels in EEN spelling (shaer-a937).
- *
- * Deze database draagt twee vormen door elkaar: '2026-08-06 09:02:01' van
- * CURRENT_TIMESTAMP en '2026-08-06T02:08:01.000Z' van toISOString(). SQLite
- * vergelijkt ze als TEKST, en op positie 10 staat een 'T' (0x54) tegenover een
- * spatie (0x20) -- dus binnen dezelfde dag wint de ISO-vorm altijd, hoe laat hij
- * ook is. Een antwoord van 02:08 kwam zo boven een like van 09:02 te staan.
- *
- * `NU_ISO` is wat je SCHRIJFT, `isoSql()` is waarmee je VERGELIJKT of SORTEERT.
- * De twee horen bij elkaar: het eerste zorgt dat er niets nieuws bijkomt, het
- * tweede dat wat er al staat toch goed op volgorde komt.
- *
- * isoSql valt met COALESCE terug op de RAUWE waarde: strftime geeft NULL op iets
- * dat het niet als tijd herkent, en zonder die terugval zou zo'n rij uit de
- * sortering vallen -- of erger, als hij ook geSELECTeerd wordt (de cursor in de
- * gesprekslezing) zou de client een lege stempel terugkrijgen en zijn plek
- * kwijtraken. Waar zo'n onleesbare waarde dan LANDT is onbepaald: hij wordt als
- * tekst vergeleken en 'geen datum' staat nu eenmaal boven '2026-...'. Dat is de
- * juiste ruil -- data die je niet begrijpt bewaar je, je gooit hem niet weg.
- *
- * Ze staan HIER en niet in een dienst omdat ze over de opslag gaan: elke plek
- * die sorteert importeert `db` toch al uit dit bestand.
- */
-export const NU_ISO = "strftime('%Y-%m-%dT%H:%M:%SZ','now')";
-export const isoSql = (expr) => `COALESCE(strftime('%Y-%m-%dT%H:%M:%SZ', ${expr}), ${expr})`;
 
 export function initializeDatabase() {
@@ -65,151 +34,13 @@
   // SQLite throws if the column already exists; we swallow that.
   ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
-  // Eigenaarspoort (Robins wens, 18-8-2026): volgers niet automatisch
-  // accepteren maar door de eigenaar laten beslissen, op z'n fediverse.
-  // STANDAARD AAN (ook Robins wens, zelfde dag): een nieuwe of bijgewerkte
-  // klonkt beschermt zijn eigenaar meteen; uitzetten is de bewuste keuze.
-  ensureColumn('sites', 'approve_followers', 'INTEGER DEFAULT 1');
-  // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden
-  // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel
-  // ongebruikt; nieuwe krijgen ze niet meer.)
-  // FEP-633c §5.3: follows targeting a ward are held pending until its
-  // guardians approve (Guardian 2). Gating applies only to ward-actors.
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follows (
-    id TEXT PRIMARY KEY,
-    ward_slug TEXT NOT NULL,
-    follower_uri TEXT NOT NULL,
-    follower_inbox TEXT,
-    follower_shared_inbox TEXT,
-    follower_name TEXT,
-    follower_handle TEXT,
-    follower_icon TEXT,
-    activity_json TEXT,
-    quorum TEXT DEFAULT 'any',
-    status TEXT DEFAULT 'pending',
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP
-  )`);
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_follow_approvals (
-    follow_id TEXT NOT NULL,
-    guardian_uri TEXT NOT NULL,
-    decision TEXT NOT NULL,
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-    PRIMARY KEY (follow_id, guardian_uri)
-  )`);
-  // FEP-633c §5.3, the OTHER direction (shaer-p729): a ward's own follow is
-  // held until its guardians approve. Deliberately not ap_pending_follows —
-  // that table is keyed with the ward as the TARGET ("who wants to follow me"),
-  // and adding a direction column would make every existing query ambiguous.
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_pending_outgoing_follows (
-    id TEXT PRIMARY KEY,
-    ward_slug TEXT NOT NULL,
-    target_uri TEXT NOT NULL,
-    target_inbox TEXT,
-    target_name TEXT,
-    target_handle TEXT,
-    target_icon TEXT,
-    quorum TEXT DEFAULT 'any',
-    status TEXT DEFAULT 'pending',
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP
-  )`);
-  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ap_outgoing_follows_target
-           ON ap_pending_outgoing_follows(ward_slug, target_uri)`);
-  // Wat er gebeurd is, en waarom (shaer-p729, §4.2). Guardianship-events waren
-  // vluchtig: onGuardianshipEvent wekte de long-poll en stuurde eventueel een
-  // push, en de rest van de gebeurtenis loste op. Een weigering droeg
-  // `reason: 'not_a_teapot'` tot in die functie en verder niet -- de ward en
-  // zijn guardians hoorden het alleen doordat het aanbod uit de wachtrij
-  // verdween. §4.2 eist dat ze het TE HOREN krijgen, met de reden erbij.
-  //
-  // Een logboek, geen wachtrij: hier staat niets dat om een antwoord vraagt.
-  // Daarom hoort het ook ingeklapt te staan -- naast wat nog wel wacht, maakt
-  // afgelopen nieuws de open vraag onleesbaar.
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_events (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    slug TEXT NOT NULL,
-    kind TEXT NOT NULL,
-    payload TEXT,
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP
-  )`);
-  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_guardian_events_slug ON ap_guardian_events(slug, id DESC)');
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_outgoing_follow_approvals (
-    follow_id TEXT NOT NULL,
-    guardian_uri TEXT NOT NULL,
-    decision TEXT NOT NULL,
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-    PRIMARY KEY (follow_id, guardian_uri)
-  )`);
-  // Cross-instance follow-approval (modelled on the guardian offer): the
-  // guardian-side COPY of a gated follow on a REMOTE ward, forwarded here by
-  // the ward's server as an Offer(Follow). The decision is sent back to the
-  // ward's inbox. (Local wards use ap_pending_follows directly.)
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_follow_reviews (
-    id TEXT NOT NULL,
-    guardian_slug TEXT NOT NULL,
-    ward_uri TEXT NOT NULL,
-    ward_inbox TEXT,
-    follower_uri TEXT NOT NULL,
-    follower_handle TEXT,
-    follower_icon TEXT,
-    follow_json TEXT,
-    status TEXT DEFAULT 'pending',
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-    PRIMARY KEY (guardian_slug, id)
-  )`);
-  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
-  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
-  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
-  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
-  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
-  // zinnigs te tonen, hoe je de wachtrij ook vult.
-  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
-  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
-  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
   ensureColumn('sites', 'profile_photo', 'TEXT');
-  // De MusicBrainz-koppeling van de artiest (shaer-mbz). Een MBID is een
-  // verwijzing naar hun register, geen kopie ervan -- de naam staat erbij zodat
-  // het beheerscherm kan tonen WAT er gekoppeld is zonder ervoor te moeten
-  // netwerken, en zodat een verkeerde koppeling opvalt.
-  ensureColumn('sites', 'mb_artist_id', 'TEXT');
-  ensureColumn('sites', 'mb_artist_name', 'TEXT');
-  // Een UITGAVE heeft twee dingen die een afspeellijst niet heeft (shaer-756s).
-  //
-  // release_date en niet `year`: die kolom bestaat al en blijft, maar hun
-  // AlbumSerializer leest `released` als een DateField. Een jaartal als
-  // 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we bij
-  // artiesten en albums niet doen. Volledige datum of niets.
-  //
-  // mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde soort
-  // verwijzing naar MusicBrainz, een niveau lager.
-  //
-  // Ze horen ALLEEN bij kind='album'. PlaylistService dwingt dat af bij het
-  // opslaan -- zie daar waarom dat niet alleen in het scherm mag zitten.
-  ensureColumn('playlists', 'release_date', 'TEXT');
-  ensureColumn('playlists', 'mb_release_id', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
   ensureColumn('audio_tracks', 'album', 'TEXT');
   ensureColumn('users', 'reset_token', 'TEXT');
   ensureColumn('users', 'reset_token_expires', 'DATETIME');
-  // Google OAuth: link a Google account to a user (login via Google).
-  ensureColumn('users', 'google_sub', 'TEXT');
-  // Read-only/viewer account: can view everything but make no changes.
-  ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
-  // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
-  ensureColumn('users', 'lang', 'TEXT');
   // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
-  // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
-  ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
-
-  // One EXPLICIT primary/main site (= the company/label site in hub mode,
-  // the only site in solo) instead of the fragile "oldest = main" convention
-  // that was duplicated in 4 places. Backfill: mark the oldest if no primary
-  // site exists yet, so existing behaviour is preserved exactly.
-  ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
-  try {
-    const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
-    if (!hasPrimary) {
-      const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
-      if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
-    }
-  } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
+  ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'trust'");
+  // Per-site Prutter toggle: when off, DM endpoints/UI are hidden for that site.
+  ensureColumn('sites', 'enable_prutter', 'INTEGER DEFAULT 1');
 
   // v9 audit additions —————————————————————————————————————————
@@ -225,89 +56,12 @@
   ensureColumn('sites', 'profile_bio',     'TEXT');     // short bio for header
   ensureColumn('sites', 'profile_links',   'TEXT');     // JSON array [{platform, url}]
-  ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
+  ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'timeline'"); // timeline | grid
   ensureColumn('sites', 'feed_view_switch',  'INTEGER DEFAULT 1');       // show switcher
-  // Welke TWEEDE weergave deze site aanbiedt naast Grid.
-  //   'reader'    Lezen, hele berichten, een per scherm
-  //   'timeline'  de chronologische lijst
-  //   'auto'      Lezen op mobiel, Tijdlijn op desktop (grens 768px)
-  // Lezen verving Tijdlijn, maar de tijdlijn is nooit weggehaald -- home.ejs
-  // rendert alle drie de secties en de CSS kiest. Dit maakt er weer een keuze
-  // van in plaats van een besluit voor iedereen. 'auto' kost geen JavaScript:
-  // het is een mediaquery, dus geen resize-afhandeling en geen flikkering.
-  ensureColumn('sites', 'feed_alt_view', "TEXT DEFAULT 'reader'");   // reader | timeline | auto
-  // Krijgt elk bericht in Lezen zijn EIGEN scherm? Aan betekent min-height:100svh
-  // per bericht: een kort bericht vult dan het scherm en je ziet het volgende
-  // pas na een snap. Uit is de stroom zoals hij nu is, bericht na bericht zonder
-  // lege ruimte. Stond eerst vast op AAN, is toen vast op UIT gezet omdat een
-  // kort bericht een halve pagina leegte gaf; nu is het een keuze.
-  ensureColumn('sites', 'reader_full_page', 'INTEGER DEFAULT 0');
   ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
   ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
-  // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to
-  // this account? NULL = auto, which means OFF for a ward and ON for anyone
-  // else. The guardians flip it; the gate itself lives server-side, so a ward
-  // never even receives the thumbnail it is not allowed to see.
-  ensureColumn('sites', 'external_embeds', 'INTEGER');
-  // De rest van de gate-familie (shaer-ahy.1, "maak ze allemaal functioneel",
-  // Barts opdracht 8-8). Zelfde drietal als external_embeds: NULL is de
-  // automatiek (dicht voor een ward, open voor de rest), 0/1 is een besluit
-  // van de guardians en wint van de automatiek.
-  ensureColumn('sites', 'external_threads', 'INTEGER');   // replies van vreemden onder een post (shaer-9y2)
-  ensureColumn('sites', 'gate_replies', 'INTEGER');       // zelf antwoorden in een gesprek (shaer-r4c)
-  ensureColumn('sites', 'gate_images', 'INTEGER');        // afbeeldingsbijlagen (shaer-6p5)
-  ensureColumn('sites', 'gate_messages', 'INTEGER');      // heel Messages (shaer-3ow)
-  ensureColumn('sites', 'gate_compose', 'INTEGER');       // zelf posten, de (+) kaart (shaer-qgev)
-  ensureColumn('sites', 'gate_music', 'INTEGER');         // audiobijlagen (shaer-rmz)
-  ensureColumn('sites', 'gate_quote_cards', 'INTEGER');   // ingebedde quote-kaarten (shaer-mls)
-  ensureColumn('sites', 'gate_custom_emoji', 'INTEGER');  // FEP-9098 emoji-plaatjes (shaer-ytw)
-  ensureColumn('sites', 'gate_account_move', 'INTEGER');  // FEP-7628 Move (shaer-tge)
-  // Wie de ward ZELF mag volgen (shaer-p729): de tegenhanger van shaer:follows,
-  // dat over de andere richting gaat. §5.3 schrijft alleen het doorsturen voor
-  // van een Follow NAAR een ward; wat je verder gated is een keuze van de
-  // implementatie, en dit is die keuze. Verstelbaar, anders dan de inkomende
-  // kant: een kind dat ouder wordt hoort niet eeuwig te blijven vragen.
-  ensureColumn('sites', 'gate_following', 'INTEGER');     // zelf iemand volgen (shaer-p729)
-  // The heavier sibling (FEP-633c 5.6): may a player from outside this app run
-  // INSIDE it? A preview is a picture; playback hands the screen to a third
-  // party's engine, recommendations and all. Two settings, so the guardians can
-  // allow the one without the other. NULL = auto, which means off for a ward.
-  ensureColumn('sites', 'external_playback', 'INTEGER');
-  ensureColumn('sites', 'og_theme', 'TEXT');             // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark'
-  // FEP-7628: former identities this actor claims (JSON array of actor URIs).
-  // Publishing them as alsoKnownAs is what lets the OLD server approve a Move
-  // of its followers to this account — the claim must be visible on OUR side.
-  ensureColumn('sites', 'ap_aliases', 'TEXT');
-  ensureColumn('sites', 'moved_to', 'TEXT');   // FEP-7628 slice 2: waarheen dit account vertrok
-  // FEP-1580: staat de ingest-routine nog open? 1 = klaar (en dat is ook de
-  // stand van een site die nooit iets gemigreerd heeft, want er hangt niets).
-  // Derden pollen op deze vlag, dus hij moet ook "er valt niets te wachten"
-  // kunnen zeggen.
-  ensureColumn('sites', 'migration_complete', 'INTEGER DEFAULT 1');
 
   // Per-post noindex + type
   ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
-  ensureColumn('posts', 'publish_at', 'DATETIME');         // release planning (premium #3): scheduled go-live
-  ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
-  ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
-  ensureColumn('posts', 'cover_video_url', 'TEXT');        // muted loop MP4 for an animated cover (Safari-smooth)
-  ensureColumn('posts', 'cover_alt', 'TEXT');              // alt text / description for the cover (a11y → AS2 attachment `name`)
-  ensureColumn('posts', 'language', 'TEXT');               // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
-  ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
   ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
-  ensureColumn('posts', 'poll_json', 'TEXT');              // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
-
-  // Statistics (premium module) — bare counters, cookie-free.
-  ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // views per post
-  ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0');  // plays per track
-  ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
-  ensureColumn('audio_tracks', 'credit', 'TEXT');   // owner/credit (copyright holder)
-  ensureColumn('audio_tracks', 'license', 'TEXT');  // license (e.g. "CC BY 4.0", "All rights reserved")
-  ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in" links per track
-  ensureColumn('audio_tracks', 'link_youtube',    'TEXT');
-  ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
-  // Per-track: federate the actual audio file as an AS2 Audio attachment so it plays inline
-  // in EVERY fediverse client (incl. the Mastodon apps). Default 0 = gated (web player only,
-  // file not exposed). Opt-in 1 = the file is served ungated + shared on the fediverse.
-  ensureColumn('audio_tracks', 'fedi_open', 'INTEGER DEFAULT 0');
 
   // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
@@ -337,832 +91,4 @@
       ON playlist_tracks(playlist_id, position);
   `);
-
-  // Global app settings (key/value singleton). One instance is one owner, so
-  // there is no tenancy mode here anymore — see SettingsService.
-  // Oudere installaties dragen nog een dode rij key='tenancy' ('solo' of
-  // 'circle'). Niets leest hem; bewust laten staan (shaer-x7c0) in plaats van
-  // er opruimcode voor te schrijven die na één ronde zelf dood is.
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS app_settings (
-      key TEXT PRIMARY KEY,
-      value TEXT,
-      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-  `);
-
-  // ── Statistics (premium) — cookie-free ──────────────────────
-  // stat_daily: pageview count per day per site (bare counter).
-  // stat_visitor_day: one row per UNIQUE visitor hash per day per site
-  //   (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
-  //   → no persistent identifier, no cookie, no consent required).
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS stat_daily (
-      site_id TEXT NOT NULL,
-      day TEXT NOT NULL,
-      pageviews INTEGER NOT NULL DEFAULT 0,
-      PRIMARY KEY (site_id, day)
-    );
-    CREATE TABLE IF NOT EXISTS stat_visitor_day (
-      site_id TEXT NOT NULL,
-      day TEXT NOT NULL,
-      visitor_hash TEXT NOT NULL,
-      PRIMARY KEY (site_id, day, visitor_hash)
-    );
-    CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
-    CREATE TABLE IF NOT EXISTS stat_referrer (
-      site_id TEXT NOT NULL,
-      host TEXT NOT NULL,
-      count INTEGER NOT NULL DEFAULT 0,
-      PRIMARY KEY (site_id, host)
-    );
-  `);
-
-  // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
-  // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
-  // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS subscribers (
-      id TEXT PRIMARY KEY,
-      site_id TEXT NOT NULL,
-      email TEXT NOT NULL,
-      status TEXT NOT NULL DEFAULT 'pending',
-      source TEXT DEFAULT 'widget',
-      token TEXT NOT NULL,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      confirmed_at DATETIME,
-      UNIQUE(site_id, email)
-    );
-    CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
-  `);
-
-  // Sent newsletters (history + counts).
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS newsletters (
-      id TEXT PRIMARY KEY,
-      site_id TEXT NOT NULL,
-      subject TEXT NOT NULL,
-      body TEXT NOT NULL,
-      sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      recipient_count INTEGER DEFAULT 0
-    );
-  `);
-
-  // Show agenda (premium #8): tour dates / gigs per site.
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS shows (
-      id TEXT PRIMARY KEY,
-      site_id TEXT NOT NULL,
-      date TEXT NOT NULL,
-      time TEXT,
-      city TEXT NOT NULL,
-      venue TEXT,
-      country TEXT,
-      ticket_url TEXT,
-      notes TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
-  `);
-
-  // Link-in-bio click statistics (premium #6). One counter per (site, url); the
-  // link-in-bio page links via /links/go/:i which counts the click and redirects.
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS link_clicks (
-      site_id TEXT NOT NULL,
-      url TEXT NOT NULL,
-      clicks INTEGER DEFAULT 0,
-      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      PRIMARY KEY (site_id, url)
-    );
-  `);
-
-
-  // ── ActivityPub (fediverse bridge) ──────────────────────────
-  // RSA keypair per actor (Mastodon-compatible HTTP Signatures; separate from
-  // the Cirkels Ed25519 keys). ap_followers = remote AP actors following us.
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS ap_keys (
-      slug TEXT PRIMARY KEY,
-      public_pem TEXT NOT NULL,
-      private_pem TEXT NOT NULL,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );    -- LUISTERAARS (shaer-0nh). Wie de BIBLIOTHEEK volgt, niet de actor.
-    --
-    -- Een eigen tabel en niet een vlag op ap_followers, en dat is met opzet:
-    -- deze accounts horen onze gewone posts NIET te krijgen. Zolang ze in een
-    -- andere tabel staan kan een bezorging ze niet per ongeluk meenemen -- een
-    -- vlag die iemand vergeet te filteren zou dat wel doen, en dan komt de
-    -- Krant van een site bij mensen die alleen muziek wilden.
-    CREATE TABLE IF NOT EXISTS ap_library_followers (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,
-      actor_uri TEXT NOT NULL,
-      inbox TEXT,
-      shared_inbox TEXT,
-      name TEXT,
-      handle TEXT,
-      icon TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      last_delivery_at DATETIME,
-      last_error_at DATETIME,
-      UNIQUE (slug, actor_uri)
-    );
-
-    -- OpenWebAuth (FEP-61cf): eenmalige tokens waarmee een BEZOEKER van elders
-    -- bewijst wie hij is. Klonkt is hier de 'target instance': we hebben nooit
-    -- iemands prive-sleutel nodig, alleen zijn publieke -- dus staat hier ook
-    -- geen geheim van een ander in.
-    --
-    -- Het token is kort houdbaar (minuten, zie OpenWebAuthService) en gaat na
-    -- inwisselen meteen weg: eenmalig is de hele bedoeling. De FEP noemt de
-    -- opruiming expliciet als DoS-verdediging -- zonder vervaltijd vult iemand
-    -- deze tabel met tokens die hij nooit inwisselt.
-    CREATE TABLE IF NOT EXISTS owa_tokens (
-      token TEXT PRIMARY KEY,
-      actor_uri TEXT NOT NULL,
-      created_at INTEGER NOT NULL          -- ms sinds epoch
-    );
-    CREATE INDEX IF NOT EXISTS idx_owa_tokens_created ON owa_tokens(created_at);
-
-    CREATE TABLE IF NOT EXISTS ap_followers (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,
-      actor_uri TEXT NOT NULL,
-      inbox TEXT,
-      shared_inbox TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, actor_uri)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_followers_slug ON ap_followers(slug);
-    CREATE TABLE IF NOT EXISTS ap_interactions (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      kind TEXT NOT NULL,                   -- 'reply' | 'like' | 'announce'
-      post_id TEXT NOT NULL,
-      object_uri TEXT NOT NULL DEFAULT '',  -- remote note id (reply) or '' (like/announce)
-      actor_uri TEXT NOT NULL,
-      actor_name TEXT,
-      actor_handle TEXT,
-      actor_url TEXT,
-      actor_icon TEXT,
-      content TEXT,                         -- sanitized HTML (reply)
-      published TEXT,
-      parent_uri TEXT,                      -- the note this reply replies to (for nesting)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(kind, post_id, actor_uri, object_uri)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
-    -- Moderation tombstones: object URIs the site owner removed. Checked at ingest
-    -- (handleInbox) AND by the thread-crawler, so a removed reply never comes back
-    -- via thread-filling. Private notes can't be flagged via authorize_interaction
-    -- (their fetch 401s), so owner moderation acts on the locally stored copy.
-    CREATE TABLE IF NOT EXISTS ap_rejected_objects (
-      object_uri TEXT PRIMARY KEY,
-      post_id TEXT,
-      reason TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    -- ActivityPub C2S (client-to-server): OAuth 2.0 for native/web clients (Shaer).
-    -- Public clients + PKCE (RFC 8252); tokens stored hashed; token is per user+site.
-    CREATE TABLE IF NOT EXISTS oauth_clients (
-      client_id TEXT PRIMARY KEY,
-      client_name TEXT,
-      redirect_uris TEXT NOT NULL,        -- JSON array
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE TABLE IF NOT EXISTS oauth_codes (
-      code TEXT PRIMARY KEY,
-      client_id TEXT NOT NULL,
-      user_id TEXT NOT NULL,
-      site_slug TEXT NOT NULL,
-      redirect_uri TEXT NOT NULL,
-      code_challenge TEXT,                -- PKCE S256 (verplicht voor public clients)
-      scope TEXT,
-      expires_at DATETIME NOT NULL,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE TABLE IF NOT EXISTS oauth_tokens (
-      token_hash TEXT PRIMARY KEY,        -- sha256(bearer); het token zelf slaan we nooit op
-      client_id TEXT NOT NULL,
-      user_id TEXT NOT NULL,
-      site_slug TEXT NOT NULL,
-      scope TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      last_used_at DATETIME
-    );
-    -- Paid posts (klonkt-demo-aki): the site owner's own Patreon campaign.
-    -- Secrets are encrypted at rest (CryptoBox). Never reuses the instance-level
-    -- patreon_* settings, which are Klonkt Premium's separate license flow.
-    CREATE TABLE IF NOT EXISTS paid_patreon (
-      site_id TEXT PRIMARY KEY,
-      client_id TEXT,
-      client_secret_enc TEXT,
-      campaign_id TEXT,
-      access_token_enc TEXT,
-      refresh_token_enc TEXT,
-      token_exp INTEGER,               -- unix seconds
-      default_min_cents INTEGER DEFAULT 0,
-      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    -- One row per passkey. NO patron identity is stored (design decision):
-    -- {passkey, site, proven cents, expiry}. Not traceable to a person.
-    CREATE TABLE IF NOT EXISTS paid_entitlements (
-      credential_id TEXT PRIMARY KEY,   -- WebAuthn credential id (opaque, base64url)
-      site_id TEXT NOT NULL,
-      public_key TEXT NOT NULL,         -- COSE public key, base64url
-      counter INTEGER DEFAULT 0,
-      transports TEXT,
-      min_cents INTEGER DEFAULT 0,      -- the amount proven at link time
-      expires_at INTEGER NOT NULL,      -- unix seconds; re-link after
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    -- Web Push (docs/webpush-design.md): one row per browser/device the owner
-    -- enabled notifications on. Payloads are encrypted to p256dh/auth (RFC 8291).
-    CREATE TABLE IF NOT EXISTS push_subscriptions (
-      endpoint TEXT PRIMARY KEY,       -- push-service URL for this device
-      user_id TEXT NOT NULL,
-      p256dh TEXT NOT NULL,            -- client public key
-      auth TEXT NOT NULL,              -- client auth secret
-      alert_types TEXT,                -- JSON {follow,reply,like,boost,dm}
-      ua_label TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      last_ok_at DATETIME
-    );
-    CREATE TABLE IF NOT EXISTS ap_outbox (
-      id TEXT PRIMARY KEY,            -- note path segment (uuid) → /ap/notes/<id>
-      site_slug TEXT NOT NULL,
-      post_id TEXT NOT NULL,
-      post_slug TEXT,
-      in_reply_to TEXT,               -- remote status uri we reply to
-      to_actor TEXT,                  -- remote actor uri (mentioned)
-      to_handle TEXT,
-      content TEXT NOT NULL,          -- sanitized HTML of our reply
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
-    -- Your like/boost state on a REMOTE post (the interact page), so those become toggles.
-    CREATE TABLE IF NOT EXISTS ap_my_reactions (
-      site_slug TEXT NOT NULL,
-      target_uri TEXT NOT NULL,
-      kind TEXT NOT NULL,             -- 'like' | 'boost'
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(site_slug, target_uri, kind)
-    );
-  `);
-  ensureColumn('ap_interactions', 'parent_uri', 'TEXT'); // nesting (existing DBs)
-  ensureColumn('ap_interactions', 'acted_boost', 'INTEGER DEFAULT 0'); // owner boosted this comment (🔁) → can undo
-  ensureColumn('ap_interactions', 'acted_like', 'INTEGER DEFAULT 0'); // owner liked this comment (⭐) → can undo
-
-  // Fediverse CLIENT: accounts WE follow (outbound) + the home timeline of their posts.
-  db.exec(`
-    CREATE TABLE IF NOT EXISTS ap_following (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,            -- our site that follows
-      actor_uri TEXT NOT NULL,       -- the followed account's actor id
-      handle TEXT, name TEXT, icon TEXT, url TEXT,
-      inbox TEXT,                    -- their inbox (for Create delivery / Undo)
-      follow_id TEXT,                -- the Follow activity id we sent (Accept matching)
-      status TEXT DEFAULT 'pending', -- pending | accepted
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, actor_uri)
-    );
-    -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
-    -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
-    -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
-    -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
-    -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
-    -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
-    -- antwoord, dus langer bewaren levert niets op.
-    CREATE TABLE IF NOT EXISTS ap_seen_notes (
-      uri TEXT PRIMARY KEY,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
-    CREATE TABLE IF NOT EXISTS ap_timeline (
-      id TEXT NOT NULL,              -- the remote note's AP id
-      slug TEXT NOT NULL,            -- whose home timeline (our site)
-      author_uri TEXT, author_name TEXT, author_handle TEXT, author_icon TEXT, author_url TEXT,
-      content TEXT, url TEXT, published TEXT, media_json TEXT,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, id)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_timeline_slug ON ap_timeline(slug, published);
-    -- canonicalReactionUri herleidt een permalink naar het object-id door op (slug, url)
-    -- te zoeken. Zonder deze index viel dat terug op idx_ap_timeline_slug, dus een scan
-    -- van elke rij van die slug. Dat gebeurt PER REACTIE in getInteractions, en de
-    -- reactie-migratie erft het in haar re-key-join, die synchroon vóór listen draait:
-    -- de opstartkosten waren reacties maal tijdlijnrijen.
-    CREATE INDEX IF NOT EXISTS idx_ap_timeline_url ON ap_timeline(slug, url);
-    -- FEP-1580: de vertaaltabel van een verhuizing. Per gemigreerd object waar
-    -- het VANDAAN kwam en welke URI het HIER kreeg. Derden lezen deze mapping en
-    -- werken er hun eigen inReplyTo/Like-verwijzingen mee bij; zonder deze tabel
-    -- is "de berichten krijgen nieuwe adressen" een permanent kapotte draad.
-    --
-    -- De spec eist omgekeerd-chronologisch op het moment dat de kopie HIER is
-    -- aangemaakt (niet de oorspronkelijke publicatiedatum). Daarom sorteren we
-    -- op de autoincrement-id en niet op created_at: die heeft secondeprecisie,
-    -- en een ingest zet er tientallen per seconde in.
-    CREATE TABLE IF NOT EXISTS ap_migration (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,          -- onze site, de DOELkant van de verhuizing
-      origin TEXT NOT NULL,        -- object-URI op de broninstantie
-      target TEXT NOT NULL,        -- de URI die het object hier kreeg
-      source_actor TEXT NOT NULL DEFAULT '',
-      is_public INTEGER NOT NULL DEFAULT 1,   -- niet-publieke items horen niet in een publieke pagina
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, origin)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_migration_slug ON ap_migration(slug, id DESC);
-    -- De Move-activities zelf, gededupliceerd. FEP-1580 wil dat deze collectie
-    -- een op zichzelf staand bewijs vormt voor de items in ap_migration, met een
-    -- FEP-8b32 integrity proof van de bron-actor plus een kopie van diens
-    -- actor-document. Wij bewaren allebei die stukken al (activity_json en
-    -- actor_json), maar Klonkt kent 8b32 nog niet: zie shaer-j1v0. Zolang dat
-    -- open staat is deze collectie structureel goed en niet verifieerbaar.
-    CREATE TABLE IF NOT EXISTS ap_moves (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,
-      move_id TEXT NOT NULL,       -- id van de Move-activity, tevens dedup-sleutel
-      source_actor TEXT NOT NULL,
-      target_actor TEXT NOT NULL,
-      activity_json TEXT NOT NULL,
-      actor_json TEXT,             -- inline kopie van het bron-actordocument
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, move_id)
-    );
-    CREATE TABLE IF NOT EXISTS ap_blocks (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,          -- our site that set the block
-      target TEXT NOT NULL,        -- actor URI (actor block) or domain (domain block)
-      kind TEXT NOT NULL,          -- 'actor' | 'domain'
-      label TEXT,                  -- display (@handle or domain)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, target)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
-    -- Committed guardian ↔ ward relations, one row per local side. role
-    -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
-    -- slug guards other_uri. status is always 'accepted' here now: PENDING
-    -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
-    CREATE TABLE IF NOT EXISTS ap_guardianships (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,          -- our local site in this relation (guardianship module)
-      role TEXT NOT NULL,          -- 'guardian' (slug guards other) | 'ward' (other guards slug)
-      other_uri TEXT NOT NULL,     -- the counterpart actor URI (local or remote)
-      other_handle TEXT,           -- cached @user@host for display
-      status TEXT NOT NULL,        -- 'offered' (legacy) | 'accepted'
-      offer_id TEXT,               -- the Offer activity id (FEP-633c section 3)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, role, other_uri)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
-    -- The multi-party handshake (FEP-633c section 3), one row per offer this
-    -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
-    -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
-    -- only when the candidate returns the handle after ward + candidate + at
-    -- least one existing guardian have accepted.
-    CREATE TABLE IF NOT EXISTS ap_guardian_offers (
-      offer_id TEXT NOT NULL,      -- the Offer activity id (minted by the candidate)
-      slug TEXT NOT NULL,          -- the local site tracking this handshake (each party keeps its own copy)
-      ward_uri TEXT NOT NULL,      -- the ward-to-be
-      candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
-      existing_guardians TEXT NOT NULL DEFAULT '[]',  -- JSON array of the ward's current guardian URIs
-      status TEXT NOT NULL DEFAULT 'pending',         -- 'pending' | 'committed' | 'void'
-      handle TEXT,                 -- the escalation handle returned at commit (section 6)
-      ward_handle TEXT,            -- cached @ward@host for display
-      candidate_handle TEXT,       -- cached @candidate@host for display
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      PRIMARY KEY (slug, offer_id)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
-    CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
-      offer_id TEXT NOT NULL,      -- FK to ap_guardian_offers
-      slug TEXT NOT NULL,          -- the local site's copy of the tally
-      party_uri TEXT NOT NULL,     -- the party who accepted (ward | candidate | an existing guardian)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      PRIMARY KEY (slug, offer_id, party_uri)
-    );
-    -- FEP-633c §5.6: a gated setting a ward's guardians decide together, which
-    -- has to work when they live on other servers (the ordinary case). One row
-    -- per guardian answer; the ward's server tallies (§3.5) and enforces.
-    -- The proposals themselves, so an Accept that only references the offer
-    -- id can still be resolved to "which feature, which value".
-    CREATE TABLE IF NOT EXISTS ap_gated_offers (
-      offer_id TEXT PRIMARY KEY,
-      slug TEXT NOT NULL,          -- the ward, on this server
-      feature TEXT NOT NULL,
-      value INTEGER NOT NULL,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    -- The guardian-side COPY of a gated-setting proposal on a ward, forwarded
-    -- here by the WARD's server (the same shape ap_follow_reviews has for a
-    -- gated follow). Without it a guardian on another server never learns a
-    -- proposal exists and can never answer it, so a threshold of two can never
-    -- be reached and every proposal expires. The answer goes back to the
-    -- ward's inbox, which tallies (5.6).
-    CREATE TABLE IF NOT EXISTS ap_gated_reviews (
-      id TEXT NOT NULL,            -- the offer id, as minted by the proposer
-      guardian_slug TEXT NOT NULL, -- us, one of the ward's guardians
-      ward_uri TEXT NOT NULL,
-      ward_inbox TEXT,
-      proposer TEXT,               -- who opened it (for display)
-      feature TEXT NOT NULL,
-      value INTEGER NOT NULL,
-      created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-      PRIMARY KEY (guardian_slug, id)
-    );
-    -- The PROPOSER's own record of a gated proposal it sent (5.6). Without it
-    -- a guardian clicks "propose", the ward's server tallies somewhere else,
-    -- and the proposer has nowhere to even see that something is running: the
-    -- status was a button caption that did not survive a page refresh. The
-    -- ward's server answers the Offer once the decision settles (Accept when
-    -- it settled on the proposed value, Reject otherwise); that answer lands
-    -- in status. An open row past the decision window renders as expired.
-    CREATE TABLE IF NOT EXISTS ap_gated_sent (
-      offer_id TEXT PRIMARY KEY,   -- as minted by us, the proposer
-      guardian_slug TEXT NOT NULL, -- us
-      ward_uri TEXT NOT NULL,
-      feature TEXT NOT NULL,
-      value INTEGER NOT NULL,      -- what we proposed
-      status TEXT NOT NULL DEFAULT 'open',  -- 'open' | 'accepted' | 'rejected'
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE TABLE IF NOT EXISTS ap_gated_votes (
-      slug TEXT NOT NULL,          -- the WARD, on this server
-      feature TEXT NOT NULL,       -- e.g. 'shaer:externalEmbeds'
-      guardian_uri TEXT NOT NULL,  -- who answered (must be a committed guardian)
-      value INTEGER NOT NULL,      -- the value they voted for (0/1)
-      opened_at DATETIME NOT NULL, -- when this decision opened (the window start)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      PRIMARY KEY (slug, feature, guardian_uri)
-    );
-    -- Guardian availability (FEP-633c 3.6): one guardian's attention as seen
-    -- from one ward on this server. Never public; the ward reads it via the
-    -- owner-only guardians queue. One rule above all: one answer restores
-    -- everything, so every row here is one answer away from disappearing.
-    CREATE TABLE IF NOT EXISTS ap_guardian_attention (
-      ward_slug TEXT NOT NULL,
-      guardian_uri TEXT NOT NULL,
-      state TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'away' | 'dormant'
-      away_until INTEGER,                    -- epoch ms while declared away
-      PRIMARY KEY (ward_slug, guardian_uri)
-    );
-    -- The ONLY admissible dormancy evidence (3.6.2): directly addressed
-    -- requests that went unanswered. Calendar time alone never counts.
-    CREATE TABLE IF NOT EXISTS ap_attention_requests (
-      ward_slug TEXT NOT NULL,
-      guardian_uri TEXT NOT NULL,
-      request_id TEXT NOT NULL,
-      asked_at INTEGER NOT NULL,             -- epoch ms
-      PRIMARY KEY (ward_slug, guardian_uri, request_id)
-    );
-    -- A lapse (3.6.3): the available co-guardians deciding to release a
-    -- dormant one. Irreversible, so the window always runs in full; any sign
-    -- of life from the target cancels it outright.
-    CREATE TABLE IF NOT EXISTS ap_lapses (
-      id TEXT PRIMARY KEY,
-      ward_slug TEXT NOT NULL,
-      ward_uri TEXT NOT NULL,
-      target_uri TEXT NOT NULL,
-      opened_by TEXT NOT NULL,
-      set_json TEXT NOT NULL,                -- the available set at open, target excluded
-      accepts_json TEXT NOT NULL DEFAULT '[]',
-      rejects_json TEXT NOT NULL DEFAULT '[]',
-      opened_at INTEGER NOT NULL,            -- epoch ms
-      window_ms INTEGER NOT NULL,
-      cancelled INTEGER NOT NULL DEFAULT 0,
-      applied INTEGER NOT NULL DEFAULT 0,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE TABLE IF NOT EXISTS ap_delivery (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,          -- our site/actor that signs the delivery
-      inbox TEXT NOT NULL,         -- recipient inbox URL
-      body TEXT NOT NULL,          -- the activity JSON to POST
-      attempts INTEGER NOT NULL DEFAULT 0,
-      next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
-    CREATE TABLE IF NOT EXISTS poll_votes (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      post_id INTEGER NOT NULL,     -- our local poll post (posts.id)
-      actor_uri TEXT NOT NULL,      -- the remote voter's AP actor URI
-      choice TEXT NOT NULL,         -- the chosen option's name (matches poll_json options[].name)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(post_id, actor_uri, choice)
-    );
-    CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
-    CREATE TABLE IF NOT EXISTS ap_mentions (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,           -- our mentioned site/actor
-      object_uri TEXT NOT NULL,     -- the remote note that mentions us
-      note_url TEXT,                -- its human URL (open/interact)
-      actor_uri TEXT, actor_name TEXT, actor_handle TEXT, actor_icon TEXT, actor_url TEXT,
-      content TEXT,                 -- sanitized HTML snippet of the mentioning note
-      published TEXT,
-      in_reply_to TEXT,             -- de note waarop dit een antwoord is (AS2 inReplyTo)
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-      UNIQUE(slug, object_uri)
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_mentions_slug ON ap_mentions(slug, created_at);
-    CREATE TABLE IF NOT EXISTS ap_reports (
-      id INTEGER PRIMARY KEY AUTOINCREMENT,
-      slug TEXT NOT NULL,           -- our site the report is about (its owner moderates)
-      actor_uri TEXT,               -- the reporter's actor URI
-      actor_name TEXT, actor_handle TEXT, actor_icon TEXT,
-      content TEXT,                 -- the reason (plain text)
-      objects TEXT,                 -- JSON array of reported object URIs (our actor + statuses)
-      seen INTEGER DEFAULT 0,
-      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
-    );
-    CREATE INDEX IF NOT EXISTS idx_ap_reports_slug ON ap_reports(slug, created_at);
-  `);
-  // "Feature" a followed account: its posts show in the local Cirkel. Heet in de
-  // UI "Uitgelicht" / "Featured" (tl.autoboost), en zo heet de kolom ook in
-  // following.csv. Niet te verwarren met `featured` op de ACTOR: dat is de
-  // collectie vastgezette POSTS (toot:featured), iets heel anders.
-  ensureColumn('ap_following', 'auto_boost', 'INTEGER DEFAULT 0');
-  // A timeline post you boosted (🔁) — also shown in the Cirkel (mixed by date).
-  ensureColumn('ap_timeline', 'boosted', 'INTEGER DEFAULT 0');
-  ensureColumn('ap_timeline', 'liked', 'INTEGER DEFAULT 0'); // a feed post you liked (⭐) → toggle
-  ensureColumn('ap_timeline', 'nsfw', 'INTEGER DEFAULT 0');  // remote sensitive post → blur in the Cirkel
-  ensureColumn('ap_timeline', 'cw', 'TEXT');                 // remote content-warning text
-  ensureColumn('ap_timeline', 'emoji_json', 'TEXT');         // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
-  ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
-  ensureColumn('ap_timeline', 'quote_json', 'TEXT');         // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
-  // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
-  // time so buildNote (sync, also used by the outbox) needs no network.
-  ensureColumn('posts', 'quote_uri', 'TEXT');     // the quoted object's id
-  // De kaart op je EIGEN post (shaer-k3f): dezelfde snapshots die ap_timeline
-  // voor binnenkomende posts draagt, maar dan voor wat je zelf publiceert --
-  // zonder deze twee kan de app een eigen post nooit als kaart tonen.
-  ensureColumn('posts', 'quote_json', 'TEXT');    // FEP-044f resolved quote snapshot
-  ensureColumn('posts', 'embed_json', 'TEXT');    // externe linkkaart (oEmbed/OG), thumbnail-only
-  ensureColumn('posts', 'quote_actor', 'TEXT');   // its author, so we can address them
-  ensureColumn('ap_timeline', 'embed_json', 'TEXT');       // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
-  ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
-  ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
-  ensureColumn('ap_timeline', 'reblog_name', 'TEXT');        // a followed account boosted this → "X boosted"
-  ensureColumn('ap_timeline', 'reblog_handle', 'TEXT');      //   the booster's @handle
-  ensureColumn('ap_timeline', 'reblog_icon', 'TEXT');        //   the booster's avatar
-  ensureColumn('ap_timeline', 'poll_json', 'TEXT');          // a Question (poll): {multiple,options[{name,count}],endTime,closed,voters,voted}
-
-  // Delivery health per follower → surface dead accounts for manual cleanup.
-  ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
-  ensureColumn('ap_followers', 'last_error_at', 'DATETIME');    // last time a delivery to it gave up (max retries)
-
-  // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
-  // @mentions linkified once at save). `content` stays the raw source used for editing and
-  // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
-  ensureColumn('posts', 'content_rendered', 'TEXT');
-
-  // AP addressing of an incoming interaction: 'public' | 'unlisted' | 'followers' | 'direct',
-  // derived from the note's to/cc at ingest. The public post page only renders public/unlisted
-  // replies; followers/direct replies surface in notifications (and later Messages) with post
-  // context instead. Existing rows default to 'public' (historically almost all were).
-  ensureColumn('ap_interactions', 'visibility', "TEXT DEFAULT 'public'");
-  ensureColumn('ap_interactions', 'emoji_json', 'TEXT');        // FEP-9098 custom emojis in a reply's content (messages + thread)
-  ensureColumn('ap_interactions', 'actor_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the reply author's display name
-  // Rich replies: the reply's language (BCP47 code) → contentMap on the outgoing Note.
-  ensureColumn('ap_outbox', 'language', 'TEXT');
-  // Rich replies: JSON array [{url, mediaType, name}] → `attachment` on the Note.
-  ensureColumn('ap_outbox', 'attachments', 'TEXT');
-  ensureColumn('posts', 'ap_visibility', 'TEXT');   // public|quiet|friends|direct (C2S addressing, shaer-60b)
-  ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0');        // paid post (klonkt-demo-aki)
-  ensureColumn('posts', 'paid_min_cents', 'INTEGER');        // required support; null = owner default
-  ensureColumn('paid_patreon', 'patreon_url', 'TEXT');       // owner's public Patreon page → "Word supporter" link (klonkt-demo-aki)
-  ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
-  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
-  ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
-  // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
-  // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
-  // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
-  // kolom op de mention zou alleen over onszelf gaan.
-  //
-  // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
-  // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
-  // ander het "geclaimd" had, wel.
-  //
-  // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
-  // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
-  // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
-  // herschreven, er wordt toegevoegd.
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
-    note_uri TEXT NOT NULL,
-    guardian_uri TEXT NOT NULL,
-    kind TEXT NOT NULL,                 -- pickup | handled
-    guardian_handle TEXT,
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-    PRIMARY KEY (note_uri, guardian_uri, kind)
-  )`);
-  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
-  // Een kind dat zelf om een poort vraagt (shaer-8ru). Een VRAAG, geen stem:
-  // pas als een guardian hem oppakt wordt het een voorstel dat langs de tally
-  // gaat. handled_at in plaats van verwijderen -- wat een kind gevraagd heeft
-  // hoort terug te vinden te zijn, ook als het antwoord nee was.
-  db.exec(`CREATE TABLE IF NOT EXISTS ap_gate_requests (
-    id INTEGER PRIMARY KEY AUTOINCREMENT,
-    slug TEXT NOT NULL,                 -- de guardian die hem ontving
-    ward_uri TEXT NOT NULL,
-    feature TEXT NOT NULL,
-    note_uri TEXT,
-    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
-    handled_at TEXT,
-    UNIQUE (slug, ward_uri, feature, handled_at)
-  )`);
-  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_gate_requests_slug ON ap_gate_requests(slug, handled_at)');
-  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
-  ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
-  ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
-  ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them
-  // Zou JOUW antwoord het besluit afmaken (shaer-8vt)? De telling loopt op de
-  // server van het kind; zonder dit veld kan een guardian elders niet weten dat
-  // hij de doorslag geeft. Ontbreekt hij, dan waarschuwen we -- bij twijfel.
-  ensureColumn('ap_gated_reviews', 'decisive', 'INTEGER');
-  // Did a guardian actually say yes to this follower? That is what makes the
-  // mutual shortcut sound: a ward may follow back anyone its guardians already
-  // admitted, without asking the same question twice. Only follows that came
-  // through the §5.3 gate carry the mark; a free actor's followers never faced
-  // one. Everyone already following when this column arrives is grandfathered
-  // in (Barts besluit, 3-8): the rule is exact from that moment forward rather
-  // than retroactively suspicious of relationships that already exist.
-  {
-    const had = db.prepare("SELECT COUNT(*) AS n FROM pragma_table_info('ap_followers') WHERE name = 'gate_approved'").get();
-    ensureColumn('ap_followers', 'gate_approved', 'INTEGER DEFAULT 0');
-    if (!had || !had.n) {
-      try { db.prepare('UPDATE ap_followers SET gate_approved = 1').run(); } catch { /* table still empty on a fresh init */ }
-    }
-  }
-  ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them
-  // 30-7: C2S posts briefly got their content media copied onto the cover,
-  // which showed the same video twice on the post page. Clear the covers that
-  // duplicate their own content; idempotent, only ever touches those.
-  try {
-    db.prepare("UPDATE posts SET cover_video_url = NULL WHERE cover_video_url LIKE '/media/reply-media/%' AND instr(content, cover_video_url) > 0").run();
-    db.prepare("UPDATE posts SET cover_image_url = NULL WHERE cover_image_url LIKE '/media/reply-media/%' AND instr(content, cover_image_url) > 0").run();
-  } catch { /* posts table absent on fresh init */ }
-  // Bestaande rijen naar EEN spelling (shaer-a937). De schrijfwegen leveren
-  // sinds deze release ISO; dit haalt na wat er in SQL-notatie is blijven
-  // staan, zodat de sortering ook zonder de isoSql-wikkel klopt.
-  //
-  // Alleen rijen met een spatie op positie 11 en geen 'T': dat is precies de
-  // CURRENT_TIMESTAMP-vorm. Idempotent -- een tweede keer draaien vindt niets
-  // meer -- en het raakt een ISO-stempel nooit aan.
-  //
-  // strftime geeft NULL op iets dat het niet als tijd herkent; de WHERE laat
-  // zulke rijen met rust, want een onleesbare stempel vervangen door NULL is
-  // gegevens weggooien. Ze blijven staan zoals ze stonden.
-  {
-    const kolommen = [
-      ['ap_timeline', 'created_at'], ['ap_timeline', 'published'],
-      ['ap_mentions', 'created_at'], ['ap_mentions', 'published'],
-      ['ap_interactions', 'created_at'], ['ap_interactions', 'published'],
-      ['ap_outbox', 'created_at'],
-    ];
-    let veranderd = 0;
-    for (const [tabel, kolom] of kolommen) {
-      try {
-        const r = db.prepare(
-          `UPDATE ${tabel} SET ${kolom} = strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom})
-            WHERE ${kolom} IS NOT NULL AND ${kolom} LIKE '____-__-__ %'
-              AND strftime('%Y-%m-%dT%H:%M:%SZ', ${kolom}) IS NOT NULL`).run();
-        veranderd += r.changes;
-      } catch { /* tabel bestaat nog niet op een verse installatie */ }
-    }
-    if (veranderd) console.log(`🕒 tijdstempels genormaliseerd: ${veranderd} rijen`);
-  }
-  ensureColumn('ap_mentions', 'wave', 'INTEGER');  // inbound guardian wave
-  // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
-  // used later at reddings-boei / escalation routing.
-  ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
-  ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
-  // Berichten and de Krant render a post the same way, so a mention or a reply
-  // needs the same trimmings a timeline row already has: custom emojis, the
-  // media the note carried, and the quote / link-preview card.
-  ensureColumn('ap_mentions', 'emoji_json', 'TEXT');        // FEP-9098, in the content
-  ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT');  // FEP-9098, in the display name
-  ensureColumn('ap_mentions', 'media_json', 'TEXT');
-  ensureColumn('ap_mentions', 'quote_json', 'TEXT');        // FEP-044f quoted post
-  ensureColumn('ap_mentions', 'embed_json', 'TEXT');        // external link preview
-  // AS2 inReplyTo op een vermelding/bericht. Bestond hier niet, en daarmee ging
-  // de ouder bij het opslaan verloren: elk ANTWOORD in een gesprek kwam bij de
-  // client aan alsof het een gesprek begon (Robins melding, 26-8). Bestaande
-  // rijen blijven leeg -- die ouder is niet meer te achterhalen zonder hem
-  // opnieuw op te halen, en een verzonnen ouder is erger dan geen.
-  ensureColumn('ap_mentions', 'in_reply_to', 'TEXT');
-  ensureColumn('ap_interactions', 'media_json', 'TEXT');
-  ensureColumn('ap_interactions', 'quote_json', 'TEXT');
-  ensureColumn('ap_interactions', 'embed_json', 'TEXT');
-  ensureColumn('ap_followers', 'name', 'TEXT');    // cached display name (shaer-aa3)
-  ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
-  ensureColumn('ap_followers', 'icon', 'TEXT');    // avatar URL
-  feedStateTriggers();
-}
-
-/**
- * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
- *
- * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
- * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
- * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
- * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
- * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
- * ap_timeline.liked (shaer-9e9).
- *
- * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
- * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
- * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
- *
- * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
- * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
- * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
- * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
- * bij de tabel, en niet verspreid.
- *
- * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
- * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
- * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
- */
-function feedStateTriggers() {
-  try {
-    db.exec(`
-      -- Tot waar jij een gesprek gelezen hebt (shaer-frontend-3tx).
-      --
-      -- Een MARKERING, geen teller: het aantal ongelezen berichten is een
-      -- COUNT over de berichten die na deze cursor komen. Een opgeslagen
-      -- getal zou opgehoogd, verlaagd en gerepareerd moeten worden, en zou
-      -- blijven staan als er iets verwijderd wordt -- badge zegt 3, er is
-      -- niets.
-      --
-      -- De cursor is samengesteld ('<stempel>|<ref>'), dezelfde vorm als de
-      -- gesprekspaginering en om dezelfde reden: twee berichten in dezelfde
-      -- seconde is bij DM's een gesprek, geen randgeval.
-      CREATE TABLE IF NOT EXISTS ap_read_markers (
-        slug TEXT NOT NULL,
-        other TEXT NOT NULL,          -- de tegenpartij (actor uri)
-        cursor TEXT NOT NULL,
-        at DATETIME DEFAULT CURRENT_TIMESTAMP,
-        PRIMARY KEY (slug, other)
-      );
-      CREATE TABLE IF NOT EXISTS ap_feed_state (
-        slug TEXT NOT NULL,
-        object_uri TEXT NOT NULL,
-        rev INTEGER NOT NULL,
-        kind TEXT NOT NULL,             -- new | updated | deleted
-        at DATETIME DEFAULT CURRENT_TIMESTAMP,
-        PRIMARY KEY (slug, object_uri)
-      );
-      CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
-      -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
-      -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
-      -- client dat er niets gebeurd is.
-      CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
-    `);
-    if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
-      db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
-    }
-    // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
-    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '', wanneer = '') => `
-      DROP TRIGGER IF EXISTS ${naam};
-      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel}${wanneer ? ` WHEN ${wanneer}` : ''} BEGIN
-        UPDATE ap_feed_rev SET n = n + 1;
-        INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
-          ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
-          ON CONFLICT(slug, object_uri) DO UPDATE
-            SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
-      END;`;
-    const joinPosts = (uri, kind) => `
-          SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
-            FROM posts p JOIN sites s ON s.id = p.site_id`;
-    db.exec([
-      zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
-      zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
-      zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
-      zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
-      zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
-      zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
-      zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
-      zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
-      zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
-      // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
-      // en vandaar dat deze drie niet in de gewone vorm passen.
-      //
-      // De WHEN op kind='reply' is nodig omdat deze tabel ook likes en announces
-      // draagt, en die schrijven object_uri = '' (zie recordInteraction). Zonder de
-      // WHEN bumpte elke inkomende like de rev, werd elke wachter gewekt en kreeg
-      // die de hele collectie opnieuw terwijl er niets aan veranderd was: precies de
-      // kosten die de 304 moest wegnemen. Bovendien belandde er dan een rij op de
-      // lege string in ap_feed_state, die feedChangesSince vervolgens uitdeelt.
-      // De oude cursor filterde hier wel op kind; bij ap_timeline is dit ook gedaan
-      // (de UPDATE OF sluit liked/boosted uit) en één tabel verder vergeten.
-      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
-      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`, "NEW.kind = 'reply'"),
-      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`, "OLD.kind = 'reply'"),
-    ].join('\n'));
-  } catch (e) {
-    // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
-    // volmaken", en dat is traag maar niet stuk.
-    console.error('❌ feed-state triggers:', e.message);
-  }
 }
 
Index: src/config/features.js
===================================================================
--- src/config/features.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,12 +1,0 @@
-// Feature flags (boot-time, via env).
-//
-// Lite mode: set KLONKT_AUDIO=off in .env to disable the ENTIRE audio feature —
-// no audio/playlist/download/embed routes, no ffmpeg calls, no player, and no
-// [[track]]/[[playlist]] shortcodes. This lets Klonkt run as a lightweight
-// blog/photo/EPK site on environments without ffmpeg/exec. Hub and Circles
-// keep working (they have no audio dependency).
-//
-// Default = on (full version). Only the literal value 'off' disables it.
-export function audioEnabled() {
-  return String(process.env.KLONKT_AUDIO ?? 'on').toLowerCase() !== 'off';
-}
Index: src/config/mailer.js
===================================================================
--- src/config/mailer.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,61 +1,0 @@
-// Send email (password reset, newsletter, notify). Optional: only active
-// when SMTP is configured — via Admin → Settings (app_settings) OR env vars.
-//
-// Config source (in this order): app_settings (set via the UI), otherwise env:
-//   SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_FROM (default = USER)
-// Not configured → sending falls back to CLI (reset-admin) / is skipped.
-
-import nodemailer from 'nodemailer';
-import { getSetting } from '../services/SettingsService.js';
-
-function cfg() {
-  const host = getSetting('smtp_host', '') || process.env.SMTP_HOST || '';
-  const port = parseInt(getSetting('smtp_port', '') || process.env.SMTP_PORT || '587', 10) || 587;
-  const user = getSetting('smtp_user', '') || process.env.SMTP_USER || '';
-  const pass = getSetting('smtp_pass', '') || process.env.SMTP_PASS || '';
-  const from = getSetting('smtp_from', '') || process.env.SMTP_FROM || user;
-  return { host, port, user, pass, from };
-}
-
-export function mailerConfigured() {
-  const c = cfg();
-  return !!(c.host && c.user && c.pass);
-}
-
-// Status for the UI (without leaking the password).
-export function mailerStatus() {
-  const c = cfg();
-  return {
-    configured: mailerConfigured(),
-    host: c.host,
-    port: c.port,
-    user: c.user,
-    from: c.from,
-    passSet: !!c.pass,
-    // source: useful to show that env vars are still active
-    fromEnv: !getSetting('smtp_host', '') && !!process.env.SMTP_HOST,
-  };
-}
-
-// Cache the transport, but rebuild it whenever the config changes (UI edit without restart).
-let _transport = null, _key = null;
-function transport() {
-  const c = cfg();
-  const key = [c.host, c.port, c.user, c.pass].join('|');
-  if (!_transport || _key !== key) {
-    _transport = nodemailer.createTransport({
-      host: c.host,
-      port: c.port,
-      secure: c.port === 465, // 465 = implicit TLS; 587 = STARTTLS
-      auth: { user: c.user, pass: c.pass },
-    });
-    _key = key;
-  }
-  return _transport;
-}
-
-export async function sendMail({ to, subject, text, html }) {
-  if (!mailerConfigured()) throw new Error('SMTP not configured');
-  const c = cfg();
-  return transport().sendMail({ from: c.from, to, subject, text, html });
-}
Index: src/config/paths.js
===================================================================
--- src/config/paths.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,72 +1,0 @@
-/**
- * Where user data lives on disk.
- *
- * Every media subdirectory derives from MEDIA_PATH, so one setting moves the
- * whole media tree out of the checkout. That matters because the deploy checks
- * out the work-tree: anything the app writes next to its own code can be wiped
- * by a cleanup on the next deploy.
- *
- * Read at import time, like the rest of the config. `dotenv/config` is the first
- * import in server.js, so the environment is already populated by the time this
- * module is evaluated.
- */
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-/** Root for public, user-uploaded media (served by the /media handler). */
-export const MEDIA_ROOT = path.resolve(
-  process.env.MEDIA_PATH || path.join(__dirname, '..', '..', 'storage', 'media')
-);
-
-/**
- * Resolve one media subdirectory.
- *
- * A per-subdirectory variable still wins, so installs that already pin an
- * individual path keep working. Only the fallback changed: it now follows
- * MEDIA_ROOT instead of pointing back into the checkout.
- *
- * @param {string} envVar  per-subdirectory override, e.g. 'AVATAR_PATH'
- * @param {string} sub     subdirectory name under MEDIA_ROOT, e.g. 'avatars'
- */
-export function mediaDir(envVar, sub) {
-  return path.resolve(process.env[envVar] || path.join(MEDIA_ROOT, sub));
-}
-
-/**
- * Waar de gehoste audio staat.
- *
- * BEWUST BUITEN MEDIA_ROOT: de publieke /media-handler mag er niet bij, elke
- * fetch loopt via de gated route in routes/audio.js. Diezelfde route resolvet
- * met AUDIO_DIR + bestandsnaam, en negeert media.storage_path volledig.
- */
-export const AUDIO_ROOT = path.resolve(
-  process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'),
-);
-
-/**
- * Het echte pad van een audiobestand, op DEZELFDE manier als de speler het zoekt.
- *
- * Dit bestaat omdat die twee uit elkaar liepen en dat een verhuizing sloopte.
- * Op sound-fabrics.com wees media.storage_path voor 124 van de 139 tracks nog
- * naar /srv/prutfolio/storage/audio (van voor de dataverhuizing), terwijl de
- * bestanden allang op ~/data/prutfolio/audio stonden. De site merkte er niets
- * van, want de speler kijkt alleen naar de bestandsnaam. De exporter las wel
- * storage_path, vond niets, en liet 124 nummers stil achter.
- *
- * Volgorde: eerst zoals de speler kijkt (bestandsnaam in AUDIO_ROOT), dan pas
- * het opgeslagen pad. Zo klopt de export met wat de gebruiker hoort, en niet
- * met wat de database ooit dacht.
- *
- * @returns {string|null} een bestaand pad, of null
- */
-export function resolveAudioPath(storagePath, fs) {
-  const s = String(storagePath || '');
-  if (!s) return null;
-  const kandidaten = [path.join(AUDIO_ROOT, path.basename(s)), path.resolve(s)];
-  for (const p of kandidaten) {
-    try { if (fs.statSync(p).isFile()) return p; } catch { /* volgende kandidaat */ }
-  }
-  return null;
-}
Index: src/config/post-types.js
===================================================================
--- src/config/post-types.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,29 +1,0 @@
-/**
- * Welke posttypes bestaan er. (shaer-cyg)
- *
- * Dit stond op drie plaatsen los van elkaar: twee keer in routes/posts.js (het
- * opslaan) en een keer in routes/types.js (de /type/-pagina's). Zolang die
- * lijsten hetzelfde waren viel dat niet op, maar het is precies het soort naad
- * dat stil faalt: kent de opslag 'playlist' niet, dan wordt de post zonder
- * melding een gewone post, en dan is de keuze verdwenen in plaats van geweigerd.
- *
- * AUDIO STAAT ER NOG WEL IN, MAAR IS GEEN KEUZE MEER. Alles wat muziek is landt
- * voortaan op album of playlist (Robins besluit, 8-8). Bestaande posts hebben
- * type=audio nog wel, dus /type/audio moet blijven werken en het opslaan van
- * zo'n post mag zijn type niet stilzwijgend weggooien -- tot de backfill hem
- * heeft omgezet. Daarna kan audio hier weg.
- *
- * MIXTAPE erbij (Robins idee, 21-8): een bandje, samengesteld uit de nummers
- * van een playlist, dat alleen vooruit en achteruit speelt. Het draagt muziek,
- * dus het deelt het muziekpaneel en de afleiding met album en playlist -- het
- * verschil zit in de playlist die je insluit, niet in wat je hier uploadt.
- */
-
-/** Types die een gebruiker in de editor kan kiezen. */
-export const KEUZE_TYPES = ['post', 'foto', 'video', 'album', 'playlist', 'mixtape'];
-
-/** Alles wat in de kolom posts.type mag staan, inclusief wat er historisch is. */
-export const POST_TYPES = new Set([...KEUZE_TYPES, 'audio']);
-
-/** De types die muziek dragen -- ze delen hetzelfde paneel en dezelfde afleiding. */
-export const MUZIEK_TYPES = new Set(['album', 'playlist', 'audio', 'mixtape']);
Index: src/db/migrations/001-init.sql
===================================================================
--- src/db/migrations/001-init.sql	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/db/migrations/001-init.sql	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,5 +1,5 @@
--- Klonkt — initial schema
--- Forked from a v9 PHP, file-based CMS → SQLite/Node
--- Complete schema with the v9 features (Forum, Audio, Themes) + v10 CRDT
+-- PrutFolio v1 — initial schema
+-- Forked from PrutCMS v9 (PHP, file-based) → SQLite/Node
+-- Complete schema with v9 features (Prutter, Forum, Audio, Themes) + v10 CRDT
 
 -- ==================== USERS ====================
@@ -62,5 +62,5 @@
     closed_circle_mode INTEGER DEFAULT 0,
     robots_index INTEGER DEFAULT 1,
-    require_login_to_comment INTEGER DEFAULT 1,
+    require_login_to_comment INTEGER DEFAULT 0,
     enable_audio_player INTEGER DEFAULT 1,
     profile_photo TEXT,
Index: src/middleware/auth.js
===================================================================
--- src/middleware/auth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/middleware/auth.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -2,7 +2,4 @@
  * Auth middleware
  */
-
-import db from '../config/database.js';
-import PermissionsService from '../services/PermissionsService.js';
 
 /**
@@ -38,44 +35,9 @@
 }
 
-// A 'kijker' (viewer) may VIEW everything (incl. Admin) but CHANGE nothing. The
-// write block lives in the global guard in server.js; this helper only determines
-// "is this a read-only account?". `readonly` is the legacy flag we still include
-// so unmigrated demo accounts remain blocked.
-export function isViewer(user) {
-  return !!user && (user.role === 'kijker' || !!user.readonly);
-}
-
 export function requireGod(req, res, next) {
   if (!req.session?.user) return loginRedirect(req, res);
-  const role = req.session.user.role;
-  // god manages; a viewer MAY see the Admin panel (read-only) — the
-  // global guard 403s every write, so this only grants view access.
-  if (role !== 'god' && role !== 'kijker') {
+  if (req.session.user.role !== 'god') {
     return res.status(403).send('God role required');
   }
   next();
 }
-
-// Can the logged-in user manage the CURRENT site (res.locals.site)? god always;
-// otherwise only the owner of that site. Used for site-scoped admin routes
-// that an artist reaches via /user/<own-slug>/admin/... (res.locals.site is then
-// their own site; a foreign slug yields a different site -> 403).
-export function requireSiteManager(req, res, next) {
-  if (!req.session?.user) return loginRedirect(req, res);
-  const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
-  const site = res.locals.site;
-  // owner OR assigned co-admin (site_members) — canAdminSite covers both.
-  if (site && PermissionsService.canAdminSite(u, site)) return next();
-  return res.status(403).send('Geen toegang tot deze site.');
-}
-
-// Same, but the site is determined by the :slug parameter (e.g. site-edit).
-export function requireSiteManagerBySlug(req, res, next) {
-  if (!req.session?.user) return loginRedirect(req, res);
-  const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
-  const site = db.prepare('SELECT id, owner_id FROM sites WHERE slug = ?').get(req.params.slug);
-  if (site && PermissionsService.canAdminSite(u, site)) return next();
-  return res.status(403).send('Geen toegang tot deze site.');
-}
Index: src/middleware/rate-limit.js
===================================================================
--- src/middleware/rate-limit.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/middleware/rate-limit.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -13,28 +13,4 @@
 import rateLimit from 'express-rate-limit';
 import { renderPage } from './render.js';
-
-// Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with
-// port). express-rate-limit v7 validates the IP and otherwise throws
-// ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2
-// enters a restart loop). Strip a trailing IPv4 port, fall back to the
-// socket address, and leave IPv6 (multiple colons) untouched.
-function clientKey(req) {
-  let ip = req.ip || req.socket?.remoteAddress || '';
-  // Strip a trailing IPv4 port (1.2.3.4:11046 -> 1.2.3.4)
-  if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
-  // IPv6-mapped IPv4 (::ffff:1.2.3.4) -> the plain IPv4
-  const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
-  if (mapped) return mapped[1];
-  // Real IPv6: key on the /64 network prefix, not the full address. A single
-  // user/allocation is usually a whole /64, so this stops an attacker from
-  // getting a fresh budget by rotating addresses within their own range.
-  if (ip.includes(':')) {
-    const left = ip.includes('::') ? ip.split('::')[0] : ip;
-    const groups = left.split(':').filter(Boolean);
-    while (groups.length < 4) groups.push('0');
-    return groups.slice(0, 4).join(':') + '::/64';
-  }
-  return ip || 'unknown';
-}
 
 function blockedHandler(viewName, bodyClass, friendlyMsg) {
@@ -66,6 +42,4 @@
   standardHeaders: true,
   legacyHeaders: false,
-  keyGenerator: clientKey,
-  validate: { ip: false },
   // Only count failed attempts. Successful logins don't burn the budget.
   skipSuccessfulRequests: true,
@@ -78,65 +52,5 @@
   standardHeaders: true,
   legacyHeaders: false,
-  keyGenerator: clientKey,
-  validate: { ip: false },
   skipSuccessfulRequests: false,   // any attempt counts (registration spam is the concern)
   handler: blockedHandler('pages/auth-register', 'on-special', 'Too many signup attempts.'),
 });
-
-// ─── Fediverse (/ap/*) ────────────────────────────────────────────
-// These endpoints are hit by REMOTE SERVERS, not browsers, so the default
-// plain-text 429 is the right response (no HTML page). Deliberately generous:
-// legitimate federation from one instance never comes close, but a flood from
-// a single IP is capped. Per-IP via the same /64-aware clientKey.
-
-// Baseline read cap across all /ap/* (actor, outbox, notes, webfinger, …).
-// 5 req/sec per IP — far above any real Mastodon polling.
-export const apReadLimiter = rateLimit({
-  windowMs: 60 * 1000,
-  max: 300,
-  standardHeaders: true,
-  legacyHeaders: false,
-  keyGenerator: clientKey,
-  validate: { ip: false },
-});
-
-// ─── OpenWebAuth /magic ───────────────────────────────────────────
-// Elke poging doet EEN RSA-ontsleuteling met de actorsleutel van een site. Dat
-// is precies de vorm waar een Bleichenbacher/Marvin-orakel op draait: veel
-// aangepaste ciphertexts, en uit de antwoorden de sleutel afleiden. De
-// ontsleuteling zelf is daartegen gehard (implicit rejection in
-// OpenWebAuthService.decryptToken), maar echte constant-time code bestaat niet
-// in JavaScript. Een grens op het AANTAL pogingen doet daarom het zware werk:
-// een orakel heeft er honderdduizenden nodig.
-//
-// TELT ALLE POGINGEN, niet alleen de mislukte. Een teller die alleen faalt
-// meetelt is zelf weer een orakel -- dan leest een aanvaller aan het knijpen af
-// of zijn padding klopte, en is de vertakking die we bij de ontsleuteling
-// weghaalden aan de achterdeur terug.
-//
-// Per SITE-SLUG, want dat is wat een sleutelpaar heeft (getOrCreateKeys(slug)):
-// de grens hoort bij de sleutel die beschermd wordt, niet bij het IP van de
-// eigenaar of bij de doel-host die de aanvaller zelf kiest.
-//
-// Twintig per uur is voor een mens onzichtbaar -- je klikt een handvol keer per
-// dag naar een andere site -- en voor een orakel dodelijk.
-export const owaMagicLimiter = rateLimit({
-  windowMs: 60 * 60 * 1000,
-  max: 20,
-  standardHeaders: true,
-  legacyHeaders: false,
-  keyGenerator: (req) => 'owa:' + String((req.body && req.body.slug) || (req.session && req.session.user && req.session.user.id) || 'onbekend'),
-  validate: { ip: false },
-});
-
-// Inbox POSTs each trigger an outbound actor fetch (signature verify) → cap the
-// amplification/queue-inflation a single source can drive. 120/min/IP is still
-// generous for a small site's inbound federation; bump if a busy instance trips it.
-export const apInboxLimiter = rateLimit({
-  windowMs: 60 * 1000,
-  max: 120,
-  standardHeaders: true,
-  legacyHeaders: false,
-  keyGenerator: clientKey,
-  validate: { ip: false },
-});
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/middleware/render.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -10,112 +10,24 @@
  */
 
-import fs from 'fs';
 import path from 'path';
 import { fileURLToPath } from 'url';
 import ejs from 'ejs';
-import db from '../config/database.js';
 import PermissionsService from '../services/PermissionsService.js';
-import { isViewer } from './auth.js';
-import { getSetting, apEnabled } from '../services/SettingsService.js';
-import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
-import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import { imgProxyUrl } from '../services/ThumbnailService.js';
-import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
-
-// Add the per-request CSP nonce to every <script> tag that doesn't already have one, so the
-// strict script-src (nonce + 'strict-dynamic') allows them — including scripts in htmx
-// partials. HTML-escaped "&lt;script" in rendered content (e.g. sanitized post bodies) won't
-// match, so this only touches real tags.
-export function injectCspNonce(html, nonce) {
-  if (!html || !nonce) return html;
-  return String(html).replace(/<script(?![^>]*\snonce=)/gi, () => `<script nonce="${nonce}"`);
-}
 import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
-import { t as i18nT, resolveLang, SUPPORTED as LANGS, LANG_NAMES } from '../services/i18n.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const VIEWS_DIR = path.join(__dirname, '..', 'views');
 
-/**
- * Render a post body to an HTML string with the SAME partial de Krant uses.
- *
- * For surfaces that are not an EJS page: the Guardian PWA builds its cards in
- * the browser, so it gets the finished HTML in its state blob instead of the
- * raw columns. One renderer, so a post cannot drift into looking different
- * depending on where you run into it.
- */
-export function renderNoteBody(nb, lang) {
-  if (!nb || !nb.content) return '';
-  const _l = lang || 'nl';
-  // ejs.renderFile hands back a Promise even with async:false, and the callers
-  // here are plain synchronous route code. Compile the file ourselves instead;
-  // `filename` is what lets the partial's own relative includes resolve.
-  const file = path.join(VIEWS_DIR, 'partials', 'note-body.ejs');
-  try {
-    return ejs.render(fs.readFileSync(file, 'utf8'), {
-      nb,
-      t: (key, vars) => i18nT(_l, key, vars),
-      emojiHtml,
-      emojiName,
-      noteQuote: parseQuote,
-      thumb: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 480) : url),
-      avatar: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 128) : url),
-    }, { filename: file, async: false });
-  } catch (e) {
-    console.warn('[render] note body failed:', e.message);
-    return '';
-  }
-}
-
-// App version (from package.json) + short commit hash (from .klonkt-version, written by
-// the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated
-// automatically on every deploy, so the displayed version is never stale.
-let APP_VERSION = '';
-try {
-  APP_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version || '';
-  try {
-    const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7);
-    if (sha) APP_VERSION += ' · ' + sha;
-  } catch { /* no .klonkt-version (local dev) */ }
-} catch { /* no version available */ }
-
-// Site timezone (Admin → Settings). Empty = server default (UTC). Applied to
-// all server-side formatted dates so they display in the site's timezone instead of UTC.
-const siteTimezone = () => getSetting('timezone') || undefined;
-
-/**
- * Read a stored timestamp as the moment it actually is.
- *
- * SQLite's CURRENT_TIMESTAMP writes UTC without saying so ("2026-07-28
- * 18:20:33"), and new Date() reads a string in that shape as LOCAL time. That
- * is right only as long as the server runs on UTC; set the machine to
- * Europe/Amsterdam and every stored date silently shifts two hours. So say UTC
- * out loud. Anything already carrying a zone (AP `published` ends in Z) is left
- * to the normal parser.
- */
-const parseStamp = (v) => {
-  if (!v) return null;
-  if (typeof v === 'number') {   // epoch ms (e.g. an availability endTime)
-    const d = new Date(v);
-    return Number.isNaN(d.getTime()) ? null : d;
-  }
-  const s = String(v);
-  const d = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(s)
-    ? new Date(`${s.replace(' ', 'T')}Z`)
-    : new Date(s);
-  return Number.isNaN(d.getTime()) ? null : d;
+const formatDate = (iso) => {
+  if (!iso) return '';
+  const d = new Date(iso);
+  const months = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'];
+  return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
 };
 
-const formatDate = (iso) => {
-  const d = parseStamp(iso);
-  return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : '';
-};
-
-/** A timestamp in the site's own timezone (Beheer → Instellingen). Exported so
- *  surfaces outside the EJS pages (the Guardian PWA) read the same clock. */
-export const formatDateTime = (iso) => {
-  const d = parseStamp(iso);
-  return d ? d.toLocaleString('nl-NL', { timeZone: siteTimezone(), dateStyle: 'medium', timeStyle: 'short' }) : '';
+const formatDateTime = (iso) => {
+  if (!iso) return '';
+  const d = new Date(iso);
+  return d.toLocaleString('nl-NL', { dateStyle: 'medium', timeStyle: 'short' });
 };
 
@@ -124,140 +36,20 @@
   const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
 
-  // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
-  // under the same URL and serving it as a full page on "back" → unstyled HTML.
-  // Vary: HX-Request separates partial and full responses in the cache;
-  // no-store on the partial forces "back" to always re-fetch the full page.
-  // (Vary also applies to intermediate caches / Cloudflare.)
-  res.setHeader('Vary', 'HX-Request');
-  // A full HTML page must always be revalidated so an online visitor gets the
-  // fresh site, never a heuristically-cached copy. no-cache (not no-store) still
-  // allows bfcache and conditional requests. Partials stay no-store (see above).
-  res.setHeader('Cache-Control', isPartial ? 'no-store' : 'no-cache');
-
-  // Does this (non-god) user own a site? Determines whether they see an "Admin"
-  // entry (artist self-manage). god always sees admin (by role).
-  let _u = req.session?.user || null;
-  // Refresh avatar + role from the DB so a stale session (e.g. after an
-  // avatar change or role switch) heals itself without a new login.
-  if (_u && _u.id) {
-    const _fresh = db.prepare('SELECT role, lang FROM users WHERE id = ?').get(_u.id);
-    if (_fresh) _u = { ..._u, role: _fresh.role, lang: _fresh.lang };
-    // ONE image: a user's avatar everywhere (nav, account, comments) is simply their SITE
-    // photo — there is no separate account avatar. Falls back to the initial-letter
-    // placeholder when the site has no photo yet.
-    const _sp = db.prepare("SELECT profile_photo FROM sites WHERE owner_id = ? AND profile_photo IS NOT NULL ORDER BY is_primary DESC, created_at ASC LIMIT 1").get(_u.id);
-    _u = { ..._u, avatar_url: (_sp && _sp.profile_photo) || null };
-  }
-  const userOwnsSite = !!(_u && _u.role !== 'god' &&
-    db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
-
-  const _site = data.site || res.locals.site || null;
-  // The site header uses the SITE photo (site.profile_photo) — the one and only image,
-  // set in site settings. (No separate account-avatar fallback anymore.)
-  const siteOwnerAvatar = null;
-
-  // Viewer mode: may view everything, change nothing. Views use canMutate
-  // to hide/disable write buttons (post, save, delete).
-  const _isViewer = isViewer(_u);
-
-  // Embeds are framed broadly (frame-src https: globally), EXCEPT on authorize_interaction:
-  // that page renders untrusted remote content next to the interact buttons, so lock its
-  // frame-src down to 'self' (no embeds → no clickjacking/overlay over the buttons).
-  if (viewName === 'pages/authorize-interaction') {
-    try {
-      const csp = res.getHeader('Content-Security-Policy');
-      if (csp) res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src [^;]*/i, "frame-src 'self'"));
-    } catch { /* best-effort */ }
-  }
-
-  // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
-  // and a viewer (may view Admin read-only). One source of truth,
-  // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
-  // for those who should see it (viewer didn't see it anywhere before).
-  const _role = _u ? _u.role : null;
-  const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
-  // Who may use the fediverse client (timeline/notifications/blocking) — actual
-  // site managers only (these routes are requireSiteManager; viewers are excluded).
-  const canManageFedi = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
-
-  // Interface language: session choice (this session) → logged-in user's own preference
-  // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
-  const _lang = resolveLang(req, {
-    userLang: _u && _u.lang,
-    defaultLang: getSetting('default_lang'),
-  });
-
   // Common locals
   const locals = {
-    user: _u,
-    lang: _lang,
-    t: (key, vars) => i18nT(_lang, key, vars),
-    langs: LANGS.map((c) => ({ code: c, name: LANG_NAMES[c], active: c === _lang })),
-    timezone: getSetting('timezone') || '',
-    notifUnread: (canManageFedi && _site) ? ActivityPubService.countUnseenNotifications(_site.slug) : 0,
-    userOwnsSite,
-    canSeeBeheer,
-    canManageFedi,
-    apEnabled: apEnabled(),
-    // Cirkel = the artists you feature (auto-boost): shown when AP is on and
-    // you auto-boost at least one account.
-    hasCirkel: !!(_site && apEnabled() && (ActivityPubService.autoBoostCount(_site.slug) > 0 || ActivityPubService.boostedCount(_site.slug) > 0)),
-    isViewer: _isViewer,
-    canMutate: !_isViewer,
-    isPremium: isPremiumInstance(),
-    premiumEnabled: premiumEnabled(),
-    premiumUnlocked: premiumUnlocked(),
-    siteOwnerAvatar,
-    site: _site,
-    audioEnabled: audioFeatureEnabled(),
+    user: req.session?.user || null,
+    site: data.site || res.locals.site || null,
     audioTracks: data.audioTracks || res.locals.audioTracks || [],
     siteUrlBase: res.locals.siteUrlBase || '',
-    footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
-    agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
     platforms_catalog: PLATFORMS_CATALOG,
     permissions: PermissionsService,
     formatDate,
     formatDateTime,
-    // Render the Shaer-native bits server-side so the web timeline matches the
-    // apps: FEP-9098 custom emojis in content/names, and the FEP-044f quote.
-    emojiHtml,   // (html, emoji_json) → HTML with :shortcode: as <img>
-    emojiName,   // (text, emoji_json) → escaped name with :shortcode: as <img>
-    noteQuote: parseQuote,   // (quote_json) → the resolved quoted-post object or null
-    // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
-    // (crisp grid/list images). External URLs + already-thumb URLs pass through.
-    thumb: (url, w) => {
-      if (!url || typeof url !== 'string') return url;
-      // Local cover → local thumb route; remote (federated) cover → signed downscale
-      // proxy (same as avatars), so remote line-art covers aren't browser-downscaled jagged.
-      if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 480}/${url.slice(7)}`;
-      if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 480);
-      return url;
-    },
-    // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
-    // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
-    // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
-    avatar: (url, w) => {
-      if (!url || typeof url !== 'string') return url;
-      if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
-      if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
-      return url;
-    },
-    // pageTitleKey (translated with the resolved language) wins over a raw pageTitle string,
-    // so admin page titles aren't hardcoded in one language. Falls back to the site title.
-    pageTitle: (data.pageTitleKey ? i18nT(_lang, data.pageTitleKey, data.pageTitleVars) : data.pageTitle)
-      || (data.site && data.site.title) || 'Klonkt',
-    appVersion: APP_VERSION,
+    pageTitle: data.pageTitle || (data.site && data.site.title) || 'PrutCMS',
     bodyClass: data.bodyClass || 'on-home',
-    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
-    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
-    // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
-    // pad.
-    pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
     socialDescr: data.socialDescr || '',
     socialImage: data.socialImage || '',
     cspNonce: () => '',
     currentPath: req.path,
-    // Absolute origin (for building absolute URLs like the generated og:image).
-    ogOrigin: (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host') || ''}`).replace(/\/+$/, ''),
     ...data,
   };
@@ -269,32 +61,7 @@
 
     if (isPartial) {
-      // A "Load more" append (hx-swap=beforeend into a sub-list) is NOT a
-      // navigation: it only adds rows to the existing page. It must NOT touch
-      // the site chrome or the body class. Emitting the nav HX-Trigger + OOB
-      // chrome here (below) rebuilds the header for the DEFAULT bodyClass —
-      // which, on a 'on-special' page like Messages, swaps in the full Klonkt
-      // header that the page had hidden. So for an append, send content only.
-      if (req.query.append === '1') {
-        return res.send(injectCspNonce(pageContent, res.locals.cspNonce));
-      }
-      // HTMX: just send the content. Set HX-Trigger for body class swap.
-      // HTTP-header values are Latin-1 only — a title with an em-dash, smart
-      // quote or emoji (e.g. "Welkom — gebouwd met Klonkt") would make
-      // setHeader throw ERR_INVALID_CHAR and 500 the partial, so the card
-      // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
-      // ASCII-safe and remains valid JSON that htmx parses back unchanged.
-      // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
-      // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
-      // so the client updates them — otherwise an artist inherits the previous page's
-      // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
-      const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
-        ? _site.accent : '#e8b04b';
-      const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
-      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
-      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
-      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
-      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
-      const triggerJson = JSON.stringify({
-        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
+      // HTMX: just send the content. Set HX-Trigger for body class swap
+      res.setHeader('HX-Trigger-After-Settle', JSON.stringify({
+        pcmsNav: { bodyClass: locals.bodyClass },
         pcmsPostSwap: data.post ? {
           title: data.post.title,
@@ -302,26 +69,11 @@
           pageTitle: locals.pageTitle,
         } : null,
-      }).replace(/[-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
-      res.setHeader('HX-Trigger-After-Settle', triggerJson);
-      // Render the site chrome out-of-band so the header (topnav/profile header/
-      // view-switcher) ALWAYS matches the new page/artist on navigation —
-      // while the audio player (separate in document.body) keeps playing (no
-      // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
-      // if it fails, the old chrome remains (no crash).
-      let oobChrome = '';
-      try {
-        oobChrome = await ejs.renderFile(
-          path.join(VIEWS_DIR, 'partials', 'chrome.ejs'),
-          { ...locals, oob: true },
-          { async: false },
-        );
-      } catch (e) { /* skip chrome OOB */ }
-      return res.send(injectCspNonce(pageContent + oobChrome, res.locals.cspNonce));
+      }));
+      return res.send(pageContent);
     }
 
-    // Full: wrap content in shell (rendered to a string so we can inject the CSP nonce).
+    // Full: wrap content in shell
     locals.pageContent = pageContent;
-    const shellHtml = await ejs.renderFile(path.join(VIEWS_DIR, 'shell.ejs'), locals, { async: false });
-    res.send(injectCspNonce(shellHtml, res.locals.cspNonce));
+    res.render('shell', locals);
   } catch (err) {
     console.error('[renderPage] Error rendering', viewName, err);
Index: src/middleware/site.js
===================================================================
--- src/middleware/site.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/middleware/site.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,52 +1,47 @@
 /**
  * Site middleware — resolve which site this request is for.
- *
- * Resolution order (hub-modus):
- *   1. Pad /user/:slug → die site  (legacy /sites/:slug → 301 naar /user/)
- *   2. Anders (solo, of hub-landing): de primaire/hoofd-site
- *
+ * 
+ * Resolution order:
+ *   1. Path /sites/:slug → that site
+ *   2. (Future) Subdomain bedrijf1.example.com → matching site
+ *   3. Default site (first one in DB)
+ * 
  * Sets res.locals.site for all downstream handlers.
  */
 
 import db from '../config/database.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { audioEnabled } from '../config/features.js';
-import * as Guardianship from '../services/guardianship/index.js';
-
-/**
- * The primary/main site — ONE source of truth (replaces the "oldest site ="
- * main" assumption that was previously scattered across resolveSite/hub/account/admin).
- * Reads the explicit is_primary flag; falls back to the oldest if it isn't set
- * anywhere yet, so existing behaviour is preserved exactly.
- */
-export function getPrimarySite() {
-  return db.prepare('SELECT * FROM sites WHERE is_primary = 1 LIMIT 1').get()
-      || db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get()
-      || null;
-}
 
 export function resolveSite(req, res, next) {
-  // One instance is one owner (Robins besluit, 31-7): there is one site tree,
-  // pinned to the primary site. The /user/:slug routing that hub mode needed
-  // is gone with it.
-  const defaultSite = getPrimarySite();
+  // Try /sites/:slug pattern
+  const m = req.path.match(/^\/sites\/([a-zA-Z0-9_-]+)(\/.*)?$/);
+  if (m) {
+    const slug = m[1];
+    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+    if (site) {
+      res.locals.site = site;
+      // Strip /sites/:slug from req.url so downstream routes see the rest
+      req.url = (m[2] || '/');
+      // Also rewrite originalUrl for redirect targets to keep the prefix
+      res.locals.siteUrlBase = `/sites/${slug}`;
+      return next();
+    }
+  }
+
+  // Future: subdomain mapping
+  const host = req.get('host')?.toLowerCase().replace(/:\d+$/, '');
+  if (host) {
+    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(host);
+    if (site) {
+      res.locals.site = site;
+      res.locals.siteUrlBase = '';
+      return next();
+    }
+  }
+
+  // Default: pick first site
+  const defaultSite = db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
   if (defaultSite) {
     res.locals.site = defaultSite;
     res.locals.siteUrlBase = '';
-    // Mag deze account antwoorden (shaer-r4c)? Eén keer hier, zodat de
-    // antwoordvelden in de views hem kunnen lezen zonder dat elke route hem
-    // apart doorgeeft. De server weigert het antwoord toch al in deliverReply;
-    // dit voorkomt alleen dat een kind tegen een deur duwt die op slot zit.
-    try {
-      const isWard = Guardianship.listGuardians(defaultSite.slug).length > 0;
-      res.locals.mayReply = Guardianship.wardGateAllowed(defaultSite.gate_replies, isWard);
-    } catch { res.locals.mayReply = true; }
-    // Verhuisd (FEP-7628)? Dan staat de uitgaande kant op slot. Om dezelfde reden
-    // hier en niet per route: elke view moet kunnen grijzen wat toch geweigerd
-    // wordt. Een knop die niets doet is erger dan geen knop, want je gaat zoeken
-    // naar een storing die er niet is. De poort zelf zit in de service; dit is
-    // alleen de deurbel die zegt dat er niet opengedaan wordt.
-    res.locals.movedTo = defaultSite.moved_to && /^https?:\/\//i.test(String(defaultSite.moved_to))
-      ? String(defaultSite.moved_to) : null;
   }
 
@@ -61,5 +56,4 @@
  */
 export function loadAudioTracks(req, res, next) {
-  if (!audioEnabled()) { res.locals.audioTracks = []; return next(); }   // lite-modus
   const site = res.locals.site;
   if (!site || site.enable_audio_player === 0) {
@@ -69,9 +63,7 @@
 
   try {
-    // m.filename = the bare filename; the playable URL is the gated stream route
-    // (audioUrl). The media table has NO url column — the old query selected
-    // m.url and always failed silently (empty player). Now we build the URL from filename.
-    const rows = db.prepare(`
-      SELECT t.id, t.title, t.artist, t.duration, t.position, m.filename
+    res.locals.audioTracks = db.prepare(`
+      SELECT t.id, t.title, t.artist, t.duration, t.position,
+             m.url AS media_url
       FROM audio_tracks t
       LEFT JOIN media m ON m.id = t.media_id
@@ -79,8 +71,4 @@
       ORDER BY t.position ASC, t.created_at ASC
     `).all(site.id);
-    res.locals.audioTracks = rows.map((r) => ({
-      id: r.id, title: r.title, artist: r.artist, duration: r.duration, position: r.position,
-      media_url: r.filename ? audioUrl(r.filename) : null,
-    }));
   } catch (e) {
     // media table might not be queryable in some test setups — fall back gracefully
@@ -95,17 +83,14 @@
  */
 export function loadTheme(req, res, next) {
-  const PALETTES = ['klonkt','forest','ocean','teal','lilac','sunset','candy','amber'];
+  const PALETTES = ['sage','paper','ocean','forest','stone','midnight','sunset','cream'];
   
   const user = req.session?.user;
   const site = res.locals.site;
   
-  // A site always renders in ITS OWN palette, regardless of who is viewing. There is
-  // no per-user palette UI (user.palette is vestigial/stale data from old migrations),
-  // and the htmx pcmsNav path (render.js) already uses the site palette only — so reading
-  // user.palette here made a full page load (owner logged in) flip to the viewer's stale
-  // palette while htmx-nav kept the site's, i.e. "palette changes on hard refresh".
-  const palette = (site && PALETTES.includes(site.palette) ? site.palette : null)
-                || 'klonkt';
-
+  // Priority: user setting > site setting > default
+  const palette = (user && PALETTES.includes(user.palette) ? user.palette : null)
+                || (site && PALETTES.includes(site.palette) ? site.palette : null)
+                || 'sage';
+  
   res.locals.palette = palette;
   res.locals.theme = (user && ['dark','light'].includes(user.theme)) ? user.theme : 'dark';
Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/account.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -16,18 +16,16 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import bcrypt from 'bcryptjs';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
-import { getPrimarySite } from '../middleware/site.js';
 import { renderPage } from '../middleware/render.js';
-import OAuth from '../services/OAuthService.js';
-import { t } from '../services/i18n.js';
 import { requireAuth } from '../middleware/auth.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { SUPPORTED } from '../services/i18n.js';
-import { mediaDir } from '../config/paths.js';
 
-const AVATAR_DIR = mediaDir('AVATAR_PATH', 'avatars');
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const AVATAR_DIR = path.resolve(
+  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
+);
 fs.mkdirSync(AVATAR_DIR, { recursive: true });
 
@@ -59,21 +57,12 @@
 router.get('/', requireAuth, (req, res) => {
   const account = db.prepare(`
-    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub, lang
+    SELECT id, username, email, role, bio, avatar_url, created_at
     FROM users WHERE id = ?
   `).get(req.session.user.id);
-  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
-  if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
 
-  const editableSite = ownedSite(req.session.user);
   renderPage(req, res, 'pages/account', {
     pageTitle: 'Account',
     bodyClass: 'on-special',
     account,
-    hasPassword,
-    editableSite,
-    // Display fallback: when you have no own account avatar, show your site's photo.
-    siteAvatar: editableSite ? editableSite.profile_photo : null,
-    // OAuth apps (C2S) this user has authorized, so they can revoke them here.
-    authorizations: OAuth.listAuthorizations(req.session.user.id),
     success: req.query.success || null,
     error: req.query.error || null,
@@ -81,95 +70,10 @@
 });
 
-// ==================== REVOKE AN OAUTH APP AUTHORIZATION ====================
-router.post('/oauth/revoke', requireAuth, (req, res) => {
-  const lang = req.session.lang || (req.session.user && req.session.user.lang) || 'nl';
-  const ok = OAuth.revokeAuthorization(req.session.user.id, req.body.token_hash);
-  const msg = ok ? t(lang, 'acct.oauth_revoked') : t(lang, 'acct.oauth_revoke_none');
-  res.redirect('/account?' + (ok ? 'success' : 'error') + '=' + encodeURIComponent(msg));
-});
-
-// ==================== PERSONAL INTERFACE LANGUAGE ====================
-// Saves the language choice on the account (persists across devices/sessions) and
-// also sets it in the session immediately so it takes effect right away.
-router.post('/lang', requireAuth, (req, res) => {
-  const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
-  if (code) {
-    db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
-    req.session.user.lang = code;
-    req.session.lang = code;
-  }
-  res.redirect('/account?success=' + encodeURIComponent('Taal opgeslagen'));
-});
-
-// The site this user may edit from their account: their own site
-// (owner_id), or for a god the primary site. Null if nothing found.
-function ownedSite(user) {
-  if (!user) return null;
-  let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
-  if (!site && user.role === 'god') {
-    site = getPrimarySite(); // primary/main site as fallback
-  }
-  return site || null;
-}
-
-// ==================== UPDATE SITE-NAAM (eigenaar) ====================
-router.post('/site', requireAuth, (req, res) => {
-  const site = ownedSite(req.session.user);
-  if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
-  if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
-    return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
-  }
-  const title = (req.body.site_title || '').toString().slice(0, 200).trim();
-  if (!title) return res.redirect('/account?error=' + encodeURIComponent('Site-naam mag niet leeg zijn.'));
-  const tagline = (req.body.site_tagline || '').toString().slice(0, 200).trim();
-  db.prepare('UPDATE sites SET title = ?, tagline = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-    .run(title, tagline || null, site.id);
-  res.redirect('/account?success=' + encodeURIComponent('Site-naam bijgewerkt'));
-});
-
 // ==================== UPDATE BIO ====================
-const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']);
-
 router.post('/profile', requireAuth, (req, res) => {
   const bio = (req.body.bio || '').toString().slice(0, 500).trim();
-
-  // Username (login + display name; does NOT affect the fediverse handle, which
-  // is the site slug). Validate: format + reserved + unique (case-insensitive).
-  const username = (req.body.username || '').toString().trim();
-  if (username && username !== req.session.user.username) {
-    if (!/^[A-Za-z0-9_-]{2,30}$/.test(username)) {
-      return res.redirect('/account?error=' + encodeURIComponent('Gebruikersnaam: 2-30 tekens; letters, cijfers, _ en - .'));
-    }
-    if (RESERVED_USERNAMES.has(username.toLowerCase())) {
-      return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is gereserveerd.'));
-    }
-    const uTaken = db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(username, req.session.user.id);
-    if (uTaken) {
-      return res.redirect('/account?error=' + encodeURIComponent('Die gebruikersnaam is al in gebruik.'));
-    }
-    db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(username, req.session.user.id);
-    req.session.user.username = username;
-  }
-
-  // Email (optionally also changed). Validation: valid format + not already in use
-  // by another account. Email is the login/reset anchor, so it must be unique.
-  const email = (req.body.email || '').toString().trim();
-  if (email) {
-    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
-      return res.redirect('/account?error=' + encodeURIComponent('Voer een geldig e-mailadres in.'));
-    }
-    const taken = db.prepare('SELECT 1 FROM users WHERE LOWER(email) = LOWER(?) AND id != ?')
-      .get(email, req.session.user.id);
-    if (taken) {
-      return res.redirect('/account?error=' + encodeURIComponent('Dit e-mailadres is al in gebruik.'));
-    }
-    db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-      .run(email, req.session.user.id);
-    req.session.user.email = email; // update session so the UI reflects the change
-  }
-
   db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(bio || null, req.session.user.id);
-  res.redirect('/account?success=' + encodeURIComponent('Profiel bijgewerkt'));
+  res.redirect('/account?success=' + encodeURIComponent('Profile updated'));
 });
 
@@ -184,20 +88,16 @@
   const { current, new_password, confirm } = req.body;
   if (!current || !new_password || !confirm) {
-    return res.redirect('/account?error=' + encodeURIComponent('Alle wachtwoordvelden zijn verplicht'));
+    return res.redirect('/account?error=' + encodeURIComponent('All password fields required'));
   }
   if (new_password.length < 8) {
-    return res.redirect('/account?error=' + encodeURIComponent('Nieuw wachtwoord moet minstens 8 tekens zijn'));
+    return res.redirect('/account?error=' + encodeURIComponent('New password must be at least 8 characters'));
   }
   if (new_password !== confirm) {
-    return res.redirect('/account?error=' + encodeURIComponent('Nieuwe wachtwoorden komen niet overeen'));
+    return res.redirect('/account?error=' + encodeURIComponent('New passwords do not match'));
   }
 
   const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
-  // Google-only accounts (listeners) have no real password.
-  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
-    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
-  }
-  if (!bcrypt.compareSync(current, row.password_hash)) {
-    return res.redirect('/account?error=' + encodeURIComponent('Huidig wachtwoord is onjuist'));
+  if (!row || !bcrypt.compareSync(current, row.password_hash)) {
+    return res.redirect('/account?error=' + encodeURIComponent('Current password is incorrect'));
   }
 
@@ -206,5 +106,5 @@
     .run(newHash, req.session.user.id);
 
-  res.redirect('/account?success=' + encodeURIComponent('Wachtwoord gewijzigd'));
+  res.redirect('/account?success=' + encodeURIComponent('Password changed'));
 });
 
@@ -219,5 +119,5 @@
     }
 
-    const url = `/media/avatars/${toWebp(req.file)}`;
+    const url = `/media/avatars/${req.file.filename}`;
 
     // Remove the old avatar file (if it lives in our avatar dir)
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1496 +1,0 @@
-/**
- * ActivityPub — public endpoints (Phase 1: discover + fetch).
- *
- *   GET /.well-known/webfinger?resource=acct:<slug>@<host>
- *   GET /ap/users/:slug            actor (content-negotiated: AP-JSON vs redirect to HTML profile)
- *   GET /ap/users/:slug/outbox     OrderedCollection of Create(Note)
- *   GET /ap/users/:slug/followers  count-only OrderedCollection
- *   GET /ap/users/:slug/featured   pinned posts (Mastodon "Featured" tab)
- *   GET /ap/notes/:id              a single Note
- *   POST /ap/users/:slug/inbox, /ap/inbox  → 202 (Follow/Accept + signature verify: next step)
- *
- * Mounted before resolveSite; resolves the site by slug itself.
- */
-import express from 'express';
-import { readFileSync } from 'fs';
-import db, { isoSql } from '../config/database.js';
-import AP from '../services/ActivityPubService.js';
-import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
-import { apEnabled } from '../services/SettingsService.js';
-import OAuth from '../services/OAuthService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import * as Migration from '../services/MigrationService.js';
-import { getPrimarySite } from '../middleware/site.js';
-import multer from 'multer';
-import path from 'path';
-import fs from 'fs';
-import { randomUUID } from 'crypto';
-import { mediaDir } from '../config/paths.js';
-
-const router = express.Router();
-
-/**
- * Welke pagina vraagt de lezer? (shaer-sk4)
- *
- * Hier stond `!!req.query.page` -- of de parameter er STAAT, niet welke. Daardoor
- * gaf ?page=2 en ?page=99 allemaal pagina 1, en noemde het antwoord zichzelf ook
- * nog pagina 1. Onleesbaar getal of geen parameter: dan de wortel.
- */
-function paginaNr(req) {
-  if (req.query.page === undefined) return false;
-  const n = Math.floor(Number(req.query.page));
-  return Number.isFinite(n) && n > 0 ? n : 1;
-}
-// The whole fediverse layer can be turned off (solo "no federation" mode):
-// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
-// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
-// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
-// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
-// (the /ap/* paths then fall through to the app's normal 404, which is correct).
-router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
-// Generous per-IP baseline over the AP-READ paths. The inbox POST gets an
-// additional, tighter cap inline (it triggers outbound fetches).
-//
-// PADGEBONDEN, niet router.use kaal (Barts 429-jacht, 9-8): deze router is op
-// de ROOT gemonteerd, dus een kale use() draait voor ELKE request van de hele
-// site -- pagina's, media, avatars, de PWA. De guardian-PWA met honderd
-// ward-avatars leegde zo in seconden een emmer die "voor /ap-reads" heette,
-// en hield hem leeg: vandaar een Too many requests die niet overging. De
-// kijkbuis die dit vond: een lege /ap-teller naast remaining: 0.
-router.use(['/ap', '/.well-known', '/nodeinfo'], apReadLimiter);
-let _ver = '1.0.0';
-try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
-const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
-// The primary site, via the one source of truth in middleware/site.js — which
-// falls back to the oldest site when nothing carries the is_primary flag. This
-// route used to keep its own is_primary-only copy, so a fresh instance whose
-// site was never flagged served its HTML at / (that resolver falls back) while
-// WebFinger and the actor route insisted it had no primary at all.
-const primarySlug = () => { const s = getPrimarySite(); return s && s.slug; };
-// A hostname as a human types it and as DNS stores it are the same host:
-// `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
-// so compare the ASCII form and never the bytes the client happened to send.
-const asciiHost = (h) => {
-  try { return new URL(`https://${h}`).host.toLowerCase(); } catch { return String(h).trim().toLowerCase(); }
-};
-
-// ── host-meta ─────────────────────────────────────────────────────
-// De klassieke eerste stap van WebFinger (RFC 6415): een client die het
-// webfinger-pad niet wil raden, vraagt hier de sjabloon op. Mastodon serveert
-// dit ook, en een client die ermee begint kreeg bij ons een 404 en gaf het dan
-// op -- terwijl de webfinger eronder gewoon werkte.
-//
-// Twee vormen, want beide worden in het wild gevraagd: XRD (het origineel) en
-// JRD (de JSON-variant, RFC 6415 §3).
-const lrddSjabloon = (req) => `${baseUrl(req)}/.well-known/webfinger?resource={uri}`;
-
-router.get('/.well-known/host-meta', (req, res) => {
-  res.type('application/xrd+xml; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(`<?xml version="1.0" encoding="UTF-8"?>
-<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
-  <Link rel="lrdd" template="${lrddSjabloon(req)}"/>
-</XRD>`);
-});
-
-router.get('/.well-known/host-meta.json', (req, res) => {
-  res.type('application/jrd+json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(JSON.stringify({ links: [{ rel: 'lrdd', template: lrddSjabloon(req) }] }));
-});
-
-// ── WebFinger ─────────────────────────────────────────────────────
-/**
- * De `resource` uitpakken tot de gebruiker die bedoeld wordt.
- *
- * RFC 7033 schrijft een URI voor, en `acct:` is de nette vorm -- maar in het
- * wild komen er vier spellingen langs, en drie daarvan wezen we af met een 400
- * terwijl we prima wisten wie er bedoeld werd:
- *
- *   acct:naam@host      de nette vorm (Mastodon stuurt altijd deze)
- *   naam@host           zonder schema
- *   @naam@host          met het apenstaartje dat mensen intypen
- *
- * Coulant zijn kost hier niets: het antwoord noemt altijd de canonieke
- * `acct:`-vorm terug, dus een slordige vraag levert geen slordig antwoord.
- *
- * De ACTOR-URI als resource (die Mastodon ook accepteert) hoort hier NIET bij,
- * bewust: test/webfinger-bare-host.test.js legt vast dat die een 400 geeft.
- * Dat is een uitgesproken keuze van eerder en geen vergetelheid, dus die draai
- * ik niet om als bijvangst van een coulance-fix.
- */
-function webfingerGebruiker(resource) {
-  const r = String(resource || '').trim();
-  if (!r) return null;
-  const acct = r.match(/^(?:acct:)?@?([^@/]+)@(.+)$/i);
-  return acct ? acct[1] : null;
-}
-
-/**
- * Is dit een vraag naar de WORTEL van deze server?
- *
- * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
- * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
- * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
- * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
- * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
- * alles met een pad niet.
- */
-function isEigenWortel(resource, req) {
-  const r = String(resource || '').trim();
-  if (!/^https?:\/\//i.test(r)) return false;
-  try {
-    const u = new URL(r);
-    if (u.pathname && u.pathname !== '/') return false;
-    if (u.search || u.hash) return false;
-    return asciiHost(u.host) === asciiHost(hostOf(req));
-  } catch { return false; }
-}
-
-router.get('/.well-known/webfinger', (req, res) => {
-  if (isEigenWortel(req.query.resource, req)) {
-    res.type('application/jrd+json; charset=utf-8');
-    res.set('Cache-Control', 'public, max-age=300');
-    return res.send(JSON.stringify({
-      subject: baseUrl(req) + '/',
-      links: [
-        // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
-        // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
-        // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
-        { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
-      ],
-    }));
-  }
-  const user = webfingerGebruiker(req.query.resource);
-  if (!user) return res.status(400).type('text/plain').send('bad resource');
-  let site = publicSite(user);
-  // `acct:<host>@<host>` asks for this server's primary actor — the convention
-  // Shaer's Handle relies on so a Ward is reachable without knowing anyone's
-  // slug. Typing `🩵.is.wildenvrij.nl`, pasting `https://🩵.is.wildenvrij.nl`
-  // (which the client's URL parser silently punycodes) and sending the xn--
-  // form by hand are three spellings of one address; all arrive here with the
-  // host sitting in the user position, and all must find the same actor.
-  if (!site && asciiHost(user) === asciiHost(hostOf(req))) {
-    const slug = primarySlug();
-    if (slug) site = publicSite(slug);
-  }
-  if (!site) return res.status(404).end();
-  res.type('application/jrd+json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=300');
-  const actorUri = AP.actorId(baseUrl(req), site.slug);
-  const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
-  res.send(JSON.stringify({
-    subject: `acct:${site.slug}@${hostOf(req)}`,
-    aliases: [actorUri, profileUrl],
-    links: [
-      { rel: 'self', type: 'application/activity+json', href: actorUri },
-      { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
-      // FEP-61cf: hier stuurt een doelsite deze gebruiker heen om zich te
-      // bewijzen. Zonder deze regel valt zo'n site terug op /magic geraden --
-      // wat toevallig klopt, maar raden is geen afspraak.
-      { rel: 'http://purl.org/openwebauth/v1#redirect', href: baseUrl(req) + '/magic' },
-    ],
-  }));
-});
-
-// ── Actor ─────────────────────────────────────────────────────────
-router.get('/ap/users/:slug', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (!AP.apWants(req)) {
-    // A browser hit the AP actor URL → send them to the human profile.
-    const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
-    return res.redirect(302, baseUrl(req) + human);
-  }
-  site.primary_slug = primarySlug();
-  AP.sendAP(res, AP.buildActor(baseUrl(req), site));
-});
-
-// ── Outbox ────────────────────────────────────────────────────────
-router.get('/ap/users/:slug/outbox', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  // Authorized fetch (30-7): who is asking decides what they see.
-  //  - the owner's own app (bearer) and a verified accepted follower or
-  //    guardian get the friends-only history too, so a NEW friend's backfill
-  //    brings the past along (Robins besluit: vrienden krijgen de
-  //    geschiedenis mee);
-  //  - a verified caller this instance BLOCKS gets an EMPTY collection, not
-  //    even the public set: a block is a closed door, and a signed fetch is
-  //    the caller knocking with their name on it;
-  //  - everyone else gets the public collection, exactly as before.
-  const bearer = OAuth.verifyBearer(req.headers.authorization);
-  let verifiedActor = null;
-  if (!bearer && req.headers['signature']) {
-    const verified = await AP.verifyRequest(req).catch(() => null);
-    verifiedActor = verified && verified.id;
-  }
-  const audience = AP.outboxAudience(req.params.slug, {
-    bearerSlug: bearer ? bearer.site.slug : null,
-    verifiedActor,
-  });
-  if (audience === 'blocked') {
-    return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: paginaNr(req) }), 'private, no-store');
-  }
-  // ECHT DOORBLADEREN (shaer-sk4). Hier stonden twintig posts uit SQL met een
-  // tweede kap van twintig eroverheen: alles daarvoor was niet op een volgende
-  // pagina maar helemaal onbereikbaar. outboxSlice pagineert over de UNION van
-  // posts en tracks, want die vlechten op datum en zijn met twee losse queries
-  // niet te offsetten.
-  //
-  // De tracks gaan mee voor iedereen die de deur door mag; de blocked-tak
-  // hierboven levert bewust een outbox ZONDER posts en zonder tracks.
-  const nr = paginaNr(req);
-  const { posts, tracks, totaal } = AP.outboxSlice(site.id, {
-    fanOnly: audience === 'friend',
-    offset: (Math.max(1, nr || 1) - 1) * AP.PAGINA_GROOTTE,
-    limit: AP.PAGINA_GROOTTE,
-  });
-  // FEP-1580: de instantie waar dit account naartoe verhuisd is krijgt de
-  // RAUWE inhoud, met [[track:]] en [[playlist:]] er nog in. Zij is een
-  // Klonkt en rendert die zelf tot een speler. De gebakken variant komt
-  // daar aan als tekstlink, en is bovendien onherstelbaar afgeknot: het
-  // bakken plakt hooguit vier titels aan.
-  const rauweInhoud = verifiedActor ? AP.isMoveTarget(site.slug, verifiedActor) : false;
-  const ob = AP.buildOutbox(baseUrl(req), site, posts, tracks, { page: nr, totalItems: totaal, alGesneden: true, rauweInhoud });
-  if (audience === 'friend') {
-    // The owner's app builds its feed from this leg, and every note here is
-    // by the site itself, so give it a byline too (avatar + name): de
-    // ingesloten actor in attributedTo, net als de tijdlijn.
-    const me = AP.selfAuthor(baseUrl(req), site);
-    // De kaart op je eigen post (shaer-k3f): dezelfde quote/preview die de
-    // tijdlijn voor andermans posts draagt, uit de snapshots die
-    // deliverCreate bij het publiceren opsloeg. Op note-id gekoppeld, want
-    // buildOutbox sorteert en mengt tracks erdoorheen. De embed alleen voor de
-    // BEARER en langs zijn eigen poort: een remote vriend krijgt hem niet
-    // (diens server resolvet en gate zelf bij ontvangst), en een ward zonder
-    // open embeds-poort krijgt hem hier net zo min als in de tijdlijn.
-    const byNote = new Map(posts.map((p) => [AP.noteId(baseUrl(req), p.id), p]));
-    const bearerEmbeds = bearer ? (() => {
-      const isWard = (() => { try { return Guardianship.listGuardians(bearer.site.slug).length > 0; } catch { return false; } })();
-      return Guardianship.externalEmbedsAllowed(bearer.site.external_embeds, isWard)
-        ? { playback: Guardianship.externalPlaybackAllowed(bearer.site.external_playback, isWard) } : null;
-    })() : null;
-    for (const it of ob.orderedItems) {
-      if (it && it.object && typeof it.object === 'object') {
-        it.object.attributedTo = AP.actorObject(
-          (typeof it.object.attributedTo === 'string' ? it.object.attributedTo : undefined) || AP.actorId(baseUrl(req), site.slug),
-          me,
-        );
-        const row = byNote.get(it.object.id);
-        if (row) {
-          it.object.quote = AP.quoteObject(row.quote_json);
-          if (bearerEmbeds) {
-            it.object.preview = AP.previewObject(row.embed_json, { playback: bearerEmbeds.playback });
-          }
-        }
-      }
-    }
-  }
-  AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
-});
-
-// ── Follow-QR (Robins verzoek, 31-7) ──────────────────────────────
-// The QR carries an HTTPS url, not the share: scheme: camera apps (Google
-// Lens voorop) treat unknown schemes as plain text and only offer to OPEN
-// https links (Robins melding, 31-7). The url lands on the interstitial
-// below, whose one big button fires the share: scheme — from a browser the
-// custom scheme DOES work (BROWSABLE intent-filter; Safari prompts).
-// Public on purpose: it encodes only the public handle, and the app's plain
-// image loaders carry no bearer.
-router.get('/ap/users/:slug/follow-qr.png', async (req, res) => {
-  const site = db.prepare('SELECT slug FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.status(404).end();
-  try {
-    const { default: QRCode } = await import('qrcode');
-    const png = await QRCode.toBuffer(`${baseUrl(req)}/ap/users/${encodeURIComponent(site.slug)}/follow`, { width: 600, margin: 1 });
-    res.set('Content-Type', 'image/png');
-    res.set('Cache-Control', 'public, max-age=86400');
-    res.send(png);
-  } catch (e) {
-    console.warn('[AP] follow-qr failed:', e && e.message);
-    res.status(500).end();
-  }
-});
-
-// The interstitial the QR opens: one big button into Shaer, and the handle
-// in plain sight for whoever has no Shaer (yet).
-router.get('/ap/users/:slug/follow', (req, res) => {
-  const site = db.prepare('SELECT slug, title FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.status(404).end();
-  const host = new URL(baseUrl(req)).host;
-  const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
-  const handle = `@${site.slug}@${host}`;
-  const name = esc(site.title || site.slug);
-  res.set('Cache-Control', 'public, max-age=3600');
-  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Follow ${name}</title>
-<style>
-  body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
-         background: linear-gradient(160deg, #5A32E6, #2a1a5e); color: #fff; text-align: center; }
-  main { padding: 32px; max-width: 420px; }
-  h1 { font-size: 1.5rem; margin: 0 0 .4rem; }
-  .handle { opacity: .85; font-family: ui-monospace, monospace; word-break: break-all; }
-  a.go { display: block; margin: 28px auto 14px; padding: 16px 28px; border-radius: 999px; background: #fff; color: #2a1a5e;
-         font-weight: 700; font-size: 1.15rem; text-decoration: none; }
-  p.small { font-size: .85rem; opacity: .75; line-height: 1.5; }
-</style></head><body><main>
-  <h1>Follow ${name}</h1>
-  <div class="handle">${esc(handle)}</div>
-  <a class="go" href="share:social/follow/AP/${esc(handle)}">Open in Shaer</a>
-  <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
-</main></body></html>`);
-});
-
-/** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */
-function authorInfoFrom(r, prefix, gates) {
-  const info = {
-    name: r[`${prefix}name`] || undefined, handle: r[`${prefix}handle`] || undefined,
-    icon: r[`${prefix}icon`] || undefined, url: r[`${prefix}url`] || undefined,
-    emojis: (() => { try { return r[`${prefix}emoji_json`] ? JSON.parse(r[`${prefix}emoji_json`]) : undefined; } catch { return undefined; } })(),
-  };
-  return (info.name || info.handle || info.icon) ? gates.gateAuthor(info) : undefined;
-}
-
-// ── Een tijdlijnpost als AS2-item: EEN beschrijving van de kaartvorm ──
-//
-// Zelfde reden als messageItem hieronder: de volledige lezing en de
-// verschil-lezing bouwen dezelfde kaart, en twee beschrijvingen lopen uit de
-// pas zonder dat iemand het merkt.
-function timelineItem(t, { p, reactions }) {
-  const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p);
-  const {
-    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed, emojiAllowed,
-  } = p;
-  const reacties = reactions || new Map();
-    const auteur = authorInfo(t, 'author_');
-    const booster = authorInfo(t, 'reblog_');
-    const boosterUri = t.reblog_url || t.reblog_handle || undefined;
-    return {
-    id: `${t.id}#create`,
-    // EEN BOOST IS EEN ANNOUNCE (shaer-nmw): een Create met een
-    // zijkanaal-property was onze uitvinding; de wrapper is de standaard, en
-    // elke AP-client leest hem al.
-    type: booster ? 'Announce' : 'Create',
-    actor: booster ? (AP.actorObject(boosterUri || t.author_uri, booster)) : t.author_uri,
-    published: t.published || t.created_at || undefined,
-    object: {
-      id: t.id,
-      type: 'Note',
-      // AS2 staat een INGESLOTEN actor toe; dan heeft elke client de byline,
-      // niet alleen de onze (shaer-nmw).
-      attributedTo: AP.actorObject(t.author_uri, auteur),
-      content: t.content,
-      url: t.url || undefined,
-      published: t.published || t.created_at || undefined,
-      sensitive: !!t.nsfw,
-      summary: t.cw || undefined,
-      // Friends' media travels along (media_json → AS2 attachment), so the
-      // client renders their images/audio like own outbox posts.
-      attachment: AP.gateAttachments(AP.timelineAttachments(t.media_json), { images: imagesAllowed, audio: musicAllowed }),
-      // The note's preserved tags, so the client can render them: FEP-9098
-      // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
-      // inline object references). Combined into one `tag` array; omitted
-      // when the note has neither.
-      tag: (() => {
-        const tags = [...(emojiAllowed ? (AP.timelineEmojis(t.emoji_json) || []) : []), ...(AP.timelineObjectLinks(t.link_json) || [])];
-        return tags.length ? tags : undefined;
-      })(),
-      // Whether THIS account already liked/boosted the note, so the app's
-      // detail-view buttons show the current state (and can toggle/undo).
-      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
-      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
-      // FEP-044f: de geciteerde post als object, zodat de client een kaart
-      // rendert in plaats van een kale link. AS2 preview is diezelfde kaart
-      // voor een EXTERNE link: thumbnail, nooit de iframe van de aanbieder.
-      // Allebei weg zodra hun poort dicht staat; de speler in preview hangt
-      // aan de playback-poort.
-      quote: quotesAllowed ? AP.quoteObject(t.quote_json) : undefined,
-      preview: embedsAllowed ? AP.previewObject(t.embed_json, { playback: playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Een inkomend antwoord op je eigen post als AS2-item. */
-function replyItem(m, { base, me, myHandle, p }) {
-  return {
-    id: `${m.object_uri}#create`,
-    type: 'Create',
-    actor: m.actor_uri,
-    published: AP.isoStamp(m.published || m.created_at),
-    object: {
-      id: m.object_uri,
-      type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
-        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
-        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
-        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
-      }) : undefined),
-      content: AP.stripLeadingMentions(m.content),
-      inReplyTo: m.parent_uri || `${base}/ap/notes/${m.post_id}`,
-      published: AP.isoStamp(m.published || m.created_at),
-      to: [me],
-      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
-      attachment: AP.timelineAttachments(m.media_json),
-      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Wat deze lezer mag (FEP-633c 5.6), op EEN plek.
- *
- *  De verschil-lezing draagt ze net zo goed: een antwoord zonder rechten zou
- *  de client naar zijn standaard laten terugvallen, en die standaard is
- *  'alles mag'. Dan zet een gesloten poort zichzelf stil open. Dezelfde reden
- *  waarom een 304 de caps met rust laat.
- */
-function capabilitiesOf(p, gate) {
-  return {
-      'shaer:externalEmbeds': p.embedsAllowed,
-      'shaer:externalPlayback': p.playbackAllowed,
-      // Leaving the app is the same decision as playing inside it: with the
-      // gate shut a link is shown but not followed, so the door is closed too
-      // and not just the picture over it.
-      'shaer:externalLinks': p.playbackAllowed,
-      // De rest van de familie (8-8): de app hoort VOORAF te weten wat hij mag
-      // aanbieden in plaats van het bij de eerste weigering te ontdekken. De
-      // (+) kaart leest shaer:compose al (Barts gate); de rest is er voor de
-      // schermen die nog komen. Serveren wat waar is kost hier niets.
-      'shaer:compose': p.composeAllowed,
-      'shaer:replies': p.repliesAllowed,
-      'shaer:messages': p.messagesAllowed,
-      'shaer:images': p.imagesAllowed,
-      'shaer:music': p.musicAllowed,
-      'shaer:quoteCards': p.quotesAllowed,
-      'shaer:customEmoji': p.emojiAllowed,
-      'shaer:externalThreads': p.threadsAllowed,
-      'shaer:following': p.followingAllowed,
-      // Stond in de catalogus mét kolom, en ontbrak hier: de guardian zag de
-      // poort in zijn paneel en de app van het kind heeft er nooit van gehoord.
-      // Gevonden door de pariteitstest, niet door iemand die het toevallig zag.
-      'shaer:accountMove': gate('gate_account_move'),
-    };
-}
-
-// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
-//
-// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
-// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
-// dat evenveel kopieen worden -- en een poort die op een van die plekken
-// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
-function gatesFor(site) {
-  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-  const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
-  const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
-  const emoji = gate('gate_custom_emoji');
-  return {
-    isWard,
-    embedsAllowed: embeds,
-    playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
-    imagesAllowed: gate('gate_images'),
-    musicAllowed: gate('gate_music'),
-    quotesAllowed: gate('gate_quote_cards'),
-    emojiAllowed: emoji,
-    messagesAllowed: gate('gate_messages'),
-    composeAllowed: gate('gate_compose'),
-    repliesAllowed: gate('gate_replies'),
-    threadsAllowed: gate('external_threads'),
-    followingAllowed: gate('gate_following'),
-    // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
-    // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
-    gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
-  };
-}
-
-/**
- * De naam waaronder deze lezer zichzelf herkent in een Mention.
- *
- * Via deriveHandle op de actor-URI, niet uit de slug hier opgebouwd. Dit stond
- * er als `@${slug}@${host}` met `@${slug}` als terugval, en die terugval is een
- * HALVE naam: zonder host zegt @dev niets op een oppervlak waar iedereen @dev
- * kan heten. Hij ging alleen af bij een onparseerbare PUBLIC_BASE_URL -- maar
- * dan klopt elke URI die we bouwen al niet, en is de kale actor-URI (wat
- * deriveHandle dan teruggeeft) eerlijker dan een naam die compleet lijkt.
- */
-function ownHandle(base, slug) {
-  return AP.deriveHandle(AP.actorId(base, slug));
-}
-
-// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
-//
-// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
-// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
-// als een kaart ergens anders rendert dan waar je keek.
-function messageItem(m, { base, me, myHandle, p }) {
-  return {
-    id: `${m.object_uri}#create`,
-    type: 'Create',
-    actor: m.actor_uri,
-    published: AP.isoStamp(m.published || m.created_at),
-    object: {
-      id: m.object_uri,
-      type: 'Note',
-      attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
-        name: m.actor_name || undefined, handle: m.actor_handle || undefined,
-        icon: m.actor_icon || undefined, url: m.actor_url || undefined,
-        emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
-      }) : undefined),
-      content: AP.stripLeadingMentions(m.content),
-      url: m.note_url || undefined,
-      // Waar dit een antwoord op is (Robins melding, 26-8). Zonder dit veld
-      // kwam elk antwoord binnen als het begin van een gesprek: de client kan
-      // een keten alleen teruglopen langs inReplyTo. De andere twee legs
-      // (replyItem uit ap_interactions, sentItem via buildNote) droegen hem
-      // al -- deze was de enige die hem niet eens opsloeg.
-      inReplyTo: m.in_reply_to || undefined,
-      published: AP.isoStamp(m.published || m.created_at),
-      // Addressed to us and to nobody we know of: the other recipients of a
-      // note to several people are not ours to see, so we serve what we know.
-      to: [me],
-      // The Mention is how the client recognises itself as the addressee and
-      // groups the note into a conversation. No FEP-e232 link tags here: a
-      // mention row keeps the resolved quote, not the raw tags.
-      tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
-      attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed }),
-      // FEP-633c: what kind of message this is. The wave is a gentle nudge from
-      // a guardian; the help request is the buoy. Both render differently.
-      'shaer:wave': m.wave ? true : undefined,
-      'shaer:helpRequest': m.help_request ? true : undefined,
-      quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
-      preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
-    },
-  };
-}
-
-/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
-function sentItem(n, { me, mine }) {
-  return {
-    id: `${n.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: n.published,
-    // The leading mention anchor is addressing, not prose (the DM leg strips
-    // it the same way); the Mention tags built from the full content stay.
-    object: {
-      ...n, content: AP.stripLeadingMentions(n.content),
-      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
-    },
-  };
-}
-
-// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
-//
-// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
-// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
-// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
-// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
-// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
-// ophoudt.
-//
-// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
-// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
-// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
-function conversationItems(req, auth, refs) {
-  const base = baseUrl(req);
-  const P = gatesFor(auth.site);
-  const me = AP.actorId(base, auth.site.slug);
-  const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
-  const mine = AP.selfAuthor(base, auth.site);
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-
-  const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
-    .map((m) => [m.object_uri, m]));
-  // PAREN, geen losse lijst: een kop levert niet altijd een item op (dichte
-  // poort, ontbrekende rij), en dan zou de aanroeper op index koppelen en de
-  // telling aan het verkeerde gesprek hangen. Stil, en pas te zien als iemand
-  // een badge op de verkeerde naam ziet staan.
-  const pairs = [];
-  for (const r of refs) {
-    if (r.direction === 'in') {
-      const m = incoming.get(r.ref);
-      if (!m) continue;
-      if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
-      pairs.push({ head: r, item: messageItem(m, ctx) });
-    } else {
-      const n = AP.getOutboxNote(base, r.ref);
-      // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
-      // niet wat je zelf gezegd hebt.
-      if (n) pairs.push({ head: r, item: sentItem(n, { me, mine }) });
-    }
-  }
-  return pairs;
-}
-
-router.get('/ap/users/:slug/conversations', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const heads = AP.conversationHeads(auth.site.slug);
-  const pairs = conversationItems(req, auth, heads);
-  const items = pairs.map((x) => x.item);
-  // Ongelezen per gesprek (shaer-frontend-3tx): een COUNT, geen bijgehouden
-  // getal. Hij hangt aan het NIEUWSTE kopje van elke persoon -- er kunnen er
-  // twee zijn (zie conversationHeads) en het aantal hoort bij het gesprek, niet
-  // bij een bericht.
-  //
-  // AS2 heeft geen term voor ongelezen; dit is per-lezer-interactiestatus,
-  // dezelfde categorie als shaer:liked. Niet in totalItems persen: dat betekent
-  // 'hoeveel er zijn' en niet 'hoeveel jij nog niet zag'.
-  // De poorten van DEZE lezer, niet die van de inbox-handler: die leeft in een
-  // andere functie en heette hier per ongeluk P.
-  const poorten = gatesFor(auth.site);
-  const ongelezen = AP.unreadPerConversation(auth.site.slug, {
-    messagesAllowed: poorten.messagesAllowed,
-    guardians: (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })(),
-  });
-  const gezien = new Set();
-  for (const { head, item } of pairs) {
-    if (gezien.has(head.other)) continue;
-    gezien.add(head.other);
-    const u = ongelezen.get(head.other);
-    if (!u) continue;
-    item.object['shaer:unread'] = u.n;
-    // Een zwaai is geen aantal maar een zetje van een guardian: eigen teken.
-    if (u.wave) item.object['shaer:unreadWave'] = true;
-  }
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-    'shaer:cursor': AP.feedCursor(auth.site.slug),
-  }, 'private, no-store');
-});
-
-router.get('/ap/users/:slug/messages', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const other = String(req.query.with || '');
-  if (!/^https?:\/\//i.test(other)) return res.status(400).json({ error: 'with must be an actor URI' });
-  const page = AP.conversationHistory(auth.site.slug, other, {
-    before: req.query.before ? String(req.query.before) : null,
-    limit: req.query.limit,
-  });
-  const items = conversationItems(req, auth, page.rows).map((x) => x.item);
-  // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
-  // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
-  // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
-  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
-  const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?with=${encodeURIComponent(other)}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
-    type: 'OrderedCollectionPage',
-    partOf: self,
-    orderedItems: items,
-    // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
-    // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
-    // want anders kan een 'load more' niet eerlijk verschijnen.
-    next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
-  }, 'private, no-store');
-});
-
-// De bel (/inbox/wait) is weg (shaer-pq4, 10-8). Hij deed hetzelfde als de
-// WACHTENDE inbox-lezing hierboven, maar in twee rondjes in plaats van een:
-// eerst 'er is nieuws', dan alsnog de lezing. Die lezing kan het zelf, en
-// sinds ?changes=1 stuurt hij alleen nog het verschil.
-//
-// AP.onNews blijft bestaan: de Guardian-PWA hangt er ook aan.
-
-// The server blocklist is the source of truth for Shaer's "in Orbit":
-// clients read it here instead of keeping their own state. Actor-kind
-// blocks only (domain blocks are instance policy, not an Orbit member).
-//
-// FEP-1580 zet deze deur één spleet verder open: de bronkant MOET de blokkades
-// beschikbaar maken voor de instantie waar je NAARTOE verhuist, zodat je
-// zichtbaarheidsvoorkeuren meeverhuizen. De doelkant haalt ze als eerste op,
-// want ze bepalen wat de rest te zien krijgt. Geen nieuwe collectie: deze
-// bestond al en staat al op de actor, alleen de toegang verbreedt.
-router.get('/ap/users/:slug/blocked', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  let slug = (auth && auth.site.slug === req.params.slug) ? auth.site.slug : null;
-  if (!slug && req.headers['signature']) {
-    const verified = await AP.verifyRequest(req).catch(() => null);
-    if (verified && verified.id && AP.isMoveTarget(req.params.slug, verified.id)) slug = req.params.slug;
-  }
-  if (!slug) return res.status(403).end();
-  const base = baseUrl(req);
-  const items = AP.listBlocks(slug)
-    .filter((b) => b.kind === 'actor')
-    .map((b) => b.target);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${slug}/blocked`,
-    type: 'OrderedCollection',
-    totalItems: items.length,
-    orderedItems: items,
-  }, 'private, no-store');
-});
-
-// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
-// The dashboard collections the Shaer clients read: pending adoption offers,
-// gated follows (empty in Klonkt for now) and the guardian's wards. Same
-// contract as the Shaer test daemon.
-function queueRoute(name, build) {
-  router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
-    const auth = OAuth.verifyBearer(req.headers.authorization);
-    if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-    const base = baseUrl(req);
-    const me = `${base}/ap/users/${auth.site.slug}`;
-    // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
-    // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
-    // honderd wards inclusief poorten terug.
-    AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
-  });
-}
-queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
-// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
-// waiting on its guardians. Owner-only like the rest — who a child wants to
-// follow is nobody else's business.
-queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
-queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
-// Availability (FEP-633c 3.6.1) is never public: the ward reads its
-// guardians' real states here and nowhere else.
-queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
-
-// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
-// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
-// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
-// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
-// er iets uit een lijst verdween, en "het is weg" is geen reden.
-router.get('/ap/users/:slug/log', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
-  }, 'private, no-store');
-});
-// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
-// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
-// verzoek daar staan (Barts melding, 8-8).
-queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
-
-// ── Inbox read (owner only, AP C2S) ───────────────────────────────
-// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
-// scoped to this site) reads recent inbound posts (the timeline: accounts
-// they follow) as Create(Note) items, so an app (Shaer) can build a unified
-// feed. Anyone else gets 403; the inbox stays write-only for the public.
-router.get('/ap/users/:slug/inbox', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const base = baseUrl(req);
-  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
-  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
-  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
-  // gedraagt de route zich exact zoals altijd.
-  //
-  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
-  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
-  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
-  // ronde.
-  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
-  if (req.query.since && wachtS > 0) {
-    const afbreken = new AbortController();
-    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
-    const uit = await AP.waitForFeedChange(auth.site.slug, {
-      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
-    });
-    if (res.writableEnded || afbreken.signal.aborted) return undefined;
-    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
-    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
-    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
-    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
-    // dat voor nieuws twee rondjes nodig heeft.
-    //
-    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
-    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
-    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
-    // een lege merksteen sturen we dus gewoon de collectie.
-    if (!uit.changed && uit.cursor !== '0') {
-      res.set('Vary', 'Authorization');
-      return res.status(304).end();
-    }
-  }
-  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
-  // world outside the fediverse is the guardians' call. The gate is applied
-  // here, at serialisation: a blocked embed is never sent, because an embed the
-  // client merely hides has still been delivered to the device.
-  // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
-  // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
-  // kopie krijgen die kan gaan afwijken.
-  const P = gatesFor(auth.site);
-  const {
-    embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, quotesAllowed,
-    emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
-    followingAllowed, gateAuthor,
-  } = P;
-  // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
-  const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
-  // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
-  //
-  // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
-  // actor), quote (FEP-044f als object), preview (AS2 core) en de
-  // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
-  // in het veld; die gaan eruit als de clients om zijn.
-  // Wie ik ben en wie mijn guardians zijn: allebei de lezingen hieronder
-  // hebben ze nodig, dus een keer, hierboven.
-  const me = AP.actorId(base, auth.site.slug);
-  const myHandle = AP.deriveHandle(me);   // een naam, of de kale URI -- nooit een halve
-  const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
-  // ── Alleen het VERSCHIL, als de client daarom vraagt (shaer-pq4) ──
-  //
-  // De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
-  // client alles opnieuw las: vier legs van zestig met al hun media-, quote- en
-  // embed-JSON, voor een enkel nieuw bericht. ap_feed_state houdt per object al
-  // bij wat er wanneer veranderde, dus het verschil lag er klaar en werd alleen
-  // nooit uitgedeeld (feedChangesSince had geen enkele aanroeper).
-  //
-  // OPT-IN met ?changes=1, en dat is geen franje: een app in het veld stuurt
-  // `since` al mee en vervangt haar hele feed door wat er terugkomt. Zou
-  // `since` opeens een verschil betekenen, dan wist die app zichzelf leeg.
-  //
-  // Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het
-  // IS -- een deel, geen collectie. Een generieke lezer ziet dat verschil ook.
-  if (req.query.changes && req.query.since) {
-    const veranderd = AP.feedChangesSince(auth.site.slug, String(req.query.since));
-    const levend = veranderd.filter((c) => c.kind !== 'deleted').map((c) => c.object_uri);
-    const tl = new Map(AP.timelineRowsByIds(auth.site.slug, levend).map((r) => [r.id, r]));
-    const mn = new Map(AP.messageRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u))).map((r) => [r.object_uri, r]));
-    const rp = new Map(AP.replyRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u) && !mn.has(u))).map((r) => [r.object_uri, r]));
-    const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
-    const ctx = { base, me, myHandle, p: P };
-    const items = [];
-    for (const c of veranderd) {
-      if (c.kind === 'deleted') {
-        // Een verwijdering reisde tot nu toe als AFWEZIGHEID mee: de volledige
-        // lezing bevatte hem simpelweg niet meer. Die volledigheid is precies
-        // wat hier wegvalt, dus zonder grafsteen zou een weggehaalde post voor
-        // altijd in de app blijven staan -- en dat faalt stil. AS2 heeft er een
-        // vorm voor, en de rij lag er al.
-        items.push({ type: 'Delete', actor: me, object: { id: c.object_uri, type: 'Tombstone' } });
-        continue;
-      }
-      const t = tl.get(c.object_uri);
-      if (t) { items.push(timelineItem(t, { p: P, reactions: reacties })); continue; }
-      const m = mn.get(c.object_uri);
-      if (m) {
-        if (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri)) items.push(messageItem(m, ctx));
-        continue;
-      }
-      const r = rp.get(c.object_uri);
-      if (r) { items.push(replyItem(r, ctx)); continue; }
-      const n = AP.getOutboxNote(base, c.object_uri);
-      if (n) items.push(sentItem(n, { me, mine: AP.selfAuthor(base, auth.site) }));
-    }
-    return AP.sendAP(res, {
-      '@context': AP.AP_CONTEXT,
-      id: `${base}/ap/users/${encodeURIComponent(auth.site.slug)}/inbox?changes=1&since=${encodeURIComponent(String(req.query.since))}`,
-      type: 'OrderedCollectionPage',
-      partOf: `${base}/ap/users/${auth.site.slug}/inbox`,
-      orderedItems: items,
-      // De rechten gaan MEE. Zonder dit valt de client terug op zijn standaard,
-      // en die standaard is 'alles mag' -- dan zet een gesloten poort zichzelf
-      // stil open bij elke verschil-lezing. Dezelfde reden waarom een 304 de
-      // caps met rust laat.
-      'shaer:capabilities': capabilitiesOf(P, gate),
-      'shaer:cursor': AP.feedCursor(auth.site.slug),
-    }, 'private, no-store');
-  }
-  const rows = AP.getTimeline(auth.site.slug, 60);
-  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
-  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
-  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
-  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
-  const posts = rows.map((t) => timelineItem(t, { p: P, reactions: reacties }));
-  // The direct notes addressed to this account: a plain DM, a guardian's wave
-  // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
-  // they are not in the timeline; without them the app's Berichten shows only
-  // what you said yourself. Same shape as a post, so one parser handles both.
-  // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
-  // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
-  // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
-  // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
-  const messageCtx = { base, me, myHandle, p: P };
-  const messages = AP.getDirectMessages(auth.site.slug, 60)
-    .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
-    .map((m) => messageItem(m, messageCtx));
-  // Inbound REPLIES on your own posts: stored as interactions (the web's
-  // comment machinery), never as mentions, so this read missed them and a
-  // friend's reply arrived everywhere except in your app (Robins melding,
-  // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
-  const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => replyItem(m, messageCtx));
-  // Your OWN sent notes (replies and direct messages, ap_outbox): without
-  // them a reply existed everywhere except in your own app, Messages showed
-  // half a conversation, and a retry ran into the duplicate guard (Robins
-  // melding, 30-7). Served like the other legs: same shape, one parser.
-  const mine = AP.selfAuthor(base, auth.site);
-  const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
-    id: `${n.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: n.published,
-    // The leading mention anchor is addressing, not prose (the DM leg strips
-    // it the same way); the Mention tags built from the full content stay.
-    object: {
-      ...n, content: AP.stripLeadingMentions(n.content),
-      attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
-    },
-  }));
-  // Newest first over all legs, so the app can keep treating this as one feed.
-  const items = [...posts, ...messages, ...replies, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${base}/ap/users/${auth.site.slug}/inbox`,
-    type: 'OrderedCollection',
-    // What this account may do with what is in here (FEP-633c 5.6). Owner-only
-    // by construction, and never on the public actor document: it says
-    // something about a child, and only the child and its guardians need it.
-    'shaer:capabilities': capabilitiesOf(P, gate),
-    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
-    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
-    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
-    'shaer:cursor': AP.feedCursor(auth.site.slug),
-    totalItems: items.length,
-    orderedItems: items,
-  });
-  return undefined;
-});
-
-// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
-// The actor advertises endpoints.uploadMedia; this implements it. A bearer
-// scoped to this site uploads one image/audio/video (multipart field "file",
-// AP convention) into the same store the reply editor uses, and gets back
-// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
-const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
-const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
-const apMediaUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
-    filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
-  }),
-  limits: { fileSize: 32 * 1024 * 1024 },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname || '').toLowerCase();
-    if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
-    cb(null, true);
-  },
-});
-router.post('/ap/users/:slug/uploadMedia', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  apMediaUpload.single('file')(req, res, (err) => {
-    if (err) return res.status(400).json({ error: err.message });
-    if (!req.file) return res.status(400).json({ error: 'No file' });
-    const mime = String(req.file.mimetype || '');
-    if (!/^(image|audio|video)\//.test(mime)) {
-      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
-      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
-    }
-    // A video gets a poster frame next to it (shaer-zowq), best-effort and
-    // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
-    // machine without ffmpeg nothing happens and nothing breaks; the clients
-    // fall back to extracting a frame natively.
-    if (mime.startsWith('video/')) {
-      // The bundled static build (ffmpeg-static) does the work, exactly like
-      // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
-      // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
-      // machine. Soft dependency + best-effort: absent stays silent, and
-      // FFMPEG_PATH can still override for an operator who wants a newer one.
-      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
-        const bin = process.env.FFMPEG_PATH || ff.default;
-        if (!bin) return;
-        const poster = req.file.path + '.poster.jpg';
-        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
-          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
-      }).catch(() => { /* never blocks the upload */ });
-    }
-    // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
-    // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
-    // White on transparent, so the tile's own gradient stays the backdrop
-    // and every audio post keeps its own hue. The shape is bars, not the
-    // raw hairy wave (Robins tweede vraag): peak and average sampled into
-    // 57 columns (soft tip over bright core), blown up nearest-neighbor to
-    // 14px bars, and drawgrid ERASES 5px gaps (c=black@0 + replace=1 writes
-    // transparent pixels; h=2*ih keeps horizontal grid lines out of frame).
-    if (mime.startsWith('audio/')) {
-      Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
-        const bin = process.env.FFMPEG_PATH || ff.default;
-        if (!bin) return;
-        const poster = req.file.path + '.poster.png';
-        const graph = '[0:a]aformat=channel_layouts=mono,asplit[a][b];'
-          + '[a]showwavespic=s=57x256:colors=white@0.5:filter=peak:scale=sqrt:draw=full[pk];'
-          + '[b]showwavespic=s=57x256:colors=white:filter=average:scale=sqrt:draw=full[av];'
-          + '[pk][av]overlay=format=auto,scale=798:256:flags=neighbor,drawgrid=w=14:h=2*ih:t=5:c=black@0:replace=1';
-        execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', graph, '-frames:v', '1', poster],
-          { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
-      }).catch(() => { /* never blocks the upload */ });
-    }
-    res.status(201).json({
-      url: '/media/reply-media/' + req.file.filename,
-      mediaType: mime,
-      name: String(req.file.originalname || '').slice(0, 120),
-    });
-  });
-});
-
-// ── Followers (count-only public, full for the owner) ─────────────
-// A C2S bearer scoped to this site (the account owner) gets the real actor
-// URIs so their own client can build a friends list; everyone else gets the
-// count only (privacy).
-// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
-// Returns true and sets the response headers when the owner asked for it.
-function wantsEnriched(req, res) {
-  res.set('Vary', 'Prefer');   // enriched and bare are two representations
-  if (AP.prefersEnriched(req.get('Prefer'))) {
-    res.set('Preference-Applied', 'return=representation');
-    return true;
-  }
-  return false;
-}
-
-router.get('/ap/users/:slug/followers', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  const owner = auth && auth.site.slug === req.params.slug;
-  const site = owner ? auth.site : publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (owner) {
-    const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
-    // Default = bare references; enrich only when the client asks (FEP-9876).
-    const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
-    return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
-  }
-  const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
-  AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n, null, { page: paginaNr(req) }));
-});
-
-// ── Following (count-only public, full for the owner) ─────────────
-router.get('/ap/users/:slug/following', (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  const owner = auth && auth.site.slug === req.params.slug;
-  const site = owner ? auth.site : publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  if (owner) {
-    const enrich = wantsEnriched(req, res);   // FEP-9876 opt-in
-    let items = [];
-    try {
-      const uris = db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY created_at").all(site.slug).map((r) => r.actor_uri);
-      items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
-    } catch { /* table may not exist */ }
-    return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
-  }
-  let n = 0;
-  try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
-  AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n, null, { page: paginaNr(req) }));
-});
-
-/**
- * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar
- * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance).
- *
- * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee
- * antwoorden op dezelfde vraag lopen vroeg of laat uiteen.
- */
-async function magAlles(req, slug) {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (auth && auth.site.slug === slug) return true;
-  if (!req.headers['signature']) return false;
-  const v = await AP.verifyRequest(req).catch(() => null);
-  return !!(v && v.id && AP.isMoveTarget(slug, v.id));
-}
-
-// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
-//
-// Publiek leesbaar, want dat is het hele doel: een derde die een oude URI in
-// zijn database heeft leest hier wat de nieuwe is. Zonder deze collectie blijft
-// elke reactie op een verhuisd bericht naar een dood adres wijzen.
-//
-// Niet-publieke items komen er alleen in voor een lezer die ze mocht zien. De
-// spec: Moves voor objecten die niet aan as:Public gericht zijn MOGEN NIET
-// publiek getoond worden. Een lijst met de URIs van je fan-only posts is een
-// lek, ook al staat de inhoud er niet bij.
-router.get('/ap/users/:slug/migration', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  let alles = false;
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (auth && auth.site.slug === site.slug) alles = true;
-  else if (req.headers['signature']) {
-    const v = await AP.verifyRequest(req).catch(() => null);
-    // Een geverifieerde volger zat in het publiek van de fan-only posts, dus
-    // die mag ook weten waar ze heen zijn.
-    if (v && v.id && AP.outboxAudience(site.slug, { verifiedActor: v.id }) === 'friend') alles = true;
-  }
-  AP.sendAP(res, Migration.buildMigration(baseUrl(req), site, { page: paginaNr(req), alles }),
-    alles ? 'private, no-store' : undefined);
-});
-
-// De Moves die de vertaaltabel rechtvaardigen. Altijd publiek: een bewijs dat
-// je moet kunnen nakijken heeft niets aan een slot.
-//
-// LET OP: zonder FEP-8b32 (shaer-j1v0) staat hier geen handtekening onder. De
-// collectie is structureel goed en niet verifieerbaar, en een derde die de spec
-// streng volgt mag hem daarom weigeren. Bewust geen leeg proof-veld erbij.
-router.get('/ap/users/:slug/moves', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, Migration.buildMoves(baseUrl(req), site));
-});
-
-// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
-router.get('/ap/users/:slug/featured', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
-  // last-processed-first). So we emit it reversed (lowest pin priority first,
-  // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
-  // fan_only, ap_visibility, paid en excerpt MOETEN mee, om dezelfde reden als
-  // in outboxSlice en backfillNewFollower (shaer-6oth, en Barts melding van
-  // 15-8). buildNote beslist op deze velden, en een ontbrekende kolom is daar
-  // `undefined` -- wat stilletjes het ruimste gedrag oplevert:
-  //
-  //   zonder `paid`    slaat buildNote zijn redactie over en gaat de VOLLEDIGE
-  //                    tekst van een betaalde post mee. Deze collectie is
-  //                    publiek en onbetekend opvraagbaar, dus dat is de post
-  //                    gewoon te lezen. `excerpt` hoort erbij, anders valt de
-  //                    teaser terug op de eerste alinea van precies de tekst
-  //                    die verborgen moet blijven.
-  //   zonder ap_vis    krijgt een quiet/unlisted post `to: as:Public` in plaats
-  //                    van zijn volgers -- luider dan de schrijver koos.
-  //
-  // Het filter erbij: fan_only stond er al, ap_visibility ontbrak. Een post die
-  // niet publiek bedoeld is hoort niet in een publieke collectie, ook niet als
-  // hij vastgezet is.
-  const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at,
-            fan_only, ap_visibility, paid, paid_min_cents, excerpt
-     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND IFNULL(ap_visibility, 'public') IN ('public', 'quiet')
-       AND pinned IS NOT NULL AND pinned > 0
-     ORDER BY pinned DESC, ${isoSql('COALESCE(published_at, created_at)')} ASC LIMIT 20`
-  ).all(site.id);
-  AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts, { page: paginaNr(req) }));
-});
-
-// ── Playlist als dereferenceerbare AP-collectie (shaer-ayc) ───────
-// De eerste stap van het Funkwhale-spoor: een playlist heeft een id, dus een
-// stabiele URI. Alleen het fedi_open-deel staat erin (de poort is per bestand
-// en eenrichtings; zie setAudioFediOpen in routes/posts.js) — een collectie
-// zonder open tracks bestaat wel maar is leeg, want de playlist zelf is niet
-// geheim, alleen de bestanden erachter.
-// De lijst van alle playlist-collecties (shaer-ayc, stap 2). De actor wijst
-// hierheen via AS2 `streams`. Kaal standaard; verrijkte stubs op verzoek
-// (FEP-9876), dezelfde conventie als followers/following.
-router.get('/ap/users/:slug/playlists', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res), { page: paginaNr(req) }));
-});
-
-// De tracks van deze site: de kanonieke plek voor onze muziek (shaer-0nh,
-// stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de
-// artiest heeft opengezet, ook wat in geen enkele playlist staat.
-router.get('/ap/users/:slug/tracks', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }), { page: paginaNr(req) }));
-});
-
-// De bibliotheek van deze site (shaer-0nh). Funkwhale's Audio draagt een
-// `library`, en dat is bij hen het haakje waar een UPLOAD aan komt te hangen --
-// zonder die bak blijft een binnengehaalde track daar een naam zonder geluid.
-// Gemeten op 13-8: open.audio had onze vier tracks wel, met onze eigen AP-id's,
-// maar uploads leeg en is_playable false.
-//
-// Openbaar, want alles erin is fedi_open. Er valt dus niets goed te keuren en de
-// volgerslijst blijft leeg: wie ons volgt volgt de ACTOR, niet de bak.
-//
-// `?page=` MOET hier doorgegeven worden. Zonder dat adverteert de wortel een
-// `first` die op zichzelf uitkomt: de lezer volgt hem, krijgt weer een `Library`
-// in plaats van een pagina, en klapt eruit -- open.audio gaf op 15-8 een 500 op
-// precies deze URL. Dezelfde les als bij de outbox (shaer-sk4): een `first`
-// beloven is een pagina beloven.
-router.get('/ap/users/:slug/library', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.buildLibrary(baseUrl(req), site, AP.siteOpenTracks(site.id), { page: paginaNr(req) }));
-});
-
-// De volgerscollectie die hun docs als vereist noemen. Leeg en eerlijk: er is
-// geen goedkeuringspad omdat de bibliotheek openbaar is.
-router.get('/ap/users/:slug/library/followers', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.pagedCollection(`${AP.libraryId(baseUrl(req), site)}/followers`, [], { page: paginaNr(req) }));
-});
-
-// Eén track, los op te halen. Een gesloten track is AFWEZIG, niet leeg: 404,
-// dezelfde regel als in de collectie, zodat het bestaan van een gated nummer
-// niet uit een ander antwoord af te leiden is.
-router.get('/ap/users/:slug/tracks/:id', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const row = AP.openTrack(site.id, req.params.id);
-  if (!row) return res.status(404).end();
-  AP.sendAP(res, AP.buildTrackAudio(baseUrl(req), site, row, { standalone: true }));
-});
-
-// De losse tracks van een post als EEN uitgave (shaer-38y). Ze gingen tot nu
-// toe los de deur uit -- Audio-objecten die een lezer nergens kon plaatsen. Ze
-// horen bij elkaar omdat ze in dezelfde post staan, en die post leent zijn
-// titel, tekst, hoes en tags uit. 404 als de post geen muzikale eenheid IS:
-// dan is er niets om naar te wijzen, en dat is geen lege collectie maar een
-// collectie die niet bestaat.
-router.get('/ap/users/:slug/posts/:id/tracks', (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const post = db.prepare(
-    "SELECT id, slug, title, excerpt, content, cover_image_url, tags FROM posts WHERE id = ? AND site_id = ? AND status = 'published'"
-  ).get(req.params.id, site.id);
-  if (!post) return res.status(404).end();
-  const col = AP.buildPostTrackCollection(baseUrl(req), site, post);
-  if (!col) return res.status(404).end();
-  AP.sendAP(res, col);
-});
-
-router.get('/ap/users/:slug/playlists/:id', async (req, res) => {
-  const site = publicSite(req.params.slug);
-  if (!site) return res.status(404).end();
-  const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at FROM playlists WHERE id = ? AND site_id = ?')
-    .get(req.params.id, site.id);
-  if (!pl) return res.status(404).end();
-  // De doel-actor van een verhuizing krijgt de VOLLEDIGE plaat, niet alleen de
-  // nummers die voor de fediverse opengezet zijn (FEP-1580).
-  const alles = await magAlles(req, site.slug);
-  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id, { alles })));
-});
-
-// ── Note ──────────────────────────────────────────────────────────
-router.get('/ap/notes/:id', async (req, res) => {
-  // No fan_only filter in the SELECT anymore: a friends-only post is not
-  // absent, it is GATED. The old route hid it from EVERYONE, also from the
-  // follower whose friendship earns it — so the signed resolution the reply
-  // path performs knocked on a door that could never open, and every reply
-  // to a friends-only post (Shaer's default!) died in
-  // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
-  // note's existence stays as private as before.
-  // EEN GEBLOKKEERDE KRIJGT DE DEUR DICHT (Robin, 21-8), net als bij de outbox:
-  // wie ondertekend aanklopt, klopt met zijn naam erop, en een blokkade is een
-  // gesloten deur. Dezelfde 404 als een vreemde, zodat het bestaan van een note
-  // niets extra's verraadt. Onbetekende verzoeken kunnen we niet thuisbrengen
-  // en houden de publieke weergave -- daarvoor is de Block-bezorging.
-  if (req.headers['signature']) {
-    const wie = await AP.verifyRequest(req).catch(() => null);
-    if (wie && wie.id && AP.isBlockedAny(wie.id)) return res.status(404).end();
-  }
-  const post = db.prepare(
-    "SELECT * FROM posts WHERE id = ? AND status = 'published'"
-  ).get(req.params.id);
-  if (post && AP.noteAudience(post) !== 'public') {
-    // The whole gate in a try: this is the only async route in this file,
-    // and Express 4 does not catch an async rejection — the request would
-    // hang forever instead of failing (which is exactly how the missing
-    // default-export entry manifested while building this). Any error here
-    // reads as "not authorized", never as silence.
-    try {
-      if (AP.noteAudience(post) === 'direct') return res.status(404).end();
-      const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-      const actor = await AP.verifyRequest(req).catch(() => null);
-      if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end();
-    } catch { return res.status(404).end(); }
-  }
-  if (!post) {
-    // Could be one of OUR outbound replies (ap_outbox), not a post.
-    const note = AP.getOutboxNote(baseUrl(req), req.params.id);
-    if (!note) return res.status(404).end();
-    if (!AP.apWants(req)) {
-      // A browser hit a reply's AP URL → send them to the source it replies to
-      // (where the post + its reactions live), falling back to the site home.
-      const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
-        ? note.inReplyTo : (baseUrl(req) + '/');
-      return res.redirect(302, src);
-    }
-    return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
-  }
-  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-  if (!site) return res.status(404).end();
-  const note = AP.buildNote(baseUrl(req), site, post);
-  if (!AP.apWants(req)) {
-    // A browser hit a post's AP note URL → send them to the human post page
-    // (which shows the post + its "from the fediverse" reactions).
-    return res.redirect(302, note.url || (baseUrl(req) + '/'));
-  }
-  AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
-});
-
-// ── Replies collection ── lets remote servers fetch a post's whole thread.
-// ── De composer-preview (shaer-k3f): een URL wordt alvast een kaart ──
-//
-// Bearer-only, net als de thread: dit is de eigen app die tijdens het typen
-// vraagt wat een link gaat worden. Dezelfde pijplijn als publiceren, dus de
-// preview kan niet iets beloven dat de post niet waarmaakt. De embed gaat
-// langs de eigen poort van de lezer -- een ward zonder open embeds-poort
-// krijgt in de composer geen kaart die zijn feed hem ook niet zou tonen.
-router.get('/ap/users/:slug/card', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const uit = await AP.previewCard(String(req.query.url || ''));
-  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
-  const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
-  const playback = embedsAllowed && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    quote: AP.quoteObject(uit.quoteJson),
-    preview: embedsAllowed ? AP.previewObject(uit.embedJson, { playback }) : undefined,
-  }, 'private, no-store');
-});
-
-// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
-//
-// Bearer-only: dit is de eigen app van deze account die vraagt, nooit een
-// vreemde. Klonkt doet de ondertekende GET die de app zelf niet kan (de
-// sleutel staat hier), loopt één pagina van de replies-collectie af en geeft
-// genormaliseerde notes terug. Er wordt NIETS opgeslagen; zie getThread.
-//
-// Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
-// antwoorden uit de kring die de guardians al kennen, en shaer:hidden telt wat
-// er buiten viel. De telling staat er zodat de UI eerlijk kan zijn -- OF hij
-// getoond wordt is onderdeel van datzelfde besluit.
-router.get('/ap/users/:slug/thread', async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
-  const objectUri = String(req.query.object || '');
-  if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'object must be an https URI' });
-  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
-  const uit = await AP.getThread(auth.site.slug, objectUri);
-  if (!uit.found) {
-    // WIENS schuld is dit? De oude melding zei "jouw server kon het niet
-    // laden" terwijl onze server het prima deed en de BRON weigerde -- dat
-    // wees naar de verkeerde partij (Barts melding, 10-8: een post van een
-    // account dat hij vanochtend nog volgde, en dat nu niet meer).
-    // 401/403/404/410 is een besluit van die server; al het andere, inclusief
-    // een status die we niet eens kregen, is een storing.
-    const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
-    return res.status(geweigerd ? 404 : 502)
-      .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
-  }
-  // De poortstand komt uit de kolom (shaer-9y2): expliciete 0/1 van de
-  // guardians wint, de automatiek is dicht-voor-een-ward. Dicht is de KRING,
-  // niet niets: antwoorden van al goedgekeurd volk blijven staan, en wat er
-  // buiten valt wordt geteld. Beeld, muziek en emoji gaan door dezelfde
-  // poorten als de tijdlijn -- per verzoek, buiten de threadcache om.
-  const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
-  const gate2 = (col) => Guardianship.wardGateAllowed(auth.site[col], isWard);
-  const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes);
-  const imagesOk = gate2('gate_images'), musicOk = gate2('gate_music'), emojiOk = gate2('gate_custom_emoji');
-  uit.notes = kring.notes.map((n) => ({
-    ...n,
-    attachment: AP.gateAttachments(n.attachment, { images: imagesOk, audio: musicOk }),
-    tag: emojiOk ? n.tag : AP.stripEmojiTags(n.tag),
-    // De emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
-    // ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
-    attributedTo: (!emojiOk && n.attributedTo && typeof n.attributedTo === 'object')
-      ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
-  }));
-  uit.hidden = kring.hidden;
-  // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes
-  // mogen twee minuten oud zijn, maar of JIJ iets geliked hebt hoort van nu te
-  // zijn -- anders springt het hartje terug zodra de reader opnieuw opent.
-  const reacties = AP.getReactionsFor(auth.site.slug, uit.notes.map((n) => n.id));
-  // De thread heeft al een ?object= in zijn id, dus geen ?page= erachter: die
-  // collectie is niet te pagineren zonder de vraag zelf te herhalen. Hij is
-  // owner-only en wordt door Shaer gelezen, niet door de federatie.
-  AP.sendAP(res, {
-    '@context': AP.AP_CONTEXT,
-    id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/thread?object=${encodeURIComponent(objectUri)}`,
-    type: 'OrderedCollection',
-    totalItems: uit.notes.length,
-    orderedItems: uit.notes.map((n) => ({
-      ...n,
-      'shaer:liked': !!(reacties.get(n.id) || {}).liked,
-      'shaer:boosted': !!(reacties.get(n.id) || {}).boosted,
-    })),
-    'shaer:hidden': uit.hidden || undefined,
-  }, 'private, no-store');
-});
-
-router.get('/ap/notes/:id/replies', (req, res) => {
-  const base = baseUrl(req);
-  const items = AP.getReplyUris(base, req.params.id);
-  AP.sendAP(res, AP.pagedCollection(`${base}/ap/notes/${req.params.id}/replies`, items));
-});
-
-// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
-router.get('/.well-known/nodeinfo', (req, res) => {
-  res.type('application/json');
-  res.set('Cache-Control', 'public, max-age=3600');
-  res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
-});
-router.get('/nodeinfo/2.1', (req, res) => {
-  let users = 0; let posts = 0;
-  // "users" = public AP actors (sites), not the admin/member account rows.
-  try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
-  try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
-  res.type('application/json; charset=utf-8');
-  res.set('Cache-Control', 'public, max-age=600');
-  res.send(JSON.stringify({
-    version: '2.1',
-    software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
-    protocols: ['activitypub'],
-    services: { inbound: [], outbound: [] },
-    openRegistrations: false,
-    usage: { users: { total: users }, localPosts: posts },
-    metadata: { nodeName: 'Klonkt' },
-  }));
-});
-
-// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
-const apJson = express.json({
-  type: ['application/activity+json', 'application/ld+json', 'application/json'],
-  limit: '1mb',
-  verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
-});
-router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
-  try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
-  // Met de STACK erbij. Hier stond alleen `e.message`, en op 15-8 leverde dat
-  // zes keer "[AP inbox] error: slug is not defined" op zonder één aanwijzing
-  // waar -- een ReferenceError in een handler van duizenden regels, met een
-  // naam die overal voorkomt. Een fout die je niet kunt plaatsen is niet
-  // gemeld. Het type en de activiteit erbij, want dat zegt welke tak liep.
-  catch (e) {
-    const soort = req.body && req.body.type;
-    console.warn('[AP inbox] error:', e.message, '| type:', soort, '| slug:', req.params.slug || '(gedeeld)');
-    console.warn(e.stack);
-    return res.status(202).end();
-  }
-});
-
-// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
-// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
-// the normal delivery machinery. The token is scoped to one user+site (OAuth
-// consent), so it must match the slug in the URL. (Declared after apJson, which
-// this shares with the inbox handler.)
-router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
-  const auth = OAuth.verifyBearer(req.headers.authorization);
-  if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
-  if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
-  if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
-
-  const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
-  if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
-  // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
-  if (out.status === 201 && out.url) res.set('Location', out.url);
-  // `state` carries a third outcome the app must be able to tell apart from a
-  // plain success: a ward's follow held for its guardians (§5.3, shaer-p729).
-  return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url, ...(out.state ? { state: out.state } : {}) });
-});
-
-export default router;
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -18,10 +18,7 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { toWebp } from '../services/ImageWebpService.js';
 import { requireGod } from '../middleware/auth.js';
-import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { mediaDir } from '../config/paths.js';
-import * as ActivityPubService from '../services/ActivityPubService.js';
+import { transcodeToMp3 } from '../services/AudioTranscoder.js';
+import { signUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,5 +29,7 @@
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
 );
-const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
+const COVER_DIR = path.resolve(
+  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
+);
 fs.mkdirSync(AUDIO_DIR, { recursive: true });
 fs.mkdirSync(COVER_DIR, { recursive: true });
@@ -38,11 +37,6 @@
 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
-const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
-const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
-
-// Per-file upper limit based on extension. multer's global limit is the
-// highest (WAV); the real per-type check happens in the upload handler.
-const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
+const MAX_AUDIO_BYTES = 50 * 1024 * 1024;  // 50 MB
+const MAX_COVER_BYTES = 5 * 1024 * 1024;   // 5 MB
 
 // Multer routes audio + cover into separate dirs based on field name.
@@ -59,5 +53,5 @@
 const upload = multer({
   storage,
-  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
+  limits: { fileSize: MAX_AUDIO_BYTES }, // upper bound — per-field check below
   fileFilter: (req, file, cb) => {
     const ext = path.extname(file.originalname).toLowerCase();
@@ -73,21 +67,4 @@
 const router = express.Router();
 
-// "Open in" platform links per track: only https + the correct host accepted
-// (href arrives unescaped in the view → scheme/host guard against abuse).
-const LINK_DOMAINS = {
-  spotify: ['spotify.com'],
-  youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
-  soundcloud: ['soundcloud.com'],
-};
-function platformLink(url, domains) {
-  const u = String(url || '').trim();
-  if (!u || !/^https:\/\//i.test(u)) return null;
-  try {
-    const h = new URL(u).hostname.toLowerCase();
-    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
-  } catch (e) { /* invalid URL */ }
-  return null;
-}
-
 router.get('/', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -96,30 +73,26 @@
   const rows = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
+           t.position, t.created_at, m.filename, m.size, m.mime_type
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
     WHERE t.site_id = ?
-    ORDER BY t.created_at DESC, t.position DESC
+    ORDER BY t.position ASC, t.created_at ASC
   `).all(site.id);
 
-  // Build each track's stream URL so admins can preview audio inline.
+  // Sign each track's stream URL so admins can preview audio inline.
+  // Short TTL (default 10 min from AudioStreamService) means the URL on
+  // the page expires if it sits open too long; a refresh re-signs.
   const tracks = rows.map(t => ({
     ...t,
-    stream_url: t.filename ? audioUrl(t.filename) : null,
+    stream_url: t.filename ? signUrl(t.filename).url : null,
   }));
 
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
   renderPage(req, res, 'pages/admin-audio', {
-    // admin-audio neemt de track-editor op, dus die module hoort erbij.
-    pageJs: 'admin-audio track-editor',
-    pageTitleKey: 'admin.t_audio',
+    pageTitle: 'Audio tracks',
     bodyClass: 'on-admin',
     tracks,
-    embedUrl,
     error: req.query.error || null,
     success: req.query.success || null,
     maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
-    maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
   });
 });
@@ -148,14 +121,4 @@
       if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
       return fail(400, 'missing audio file');
-    }
-
-    // Per-type audio size check. multer's global limit was the WAV upper bound
-    // (100MB); compressed formats stay at 50MB.
-    const audioExt = path.extname(audioFile.originalname).toLowerCase();
-    const audioLimit = audioByteLimitFor(audioExt);
-    if (audioFile.size > audioLimit) {
-      try { fs.unlinkSync(audioFile.path); } catch {}
-      if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
-      return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
     }
 
@@ -187,11 +150,4 @@
     const finalArtist = artist?.trim() || null;
     const finalAlbum  = album?.trim() || null;
-    // Ownership/licence. credit falls back to the artist; these go both into the
-    // DB and into the ID3 tags of the mp3 (copyright + comment).
-    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
-    const finalLicense = (req.body.license || '').trim() || null;
-    const finalLinkSpotify    = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
-    const finalLinkYoutube    = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
-    const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
 
     console.log('[admin-audio] upload received:', {
@@ -212,6 +168,4 @@
           artist: finalArtist || undefined,
           album: finalAlbum || undefined,
-          copyright: finalCredit || undefined,
-          comment: finalLicense || undefined,
         },
       });
@@ -233,17 +187,8 @@
       `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
 
-      // Duration automatically: primarily from the transcode (ffmpeg codecData), then
-      // an optional client-side value (bulk uploader reads <audio>.duration),
-      // otherwise NULL (UI then shows '—:—', editable manually in the editor).
-      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
-      const finalDuration =
-        (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
-        : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
-        : null;
-
-      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
+      console.log('[admin-audio] inserting audio_tracks row');
       db.prepare(`
-        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
+        INSERT INTO audio_tracks (id, site_id, title, artist, album, cover_url, media_id, position)
+        VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(
           (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
           0
@@ -252,8 +197,5 @@
         trackId, site.id,
         finalTitle, finalArtist, finalAlbum,
-        finalDuration,
         coverUrl,
-        finalCredit, finalLicense,
-        finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
         mediaId, site.id
       );
@@ -277,17 +219,4 @@
 });
 
-// Download-for-email per track on/off (premium #2). No-JS toggle from the
-// audio admin list → flip + back.
-router.post('/:id/downloadable', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (row) {
-    db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
-      .run(row.downloadable ? 0 : 1, req.params.id, site.id);
-  }
-  res.redirect('/admin/audio');
-});
-
 router.post('/:id/delete', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -301,10 +230,4 @@
 
   if (!track) return res.redirect('/admin/audio?error=Not+found');
-
-  // Zeg de fediverse dat de track weg is, VOOR de rij verdwijnt -- zelfde
-  // volgorde en zelfde reden als bij een post (posts.js). Zonder dit blijft
-  // elke server die hem indexeerde ernaar wijzen terwijl het object 404 geeft;
-  // op de hub stond daardoor op 21-8 een track met een dode link.
-  ActivityPubService.deliverTrackDelete(site, track.track_id).catch(() => { /* best-effort */ });
 
   db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
@@ -378,11 +301,5 @@
     if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
   });
-  for (const o of orphans) {
-    // Ook hier aankondigen. Een wees is voor ONS een track zonder bestand, maar
-    // voor de buitenwereld was het een gewoon Audio-object dat zij hebben
-    // opgeslagen; stil weggooien laat hun kopie staan.
-    ActivityPubService.deliverTrackDelete(site, o.track_id).catch(() => { /* best-effort */ });
-    deleteOne(o);
-  }
+  for (const o of orphans) deleteOne(o);
 
   res.json({ ok: true, deleted: orphans.length });
@@ -408,22 +325,4 @@
 
 /** GET /admin/audio/api/:id — single track with all metadata */
-// Create a track WITHOUT an audio file (title + open-in links only). Appears
-// in albums/playlists in the list, with open-in icons but no play button.
-router.post('/create-link', requireGod, express.json(), (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ error: 'Site required' });
-  const trackId = uuid();
-  const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
-  try {
-    db.prepare(`
-      INSERT INTO audio_tracks (id, site_id, title, media_id, position)
-      VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
-    `).run(trackId, site.id, title, site.id);
-  } catch (e) {
-    return res.status(500).json({ error: e.message });
-  }
-  res.json({ ok: true, id: trackId });
-});
-
 router.get('/api/:id', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -431,5 +330,4 @@
   const t = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
            t.position, t.created_at, m.filename
     FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
@@ -437,6 +335,6 @@
   `).get(req.params.id, site.id);
   if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
-  // Stream URL so the modal can render an inline preview player.
-  const stream_url = t.filename ? audioUrl(t.filename) : null;
+  // Sign the stream URL so the modal can render an inline preview player.
+  const stream_url = t.filename ? signUrl(t.filename).url : null;
   res.json({ ok: true, track: { ...t, stream_url } });
 });
@@ -449,5 +347,5 @@
  * fallback keeps working.
  */
-router.post('/api/:id', requireGod, express.json(), async (req, res) => {
+router.post('/api/:id', requireGod, express.json(), (req, res) => {
   const site = res.locals.site;
   if (!site) return res.status(404).json({ error: 'Site required' });
@@ -491,23 +389,4 @@
   }
 
-  if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
-    fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
-    fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'license')) {
-    fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
-    fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
-    fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
-    fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
-  }
-
   if (fields.length === 0) {
     return res.status(400).json({ error: 'Niks om te updaten' });
@@ -521,29 +400,10 @@
   }
 
-  // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
-  // licence is also IN the file (ID3) and travels with it on download.
+  // Return fresh row so the caller can update its UI without reloading
   const fresh = db.prepare(`
-    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
-    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
-    WHERE t.id = ? AND t.site_id = ?
+    SELECT id, title, artist, album, duration, cover_url
+    FROM audio_tracks WHERE id = ? AND site_id = ?
   `).get(req.params.id, site.id);
-
-  const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
-    .some((f) => Object.prototype.hasOwnProperty.call(body, f));
-  if (fresh && fresh.storage_path && tagsChanged) {
-    try {
-      await retagMp3({ filePath: fresh.storage_path, tags: {
-        title: fresh.title || undefined,
-        artist: fresh.artist || undefined,
-        album: fresh.album || undefined,
-        copyright: fresh.credit || undefined,
-        comment: fresh.license || undefined,
-      } });
-    } catch (e) {
-      console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
-    }
-  }
-  const { storage_path, ...trackOut } = fresh || {};
-  res.json({ ok: true, track: trackOut });
+  res.json({ ok: true, track: fresh });
 });
 
@@ -574,5 +434,5 @@
     }
 
-    const newUrl = `/media/audio-covers/${toWebp(file)}`;
+    const newUrl = `/media/audio-covers/${file.filename}`;
     try {
       db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
@@ -596,53 +456,3 @@
 });
 
-// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
-// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
-// swaps the track's media_id + duration, and deletes the old media file/row.
-router.post('/api/:id/replace-audio', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
-
-  upload.single('audio')(req, res, async (err) => {
-    if (err) return res.status(400).json({ ok: false, error: err.message });
-    const file = req.file;
-    if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
-    const ext = path.extname(file.originalname).toLowerCase();
-    const limit = audioByteLimitFor(ext);
-    if (file.size > limit) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
-    }
-
-    let transcoded;
-    try {
-      transcoded = await transcodeToMp3({
-        inputPath: file.path, outputDir: AUDIO_DIR,
-        outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
-      });
-    } catch (e) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
-    }
-
-    const newMediaId = uuid();
-    try {
-      db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-        .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
-      db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
-      const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
-      if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
-      // Remove the OLD media (file + row), best-effort.
-      if (track.media_id && track.media_id !== newMediaId) {
-        try { const old = db.prepare('SELECT storage_path FROM media WHERE id = ?').get(track.media_id); if (old && old.storage_path) fs.unlinkSync(old.storage_path); } catch {}
-        try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
-      }
-      return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
-    } catch (e) {
-      return res.status(500).json({ ok: false, error: e.message });
-    }
-  });
-});
-
 export default router;
Index: src/routes/admin-comments.js
===================================================================
--- src/routes/admin-comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/admin-comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,80 @@
+/**
+ * Admin: Comment moderation queue — Phase E.
+ *
+ * GET  /admin/comments              -> list pending + recent (god-only)
+ * POST /admin/comments/:id/approve  -> set status = 'approved'
+ * POST /admin/comments/:id/reject   -> set status = 'rejected' (keeps the row
+ *                                      so we have a paper trail; admin can
+ *                                      hard-delete via the post page).
+ *
+ * Scope: shows comments for the resolved site only (the one matched by
+ * /sites/:slug or default). Future: filter by status / search.
+ */
+
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { requireGod } from '../middleware/auth.js';
+
+const router = express.Router();
+
+router.get('/', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('No site');
+
+  const pending = db.prepare(`
+    SELECT c.id, c.content, c.created_at, c.parent_comment_id,
+           u.username AS author_username,
+           p.slug AS post_slug, p.title AS post_title
+    FROM comments c
+    JOIN users u ON u.id = c.author_id
+    JOIN posts p ON p.id = c.post_id
+    WHERE p.site_id = ? AND c.status = 'pending'
+    ORDER BY c.created_at ASC
+    LIMIT 200
+  `).all(site.id);
+
+  const recent = db.prepare(`
+    SELECT c.id, c.content, c.created_at, c.status,
+           u.username AS author_username,
+           p.slug AS post_slug, p.title AS post_title
+    FROM comments c
+    JOIN users u ON u.id = c.author_id
+    JOIN posts p ON p.id = c.post_id
+    WHERE p.site_id = ? AND c.status IN ('approved', 'rejected')
+    ORDER BY c.created_at DESC
+    LIMIT 30
+  `).all(site.id);
+
+  renderPage(req, res, 'pages/admin-comments', {
+    pageTitle: 'Comment moderation',
+    bodyClass: 'on-admin',
+    pending,
+    recent,
+    moderationMode: site.comments_moderation_mode || 'trust',
+    success: req.query.success || null,
+    error: req.query.error || null,
+  });
+});
+
+function setStatus(req, res, status) {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('No site');
+
+  const row = db.prepare(`
+    SELECT c.id FROM comments c JOIN posts p ON p.id = c.post_id
+    WHERE c.id = ? AND p.site_id = ?
+  `).get(req.params.id, site.id);
+
+  if (!row) return res.redirect('/admin/comments?error=Not+found');
+
+  db.prepare(
+    'UPDATE comments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
+  ).run(status, req.params.id);
+  res.redirect('/admin/comments?success=' + encodeURIComponent('Comment ' + status));
+}
+
+router.post('/:id/approve', requireGod, (req, res) => setStatus(req, res, 'approved'));
+router.post('/:id/reject',  requireGod, (req, res) => setStatus(req, res, 'rejected'));
+
+export default router;
Index: src/routes/admin-epk.js
===================================================================
--- src/routes/admin-epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,63 +1,0 @@
-/**
- * Admin: Edit press kit (EPK) — per-site bio + press contact.
- *
- * GET  /admin/epk   -> form with current bio + contact
- * POST /admin/epk   -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
- *
- * The press kit page itself (/pers) reads these values; tracks + recent posts come
- * automatically. Press kit is premium + solo (see routes/epk.js).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { t, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-const MAX_EPK_TRACKS = 5;
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  if (!premiumUnlocked()) {
-    return res.status(403).send('Press kit is a premium feature.');
-  }
-  const allTracks = db.prepare(
-    `SELECT id, title, artist FROM audio_tracks WHERE site_id = ? ORDER BY position ASC, created_at ASC`
-  ).all(site.id);
-  let chosen = [];
-  try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
-  renderPage(req, res, 'pages/admin-epk', {
-    pageJs: 'admin-epk',
-    pageTitleKey: 'admin.t_epk',
-    bodyClass: 'on-admin',
-    site,
-    epkBio: getSetting('epk_bio_' + site.id, '') || '',
-    epkContact: getSetting('epk_contact_' + site.id, '') || '',
-    allTracks,
-    chosenTracks: chosen,
-    maxEpkTracks: MAX_EPK_TRACKS,
-    success: req.query.success || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim());
-  setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim());
-  // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order.
-  let ids = req.body.epk_tracks;
-  if (!Array.isArray(ids)) ids = ids ? [ids] : [];
-  const valid = new Set(db.prepare('SELECT id FROM audio_tracks WHERE site_id = ?').all(site.id).map((r) => r.id));
-  ids = ids.map(String).filter((id) => valid.has(id)).slice(0, MAX_EPK_TRACKS);
-  setSetting('epk_tracks_' + site.id, JSON.stringify(ids));
-  const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-  res.redirect('/admin/epk?success=' + encodeURIComponent(t(lang, 'aepk.saved')));
-});
-
-export default router;
Index: src/routes/admin-listeners.js
===================================================================
--- src/routes/admin-listeners.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,31 +1,0 @@
-/**
- * GET /admin/listeners — wie je BIBLIOTHEEK volgt (shaer-0nh).
- *
- * Een eigen tab in Mediabeheer, want dit is een eigen soort relatie: deze
- * accounts hangen aan /ap/users/<slug>/library en niet aan de actor. Ze krijgen
- * de muziek en met opzet niet de gewone posts -- wie zich op een platenkast
- * abonneert heeft niet om de Krant gevraagd.
- *
- * Dat verschil hoort ZICHTBAAR te zijn. Stonden ze tussen de gewone volgers,
- * dan zou niemand later begrijpen waarom ze andere dingen krijgen.
- */
-import express from 'express';
-import { requireGod } from '../middleware/auth.js';
-import { renderPage } from '../middleware/render.js';
-import { audioEnabled } from '../config/features.js';
-import { luisteraars } from '../services/music/index.js';
-
-const router = express.Router();
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('No site');
-  renderPage(req, res, 'pages/admin-listeners', {
-    pageTitleKey: 'admin.b_listeners',
-    bodyClass: 'on-admin',
-    audioOn: audioEnabled(),
-    luisteraars: luisteraars.lijst(site.slug),
-  });
-});
-
-export default router;
Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,176 +1,0 @@
-/**
- * admin-media.js — Beheer → Media (image library + cleanup).
- *
- * Lists the uploaded images under storage/media/post-images, shows where each is used, and lets the
- * owner copy a URL or delete unused files. An animated cover's WebP, its loop MP4 (<base>-v.mp4) and
- * poster (<base>-v.jpg) are treated as one item; deleting removes the trio. The Audio half of "Media"
- * stays at /admin/audio (linked as a tab) — this page is the new image side.
- */
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { audioEnabled } from '../config/features.js';
-import { mediaDir } from '../config/paths.js';
-
-const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
-
-const router = express.Router();
-
-const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
-const VIDEO_EXT = /\.(mp4|webm|m4v|mov)$/i;
-// C2S uploads (Shaer's composer and the help buoy) land here; the videos among
-// them are what the Video tab shows.
-const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
-
-// Basename of a /media/post-images/<file> URL (or null).
-function baseOf(url) {
-  const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
-  return m ? m[1] : null;
-}
-
-// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
-function usageMap(siteId) {
-  const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
-  const map = new Map();
-  const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
-  for (const p of posts) {
-    add(baseOf(p.cover_image_url), p.id);
-    add(baseOf(p.cover_video_url), p.id);
-    for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
-  }
-  return map;
-}
-
-function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
-function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
-
-// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
-// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
-function imageEntries(siteId) {
-  const used = usageMap(siteId);
-  let all = [];
-  try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
-  const present = new Set(all);
-  return all
-    .filter(f => IMG_EXT.test(f) && !isSibling(f))
-    .map(f => {
-      const stem = f.replace(/\.[^.]+$/, '');
-      const mp4 = `${stem}-v.mp4`;
-      const hasVideo = present.has(mp4);
-      const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
-      return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
-    });
-}
-
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const items = imageEntries(site.id)
-    .map(e => ({
-      file: e.file,
-      url: `/media/post-images/${e.file}`,
-      kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
-      hasVideo: e.hasVideo,
-      usedCount: e.usedCount,
-      _mtime: statMtime(e.file),
-    }))
-    .sort((a, b) => b._mtime - a._mtime); // newest first
-  renderPage(req, res, 'pages/admin-media', {
-    pageJs: 'admin-media',
-    pageTitleKey: 'admin.t_media',
-    bodyClass: 'on-admin',
-    items,
-    unusedCount: items.filter(i => !i.usedCount).length,
-    audioOn: audioEnabled(),
-    success: req.query.success || null,
-  });
-});
-
-// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
-// ── The Video tab (Robins opdracht, 30-7) ─────────────────────────────────
-// Videos live in reply-media (C2S uploads: Shaer's composer, the help buoy).
-// Usage is a content/attachment reference from a post, exactly like images.
-
-function videoEntries(siteId) {
-  const posts = db.prepare('SELECT id, content, c2s_attachments FROM posts WHERE site_id = ?').all(siteId);
-  const used = new Map();
-  const add = (fn, id) => { if (!fn) return; if (!used.has(fn)) used.set(fn, new Set()); used.get(fn).add(id); };
-  for (const p of posts) {
-    for (const m of String(p.content || '').matchAll(/\/media\/reply-media\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
-    try { for (const a of JSON.parse(p.c2s_attachments || '[]')) { const m = String(a.url || '').match(/\/media\/reply-media\/([^/?#"'\s)]+)/); if (m) add(m[1], p.id); } } catch { /* malformed never blocks the list */ }
-  }
-  let all = [];
-  try { all = fs.readdirSync(REPLY_MEDIA_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
-  const vstat = (name, key) => { try { const st = fs.statSync(path.join(REPLY_MEDIA_DIR, name)); return key === 'size' ? st.size : st.mtimeMs; } catch { return 0; } };
-  return all
-    .filter(f => VIDEO_EXT.test(f))
-    .map(f => ({
-      file: f,
-      url: `/media/reply-media/${f}`,
-      kb: Math.round(vstat(f, 'size') / 1024),
-      usedCount: (used.get(f) || new Set()).size,
-      _mtime: vstat(f, 'mtime'),
-    }))
-    .sort((a, b) => b._mtime - a._mtime);
-}
-
-router.get('/videos', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  renderPage(req, res, 'pages/admin-videos', {
-    pageJs: 'admin-videos',
-    pageTitleKey: 'admin.t_media',
-    bodyClass: 'on-admin',
-    items: videoEntries(site.id),
-    audioOn: audioEnabled(),
-    success: req.query.success || null,
-  });
-});
-
-// Delete one video. Basename-only + within-dir, and only when no post uses it:
-// the same guardrails the image delete has.
-router.post('/videos/delete', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ error: 'site' });
-  const file = path.basename(String(req.body?.file || ''));
-  if (!file || !VIDEO_EXT.test(file)) return res.status(400).json({ error: 'bad_file' });
-  const entry = videoEntries(site.id).find(e => e.file === file);
-  if (!entry) return res.status(404).json({ error: 'not_found' });
-  if (entry.usedCount) return res.status(409).json({ error: 'in_use' });
-  try { fs.unlinkSync(path.join(REPLY_MEDIA_DIR, file)); } catch { /* already gone is gone */ }
-  res.json({ ok: true });
-});
-
-router.post('/delete', requireGod, (req, res) => {
-  if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const f = String(req.body.file || '');
-  if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
-  const stem = f.replace(/\.[^.]+$/, '');
-  let removed = 0;
-  for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
-    const full = path.join(POST_IMAGES_DIR, name);
-    if (path.dirname(full) !== POST_IMAGES_DIR) continue;
-    try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
-  }
-  res.json({ ok: true, removed });
-});
-
-// Delete every unused image (orphan) + its siblings.
-router.post('/cleanup', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  let removed = 0;
-  for (const e of imageEntries(site.id)) {
-    if (e.usedCount) continue; // still in use
-    for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
-      try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
-    }
-  }
-  res.json({ ok: true, removed });
-});
-
-export default router;
Index: src/routes/admin-migrate.js
===================================================================
--- src/routes/admin-migrate.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,275 +1,0 @@
-/**
- * admin-migrate.js — Beheer → Migreren: je posts en media naar een andere Klonkt.
- *
- * Dezelfde machinerie als scripts/export-archive.mjs en import-archive.mjs, maar via
- * de webinterface, zodat verhuizen geen SSH-toegang meer vraagt. De services doen
- * het werk; deze routes zijn de deur.
- *
- * DRIE KEUZES DIE ER TOE DOEN:
- *
- * 1. IMPORTEREN GAAT ALTIJD EERST DROOG. Je krijgt een verslag te zien en pas
- *    daarna een knop die het echt doet. Een archief inlezen is niet terug te
- *    draaien, en "ik dacht dat ik alleen keek" is de duurste vergissing hier.
- *
- * 2. OVERSCHRIJVEN IS EEN APARTE, GEWAARSCHUWDE KEUZE. importArchive gebruikt
- *    INSERT OR REPLACE, en dat verwijdert ELKE rij die een unieke sleutel schendt:
- *    een post met een ander id maar dezelfde slug gaat dus stil mee (zie shaer-snv5).
- *    Standaard slaat hij bestaande posts over, en dat blijft ook de standaard hier.
- *
- * 3. HET ARCHIEF WORDT IN GEHEUGEN GEBOUWD. buildArchive levert een Map van
- *    Buffers; bij veel media is dat een forse allocatie. Daarom een harde grens en
- *    een eerlijke melding in plaats van een proces dat omvalt. Streamen is
- *    shaer-190t en hoort daar thuis, niet hier.
- */
-import express from 'express';
-import multer from 'multer';
-import fs from 'fs';
-import path from 'path';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
-import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
-import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
-import * as Migration from '../services/MigrationService.js';
-import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
-import { readArchiveZip, importArchive } from '../services/ArchiveImportService.js';
-import { parseApAliases } from './admin-sites.js';
-import db from '../config/database.js';
-
-const router = express.Router();
-
-// Ruim genoeg voor een gewone site met media, klein genoeg om een instance niet om
-// te laten vallen op een archief dat iemand toevallig aanlevert.
-const MAX_UPLOAD = 512 * 1024 * 1024;
-const MAX_EXPORT = 512 * 1024 * 1024;
-
-const upload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: MAX_UPLOAD, files: 1 },
-}).single('archief');
-
-const mb = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} kB` : `${(n / (1024 * 1024)).toFixed(1)} MB`);
-
-/** Wat zou er in een export zitten? Droog gebouwd, dus zonder zip. */
-function tellen(site) {
-  if (!site) return { telling: null, fout: null };
-  try {
-    const r = buildArchive(site.slug);
-    let bytes = 0;
-    for (const buf of r.files.values()) bytes += buf.length;
-    // ontbrekend telt alleen de MEDIA-verwijzingen; audioMissing komt uit
-    // buildArchive zelf. Ze door elkaar husselen was precies hoe "39
-    // mediabestanden, 14 ontbrekend" een bibliotheek van 140 nummers kon
-    // verzwijgen.
-    const mediaWeg = r.missing.filter((m) => !m.track).length;
-    return {
-      telling: {
-        ...r.counts, bytes, groot: bytes > MAX_EXPORT,
-        ontbrekend: mediaWeg, audioMissing: r.counts.audioMissing || 0,
-      },
-      fout: null,
-    };
-  } catch (e) { return { telling: null, fout: e && e.message }; }
-}
-
-/**
- * De gekoppelde accounts als tekst, een per regel.
- *
- * Als HANDLE, niet als de opgeslagen URL. Je typt @jij@mastodon.social, wij
- * slaan de actor-URL op omdat de rest van het protocol daarop draait, en dan
- * kreeg je een adres terug dat je nooit hebt ingetypt en niet herkent. Wat je
- * hier ziet hoort te lijken op wat je gaf.
- *
- * Alleen als er echt een handle uit te halen valt; anders de URL, want een
- * verkeerde handle is erger dan een lelijke URL. parseApAliases leest beide
- * vormen, dus opslaan blijft werken wat er ook in het veld staat.
- */
-function aliasTekst(site) {
-  try {
-    const lijst = JSON.parse((site && site.ap_aliases) || '[]') || [];
-    return lijst.map((u) => {
-      const h = ActivityPubService.deriveHandle(u);
-      return /^@[^@\s]+@[^@\s]+$/.test(h) ? h : u;
-    }).join('\n');
-  } catch { return ''; }
-}
-
-/**
- * Waar sta je in de verhuizing?
- *
- * Deze pagina draait op BEIDE instanties en elke stap hoort maar op een van de
- * twee. Zonder dat onderscheid leest de lijst als onzin op de helft van de
- * schermen. Vandaar: afleiden wat we kunnen zien, en verder eerlijk zeggen
- * waar iets thuishoort in plaats van het te raden.
- */
-function stappen(site, mig) {
-  const alias = aliasTekst(site).trim();
-  return {
-    // moved_to gezet betekent: DIT is de instantie die vertrokken is.
-    isOud: !!(site && site.moved_to),
-    geclaimd: !!alias,
-    // Een Move in onze moves-collectie betekent dat de bron hierheen verhuisd is.
-    aangekondigd: !!(site && site.moved_to) || !!(mig && mig.moves > 0),
-    opgehaald: !!(mig && mig.total > 0),
-  };
-}
-
-/**
- * Waar zouden we vandaan kunnen halen? De alias die we zelf claimen (FEP-7628
- * alsoKnownAs). Dat is niet toevallig hetzelfde veld als waar de ingest op
- * controleert: het is de helft van de afspraak die je hier al gezet hebt.
- */
-function bronKandidaat(site) {
-  try {
-    const aka = JSON.parse((site && site.ap_aliases) || '[]');
-    return Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
-  } catch { return null; }
-}
-
-/** De pagina. Toont wat er in een export zou zitten, zonder hem te bouwen. */
-router.get('/', requireGod, (req, res) => {
-  // Droog bouwen om te tellen. Dat kost hetzelfde geheugen als een echte export,
-  // dus hier meteen de grens bewaken in plaats van pas bij de download.
-  const site = res.locals.site;
-  const { telling, fout } = tellen(site);
-  const _mig = site ? Migration.migrationStatus(site.slug) : null;
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling, fout, mb,
-    verslag: null,
-    bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: (site && site.moved_to) || null,
-    mig: _mig, stap: stappen(site, _mig),
-    haalVerslag: null,
-    success: req.query.success || null, error: req.query.error || fout || null,
-  });
-});
-
-/**
- * FEP-1580: haal de berichten rechtstreeks bij je oude Klonkt op.
- *
- * Geen code, geen token: de autorisatie IS de Move die je al gedaan hebt. De
- * oude instantie geeft ons zijn eigen kijkrechten omdat `moved_to` daar naar
- * ons wijst, en dat veld staat er alleen als wij hem toen al in alsoKnownAs
- * hadden. Beide kanten hebben dus ooit ja gezegd, en dat is precies waarom
- * hier geen tweede vertrouwensmechanisme bij hoeft.
- *
- * De ingest kijkt die afspraak zelf nog een keer na, in beide richtingen. Dit
- * is een deur, geen controle.
- */
-router.post('/pull', requireGod, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  const opgegeven = String(req.body && req.body.bron ? req.body.bron : '').trim();
-  let r = null;
-  try {
-    r = await Migration.ingestFromSource(site, {
-      sourceUri: opgegeven || null,
-      deps: {
-        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
-        sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
-      },
-    });
-  } catch (e) {
-    r = { error: 'crash', melding: e && e.message };
-  }
-  const { telling } = tellen(site);
-  const _mig2 = Migration.migrationStatus(site.slug);
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling, fout: null, mb, verslag: null,
-    bron: opgegeven || bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
-    mig: _mig2, stap: stappen(site, _mig2),
-    haalVerslag: r,
-    success: (r && !r.error) ? 'Opgehaald' : null,
-    error: null,
-  });
-});
-
-/**
- * Je oude account claimen (FEP-7628 alsoKnownAs).
- *
- * Stond op de site-bewerkpagina, tussen de kleuren en de feedinstellingen. Maar
- * dit is stap EEN van een verhuizing, en zonder deze claim weigert de oude
- * instantie de Move met `no_backreference`. Het hoort dus hier, boven de knop
- * die hem nodig heeft.
- */
-router.post('/aliases', requireGod, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  let lijst;
-  try {
-    lijst = await parseApAliases(req.body.ap_aliases, ActivityPubService.actorId(base, site.slug));
-  } catch (e) {
-    // Welke regel niet deugde, niet alleen DAT er iets niet deugde.
-    return res.redirect('/admin/migrate?error=' + encodeURIComponent(`Onbruikbaar adres: ${e && e.message}`));
-  }
-  db.prepare('UPDATE sites SET ap_aliases = ? WHERE slug = ?').run(JSON.stringify(lijst), site.slug);
-  res.redirect('/admin/migrate?success=' + encodeURIComponent(
-    lijst.length ? `${lijst.length} adres(sen) opgeslagen als jouw vorige account.` : 'Aliassen leeggemaakt.'));
-});
-
-/** Download het archief als zip. */
-router.get('/export', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  try {
-    const r = buildArchive(site.slug);
-    const zip = zipArchive(r.files);
-    if (zip.length > MAX_EXPORT) {
-      return res.redirect('/admin/migrate?error=' + encodeURIComponent(
-        `Het archief is ${mb(zip.length)} en dat is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.`));
-    }
-    const naam = `klonkt-${site.slug}-${new Date().toISOString().slice(0, 10)}.zip`;
-    res.set('Content-Type', 'application/zip');
-    res.set('Content-Disposition', `attachment; filename="${naam}"`);
-    // Privé: dit is je hele site, niets voor een cache onderweg.
-    res.set('Cache-Control', 'private, no-store');
-    res.send(zip);
-  } catch (e) {
-    res.redirect('/admin/migrate?error=' + encodeURIComponent(`Exporteren mislukt: ${e && e.message}`));
-  }
-});
-
-/**
- * Importeren. Zonder `echt=1` is dit een DROOGLOOP: hij leest het archief, meldt
- * wat er zou gebeuren, en raakt niets aan.
- */
-router.post('/import', requireGod, upload, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen site'));
-  if (!req.file || !req.file.buffer || !req.file.buffer.length) {
-    return res.redirect('/admin/migrate?error=' + encodeURIComponent('Geen bestand ontvangen'));
-  }
-
-  const echt = String(req.body.echt || '') === '1';
-  const overschrijf = String(req.body.overschrijf || '') === '1';
-
-  let verslag = null;
-  let fout = null;
-  try {
-    const files = readArchiveZip(req.file.buffer);
-    verslag = importArchive(files, {
-      slug: site.slug,
-      dryRun: !echt,
-      overwrite: overschrijf,
-    });
-  } catch (e) {
-    fout = e && e.message;
-  }
-
-  // Bij een DROOGLOOP blijven we op de pagina met het verslag, zodat je kunt
-  // besluiten. Bij een echte import ook, maar dan met de uitkomst.
-  renderPage(req, res, 'pages/admin-migrate', {
-    pageTitle: 'Migreren', bodyClass: 'on-special',
-    telling: tellen(site).telling, fout: null, mb,
-    bron: bronKandidaat(site), aliassen: aliasTekst(site), movedTo: site.moved_to || null,
-    mig: Migration.migrationStatus(site.slug), stap: stappen(site, Migration.migrationStatus(site.slug)), haalVerslag: null,
-    verslag: verslag ? { ...verslag, echt, overschrijf, bestand: req.file.originalname, bytes: req.file.buffer.length } : null,
-    success: (echt && verslag && !fout) ? 'Archief geïmporteerd' : null,
-    error: fout ? `Importeren mislukt: ${fout}` : null,
-  });
-});
-
-export default router;
Index: src/routes/admin-newsletter.js
===================================================================
--- src/routes/admin-newsletter.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,99 +1,0 @@
-/**
- * Newsletter — admin side (premium feature #1).
- *
- *   GET  /admin/newsletter        -> compose + subscriber counts + history
- *   POST /admin/newsletter/send   -> send to all CONFIRMED subscribers (SMTP)
- *
- * Premium-gated + site manager. Sending requires configured SMTP; without SMTP
- * sign-ups are still collected (single opt-in), only sending is unavailable.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { confirmedFor, counts } from '../services/SubscriberService.js';
-import { t, resolveLang } from '../services/i18n.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-function esc(s) {
-  return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
-}
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (res_siteUrlBase(req)) + p;
-}
-function res_siteUrlBase(req) {
-  return req.res && req.res.locals ? (req.res.locals.siteUrlBase || '') : '';
-}
-
-function premiumGate(req, res, next) {
-  if (!premiumUnlocked()) {
-    const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-    return res.status(403).send(t(lang, 'aset.premium_gate', { feature: t(lang, 'admin.t_newsletter') }));
-  }
-  next();
-}
-
-function renderCompose(req, res, extra = {}) {
-  const site = res.locals.site;
-  const c = counts(site.id);
-  const history = db.prepare(
-    'SELECT subject, sent_at, recipient_count FROM newsletters WHERE site_id = ? ORDER BY sent_at DESC LIMIT 10'
-  ).all(site.id);
-  const subscribeUrl = fullUrl(req, '/nieuwsbrief');
-  renderPage(req, res, 'pages/admin-newsletter', {
-    pageTitleKey: 'admin.t_newsletter',
-    bodyClass: 'on-admin',
-    nlCounts: c,
-    nlHistory: history,
-    nlSubscribeUrl: subscribeUrl,
-    nlSmtp: mailerConfigured(),
-    ...extra,
-  });
-}
-
-router.get('/', requireSiteManager, premiumGate, (req, res) => {
-  if (!res.locals.site) return res.status(404).send('Geen site.');
-  renderCompose(req, res);
-});
-
-router.post('/send', requireSiteManager, premiumGate, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Geen site.');
-  if (!mailerConfigured()) return renderCompose(req, res, { nlMsg: 'SMTP is niet ingesteld — versturen kan nog niet.', nlMsgKind: 'bad' });
-
-  const subject = (req.body.subject || '').trim();
-  const body = (req.body.body || '').trim();
-  if (!subject || !body) return renderCompose(req, res, { nlMsg: 'Onderwerp en bericht zijn verplicht.', nlMsgKind: 'bad', nlSubject: subject, nlBody: body });
-
-  const subs = confirmedFor(site.id);
-  const bodyHtml = esc(body).replace(/\n/g, '<br>');
-  let sent = 0;
-  for (const s of subs) {
-    const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + s.token);
-    try {
-      await sendMail({
-        to: s.email,
-        subject,
-        text: body + '\n\n—\nUitschrijven: ' + unsub,
-        html: '<div>' + bodyHtml + '</div>' +
-              '<hr style="margin-top:24px;border:none;border-top:1px solid #ddd">' +
-              '<p style="color:#888;font-size:12px">Je ontvangt dit omdat je je aanmeldde voor de nieuwsbrief van ' +
-              esc(site.title) + '. <a href="' + unsub + '">Uitschrijven</a>.</p>',
-      });
-      sent++;
-    } catch (e) { /* skip this recipient, continue */ }
-  }
-  db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)')
-    .run(uuid(), site.id, subject, body, sent);
-
-  renderCompose(req, res, { nlMsg: 'Verstuurd naar ' + sent + ' van ' + subs.length + ' abonnee(s).', nlMsgKind: 'ok' });
-});
-
-export default router;
Index: src/routes/admin-paid.js
===================================================================
--- src/routes/admin-paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-/**
- * Admin: Paid posts (premium module, god-only). Slice 1 of klonkt-demo-aki.
- *
- * GET  /admin/paid          -> the owner's Patreon config form + status
- * POST /admin/paid          -> save config (secret/token stored encrypted)
- * POST /admin/paid/disconnect -> forget the config
- *
- * Premium-gated via premiumUnlocked(), like stats/downloads. This is the site
- * owner's OWN Patreon campaign, separate from Klonkt Premium's license flow.
- */
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { cryptoBoxReady } from '../services/CryptoBox.js';
-import PaidPatreon from '../services/PaidPatreonService.js';
-
-const router = express.Router();
-
-// The redirect URI the owner MUST whitelist in their Patreon client. Must match
-// exactly what paid.js sends, or Patreon shows its own error page (which we
-// cannot skin) instead of returning the visitor to us.
-const redirectUri = (req) =>
-  (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '') + '/paid/callback';
-
-function gate(req, res) {
-  if (!premiumUnlocked()) {
-    res.status(403).send('Betaalde posts is een premium-functie: koppel Patreon in Beheer, Instellingen.');
-    return false;
-  }
-  if (!res.locals.site) { res.status(400).send('Geen site.'); return false; }
-  return true;
-}
-
-router.get('/', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  renderPage(req, res, 'pages/admin-paid', {
-    pageJs: 'admin-paid',
-    pageTitleKey: 'apaid.t',
-    bodyClass: 'on-admin',
-    status: PaidPatreon.ownerStatus(res.locals.site.id),
-    secretReady: cryptoBoxReady(),
-    redirectUri: redirectUri(req),
-    saved: req.query.saved === '1',
-    error: req.query.error || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  if (!cryptoBoxReady()) return res.redirect('/admin/paid?error=' + encodeURIComponent('De encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?); secrets kunnen niet veilig worden opgeslagen.'));
-  const b = req.body || {};
-  const eur = String(b.default_min_eur || '').replace(',', '.').trim();
-  const cents = eur ? Math.round(parseFloat(eur) * 100) : undefined;
-  try {
-    PaidPatreon.saveOwnerConfig(res.locals.site.id, {
-      clientId: (b.client_id || '').trim() || undefined,
-      // Empty secret/token fields keep the stored value (no re-paste needed).
-      clientSecret: (b.client_secret || '').trim() || undefined,
-      campaignId: (b.campaign_id || '').trim() || undefined,
-      accessToken: (b.access_token || '').trim() || undefined,
-      refreshToken: (b.refresh_token || '').trim() || undefined,
-      // Empty clears it (null), a value sets it. Unlike secrets, this is not
-      // sensitive and there's a clear "remove the link" intent.
-      patreonUrl: (b.patreon_url || '').trim() || null,
-      defaultMinCents: Number.isFinite(cents) ? cents : undefined,
-    });
-    return res.redirect('/admin/paid?saved=1');
-  } catch (e) {
-    return res.redirect('/admin/paid?error=' + encodeURIComponent(e.message || 'Opslaan mislukt'));
-  }
-});
-
-router.post('/disconnect', requireGod, (req, res) => {
-  if (!gate(req, res)) return;
-  PaidPatreon.disconnect(res.locals.site.id);
-  res.redirect('/admin/paid?saved=1');
-});
-
-export default router;
Index: src/routes/admin-patreon.js
===================================================================
--- src/routes/admin-patreon.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,59 +1,0 @@
-/**
- * Admin: Link Patreon for the premium layer (god-only).
- *
- * GET /admin/patreon/connect    -> redirect the admin to the license server
- *                                  (oauth/start) with our callback as return URL.
- * GET /admin/patreon/callback   -> license server returns with ?klonkt_token
- *                                  (or ?klonkt_error). Verify + store.
- * GET /admin/patreon/disconnect -> clear entitlement.
- *
- * The real monetisation lock is in the signed token (only the
- * license server can sign). See PatreonService.js.
- */
-
-import express from 'express';
-import { requireGod } from '../middleware/auth.js';
-import {
-  licenseBase, premiumEnabled, verifyEntitlementToken, storeEntitlement, clearEntitlement,
-} from '../services/PatreonService.js';
-
-const router = express.Router();
-router.use(requireGod);
-
-function baseUrl(req) {
-  return (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
-}
-
-router.get('/connect', (req, res) => {
-  if (!premiumEnabled()) return res.redirect('/admin/settings');
-  const ret = baseUrl(req) + '/admin/patreon/callback';
-  res.redirect(`${licenseBase()}/oauth/start?return=${encodeURIComponent(ret)}`);
-});
-
-router.get('/callback', async (req, res) => {
-  if (!premiumEnabled()) return res.redirect('/admin/settings');
-  const { klonkt_token, klonkt_error, klonkt_support_cents } = req.query;
-  if (klonkt_error) {
-    const cents = Number(klonkt_support_cents || 0);
-    const msg = klonkt_error === 'not_entitled'
-      ? `Patreon gekoppeld, maar nog geen $16 lifetime (nu ${(cents / 100).toFixed(2)} aan steun). Steun de campagne en koppel opnieuw.`
-      : 'Patreon-koppeling mislukt.';
-    return res.redirect('/admin/settings?error=' + encodeURIComponent(msg));
-  }
-  try {
-    const payload = await verifyEntitlementToken(String(klonkt_token || ''));
-    if (!payload.entitled) throw new Error('not entitled');
-    storeEntitlement(payload, String(klonkt_token));
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Patreon gekoppeld — premium is actief.'));
-  } catch (e) {
-    console.error('[patreon/callback]', e.message);
-    res.redirect('/admin/settings?error=' + encodeURIComponent('Patreon-token kon niet geverifieerd worden.'));
-  }
-});
-
-router.get('/disconnect', (req, res) => {
-  clearEntitlement();
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Patreon ontkoppeld.'));
-});
-
-export default router;
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-playlists.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -23,10 +23,12 @@
 import { requireGod } from '../middleware/auth.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { mediaDir } from '../config/paths.js';
 
 // Cover storage — same convention as track covers so a single physical
 // directory holds all album/track artwork. Existing covers in the DB
 // already point at /media/audio-covers/<filename> so we reuse the path.
-const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
+const __dirname = path.dirname(new URL(import.meta.url).pathname);
+const COVER_DIR = path.resolve(
+  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
+);
 fs.mkdirSync(COVER_DIR, { recursive: true });
 
@@ -62,7 +64,5 @@
   const playlists = PlaylistService.list(site.id);
   renderPage(req, res, 'pages/admin-playlists', {
-    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
-    pageJs: 'admin-playlists playlist-editor',
-    pageTitleKey: 'admin.t_playlists',
+    pageTitle: 'Playlists',
     playlists,
     bodyClass: 'on-admin',
@@ -88,5 +88,5 @@
   const tracks = db.prepare(`
     SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
-           t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+           m.filename
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
@@ -103,7 +103,5 @@
       duration: t.duration || 0,
       cover: t.cover_url || '',
-      // Insertable if it has a hosted file OR an external link — a link-only track ([[track:]])
-      // still renders its Spotify/YouTube card on the post, so it must not be disabled in the picker.
-      playable: !!t.filename || !!(t.link_spotify || t.link_youtube || t.link_soundcloud),
+      playable: !!t.filename,
     })),
   });
@@ -114,5 +112,5 @@
   if (!site) return res.status(404).json({ error: 'Site required' });
 
-  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
+  // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
   const playlist = PlaylistService.get(site.id, req.params.id, null);
   if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
Index: src/routes/admin-push.js
===================================================================
--- src/routes/admin-push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,23 +1,0 @@
-/**
- * Admin: Notificaties (web push) — the owner's per-device toggle page.
- * Not premium-gated: notifications are infrastructure, not an extra.
- */
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import Push from '../services/PushService.js';
-
-const router = express.Router();
-
-router.get('/', requireSiteManager, async (req, res) => {
-  renderPage(req, res, 'pages/admin-push', {
-    pageJs: 'admin-push',
-    pageTitleKey: 'push.t',
-    bodyClass: 'on-admin',
-    vapidKey: await Push.publicKey(),          // null → feature unavailable
-    subscriptions: Push.listSubscriptions(req.session.user.id),
-    defaultAlerts: Push.DEFAULT_ALERTS,
-  });
-});
-
-export default router;
Index: src/routes/admin-seo.js
===================================================================
--- src/routes/admin-seo.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,159 +1,0 @@
-/**
- * Admin: advanced SEO settings for the primary site.
- *
- * GET  /admin/seo   -> form with all SEO/social fields for the main site
- * POST /admin/seo   -> save (god-only)
- *
- * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/
- * OpenGraph tags, but were previously not editable anywhere. The basic
- * fields (title/bio/robots) remain in Appearance; this is the advanced layer:
- * title template, canonical, social share image, verification metas,
- * publisher/JSON-LD and OpenGraph locale.
- *
- * Operates on the PRIMARY site (solo = the only site; hub = the company site).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getPrimarySite } from '../middleware/site.js';
-import { isMbid } from '../services/ap-core.js';
-import MusicBrainz from '../services/MusicBrainzService.js';
-
-const router = express.Router();
-
-function trimOrNull(v, max) {
-  const s = (v == null ? '' : String(v)).trim();
-  return s ? s.slice(0, max) : null;
-}
-
-// ==================== FORM ====================
-router.get('/', requireGod, (req, res) => {
-  const primary = getPrimarySite();
-  if (!primary) {
-    return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Maak eerst een site aan'));
-  }
-  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(primary.id);
-
-  renderPage(req, res, 'pages/admin-seo', {
-    pageTitleKey: 'admin.t_seo',
-    pageJs: 'admin-seo',
-    bodyClass: 'on-admin',
-    site,
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-/**
- * "Zoek jezelf op" -- kandidaten uit MusicBrainz (shaer-mbz).
- *
- * De zoekopdracht draait HIER en niet in de browser: MusicBrainz staat een
- * verzoek per seconde toe per APPLICATIE, en dat is alleen af te dwingen als
- * alles langs een plek gaat. Bovendien eisen ze een User-Agent met contact, en
- * die kan een browser niet zetten.
- *
- * Wij kiezen NIET. Ook niet als er precies een treffer is: een verkeerd geraden
- * MBID zet jouw naam onder andermans werk.
- */
-router.get('/api/musicbrainz', requireGod, async (req, res) => {
-  const site = getPrimarySite(req);
-  const q = String(req.query.q || (site && (site.publisher_name || site.title)) || '').trim();
-  if (!q) return res.json({ ok: true, q: '', kandidaten: [] });
-  // Wie zijn id al kent plakt het hier. Een zoekopdracht op een UUID levert bij
-  // MusicBrainz niets op, dus zonder deze tak geeft plakken juist het slechtste
-  // resultaat.
-  if (isMbid(q)) {
-    const een = await MusicBrainz.haalArtiest(q);
-    return res.json({ ok: true, q, kandidaten: een ? [een] : [] });
-  }
-  res.json({ ok: true, q, kandidaten: await MusicBrainz.zoekArtiesten(q) });
-});
-
-/**
- * De terug-weg: noemt de MusicBrainz-pagina ons domein? (shaer-mbz)
- *
- * Een koppeling van onze kant is een bewering -- iedereen kan een id typen.
- * Pas als de artiestenpagina TERUGWIJST is het een paar. Wij zetten die
- * verwijzing niet zelf: dat kan niet via hun API en hoort ook niet, de artiest
- * doet dat op musicbrainz.org onder "social networking".
- */
-router.get('/api/musicbrainz/terugweg', requireGod, async (req, res) => {
-  const mbid = String(req.query.mbid || '').trim().toLowerCase();
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!isMbid(mbid) || !base) return res.json({ ok: true, verified: false, urls: [] });
-  res.json({ ok: true, ...(await MusicBrainz.controleerTerugweg(mbid, base)) });
-});
-
-// ==================== SAVE ====================
-router.post('/', requireGod, (req, res) => {
-  const primary = getPrimarySite();
-  if (!primary) return res.redirect('/admin/seo?error=' + encodeURIComponent('Geen site gevonden'));
-
-  const f = req.body;
-  const schemaType = f.schema_type === 'Organization' ? 'Organization' : 'Person';
-
-  // De MusicBrainz-koppeling (shaer-mbz). Alleen een echte MBID komt de kolom
-  // in: zonder deze zeef sluipt er een URL of een handle in het veld dat naar
-  // buiten gaat, en het gaat naar TWEE uitgangen -- de JSON-LD en de actor.
-  // Leeg is een geldige keuze; dat is ontkoppelen.
-  //
-  // Deze twee regels stonden BINNEN de template-literal hieronder, dus ze waren
-  // geen code maar tekst in de SQL. Daardoor faalde elke opslag op dit paneel
-  // met `near "/": syntax error` -- niet alleen de koppeling, ook het
-  // titelsjabloon, de omschrijving en alle verificatiecodes.
-  const mbRuw = String(f.mb_artist_id || '').trim().toLowerCase();
-  const mbArtistId = isMbid(mbRuw) ? mbRuw : null;
-
-  db.prepare(`
-    UPDATE sites SET
-      robots_index = ?,
-      title_template = ?,
-      canonical = ?,
-      default_description = ?,
-      og_image_default = ?,
-      og_theme = ?,
-      og_locale = ?,
-      author = ?,
-      twitter = ?,
-      facebook_app_id = ?,
-      google_verification = ?,
-      bing_verification = ?,
-      pinterest_verification = ?,
-      yandex_verification = ?,
-      schema_type = ?,
-      publisher_name = ?,
-      publisher_url = ?,
-      publisher_logo = ?,
-      mb_artist_id = ?, mb_artist_name = ?,
-      updated_at = CURRENT_TIMESTAMP
-    WHERE id = ?
-  `).run(
-    f.robots_index ? 1 : 0,
-    (f.title_template || '{title} — {site}').slice(0, 200),
-    trimOrNull(f.canonical, 200),
-    trimOrNull(f.default_description, 500),
-    trimOrNull(f.og_image_default, 500),
-    (f.og_theme === 'light' || f.og_theme === 'dark') ? f.og_theme : null, // null = auto (follow site theme)
-    trimOrNull(f.og_locale, 32),
-    trimOrNull(f.author, 120),
-    trimOrNull(f.twitter, 64),
-    trimOrNull(f.facebook_app_id, 64),
-    trimOrNull(f.google_verification, 200),
-    trimOrNull(f.bing_verification, 200),
-    trimOrNull(f.pinterest_verification, 200),
-    trimOrNull(f.yandex_verification, 200),
-    schemaType,
-    trimOrNull(f.publisher_name, 200),
-    trimOrNull(f.publisher_url, 200),
-    trimOrNull(f.publisher_logo, 500),
-    mbArtistId,
-    mbArtistId ? (String(f.mb_artist_name || '').trim().slice(0, 200) || null) : null,
-    primary.id,
-  );
-
-  res.redirect('/admin/seo?success=' + encodeURIComponent('SEO-instellingen opgeslagen'));
-});
-
-export default router;
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,186 +1,0 @@
-/**
- * Admin: global settings.
- *  - hub branding (name/tagline/intro/hero of the generic hub home page)
- *
- * GET  /admin/settings   -> show current settings
- * POST /admin/settings   -> save (god-only). Also accepts an uploaded
- *                           hero image (multipart); an upload wins over the
- *                           URL text field. Without an upload the URL field is leading.
- *
- * The hub page is generic (belonging to no user); this branding lives in
- * global settings, not in a site.
- */
-
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import multer from 'multer';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { SUPPORTED } from '../services/i18n.js';
-import { mailerStatus, sendMail } from '../config/mailer.js';
-import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { mediaDir } from '../config/paths.js';
-
-const router = express.Router();
-
-// Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black).
-// Default 45 = the old hard-coded value, so existing hubs don't change appearance.
-function clampOverlay(raw) {
-  const v = parseInt(raw, 10);
-  return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : 45;
-}
-
-// Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
-// (the /media static handler serves storage/media). Same model as avatars.
-const HERO_DIR = mediaDir('HERO_PATH', 'hero');
-fs.mkdirSync(HERO_DIR, { recursive: true });
-
-// Only raster formats for upload. SVG is intentionally NOT allowed via upload
-// (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
-// can still be set via the URL field (like the bundled demo placeholder).
-const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_HERO_BYTES = 5 * 1024 * 1024;
-
-const heroUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, HERO_DIR),
-    filename: (req, file, cb) => {
-      const ext = path.extname(file.originalname).toLowerCase();
-      cb(null, `${uuid()}${ext}`);
-    },
-  }),
-  limits: { fileSize: MAX_HERO_BYTES },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname).toLowerCase();
-    if (!ALLOWED_HERO_EXT.has(ext)) {
-      return cb(new Error('Hero-afbeelding moet jpg/png/webp/gif zijn'));
-    }
-    cb(null, true);
-  },
-});
-
-router.get('/', requireGod, (req, res) => {
-  renderPage(req, res, 'pages/admin-settings', {
-    pageJs: 'admin-settings',
-    pageTitleKey: 'admin.t_settings',
-    bodyClass: 'on-admin',
-    hubTagline: getSetting('hub_tagline') || '',
-    hubIntro: getSetting('hub_intro') || '',
-    hubHeroImage: getSetting('hub_hero_image') || '',
-    hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
-    defaultLang: getSetting('default_lang') || '',
-    premium: entitlementStatus(),
-    smtp: mailerStatus(),
-    footerNewsletter: getSetting('footer_newsletter') === '1',
-    apEnabledSetting: getSetting('ap_enabled', '1') !== '0',
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-router.post('/', requireGod, (req, res) => {
-  // multer.single processes multipart (hub branding form). For a plain
-  // urlencoded POST: multer does nothing and req.body stays intact.
-  heroUpload.single('hub_hero_file')(req, res, (err) => {
-    if (err) {
-      return res.redirect('/admin/settings?error=' + encodeURIComponent(err.message));
-    }
-
-    if (typeof req.body.default_lang !== 'undefined') {
-      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
-      const dl = (req.body.default_lang || '').toString().toLowerCase();
-      setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
-    }
-    if (typeof req.body.timezone !== 'undefined') {
-      // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
-      // Validate with Intl so a nonsense value never breaks date rendering.
-      const tz = (req.body.timezone || '').toString().trim();
-      let valid = '';
-      if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } }
-      setSetting('timezone', valid);
-    }
-    if (typeof req.body.hub_tagline !== 'undefined') {
-      setSetting('hub_tagline', (req.body.hub_tagline || '').toString().slice(0, 120).trim());
-    }
-    if (typeof req.body.hub_intro !== 'undefined') {
-      setSetting('hub_intro', (req.body.hub_intro || '').toString().slice(0, 400).trim());
-    }
-
-    // Hero: an uploaded image wins; otherwise the URL text field.
-    if (req.file) {
-      const newUrl = `/media/hero/${toWebp(req.file)}`;
-      // Clean up a previously uploaded hero (only if it came from our hero dir).
-      const old = getSetting('hub_hero_image') || '';
-      if (old.startsWith('/media/hero/')) {
-        try { fs.unlinkSync(path.join(HERO_DIR, path.basename(old))); } catch {}
-      }
-      setSetting('hub_hero_image', newUrl);
-    } else if (typeof req.body.hub_hero_image !== 'undefined') {
-      setSetting('hub_hero_image', (req.body.hub_hero_image || '').toString().slice(0, 300).trim());
-    }
-
-    if (typeof req.body.hub_hero_overlay !== 'undefined') {
-      setSetting('hub_hero_overlay', String(clampOverlay(req.body.hub_hero_overlay)));
-    }
-
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Opgeslagen'));
-  });
-});
-
-// ── SMTP / e-mail-instellingen ────────────────────────────────────
-router.post('/smtp', requireGod, (req, res) => {
-  const b = req.body || {};
-  if (b.clear === '1') {
-    ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from'].forEach((k) => setSetting(k, ''));
-    return res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen gewist'));
-  }
-  setSetting('smtp_host', (b.smtp_host || '').toString().trim());
-  setSetting('smtp_port', (b.smtp_port || '').toString().trim());
-  setSetting('smtp_user', (b.smtp_user || '').toString().trim());
-  setSetting('smtp_from', (b.smtp_from || '').toString().trim());
-  // Only overwrite the password if a new value was entered.
-  const pass = (b.smtp_pass || '').toString();
-  if (pass) setSetting('smtp_pass', pass);
-  res.redirect('/admin/settings?success=' + encodeURIComponent('SMTP-instellingen opgeslagen'));
-});
-
-// Newsletter sign-up in the footer on/off.
-router.post('/footer', requireGod, (req, res) => {
-  setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Footer-instelling opgeslagen'));
-});
-
-// Site mode: Solo (ap off → no federation, no comments) or Circles (ap on).
-// Driven by a radio (mode=solo|cirkels); legacy ap_enabled checkbox still accepted.
-router.post('/ap', requireGod, (req, res) => {
-  let enabled;
-  if (typeof req.body.mode !== 'undefined') enabled = req.body.mode === 'solo' ? '0' : '1';
-  else enabled = req.body.ap_enabled ? '1' : '0';
-  setSetting('ap_enabled', enabled);
-  res.redirect('/admin/settings?success=' + encodeURIComponent('Modus opgeslagen'));
-});
-
-// Send a test email to a specified address (or the logged-in user).
-router.post('/smtp/test', requireGod, async (req, res) => {
-  const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
-  if (!to || to.indexOf('@') === -1) {
-    return res.redirect('/admin/settings?error=' + encodeURIComponent('Geef een geldig test-e-mailadres op.'));
-  }
-  try {
-    await sendMail({
-      to,
-      subject: 'Klonkt — SMTP-test',
-      text: 'Gelukt! Je SMTP-instellingen werken. Dit is een testbericht van je Klonkt-site.',
-      html: '<p>Gelukt! Je <strong>SMTP-instellingen werken</strong>. Dit is een testbericht van je Klonkt-site.</p>',
-    });
-    res.redirect('/admin/settings?success=' + encodeURIComponent('Testmail verstuurd naar ' + to));
-  } catch (e) {
-    res.redirect('/admin/settings?error=' + encodeURIComponent('Testmail mislukt: ' + (e.message || e)));
-  }
-});
-
-export default router;
Index: src/routes/admin-shows.js
===================================================================
--- src/routes/admin-shows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * Show agenda (premium feature #8) — admin side.
- *
- *   GET  /admin/shows           -> list + add form
- *   POST /admin/shows           -> add show (optional notify email to subscribers)
- *   POST /admin/shows/:id/delete
- *
- * Premium + site manager. Notify email requires SMTP; without SMTP the show is
- * simply saved (no email sent).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-import { renderPage } from '../middleware/render.js';
-import { requireSiteManager } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { confirmedFor, counts } from '../services/SubscriberService.js';
-import { getSetting, setSetting } from '../services/SettingsService.js';
-import { t, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (req.res.locals.siteUrlBase || '') + p;
-}
-function premiumGate(req, res, next) {
-  if (!premiumUnlocked()) {
-    const lang = resolveLang(req, { defaultLang: getSetting('default_lang') });
-    return res.status(403).send(t(lang, 'aset.premium_gate', { feature: t(lang, 'admin.t_shows') }));
-  }
-  next();
-}
-function render(req, res, extra = {}) {
-  const site = res.locals.site;
-  const shows = db.prepare('SELECT * FROM shows WHERE site_id = ? ORDER BY date DESC, time DESC').all(site.id);
-  renderPage(req, res, 'pages/admin-shows', {
-    pageTitleKey: 'admin.t_shows', bodyClass: 'on-admin',
-    shows, smtp: mailerConfigured(), notifyCount: confirmedFor(site.id, 'notify').length,
-    agendaEnabled: getSetting('agenda_enabled') === '1',
-    ...extra,
-  });
-}
-
-router.get('/', requireSiteManager, premiumGate, (req, res) => {
-  if (!res.locals.site) return res.status(404).send('Geen site.');
-  render(req, res);
-});
-
-router.post('/', requireSiteManager, premiumGate, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Geen site.');
-  const b = req.body || {};
-  const date = (b.date || '').trim();
-  const city = (b.city || '').trim();
-  if (!date || !city) return render(req, res, { msg: 'Datum en plaats zijn verplicht.', msgKind: 'bad' });
-  let ticket = (b.ticket_url || '').trim();
-  if (ticket && !/^https?:\/\//i.test(ticket)) ticket = '';
-
-  db.prepare(`INSERT INTO shows (id, site_id, date, time, city, venue, country, ticket_url, notes)
-              VALUES (?,?,?,?,?,?,?,?,?)`).run(
-    uuid(), site.id, date, (b.time || '').trim() || null, city, (b.venue || '').trim() || null,
-    (b.country || '').trim() || null, ticket || null, (b.notes || '').trim() || null,
-  );
-
-  let sent = 0;
-  if (b.notify && mailerConfigured()) {
-    const subs = confirmedFor(site.id, 'notify');
-    const where = city + (b.venue ? ' — ' + b.venue : '');
-    for (const s of subs) {
-      const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + s.token);
-      try {
-        await sendMail({
-          to: s.email,
-          subject: 'Nieuwe show: ' + where + ' (' + date + ')',
-          text: (site.title || '') + ' speelt op ' + date + ' in ' + where + '.' + (ticket ? ('\nTickets: ' + ticket) : '') + '\n\nUitschrijven: ' + unsub,
-          html: '<p><strong>' + esc(site.title) + '</strong> speelt op <strong>' + esc(date) + '</strong> in ' + esc(where) + '.</p>' +
-                (ticket ? ('<p><a href="' + ticket + '">Tickets</a></p>') : '') +
-                '<p style="color:#888;font-size:12px"><a href="' + unsub + '">Uitschrijven</a></p>',
-        });
-        sent++;
-      } catch { /* skip */ }
-    }
-  }
-  render(req, res, { msg: 'Show toegevoegd.' + (sent ? (' Notify gestuurd naar ' + sent + ' abonnee(s).') : ''), msgKind: 'ok' });
-});
-
-router.post('/toggle', requireSiteManager, premiumGate, (req, res) => {
-  // Show the agenda on the site (Agenda button in the pill + /shows page).
-  setSetting('agenda_enabled', req.body.enabled ? '1' : '0');
-  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
-});
-
-router.post('/:id/delete', requireSiteManager, premiumGate, (req, res) => {
-  const site = res.locals.site;
-  if (site) db.prepare('DELETE FROM shows WHERE id = ? AND site_id = ?').run(req.params.id, site.id);
-  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
-});
-
-export default router;
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-sites.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -17,20 +17,21 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
 import { v4 as uuid } from 'uuid';
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
+import { requireGod } from '../middleware/auth.js';
 import ThemeService from '../services/ThemeService.js';
 import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import { mediaDir } from '../config/paths.js';
-import AP from '../services/ActivityPubService.js';
-
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 // Profile photos share the avatar directory with user avatars — same physical
 // folder, same URL prefix. Filenames are uuid-prefixed so site photos and
 // user avatars never collide.
-const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
+const PHOTO_DIR = path.resolve(
+  process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
+);
 fs.mkdirSync(PHOTO_DIR, { recursive: true });
 
@@ -74,25 +75,4 @@
 }
 
-/**
- * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
- * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
- * purpose — a typo'd alias that silently lands on the actor would make a later
- * Move fail at the old server with no hint why. Throws the offending line.
- */
-export async function parseApAliases(raw, ownActorUri) {
-  const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
-  if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
-  const out = [];
-  for (const line of lines) {
-    let uri = null;
-    if (/^https?:\/\//i.test(line)) uri = line;
-    else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
-    if (!uri) throw new Error(line);
-    if (uri === ownActorUri) continue; // claiming yourself adds nothing
-    if (!out.includes(uri)) out.push(uri);
-  }
-  return out;
-}
-
 const router = express.Router();
 
@@ -102,5 +82,5 @@
 // holds the URL string in `profile_photo` — this endpoint just stores the
 // file and hands back a URL that the form can paste into the input field.
-router.post('/upload-photo', requireAuth, (req, res) => {
+router.post('/upload-photo', requireGod, (req, res) => {
   photoUpload.single('photo')(req, res, (err) => {
     if (err) return res.status(400).json({ ok: false, error: err.message });
@@ -108,5 +88,5 @@
     res.json({
       ok: true,
-      url: `/media/avatars/${toWebp(req.file)}`,
+      url: `/media/avatars/${req.file.filename}`,
       size: req.file.size,
       mime: req.file.mimetype,
@@ -117,8 +97,7 @@
 const RESERVED_SITE_SLUGS = new Set([
   'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
-  'account', 'sites', 'comments', 'posts', 'media', 'audio',
-  'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'account', 'sites', 'comments', 'posts', 'media', 'audio', 'prutter',
+  'forum', 'tag', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'paid', 'push', 'guardian',
 ]);
 
@@ -129,6 +108,6 @@
     tagline: '',
     language: 'nl',
-    palette: 'klonkt',
-    accent: '#e8b04b',
+    palette: 'sage',
+    accent: '#c2410c',
     profile_photo: '',
     profile_enabled: 1,
@@ -137,8 +116,8 @@
     is_public: 1,
     robots_index: 1,
-    require_login_to_comment: 1,
+    require_login_to_comment: 0,
     enable_audio_player: 1,
-    comments_moderation_mode: 'moderate',
-    feed_view_default: 'grid',
+    enable_prutter: 1,
+    feed_view_default: 'timeline',
     feed_view_switch: 1,
     show_search: 1,
@@ -157,37 +136,17 @@
 }
 
-/** Valid user-id for owner assignment, or null if empty/unknown. */
-function validOwnerId(raw) {
-  const id = (raw || '').toString().trim();
-  if (!id) return null;
-  return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
-}
-
-/** Grant a user admin rights on a site (idempotent upsert). */
-function grantSiteAdmin(siteId, userId) {
-  db.prepare(`
-    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
-    ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
-  `).run(siteId, userId);
-}
-
-/** Candidate owners for the owner selector field (god-only). */
-function listOwnerCandidates() {
-  return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
-}
-
 // ==================== LIST ====================
 router.get('/', requireGod, (req, res) => {
   const sites = db.prepare(`
     SELECT s.id, s.slug, s.title, s.description, s.created_at,
-           s.is_public, s.robots_index, s.is_primary,
+           s.is_public, s.robots_index,
            u.username AS owner_username,
            (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
     FROM sites s LEFT JOIN users u ON u.id = s.owner_id
-    ORDER BY s.is_primary DESC, s.created_at DESC
+    ORDER BY s.created_at DESC
   `).all();
 
   renderPage(req, res, 'pages/admin-sites', {
-    pageTitleKey: 'admin.t_sites',
+    pageTitle: 'Sites',
     bodyClass: 'on-admin',
     sites,
@@ -200,17 +159,12 @@
 router.get('/new', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_newsite',
+    pageTitle: 'New site',
     bodyClass: 'on-admin',
     isNew: true,
-    // ?owner=<id> (from the users page: "give this user a Klonkt") is
-    // pre-selected; otherwise defaults to the creating god.
-    site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
-    users: listOwnerCandidates(),
+    site: { slug: '', ...siteEditableFields() },
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks: [],
-    apAliases: '',
     error: null,
   });
@@ -232,10 +186,4 @@
 
   const f = { ...siteEditableFields(), ...req.body };
-
-  // Owner: god may assign the site to a DIFFERENT user — this is the core of
-  // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
-  // creating god themselves.
-  const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
-
   const siteId = uuid();
   db.prepare(`
@@ -243,7 +191,6 @@
       id, slug, title, description, tagline, owner_id,
       language, palette, accent, profile_photo,
-      is_public, robots_index, require_login_to_comment, enable_audio_player,
-      feed_view_default
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      is_public, robots_index, require_login_to_comment, enable_audio_player
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     siteId, slug,
@@ -251,8 +198,8 @@
     (f.description || '').slice(0, 500),
     (f.tagline || '').slice(0, 200),
-    ownerId,
+    req.session.user.id,
     f.language || 'nl',
-    f.palette || 'klonkt',
-    ThemeService.validateAccent(f.accent) || '#e8b04b',
+    f.palette || 'sage',
+    ThemeService.validateAccent(f.accent) || '#c2410c',
     f.profile_photo || null,
     f.is_public ? 1 : 0,
@@ -260,16 +207,16 @@
     f.require_login_to_comment ? 1 : 0,
     (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
-    f.feed_view_default === 'grid' ? 'grid' : 'reader',
   );
 
-  // The OWNER (not necessarily the creator) gets a site_members admin row → this
-  // lets them pass canAdminSite + requireSiteManager gates to manage their site.
-  grantSiteAdmin(siteId, ownerId);
-
-  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
+  // The site_members entry lets the god/owner show up in canAdminSite checks.
+  db.prepare(`
+    INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
+  `).run(siteId, req.session.user.id);
+
+  res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site created'));
 });
 
 // ==================== EDIT (form) ====================
-router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
+router.get('/:slug/edit', requireGod, (req, res) => {
   const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
@@ -280,19 +227,13 @@
   }
 
-  let apAliases = '';
-  try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
-
   renderPage(req, res, 'pages/admin-site-edit', {
-    pageJs: 'admin-site-edit',
-    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
+    pageTitle: `Edit: ${site.title}`,
     bodyClass: 'on-admin',
     isNew: false,
     site,
-    users: listOwnerCandidates(),
     palettes: ThemeService.listPalettes(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
     parsedLinks,
-    apAliases,
     success: req.query.success || null,
     error: req.query.error || null,
@@ -300,56 +241,13 @@
 });
 
-// ==================== MOVE (FEP-7628, slice 2) ====================
-// The explicit departure: announce to every follower that this account now
-// lives elsewhere. Deliberately its own POST with its own button, never a
-// side effect of Save: a Move is a door you close behind you.
-router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
+// ==================== SAVE ====================
+router.post('/:slug/save', requireGod, (req, res) => {
+  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
   if (!site) return res.redirect('/admin/sites?error=Not+found');
-  const r = await AP.moveAccount(site, req.body.move_target || '');
-  if (r && r.ok) {
-    return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
-  }
-  const msg = {
-    guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
-    no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
-    not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
-    unreachable: 'Het nieuwe profiel is niet bereikbaar.',
-    self: 'Dat is dit account zelf.',
-  }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
-  res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
-});
-
-// ==================== SAVE ====================
-router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
-  const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Not+found');
 
   const f = req.body;
-  // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
-  // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
-  // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
-  // Nu betekent de waarde weer wat er staat.
-  const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
-  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
+  const moderationMode = f.comments_moderation_mode === 'moderate' ? 'moderate' : 'trust';
+  const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : 'timeline';
   const profileLinksJson = buildProfileLinks(f);
-
-  // FEP-7628 aliases — validated/resolved before anything is written.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
-  // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
-  // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
-  // account door je kleuren aan te passen, en weigert de Move daarna met
-  // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
-  // een oude gecachte pagina die hem nog wel heeft blijft werken.
-  let apAliasesJson = site.ap_aliases || null;
-  if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
-    try {
-      const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
-      apAliasesJson = arr.length ? JSON.stringify(arr) : null;
-    } catch (e) {
-      return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
-    }
-  }
 
   // theme_override: only accept the three legal values. Empty string means
@@ -359,5 +257,5 @@
   // accent: only accept colors from the curated ACCENTS list. Falls back to
   // the orange default if the submitted value isn't recognised.
-  const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
+  const accent = ThemeService.validateAccent(f.accent) || '#c2410c';
 
   db.prepare(`
@@ -365,12 +263,14 @@
       title = ?, description = ?, tagline = ?, language = ?,
       palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
-      profile_enabled = ?,
+      profile_enabled = ?, profile_name = ?, profile_bio = ?,
       profile_links = ?,
-      ap_aliases = ?,
       is_public = ?, robots_index = ?, require_login_to_comment = ?,
-      enable_audio_player = ?,
-      approve_followers = ?,
-      feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?, reader_full_page = ?,
+      enable_audio_player = ?, enable_prutter = ?,
+      comments_moderation_mode = ?,
+      feed_view_default = ?, feed_view_switch = ?,
       show_search = ?, show_archive_link = ?,
+      title_template = ?, twitter = ?, canonical = ?,
+      google_verification = ?, bing_verification = ?,
+      pinterest_verification = ?, yandex_verification = ?,
       custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
       updated_at = CURRENT_TIMESTAMP
@@ -381,22 +281,29 @@
     (f.tagline || '').slice(0, 200),
     f.language || 'nl',
-    f.palette || 'klonkt',
+    f.palette || 'sage',
     accent,
     themeOverride,
     f.profile_photo || null,
     f.profile_enabled ? 1 : 0,
+    (f.profile_name || '').slice(0, 100) || null,
+    (f.profile_bio  || '').slice(0, 500) || null,
     profileLinksJson,
-    apAliasesJson,
     f.is_public ? 1 : 0,
     f.robots_index ? 1 : 0,
     f.require_login_to_comment ? 1 : 0,
     f.enable_audio_player ? 1 : 0,
-    f.approve_followers ? 1 : 0,
+    f.enable_prutter ? 1 : 0,
+    moderationMode,
     feedViewDef,
     f.feed_view_switch ? 1 : 0,
-    feedAlt,
-    f.reader_full_page ? 1 : 0,
     f.show_search ? 1 : 0,
     f.show_archive_link ? 1 : 0,
+    (f.title_template || '{title} — {site}').slice(0, 200),
+    (f.twitter || '').slice(0, 64) || null,
+    (f.canonical || '').slice(0, 200) || null,
+    (f.google_verification    || '').slice(0, 200) || null,
+    (f.bing_verification      || '').slice(0, 200) || null,
+    (f.pinterest_verification || '').slice(0, 200) || null,
+    (f.yandex_verification    || '').slice(0, 200) || null,
     f.custom_css      || null,
     f.custom_head_html || null,
@@ -405,38 +312,5 @@
   );
 
-  // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
-  // change the owner (the field is not shown to non-god users either).
-  if (req.session.user.role === 'god') {
-    const newOwner = validOwnerId(req.body.owner_id);
-    if (newOwner) {
-      db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
-      grantSiteAdmin(site.id, newOwner);
-    }
-  }
-
-  // Alias change → broadcast an actor Update so remote caches refresh. The old
-  // server re-fetches the actor live during a Move anyway; this is freshness,
-  // not correctness, hence best-effort.
-  if ((site.ap_aliases || null) !== apAliasesJson) {
-    try {
-      const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
-      AP.deliverActorUpdate(fresh).catch(() => {});
-    } catch { /* never blocks the save */ }
-  }
-
-  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
-});
-
-// ==================== MAKE PRIMARY ====================
-// God chooses which site is the primary/main site (the label/company site in hub;
-// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
-router.post('/:slug/make-primary', requireGod, (req, res) => {
-  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
-  if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
-  db.transaction(() => {
-    db.prepare('UPDATE sites SET is_primary = 0').run();
-    db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
-  })();
-  res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
+  res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Saved'));
 });
 
Index: src/routes/admin-stats.js
===================================================================
--- src/routes/admin-stats.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-/**
- * Admin: Statistics (premium module, god-only).
- *
- * GET /admin/stats -> cookie-free statistics: visitors/views per day,
- *                     plays, and the most popular posts/tracks.
- *
- * Premium-gated via premiumUnlocked() (premium layer off = freely available;
- * on = Patreon required). Tracking is in StatsService (no cookies).
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { getStats, currentIp, getExcludedIps, setExcludedIps } from '../services/StatsService.js';
-
-const router = express.Router();
-
-router.get('/', requireGod, (req, res) => {
-  if (!premiumUnlocked()) {
-    return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.');
-  }
-  // Link-in-bio clicks (premium #6) for the current site.
-  let linkClicks = [];
-  if (res.locals.site) {
-    try {
-      linkClicks = db.prepare(
-        'SELECT url, clicks FROM link_clicks WHERE site_id = ? AND clicks > 0 ORDER BY clicks DESC LIMIT 50'
-      ).all(res.locals.site.id);
-    } catch { linkClicks = []; }
-  }
-  const days = [7, 14, 30, 90].includes(parseInt(req.query.days, 10)) ? parseInt(req.query.days, 10) : 14;
-  const myIp = currentIp(req);
-  renderPage(req, res, 'pages/admin-stats', {
-    pageTitleKey: 'admin.t_stats',
-    bodyClass: 'on-admin',
-    stats: getStats(days),
-    linkClicks,
-    myIp,
-    ipExcluded: !!myIp && getExcludedIps().includes(myIp),
-  });
-});
-
-// Toggle whether the admin's current IP is counted in statistics.
-router.post('/exclude-ip', requireGod, (req, res) => {
-  const ip = currentIp(req);
-  if (ip) {
-    const list = getExcludedIps();
-    const i = list.indexOf(ip);
-    if (i >= 0) list.splice(i, 1); else list.push(ip);
-    setExcludedIps(list);
-  }
-  res.redirect('/admin/stats');
-});
-
-export default router;
Index: src/routes/admin-updates.js
===================================================================
--- src/routes/admin-updates.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,195 +1,0 @@
-/**
- * Admin: Updates (god-only).
- *   GET  /admin/updates      -> current vs. latest version + status
- *   POST /admin/updates/run  -> fetch latest + restart (fleet only; see below)
- *
- * Three topologies are supported, detected automatically:
- *   - CHECKOUT (external self-hoster): the app dir is itself a git clone with
- *     origin = GitHub. "Latest" = origin/<branch> (fetched on view). Updating is
- *     done out-of-band by `klonkt-update` (needs root for systemd), so the page
- *     shows that command instead of an in-app button.
- *   - BARE (Robin's own VPS fleet): a bare repo at KLONKT_GIT_DIR; the app dir is
- *     a `checkout -f` work-tree (no .git). "Latest" = <branch>. The detached
- *     self-update script runs in-app (no root needed) → the button works.
- *   - ANDROID (the Klonkt phone app, Termux — node reports platform 'android'):
- *     installed from a prebuilt tarball, no git at all. "Latest" = the package
- *     version on the klonkt STABLE branch (the channel the phone tarballs are
- *     built from). The button runs the phone's own `klonkt-update` command
- *     detached, which survives the server restart it causes.
- * git stderr is ignored so a foreign/missing repo never spams "fatal: ...".
- */
-
-import express from 'express';
-import { execFileSync, spawn } from 'child_process';
-import fs from 'fs';
-import path from 'path';
-import { renderPage } from '../middleware/render.js';
-import { requireGod } from '../middleware/auth.js';
-
-const router = express.Router();
-
-const HOME = process.env.HOME || '';
-const APP_DIR = process.cwd();
-const GIT_DIR = process.env.KLONKT_GIT_DIR || path.join(HOME, 'git-repos/prutfolio.git');
-const UPDATE_SCRIPT = process.env.KLONKT_UPDATE_SCRIPT || path.join(HOME, 'bin/klonkt-self-update.sh');
-
-// The app dir is a git CHECKOUT (GitHub install) when it has a .git; otherwise we
-// fall back to the BARE repo (fleet). This split keeps the version check pointed at
-// a repo that actually exists, so it never logs "fatal: not a git repository".
-const IS_CHECKOUT = (() => { try { return fs.existsSync(path.join(APP_DIR, '.git')); } catch { return false; } })();
-
-// A checkout must track the branch it is ACTUALLY on: a self-hoster who checked out
-// `stable` should be compared to origin/stable, not main — otherwise the update panel
-// shows main's commits as "latest" and a bogus "behind" count (confusing for stable
-// users). Env override wins (the bare fleet sets KLONKT_BRANCH); then the checkout's
-// current branch; then main as a last resort.
-const CHECKOUT_BRANCH = IS_CHECKOUT ? (() => {
-  try {
-    const b = execFileSync('git', ['-C', APP_DIR, 'rev-parse', '--abbrev-ref', 'HEAD'],
-      { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
-    return (b && b !== 'HEAD') ? b : null;
-  } catch { return null; }
-})() : null;
-const BRANCH = process.env.KLONKT_BRANCH || CHECKOUT_BRANCH || 'main';
-const REMOTE_REF = IS_CHECKOUT ? `origin/${BRANCH}` : BRANCH; // what "latest" resolves to
-
-// The Klonkt Android app (Termux): node there reports platform 'android'; the
-// filesystem check is belt-and-braces for exotic node builds.
-const IS_ANDROID = process.platform === 'android'
-  || (() => { try { return fs.existsSync('/data/data/com.termux/files/usr/bin'); } catch { return false; } })();
-// "Latest" for a phone = what the update button can actually INSTALL: the version
-// of the prebuilt bundle on the release (BUILD-INFO.txt's bundle-version). Reading
-// the stable branch instead showed a new version during the CI window in which the
-// bundle was still being built — pressing update then reinstalled the old version.
-const ANDROID_BUILDINFO_URL = 'https://github.com/roboburr/klonkt-android/releases/download/termux-latest/BUILD-INFO.txt';
-const ANDROID_STABLE_URL = 'https://raw.githubusercontent.com/roboburr/klonkt/stable/package.json';
-
-async function fetchWithTimeout(url, ms) {
-  const ctl = new AbortController();
-  const t = setTimeout(() => ctl.abort(), ms);
-  try { return await fetch(url, { signal: ctl.signal, redirect: 'follow' }); }
-  finally { clearTimeout(t); }
-}
-
-async function androidLatestVersion() {
-  try {
-    const r = await fetchWithTimeout(ANDROID_BUILDINFO_URL, 8000);
-    if (r.ok) {
-      const m = (await r.text()).match(/^bundle-version:\s*(\S+)/m);
-      if (m) return m[1];
-    }
-  } catch { /* fall through */ }
-  // Older releases have no bundle-version line → fall back to the stable branch.
-  try {
-    const r = await fetchWithTimeout(ANDROID_STABLE_URL, 8000);
-    if (!r.ok) return null;
-    return (await r.json()).version || null;
-  } catch { return null; }
-}
-
-function appVersion() {
-  try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
-  catch { return null; }
-}
-// stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
-function git(args) {
-  try {
-    const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
-    return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
-  } catch { return null; }
-}
-function currentSha() {
-  if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
-  try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
-}
-
-// Last 5 commits = the "recent changes" you'll get when updating.
-function recentChanges() {
-  const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
-  if (!out) return [];
-  return out.split('\n').map((l) => {
-    const i = l.indexOf('\x1f');
-    return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
-  });
-}
-
-router.get('/', requireGod, async (req, res) => {
-  // ANDROID: version-based check against the stable branch; the update button
-  // runs the phone's klonkt-update (always present, the start script writes it).
-  if (IS_ANDROID) {
-    const cur = appVersion();
-    const latest = await androidLatestVersion();
-    return renderPage(req, res, 'pages/admin-updates', {
-      pageTitleKey: 'admin.t_updates',
-      bodyClass: 'on-admin',
-      appVersion: cur,
-      currentSha: cur ? 'v' + cur : null,
-      currentDesc: null,
-      latestSha: latest ? 'v' + latest : null,
-      latestDesc: null,
-      upToDate: !!(cur && latest && cur === latest),
-      canCheck: !!latest,
-      canSelfUpdate: true,
-      manualCommand: null,
-      behind: null,
-      changes: [],
-      success: req.query.success || null,
-      error: req.query.error || null,
-    });
-  }
-  // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
-  // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
-  if (IS_CHECKOUT) {
-    try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
-  }
-  const cur = currentSha();
-  const latest = git(['rev-parse', REMOTE_REF]);
-  // The in-app "Update now" button only works with the detached self-update script
-  // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
-  const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
-  renderPage(req, res, 'pages/admin-updates', {
-    pageTitleKey: 'admin.t_updates',
-    bodyClass: 'on-admin',
-    appVersion: appVersion(),
-    currentSha: cur ? cur.slice(0, 8) : null,
-    currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
-    latestSha: latest ? latest.slice(0, 8) : null,
-    latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
-    upToDate: !!(cur && latest && cur === latest),
-    canCheck: !!latest,
-    canSelfUpdate,
-    manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
-    behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
-    changes: recentChanges(),
-    success: req.query.success || null,
-    error: req.query.error || null,
-  });
-});
-
-router.post('/run', requireGod, (req, res) => {
-  // ANDROID: run the phone's updater detached. It kills node (this process),
-  // swaps the code while keeping storage/.env, and restarts everything — the
-  // detached shell survives the pkill because it isn't a node process.
-  if (IS_ANDROID) {
-    try {
-      const child = spawn('bash', ['-c', 'klonkt-update >> "$HOME/klonkt-update.log" 2>&1'], { detached: true, stdio: 'ignore' });
-      child.unref();
-    } catch (e) {
-      return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
-    }
-    return res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site is ~1 minuut bezig (downloaden + herstarten). Ververs daarna deze pagina.'));
-  }
-  if (!fs.existsSync(UPDATE_SCRIPT)) {
-    return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
-  }
-  try {
-    // Detached + unlinked: survives the reload that restarts this app.
-    const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
-    child.unref();
-  } catch (e) {
-    return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
-  }
-  res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
-});
-
-export default router;
Index: src/routes/admin-users.js
===================================================================
--- src/routes/admin-users.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-users.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -20,8 +20,5 @@
 const router = express.Router();
 
-// 'kijker' = read-only demo/audit account: may view everything (incl. admin panel),
-// but the global guard blocks all mutations. Replaces the old separate
-// 'kijk-modus' flag (readonly), which is now covered by this role.
-const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
+const VALID_ROLES = new Set(['member', 'admin', 'god']);
 
 function godCount() {
@@ -32,5 +29,5 @@
 router.get('/', requireGod, (req, res) => {
   const users = db.prepare(`
-    SELECT u.id, u.username, u.email, u.role, u.created_at, u.avatar_url, u.readonly,
+    SELECT u.id, u.username, u.email, u.role, u.created_at, u.avatar_url,
            (SELECT COUNT(*) FROM posts p WHERE p.author_id = u.id) AS post_count,
            (SELECT COUNT(*) FROM sites s  WHERE s.owner_id  = u.id) AS site_count
@@ -40,5 +37,5 @@
 
   renderPage(req, res, 'pages/admin-users', {
-    pageTitleKey: 'admin.t_users',
+    pageTitle: 'Users',
     bodyClass: 'on-admin',
     users,
@@ -69,7 +66,5 @@
   }
 
-  // readonly=0: read-only status now lives entirely in the 'kijker' role, so
-  // on every role change we clear the legacy flag (no dual source of truth).
-  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+  db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(newRole, userId);
   res.redirect('/admin/users?success=' + encodeURIComponent('Role updated'));
@@ -89,33 +84,22 @@
   }
 
-  // Cascade delete: this user's sites (+ posts/playlists/audio/members/
-  // comments under them), their own content elsewhere, then the user themselves.
-  // Atomic in a transaction — if any FK fails, everything rolls back.
-  const del = db.transaction(() => {
-    const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
-    for (const sid of sites) {
-      db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE site_id = ?)').run(sid);
-      db.prepare('DELETE FROM posts WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM playlists WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM site_members WHERE site_id = ?').run(sid);
-      db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
-    }
-    // Own content on other sites + loose associations.
-    db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
-    db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
-    db.prepare('DELETE FROM comments WHERE author_id = ?').run(userId);
-    db.prepare('DELETE FROM site_members WHERE user_id = ?').run(userId);
-    db.prepare('DELETE FROM users WHERE id = ?').run(userId);
-  });
+  const owned = db.prepare(`
+    SELECT
+      (SELECT COUNT(*) FROM posts WHERE author_id = ?) AS posts,
+      (SELECT COUNT(*) FROM sites WHERE owner_id  = ?) AS sites
+  `).get(userId, userId);
 
-  try {
-    del();
-  } catch (e) {
-    console.error('[admin/users delete]', e.message);
-    return res.redirect('/admin/users?error=' + encodeURIComponent('Verwijderen mislukt (mogelijk gekoppelde data).'));
+  if (owned.posts > 0 || owned.sites > 0) {
+    return res.redirect('/admin/users?error=' + encodeURIComponent(
+      `Cannot delete: user owns ${owned.sites} site(s) and ${owned.posts} post(s). Reassign or delete those first.`
+    ));
   }
 
-  res.redirect('/admin/users?success=' + encodeURIComponent('Gebruiker verwijderd: ' + target.username));
+  // Clean up dangling references
+  db.prepare('DELETE FROM site_members WHERE user_id = ?').run(userId);
+  db.prepare('DELETE FROM comments WHERE author_id = ?').run(userId);
+  db.prepare('DELETE FROM users WHERE id = ?').run(userId);
+
+  res.redirect('/admin/users?success=' + encodeURIComponent('User deleted: ' + target.username));
 });
 
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,59 +6,11 @@
 
 import express from 'express';
-import db, { isoSql } from '../config/database.js';
+import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireAuth } from '../middleware/auth.js';
-import { apEnabled } from '../services/SettingsService.js';
-import { getPrimarySite } from '../middleware/site.js';
+import { requireGod } from '../middleware/auth.js';
 
 const router = express.Router();
 
-// Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs.
-// Solves the problem that drafts (status != published) were not findable anywhere:
-// the timeline shows only published posts.
-function sitePosts(siteId, siteSlug, limit = 60) {
-  const base = '';
-  return db.prepare(`
-    SELECT slug, title, status, published_at, created_at, updated_at
-    FROM posts WHERE site_id = ?
-    ORDER BY (status != 'published') DESC, ${isoSql('COALESCE(updated_at, published_at, created_at)')} DESC
-    LIMIT ?
-  `).all(siteId, limit).map((p) => ({
-    ...p,
-    isDraft: p.status !== 'published',
-    editUrl: `${base}/posts/${p.slug}/edit`,
-    viewUrl: `${base}/${p.slug}`,
-  }));
-}
-
-router.get('/', requireAuth, (req, res) => {
-  const user = req.session.user;
-
-  // A kijker may view the full (god) admin panel read-only — same as god,
-  // but writing is globally blocked. A regular artist who owns a site gets
-  // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin.
-  if (user.role !== 'god' && user.role !== 'kijker') {
-    const mySite = db.prepare(
-      'SELECT * FROM sites WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1'
-    ).get(user.id);
-    if (!mySite) return res.status(403).send('Geen beheer beschikbaar voor dit account.');
-
-    const mine = {
-      posts: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ?").get(mySite.id).c,
-      published: db.prepare("SELECT COUNT(*) AS c FROM posts WHERE site_id = ? AND status = 'published'").get(mySite.id).c,
-    };
-    return renderPage(req, res, 'pages/my-site', {
-      pageTitleKey: 'admin.t_hub',
-      bodyClass: 'on-admin',
-      mySite,
-      mine,
-      posts: sitePosts(mySite.id, mySite.slug),
-    });
-  }
-
-  // THE site: it provides the "Appearance" tile with its edit link and the
-  // posts/drafts list.
-  const primarySite = getPrimarySite();
-
+router.get('/', requireGod, (req, res) => {
   const stats = {
     users: db.prepare('SELECT COUNT(*) AS c FROM users').get().c,
@@ -70,24 +22,25 @@
   };
 
-  // Posts/drafts of the site. Drafts are listed first so they are easy to find.
-  const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug) : [];
+  const sites = db.prepare(`
+    SELECT s.slug, s.title, s.created_at, u.username AS owner_username
+    FROM sites s
+    LEFT JOIN users u ON u.id = s.owner_id
+    ORDER BY s.created_at DESC
+    LIMIT 50
+  `).all();
+
+  const users = db.prepare(`
+    SELECT username, email, role, created_at
+    FROM users
+    ORDER BY created_at DESC
+    LIMIT 50
+  `).all();
 
   renderPage(req, res, 'pages/admin', {
-    pageTitleKey: 'admin.t_admin',
+    pageTitle: 'Admin',
     bodyClass: 'on-admin',
-    circlesOn: apEnabled(),   // federatie aan/uit in de tagline
-    primarySite,
     stats,
-    posts,
-  });
-});
-
-// Handleiding — searchable explanation of all admin features. Visible to anyone
-// who may view the admin panel (logged in); purely static help text, nothing sensitive.
-router.get('/handleiding', requireAuth, (req, res) => {
-  renderPage(req, res, 'pages/admin-help', {
-    pageJs: 'admin-help',
-    pageTitleKey: 'admin.t_manual',
-    bodyClass: 'on-admin',
+    sites,
+    users,
   });
 });
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,25 +1,11 @@
 /**
- * Audio streaming routes — byte-range streaming.
+ * Audio streaming routes — v9-style signed URL + byte-range support.
  *
- * Files live in storage/audio/ and are NOT served by the static /media
- * handler — every fetch goes through this route, which adds byte-range
- * support so HTML5 <audio> can seek.
+ * Files live in storage/media/audio/ and are NOT served by the static
+ * /media handler — every fetch must go through this verified route.
  *
- * GET /audio/stream/:filename
- *   Streams the file with byte-range support.
- *
- * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
- *   The player never exposes this URL to the user — it fetch()es the bytes
- *   and plays from a blob: object URL (no shareable link, no "save audio as").
- *   This route additionally refuses anything that isn't a same-origin browser
- *   fetch, so the raw URL can't be pasted into the address bar, hotlinked from
- *   another site, or pulled with curl/yt-dlp.
- *
- *   A request is allowed when EITHER:
- *     - it carries the X-Audio-Player header (our fetch sets it), OR
- *     - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
- *       preview, which can't set custom headers).
- *   Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
- *   curl/yt-dlp send neither signal → all rejected.
+ * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
+ *   Verifies the token. If valid, streams the file with byte-range support
+ *   so HTML5 <audio> can seek. Anything invalid returns 403.
  */
 
@@ -28,11 +14,9 @@
 import path from 'path';
 import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { recordPlay } from '../services/StatsService.js';
-import AP from '../services/ActivityPubService.js';
+import { verifyToken } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Audio files live OUTSIDE storage/media — the public /media static handler
-// cannot reach them. Every fetch must go through this gated route.
+// cannot reach them. Every fetch must go through this signed route.
 const AUDIO_DIR = path.resolve(
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
@@ -55,59 +39,15 @@
 };
 
-// Access gate: same-origin browser fetches / media loads — PLUS fediverse-shared tracks.
-function isAllowedAudioRequest(req, filename) {
-  if (req.get('X-Audio-Player') === '1') return true;  // our blob fetch
-  const site = req.get('Sec-Fetch-Site');              // set by modern browsers
-  if (site === 'same-origin' || site === 'same-site') return true;
-  // fedi_open tracks are deliberately served ungated so remote servers (Mastodon, …) can
-  // fetch + play the file inline. The operator opted this specific track in (per-track flag).
-  if (filename) {
-    try {
-      const r = db.prepare(`SELECT 1 FROM audio_tracks t JOIN media m ON t.media_id = m.id
-        WHERE t.fedi_open = 1 AND (m.storage_path = ? OR m.storage_path LIKE ?) LIMIT 1`).get(filename, '%' + filename);
-      if (r) return true;
-    } catch { /* ignore */ }
-  }
-  return false;
-}
-
-/**
- * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio
- * ophalen, ook wat niet fedi_open is.
- *
- * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de
- * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is
- * precies de halve waarheid die deze hele ronde moest opruimen.
- *
- * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en
- * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan
- * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan
- * beide kanten ja gezegd.
- */
-async function isMoveTargetAudio(req, filename) {
-  if (!req.headers['signature'] || !filename) return false;
-  let rij;
-  try {
-    rij = db.prepare(`SELECT s.slug FROM audio_tracks t
-                        JOIN media m ON t.media_id = m.id
-                        JOIN sites s ON s.id = t.site_id
-                       WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`)
-      .get(filename, `%${filename}`);
-  } catch { return false; }
-  if (!rij || !rij.slug) return false;
-  const v = await AP.verifyRequest(req).catch(() => null);
-  return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id));
-}
-
-router.get('/stream/:filename', async (req, res) => {
+router.get('/stream/:filename', (req, res) => {
   const { filename } = req.params;
-
-  if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) {
-    return res.status(403).send('Direct access not allowed');
-  }
+  const { t, exp } = req.query;
 
   // Sanity: no path traversal, no slashes
   if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
     return res.status(400).send('Bad filename');
+  }
+
+  if (!verifyToken(filename, t, exp)) {
+    return res.status(403).send('Invalid or expired token');
   }
 
@@ -131,26 +71,7 @@
   const range = req.headers.range;
 
-  // Statistics: count one play on the initial player fetch (not on scrub/
-  // range continuations; replays within 24h come from the browser cache → no
-  // double counting). Best-effort, must never break the stream.
-  if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
-    try {
-      const tr = db.prepare(`
-        SELECT t.id FROM audio_tracks t JOIN media m ON t.media_id = m.id
-        WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1
-      `).get(filename, '%' + filename);
-      if (tr) recordPlay(tr.id);
-    } catch {}
-  }
-
   // Common headers
   res.setHeader('Content-Type', mime);
   res.setHeader('Accept-Ranges', 'bytes');
-  // Same lesson /media already learned: Helmet's default CORP is same-origin,
-  // and the browser then refuses to hand a cross-origin <audio> the bytes —
-  // the file arrives, the player stays silent. These URLs are precisely what
-  // we advertise in federated Audio objects (Funkwhale, the hub) to be played
-  // elsewhere; WHO may fetch is decided by the gate above, not by CORP.
-  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
   // Allow the browser to cache the file for a day so play/pause/replay
   // doesn't re-fetch the whole stream every time. `private` keeps it out of
@@ -186,19 +107,3 @@
 });
 
-// Which post contains this track? (for the mini-player → "jump to the post +
-// scroll to the track".) Fetches the newest published post with [[track:<id>]].
-router.get('/track/:id/post', (req, res) => {
-  const id = String(req.params.id || '');
-  if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' });
-  const row = db.prepare(`
-    SELECT p.slug, s.slug AS site_slug
-    FROM posts p JOIN sites s ON s.id = p.site_id
-    WHERE p.status = 'published' AND p.content LIKE ?
-    ORDER BY p.published_at DESC LIMIT 1
-  `).get('%[[track:' + id + ']]%');
-  if (!row) return res.status(404).json({ error: 'not found' });
-  const url = `/${row.slug}`;
-  res.json({ url });
-});
-
 export default router;
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/auth.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,70 +6,18 @@
 import { renderPage } from '../middleware/render.js';
 import { loginLimiter, registerLimiter } from '../middleware/rate-limit.js';
-import { safeNext, requireAuth } from '../middleware/auth.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { resolveLang, t } from '../services/i18n.js';
-import { setSetting } from '../services/SettingsService.js';
+import { safeNext } from '../middleware/auth.js';
 
 const router = express.Router();
 
-// Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the
-// user doesn't exist or has no password — no timing oracle for enumeration.
-const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
-
-// Canonical base URL for links in emails (reset). Building it from headers is
-// spoofable (X-Forwarded-Host); a fixed config eliminates that risk.
-function publicBaseUrl(req) {
-  const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
-  if (cfg) return cfg;
-  // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
-  // X-Forwarded-Host).
-  return `${req.protocol}://${req.get('host')}`;
-}
-
-function hashToken(raw) {
-  return crypto.createHash('sha256').update(String(raw)).digest('hex');
-}
-
-// First-time setup? Only while there are no users yet may /register create an
-// admin account. Afterwards registration is closed (listeners come via Google).
-function isSetupMode() {
-  return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
-}
-
 // ==================== LOGIN ====================
-// Single login = admin/owner password (no public/listener login anymore; social
-// interaction happens via the fediverse). /login and /auth/admin both show it.
 router.get('/login', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
-  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-login', {
-    pageTitle: 'Inloggen',
-    bodyClass: 'on-special on-auth',
-    error: req.query.error || null,
-    gerr: null,
+    pageTitle: 'Login',
+    bodyClass: 'on-special',
+    error: null,
     success: req.query.success || null,
     username: '',
-    adminLogin: true,
-    googleReady: false,
-    next,
-  });
-});
-
-// Hidden admin login (username + password). Not linked anywhere in the UI —
-// the admin navigates here directly (/auth/admin).
-router.get('/admin', (req, res) => {
-  const next = safeNext(req.query.next) || '';
-  if (req.session.user) return res.redirect(next || '/');
-  if (isSetupMode()) return res.redirect('/auth/register' + (next ? '?next=' + encodeURIComponent(next) : ''));
-  renderPage(req, res, 'pages/auth-login', {
-    pageTitle: 'Beheerder inloggen',
-    bodyClass: 'on-special on-auth',
-    error: req.query.error || null,
-    gerr: null,
-    success: req.query.success || null,
-    username: '',
-    adminLogin: true,
-    googleReady: false,
     next,
   });
@@ -80,100 +28,153 @@
   const next = safeNext(req.body.next) || '';
 
-  // Error display on the (hidden) admin login page: re-show the password
-  // form (adminLogin:true), not the Google-only public page.
-  const renderErr = (error, status = 400) => {
-    res.status(status);
+  if (!username || !password) {
+    res.status(400);
     return renderPage(req, res, 'pages/auth-login', {
-      pageTitle: 'Beheerder inloggen', bodyClass: 'on-special on-auth',
-      error, gerr: null, success: null, username: username || '',
-      adminLogin: true, googleReady: false, next,
-    });
+      pageTitle: 'Login',
+      bodyClass: 'on-special',
+      error: 'Username and password required',
+      username: username || '',
+      next,
+    });
+  }
+
+  const user = db.prepare(
+    'SELECT * FROM users WHERE username = ? OR email = ?'
+  ).get(username, username);
+
+  if (!user || !bcrypt.compareSync(password, user.password_hash)) {
+    res.status(401);
+    return renderPage(req, res, 'pages/auth-login', {
+      pageTitle: 'Login',
+      bodyClass: 'on-special',
+      error: 'Invalid credentials',
+      username: username,
+      next,
+    });
+  }
+
+  req.session.user = {
+    id: user.id,
+    username: user.username,
+    email: user.email,
+    role: user.role,
+    avatar_url: user.avatar_url,
+    palette: user.palette,
+    theme: user.theme,
   };
 
-  if (!username || !password) return renderErr('Gebruikersnaam en wachtwoord vereist');
-
-  const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
-  // Always one bcrypt comparison (dummy if the user has no usable password)
-  // so response time reveals nothing about whether the account exists.
-  const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
-  const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
-  if (!usable || !ok) return renderErr('Ongeldige inloggegevens', 401);
-
-  req.session.user = {
-    id: user.id, username: user.username, email: user.email, role: user.role,
-    avatar_url: user.avatar_url, palette: user.palette, theme: user.theme,
-    readonly: !!user.readonly,
-  };
   res.redirect(next || '/');
 });
 
-// ==================== FIRST-TIME SETUP (create admin account) ====================
+// ==================== REGISTER ====================
 router.get('/register', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
-  // No public registration: only the very first admin may be created here.
-  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-register', {
-    pageJs: 'auth-register',
-    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
-    error: null, username: '', email: '', siteName: '', next,
+    pageTitle: 'Register',
+    bodyClass: 'on-special',
+    error: null,
+    username: '',
+    email: '',
+    next,
   });
 });
 
 router.post('/register', registerLimiter, (req, res) => {
-  const { username, email, password, siteName } = req.body;
+  const { username, email, password } = req.body;
   const next = safeNext(req.body.next) || '';
-  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
-    pageJs: 'auth-register',
-    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
-    error, username: username || '', email: email || '', siteName: siteName || '', next,
-  });
-
-  // Hard-closed once a user exists — prevents a second "admin" via this route.
-  if (!isSetupMode()) return res.redirect('/auth/login');
-
-  if (!username || !email || !password) return renderErr('Alle velden zijn verplicht');
+
+  // Validation
+  if (!username || !email || !password) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'All fields required',
+      username: username || '',
+      email: email || '',
+      next,
+    });
+  }
+
   if (!/^[a-z0-9_-]{3,32}$/i.test(username)) {
-    return renderErr('Gebruikersnaam: 3-32 tekens, letters/cijfers/_/- alleen');
-  }
-  if (password.length < 8) return renderErr('Wachtwoord moet minstens 8 tekens zijn');
-
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Username: 3-32 characters, letters/numbers/underscore/dash only',
+      username, email, next,
+    });
+  }
+
+  if (password.length < 8) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Password must be at least 8 characters',
+      username, email, next,
+    });
+  }
+
+  // Check uniqueness
+  const existing = db.prepare(
+    'SELECT id FROM users WHERE username = ? OR email = ?'
+  ).get(username, email);
+
+  if (existing) {
+    return renderPage(req, res, 'pages/auth-register', {
+      pageTitle: 'Register',
+      bodyClass: 'on-special',
+      error: 'Username or email already taken',
+      username, email, next,
+    });
+  }
+
+  // Create user
   const userId = uuid();
   const hash = bcrypt.hashSync(password, 10);
-  // The very first user is the administrator (god).
+
+  // First user becomes god
+  const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
+  const role = userCount === 0 ? 'god' : 'member';
+
   db.prepare(`
     INSERT INTO users (id, username, email, password_hash, role, theme, palette)
-    VALUES (?, ?, ?, ?, 'god', 'dark', 'sage')
-  `).run(userId, username, email, hash);
-
-  // Auto-create a personal site (single-tenant restructure follows later).
-  // Setup wizard: site name + language come from the form; language = the language
-  // the visitor used to fill in the wizard (resolveLang) and becomes the site default.
-  if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
+    VALUES (?, ?, ?, ?, ?, 'dark', 'sage')
+  `).run(userId, username, email, hash, role);
+
+  // First user gets a personal site auto-created
+  if (userCount === 0) {
     const siteId = uuid();
-    const lang = resolveLang(req);
-    const title = (siteName || '').trim().slice(0, 80) || (username + "'s Site");
+    const siteSlug = username.toLowerCase();
     db.prepare(`
       INSERT INTO sites (id, slug, title, description, owner_id, palette, accent, language)
-      VALUES (?, ?, ?, ?, ?, 'klonkt', '#e8b04b', ?)
-    `).run(siteId, username.toLowerCase(), title, '', userId, lang);
-    db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
-    try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
-  }
-
-  req.session.user = { id: userId, username, email, role: 'god', palette: 'klonkt', theme: 'dark' };
+      VALUES (?, ?, ?, ?, ?, 'sage', '#c2410c', 'nl')
+    `).run(siteId, siteSlug, username + "'s Site", 'Welcome to my site', userId);
+    
+    db.prepare(`
+      INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
+    `).run(siteId, userId);
+  }
+
+  req.session.user = {
+    id: userId, username, email, role,
+    palette: 'sage', theme: 'dark',
+  };
+
   res.redirect(next || '/');
 });
 
-// ==================== FORGOT PASSWORD (request) ====================
+// ==================== PASSWORD RESET (request) ====================
 router.get('/reset-request', (req, res) => {
   if (req.session.user) return res.redirect('/');
   renderPage(req, res, 'pages/auth-reset-request', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: null, sent: false, devResetUrl: null, mailer: mailerConfigured(),
-  });
-});
-
-router.post('/reset-request', registerLimiter, async (req, res) => {
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    sent: false,
+    devResetUrl: null,
+  });
+});
+
+router.post('/reset-request', registerLimiter, (req, res) => {
   const email = (req.body.email || '').trim().toLowerCase();
   let devResetUrl = null;
@@ -182,52 +183,52 @@
     const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
     if (user) {
-      const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
+      // Generate token (32-byte hex, ~10 min expiry)
+      const token = crypto.randomBytes(32).toString('hex');
       const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
-      // Store only the HASH: so DB read access yields no usable token.
       db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
-        .run(hashToken(token), expires, user.id);
-
-      const url = `${publicBaseUrl(req)}/auth/reset/${token}`;
-
-      if (mailerConfigured()) {
-        try {
-          await sendMail({
-            to: user.email,
-            subject: 'Wachtwoord resetten',
-            text: `Reset je wachtwoord via deze link (30 min geldig):\n\n${url}\n\nNiet aangevraagd? Negeer deze mail.`,
-            html: `<p>Reset je wachtwoord via deze link (30 min geldig):</p><p><a href="${url}">${url}</a></p><p>Niet aangevraagd? Negeer deze mail.</p>`,
-          });
-        } catch (e) {
-          console.error('[reset-request] mail faalde:', e.message);
-        }
-      } else if (process.env.NODE_ENV !== 'production') {
-        // Dev without SMTP: show the link in the log + on the page.
-        console.log(`[password-reset] ${user.email} -> ${url}`);
-        devResetUrl = url;
-      } else {
-        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
-        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
-      }
+        .run(token, expires, user.id);
+
+      // No email sender yet — log the reset URL so dev can use it.
+      const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
+      const host = req.headers['x-forwarded-host'] || req.get('host');
+      const url = `${proto}://${host}/auth/reset/${token}`;
+      console.log(`[password-reset] ${email} -> ${url}`);
+      if (process.env.NODE_ENV !== 'production') devResetUrl = url;
     }
   }
 
-  // Anti-enumeration: same response regardless of whether the address exists.
+  // Anti-enumeration: same response regardless of whether the email exists.
   renderPage(req, res, 'pages/auth-reset-request', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: null, sent: true, devResetUrl, mailer: mailerConfigured(),
-  });
-});
-
-// ==================== RESET PASSWORD (apply) ====================
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    sent: true,
+    devResetUrl,
+  });
+});
+
+// ==================== PASSWORD RESET (apply) ====================
 router.get('/reset/:token', (req, res) => {
   const row = db.prepare(`
     SELECT id, username FROM users
-    WHERE reset_token = ? AND datetime(reset_token_expires) > datetime('now')
-  `).get(hashToken(req.params.token));
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(req.params.token);
+
+  if (!row) {
+    return renderPage(req, res, 'pages/auth-reset', {
+      pageTitle: 'Reset password',
+      bodyClass: 'on-special',
+      error: 'This reset link is invalid or has expired.',
+      token: null,
+      username: null,
+    });
+  }
+
   renderPage(req, res, 'pages/auth-reset', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: row ? null : 'Deze reset-link is ongeldig of verlopen.',
-    token: row ? req.params.token : null,
-    username: row ? row.username : null,
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: null,
+    token: req.params.token,
+    username: row.username,
   });
 });
@@ -237,27 +238,43 @@
   const row = db.prepare(`
     SELECT id, username FROM users
-    WHERE reset_token = ? AND datetime(reset_token_expires) > datetime('now')
-  `).get(hashToken(req.params.token));
+    WHERE reset_token = ? AND reset_token_expires > datetime('now')
+  `).get(req.params.token);
 
   const renderError = (msg) => renderPage(req, res, 'pages/auth-reset', {
-    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
-    error: msg, token: row ? req.params.token : null, username: row ? row.username : null,
-  });
-
-  if (!row) return renderError('Deze reset-link is ongeldig of verlopen.');
-  if (!new_password || new_password.length < 8) return renderError('Wachtwoord moet minstens 8 tekens zijn');
-  if (new_password !== confirm) return renderError('Wachtwoorden komen niet overeen');
+    pageTitle: 'Reset password',
+    bodyClass: 'on-special',
+    error: msg,
+    token: row ? req.params.token : null,
+    username: row ? row.username : null,
+  });
+
+  if (!row) return renderError('This reset link is invalid or has expired.');
+  if (!new_password || new_password.length < 8) {
+    return renderError('Password must be at least 8 characters');
+  }
+  if (new_password !== confirm) {
+    return renderError('Passwords do not match');
+  }
 
   const hash = bcrypt.hashSync(new_password, 10);
   db.prepare(`
-    UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
-      updated_at = CURRENT_TIMESTAMP WHERE id = ?
+    UPDATE users
+    SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL,
+        updated_at = CURRENT_TIMESTAMP
+    WHERE id = ?
   `).run(hash, row.id);
-  res.redirect('/auth/admin?success=' + encodeURIComponent('Wachtwoord gereset — log nu in.'));
+
+  // Force login (don't auto-log them in — let them prove ownership)
+  res.redirect('/auth/login?success=' + encodeURIComponent('Password reset — please log in.'));
 });
 
 // ==================== LOGOUT ====================
-router.get('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
-router.post('/logout', (req, res) => { req.session.destroy(() => res.redirect('/')); });
+router.get('/logout', (req, res) => {
+  req.session.destroy(() => res.redirect('/'));
+});
+
+router.post('/logout', (req, res) => {
+  req.session.destroy(() => res.redirect('/'));
+});
 
 export default router;
Index: src/routes/changelog.js
===================================================================
--- src/routes/changelog.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,40 +1,0 @@
-/**
- * Public changelog / release page.
- *
- * GET /changelog  -> renders the changelog in the visitor's language:
- *   CHANGELOG.<lang>.md if a translation exists, else CHANGELOG.md (English base).
- */
-
-import express from 'express';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import { renderPage } from '../middleware/render.js';
-import { MarkdownService } from '../services/MarkdownService.js';
-import { resolveLang, t } from '../services/i18n.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const ROOT = path.join(__dirname, '..', '..');
-
-router.get('/changelog', (req, res) => {
-  const lang = resolveLang(req, {
-    userLang: req.session && req.session.user && req.session.user.lang,
-    defaultLang: getSetting('default_lang'),
-  });
-  // Visitor's language if a translation exists, else the English base.
-  const files = [path.join(ROOT, `CHANGELOG.${lang}.md`), path.join(ROOT, 'CHANGELOG.md')];
-  let html = '';
-  for (const f of files) {
-    try { html = MarkdownService.render(fs.readFileSync(f, 'utf8')); break; } catch { /* try next */ }
-  }
-  if (!html) html = `<p>${t(lang, 'changelog.empty')}</p>`;
-  renderPage(req, res, 'pages/changelog', {
-    pageTitle: t(lang, 'changelog.title'),
-    bodyClass: 'on-changelog',
-    changelogHtml: html,
-  });
-});
-
-export default router;
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,116 +1,0 @@
-/**
- * Circle feed — the artists this site features (auto-boosts), sourced from
- * ActivityPub. Cards link to the source post. Available whenever the site
- * auto-boosts at least one account; otherwise next() -> postsRoutes.
- *   GET /cirkel
- */
-
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { apEnabled } from '../services/SettingsService.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-
-const router = express.Router();
-
-function safeUrl(u) {
-  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
-}
-/**
- * media_json als ARRAY, of leeg.
- *
- * De catch vangt KAPOTTE json; hij ving niet geldige json van het verkeerde
- * TYPE. Een remote server stuurde media_json = "[]" -- een string MET daarin
- * `[]` -- en JSON.parse geeft dan netjes een string terug. Een string heeft
- * geen .map, en daarmee lag /cirkel op boiert.eu plat: een 500 op de hele
- * pagina door een enkele post (rij 19 van 72).
- *
- * Precies dezelfde fout stond al beschreven en gerepareerd in
- * views/partials/note-body.ejs, waar drie vreemde notes de Krant meenamen.
- * Die reparatie is hier nooit gekomen -- zelfde data, andere route. Wat er
- * binnenkomt is niet van ons, dus de vorm hoort afgedwongen en niet aangenomen.
- */
-function safeJson(s) {
-  try {
-    const v = s ? JSON.parse(s) : [];
-    return Array.isArray(v) ? v : [];
-  } catch { return []; }
-}
-// The cover image + (separately) a cover video from a remote note's media. NEVER use a video/audio
-// item as the cover image — that produced a broken <img> for an animated cover that federated as an
-// MP4 (the video becomes a <video> instead).
-function coverMedia(media_json) {
-  const media = safeJson(media_json).map((m) => ({ ...m, url: safeUrl(m.url) })).filter((m) => m.url);
-  const video = media.find((m) => /video/i.test(m.type || '')) || null;
-  const image = media.find((m) => /image/i.test(m.type || ''))
-    || (media[0] && !/(video|audio)/i.test(media[0].type || '') ? media[0] : null);
-  return { image, video };
-}
-function htmlToText(html) {
-  return String(html || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-}
-// Tidy a plain-text snippet for use as a card title: drop a leading "RE: <url>" (the
-// quote/reply prefix Misskey/Akkoma and some reply federation prepend) and any other
-// leading bare URL, so the title shows the actual prose, not link noise.
-function tidySnippet(text) {
-  return String(text || '')
-    .replace(/^RE:\s*https?:\/\/\S+\s*/i, '')
-    .replace(/^https?:\/\/\S+\s*/i, '')
-    .trim();
-}
-
-const CIRKEL_PAGE = 72; // matches FEED_PAGE in posts.js: divisible by 2/3/4
-
-router.get('/cirkel', (req, res, next) => {
-  const site = res.locals.site;
-  if (!site || !apEnabled() || (ActivityPubService.autoBoostCount(site.slug) === 0 && ActivityPubService.boostedCount(site.slug) === 0)) return next();
-
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const rows = ActivityPubService.getCirkelPosts(site.slug, CIRKEL_PAGE + 1, offset);
-  const hasMore = rows.length > CIRKEL_PAGE;
-  const posts = rows.slice(0, CIRKEL_PAGE).map((r) => {
-    const text = tidySnippet(htmlToText(r.content));
-    // Show ONLY the title (the bold first line a Klonkt note carries), not the whole
-    // body. Title-less notes (e.g. plain Mastodon) fall back to a short text snippet.
-    const titleM = (r.content || '').match(/^\s*<p>\s*<strong>([\s\S]*?)<\/strong>/i);
-    const realTitle = titleM ? htmlToText(titleM[1]).trim() : '';
-    const cover = coverMedia(r.media_json);
-    const name = r.author_name || r.author_handle || 'Onbekend';
-    return {
-      id: 'ap-' + r.id,
-      slug: '',
-      title: realTitle
-        ? (realTitle.length > 90 ? realTitle.slice(0, 90) + '…' : realTitle)
-        : (text ? (text.length > 90 ? text.slice(0, 90) + '…' : text) : name),
-      excerpt: '',
-      cover_image_url: cover.image ? cover.image.url : null,
-      cover_video_url: cover.video ? cover.video.url : null,
-      published_at: r.published,
-      created_at: r.published,
-      type: 'post',
-      tags: '',
-      pinned: 0,
-      isBoost: !!r.boosted, // a post YOU boosted → render in the pinned style with a Boost badge
-      nsfw: r.nsfw ? 1 : 0, // remote sensitive post → blur in the Cirkel (post-card/tile)
-      content_warning: r.cw || '',
-      status: 'published',
-      source_name: name,
-      external_url: safeUrl(r.url),
-    };
-  });
-
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/home-append', { posts, hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase, morePath: '/cirkel' });
-  }
-
-  const sites = ActivityPubService.getCirkelMembers(site.slug)
-    .map((s) => ({ name: s.name || 'Onbekend', url: safeUrl(s.url), avatar: safeUrl(s.icon) }));
-
-  renderPage(req, res, 'pages/circle-feed', {
-    pageTitle: 'Cirkel', bodyClass: 'on-cirkel', posts, sites,
-    hasMore, nextOffset: offset + CIRKEL_PAGE, moreBase,
-  });
-});
-
-export default router;
Index: src/routes/comments.js
===================================================================
--- src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/comments.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,115 @@
+/**
+ * Comments — phase G v1.
+ *
+ * POST /comments              -> create a comment on a post (auth required)
+ * POST /comments/:id/delete   -> delete (own, or god/site-admin)
+ *
+ * Threading: 1 level deep (top-level + replies). Replies-of-replies fold up
+ * into the same parent (UI keeps it shallow).
+ *
+ * Status: auto-approved for logged-in users (trust mode). The schema's
+ * `status` column stays so we can switch to moderation later without changing
+ * shape. Anonymous comments (require_login_to_comment = 0 + no user) come
+ * later — for now we always require login.
+ */
+
+import express from 'express';
+import { v4 as uuid } from 'uuid';
+import db from '../config/database.js';
+import { requireAuth } from '../middleware/auth.js';
+import PermissionsService from '../services/PermissionsService.js';
+
+const router = express.Router();
+
+// Limits
+const MAX_LEN = 4000;
+const MIN_LEN = 1;
+
+router.post('/', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const postSlug = (req.body.post_slug || '').trim();
+  const rawContent = (req.body.content || '').trim();
+  const parentId = (req.body.parent_comment_id || '').trim() || null;
+
+  if (!postSlug) return res.status(400).send('post_slug required');
+  if (rawContent.length < MIN_LEN) return res.status(400).send('Comment cannot be empty');
+  if (rawContent.length > MAX_LEN) return res.status(413).send(`Comment too long (max ${MAX_LEN} chars)`);
+
+  const post = db.prepare(
+    'SELECT id, slug FROM posts WHERE site_id = ? AND slug = ? AND status = ?'
+  ).get(site.id, postSlug, 'published');
+  if (!post) return res.status(404).send('Post not found');
+
+  if (!PermissionsService.canComment(req.session.user, site, post)) {
+    return res.status(403).send('Comments not allowed');
+  }
+
+  // Validate parent (must belong to this post; collapses replies-of-replies
+  // up to the top-level parent so we never go deeper than 1)
+  let resolvedParent = null;
+  if (parentId) {
+    const parent = db.prepare(
+      'SELECT id, parent_comment_id FROM comments WHERE id = ? AND post_id = ?'
+    ).get(parentId, post.id);
+    if (!parent) return res.status(400).send('Invalid parent comment');
+    resolvedParent = parent.parent_comment_id || parent.id;
+  }
+
+  // Status depends on the site's moderation mode.
+  // 'trust'    = auto-approve immediately (default).
+  // 'moderate' = pending until an admin reviews in /admin/comments.
+  // Author is the post author or god → always trusted (no point gatekeeping yourself).
+  const isTrustedAuthor = req.session.user.role === 'god'
+    || req.session.user.id === post.author_id;
+  const status = (site.comments_moderation_mode === 'moderate' && !isTrustedAuthor)
+    ? 'pending'
+    : 'approved';
+
+  const commentId = uuid();
+  db.prepare(`
+    INSERT INTO comments (id, post_id, author_id, parent_comment_id, content, status)
+    VALUES (?, ?, ?, ?, ?, ?)
+  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
+
+  // Where to land after submit:
+  //   approved → scroll to the new comment
+  //   pending  → comments anchor + ?pending=1 query so post page can flash a notice
+  const target = status === 'approved'
+    ? `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`
+    : `${res.locals.siteUrlBase || ''}/${post.slug}?pending=1#comments`;
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', target);
+    return res.send('OK');
+  }
+  res.redirect(target);
+});
+
+router.post('/:id/delete', requireAuth, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+
+  const comment = db.prepare(`
+    SELECT c.id, c.author_id, c.post_id, p.slug AS post_slug
+    FROM comments c JOIN posts p ON p.id = c.post_id
+    WHERE c.id = ? AND p.site_id = ?
+  `).get(req.params.id, site.id);
+
+  if (!comment) return res.status(404).send('Not found');
+  if (!PermissionsService.canDeleteComment(req.session.user, comment, site)) {
+    return res.status(403).send('No permission');
+  }
+
+  // Delete the comment plus any replies that hung off it
+  db.prepare('DELETE FROM comments WHERE id = ? OR parent_comment_id = ?')
+    .run(req.params.id, req.params.id);
+
+  if (req.headers['hx-request']) {
+    res.setHeader('HX-Redirect', `${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+    return res.send('OK');
+  }
+  res.redirect(`${res.locals.siteUrlBase || ''}/${comment.post_slug}#comments`);
+});
+
+export default router;
Index: src/routes/download.js
===================================================================
--- src/routes/download.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,151 +1,0 @@
-/**
- * Download-for-email (premium feature #2).
- *
- *   GET  /downloads                 -> list of downloadable tracks (premium; 404 otherwise)
- *   GET  /download/:id              -> email capture page for a single track
- *   POST /download/:id              -> save email (-> mailing list) + unlock download
- *   GET  /download/:id/bestand      -> serves the file (session-gated after capture)
- *
- * The fan leaves their email and receives the file; the address is added to the
- * subscribers list (source 'download', single opt-in — no confirm step before the
- * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
- */
-
-import express from 'express';
-import path from 'path';
-import fs from 'fs';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { addSubscriber } from '../services/SubscriberService.js';
-import { postNeighbors } from './posts.js';
-
-const router = express.Router();
-
-// If a real (pinned) post with slug 'downloads' exists, the downloads list is
-// effectively attached to that post. We then also show the Newer/Older post nav
-// so the visitor can browse just like on a regular post.
-function downloadsPostNav(req, res) {
-  const site = res.locals.site;
-  if (!site) return {};
-  const post = db.prepare(
-    "SELECT id, slug, pinned FROM posts WHERE site_id = ? AND slug = 'downloads' AND status = 'published'"
-  ).get(site.id);
-  if (!post) return {};
-  try { return postNeighbors(site, post); } catch (e) { return {}; }
-}
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const AUDIO_DIR = path.resolve(process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'));
-
-const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
-const GRACE_MS = 15 * 60 * 1000; // download window after capture
-
-function dlTrack(siteId, id) {
-  return db.prepare(
-    `SELECT t.id, t.title, t.artist, t.cover_url, m.storage_path, m.filename
-       FROM audio_tracks t JOIN media m ON m.id = t.media_id
-      WHERE t.id = ? AND t.site_id = ? AND t.downloadable = 1`
-  ).get(id, siteId);
-}
-function safeName(title, storagePath) {
-  const ext = path.extname(storagePath || '').toLowerCase() || '.mp3';
-  const base = String(title || 'track').replace(/[^a-zA-Z0-9 _.-]/g, '').trim().slice(0, 80) || 'track';
-  return base + ext;
-}
-
-// List of downloadable tracks.
-router.get('/downloads', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const tracks = db.prepare(
-    `SELECT id, title, artist, cover_url FROM audio_tracks
-      WHERE site_id = ? AND downloadable = 1 ORDER BY position ASC, created_at ASC`
-  ).all(site.id);
-  const nav = downloadsPostNav(req, res);
-  renderPage(req, res, 'pages/downloads', {
-    pageTitle: 'Downloads — ' + (site.title || ''),
-    // on-special = compact profile header (like on a post); on-downloads = grey pill
-    // + feature-route behaviour. Together → downloads looks just like a post.
-    bodyClass: 'on-downloads on-special',
-    dlTracks: tracks,
-    newerPost: nav.newerPost || null,
-    olderPost: nav.olderPost || null,
-  });
-});
-
-// Capture page for a single track.
-router.get('/download/:id', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const fan = req.session && req.session.user;
-  renderPage(req, res, 'pages/download', {
-    pageTitle: track.title + ' — download',
-    bodyClass: 'on-download',
-    dlState: 'form',
-    dlTrack: track,
-    dlPrefill: (fan && fan.email && fan.email.includes('@')) ? fan.email : '',
-  });
-});
-
-// Save email + unlock download.
-router.post('/download/:id', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const email = (req.body.email || '').trim();
-  const r = addSubscriber(site.id, email, 'download', { doubleOptin: false });
-  if (!r.ok) {
-    return renderPage(req, res, 'pages/download', {
-      pageTitle: track.title + ' — download', bodyClass: 'on-download',
-      dlState: 'form', dlTrack: track, dlPrefill: email,
-      dlError: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
-    });
-  }
-  // Unlock download in the session (short window).
-  if (!req.session.dl) req.session.dl = {};
-  req.session.dl[track.id] = Date.now();
-  renderPage(req, res, 'pages/download', {
-    // De auto-start hoort ALLEEN bij ready: op het formulier zou hij de
-    // e-mailvraag omzeilen. Het script stond v66r shaer-bqr dan ook binnen
-    // de ready-tak van de template.
-    pageJs: 'download',
-    pageTitle: track.title + ' — download', bodyClass: 'on-download',
-    dlState: 'ready', dlTrack: track,
-  });
-});
-
-// Serve the file — only if an email was just submitted (session-gated).
-router.get('/download/:id/bestand', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const track = dlTrack(site.id, req.params.id);
-  if (!track) return next();
-  const ts = req.session && req.session.dl && req.session.dl[track.id];
-  if (!ts || (Date.now() - ts) > GRACE_MS) {
-    return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
-  }
-  // The playable/downloadable file = the BARE filename (storage_path is an
-  // absolute path → fails the slash-guard). Same approach as /audio/stream.
-  const sp = track.filename;
-  if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
-  const filePath = path.join(AUDIO_DIR, sp);
-  if (!filePath.startsWith(AUDIO_DIR + path.sep)) return res.status(400).send('Bad path');
-  let stat;
-  try { stat = fs.statSync(filePath); } catch { return res.status(404).send('Bestand niet gevonden'); }
-  if (!stat.isFile()) return res.status(404).send('Bestand niet gevonden');
-  const ext = path.extname(sp).toLowerCase();
-  res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
-  res.setHeader('Content-Length', stat.size);
-  res.setHeader('Content-Disposition', 'attachment; filename="' + safeName(track.title, sp) + '"');
-  fs.createReadStream(filePath).pipe(res);
-});
-
-export default router;
Index: src/routes/embed.js
===================================================================
--- src/routes/embed.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,62 +1,0 @@
-/**
- * Embeddable player (premium feature #7).
- *
- *   GET /embed   -> a standalone, compact audio player page (no shell),
- *                   intended to be placed in an <iframe> on EXTERNAL sites.
- *
- * The page is served by us (klonkt-origin), so audio requests from within
- * the iframe remain same-origin → the /audio/stream gate lets them through,
- * even when the iframe is on a foreign site. We only override Helmet's frameguard
- * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-
-const router = express.Router();
-
-router.get('/embed', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-
-  // Allow embedding on external sites (override the global frameguard/CSP).
-  res.removeHeader('X-Frame-Options');
-  res.setHeader(
-    'Content-Security-Policy',
-    "default-src 'self'; media-src 'self' blob: https:; img-src 'self' data: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline' 'self'; frame-ancestors *",
-  );
-
-  let tracks = (res.locals.audioTracks || []).map((t) => ({
-    id: t.id, title: t.title, artist: t.artist, duration: t.duration, url: t.media_url,
-  })).filter((t) => t.url);
-
-  // ?post=<slug> → scope the player to that post's tracks (for the fediverse
-  // player card). Resolve [[track]]/[[album]]/[[playlist]] shortcodes → track ids.
-  const postSlug = (req.query.post || '').toString();
-  if (postSlug) {
-    try {
-      const post = db.prepare("SELECT content FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(site.id, postSlug);
-      if (post && post.content) {
-        const ids = []; const seen = new Set();
-        const add = (id) => { if (id && !seen.has(id)) { seen.add(id); ids.push(id); } };
-        for (const m of post.content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) add(m[1]);
-        for (const m of post.content.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT id FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY position').all(site.id, m[1].trim())) add(r.id);
-        for (const m of post.content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position').all(m[1])) add(r.track_id);
-        // Strictly scope to this post's tracks — do NOT fall back to all-site
-        // tracks (that showed unrelated songs for a link-only-track post).
-        const byId = new Map(tracks.map((t) => [t.id, t]));
-        tracks = ids.map((id) => byId.get(id)).filter(Boolean);
-      }
-    } catch { /* fall back to the full site player */ }
-  }
-
-  res.render('pages/embed-player', {
-    site,
-    embedTracks: tracks,
-    siteUrlBase: res.locals.siteUrlBase || '',
-  });
-});
-
-export default router;
Index: src/routes/epk.js
===================================================================
--- src/routes/epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,78 +1,0 @@
-/**
- * EPK / press kit (premium) — a shareable press page per Klonkt site.
- *
- * GET /pers  (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase)
- *   -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks,
- *      recent posts and a contact button. Intended to share with bookers/press.
- *
- * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the
- * catch-all). The PAGE itself is public (no login) so press can view it;
- * only its EXISTENCE is premium. No login email leak: contact goes via an
- * explicitly configured press address (epk_contact, per site) or the site itself.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-router.get('/pers', (req, res, next) => {
-  if (!premiumUnlocked()) return next();      // no premium -> no press kit
-  const site = res.locals.site;
-  if (!site) return next();
-
-  // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order)
-  // if configured; otherwise automatically the top 5 most-listened.
-  let chosenIds = [];
-  try {
-    const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]');
-    if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5);
-  } catch (e) { /* invalid JSON → fall back to top */ }
-
-  let tracks;
-  if (chosenIds.length) {
-    const ph = chosenIds.map(() => '?').join(',');
-    const rows = db.prepare(
-      `SELECT id, title, artist, duration, cover_url, COALESCE(play_count, 0) AS plays
-         FROM audio_tracks WHERE site_id = ? AND id IN (${ph})`
-    ).all(site.id, ...chosenIds);
-    const byId = new Map(rows.map((r) => [r.id, r]));
-    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // preserve chosen order
-  } else {
-    tracks = db.prepare(
-      `SELECT title, artist, duration, cover_url, COALESCE(play_count, 0) AS plays
-         FROM audio_tracks
-        WHERE site_id = ?
-        ORDER BY plays DESC, position ASC, created_at ASC
-        LIMIT 5`
-    ).all(site.id);
-  }
-
-  const posts = db.prepare(
-    `SELECT slug, title, created_at
-       FROM posts
-      WHERE site_id = ? AND status = 'published'
-      ORDER BY created_at DESC
-      LIMIT 5`
-  ).all(site.id);
-
-  // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise
-  // the global epk_contact. NEVER auto-expose the login email.
-  const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim();
-  // Short press bio: per-site setting, otherwise the site's tagline.
-  const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim();
-
-  renderPage(req, res, 'pages/epk', {
-    pageTitle: (site.title || 'Perskit') + ' — Perskit',
-    bodyClass: 'on-epk',
-    epkTracks: tracks,
-    epkPosts: posts,
-    epkContact: contact,
-    epkBio: bio,
-  });
-});
-
-export default router;
Index: src/routes/feed.js
===================================================================
--- src/routes/feed.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/feed.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -12,5 +12,4 @@
 import express from 'express';
 import db from '../config/database.js';
-import { siteOpenTracks } from '../services/ActivityPubService.js';
 
 const router = express.Router();
@@ -44,24 +43,5 @@
 
 // ==================== RSS 2.0 ====================
-/**
- * Twee feeds, een bouwer.
- *
- *   /feed.xml    de site: posts EN open tracks door elkaar, chronologisch
- *   /tracks.xml  alleen de open tracks -- de hele site als muziekkanaal
- *
- * WAAROM DIE TWEEDE (Robins vraag, 10-8). Hij vroeg naar een "hele site, open
- * tracks"-library voor Funkwhale. Als AS2-object helpt zo'n ding daar niet: een
- * Funkwhale-KANAAL wijst nergens naar een library, en hun library is een eigen
- * actor met inbox en sleutel -- de optie die op 7 augustus is afgewezen. Maar de
- * vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met iTunes en een
- * enclosure per item, en dat is precies wat Klonkt hier al doet. Naast een echte
- * kanaalfeed gelegd (audio.pepemoss.com/api/v1/channels/tnd/rss): dezelfde
- * namespaces, dezelfde enclosure.
- *
- * Het enige verschil was dat onze feed ook gewone posts draagt. Een muziekkanaal
- * met blogberichten ertussen is er geen, dus die feed staat er nu apart -- een
- * URL om te plakken, zonder dat er iets naar buiten geduwd wordt.
- */
-function stuurFeed(req, res, { alleenTracks }) {
+router.get('/feed.xml', (req, res) => {
   const site = res.locals.site;
   if (!site) return res.status(404).send('No site');
@@ -69,42 +49,18 @@
   const origin = siteOrigin(req);
   const base = origin + (res.locals.siteUrlBase || '');
-  const posts = alleenTracks ? [] : postsForFeed(site.id);
+  const posts = postsForFeed(site.id);
+  const lastBuild = posts[0]?.published_at || new Date().toISOString();
 
-  // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
-  // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
-  // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
-  // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
-  //
-  // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
-  // Een album in een item proppen zou betekenen dat er van vijf nummers vier
-  // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
-  // track is de publicatie.
-  //
-  // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
-  // poortregel en die hoort op een plek te staan.
-  const tracks = siteOpenTracks(site.id);
-  const lastBuild = posts[0]?.published_at || tracks[0]?.created_at || new Date().toISOString();
-
-  // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale
-  // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg,
-  // want die leest hij hier -- niet uit `category` op de AP-actor.
-  //
-  // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook
-  // werkelijk publieke muziek is. Een blog zonder open track is naar buiten
-  // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is
-  // afwezig, ook in een categorie.
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const kanaalKunst = abs(site.profile_photo || site.og_image_default || null);
-  const kanaalTags = [
-    `    <itunes:author>${escapeXml(site.author || site.title || '')}</itunes:author>`,
-    site.description || site.tagline
-      ? `    <itunes:summary>${escapeXml(site.description || site.tagline)}</itunes:summary>` : null,
-    kanaalKunst ? `    <itunes:image href="${escapeXml(kanaalKunst)}" />` : null,
-    tracks.length ? '    <itunes:category text="Music" />' : null,
-  ].filter(Boolean).join('\n');
-
-  const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
-  const items = [
-    ...posts.map((p) => ({ op: wanneer(p.published_at), xml: `    <item>
+  res.set('Content-Type', 'application/rss+xml; charset=utf-8');
+  res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+  <channel>
+    <title>${escapeXml(site.title)}</title>
+    <link>${escapeXml(base + '/')}</link>
+    <description>${escapeXml(site.description || site.tagline || '')}</description>
+    <language>${escapeXml(site.language || 'nl')}</language>
+    <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
+    <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
+${posts.map(p => `    <item>
       <title>${escapeXml(p.title || '(untitled)')}</title>
       <link>${escapeXml(base + '/' + p.slug)}</link>
@@ -113,42 +69,8 @@
       <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
       <description>${escapeXml(p.excerpt || '')}</description>
-    </item>` })),
-    ...tracks.map((t) => {
-      const fn = t.filename || (t.storage_path || '').split('/').pop();
-      // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
-      // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
-      // permalink maar de stabiele AP-id van de track.
-      return { op: wanneer(t.created_at), xml: `    <item>
-      <title>${escapeXml(t.title || 'Audio')}</title>
-      <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
-      <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
-      <description>${escapeXml(t.artist || '')}</description>
-      <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
-      <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}${t.artist ? `
-      <itunes:author>${escapeXml(t.artist)}</itunes:author>` : ''}${abs(t.cover_url) ? `
-      <itunes:image href="${escapeXml(abs(t.cover_url))}" />` : ''}
-    </item>` };
-    }),
-  ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
-
-  res.set('Content-Type', 'application/rss+xml; charset=utf-8');
-  res.send(`<?xml version="1.0" encoding="UTF-8"?>
-<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
-  <channel>
-    <title>${escapeXml(alleenTracks ? `${site.title} \u2014 muziek` : site.title)}</title>
-    <link>${escapeXml(base + '/')}</link>
-    <description>${escapeXml(site.description || site.tagline || '')}</description>
-    <language>${escapeXml(site.language || 'nl')}</language>
-    <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
-    <atom:link href="${escapeXml(base + (alleenTracks ? '/tracks.xml' : '/feed.xml'))}" rel="self" type="application/rss+xml" />
-${kanaalTags}
-${items}
+    </item>`).join('\n')}
   </channel>
 </rss>`);
-}
-
-router.get('/feed.xml', (req, res) => stuurFeed(req, res, { alleenTracks: false }));
-// De hele site als muziekkanaal: alleen wat op de federatie openstaat.
-router.get('/tracks.xml', (req, res) => stuurFeed(req, res, { alleenTracks: true }));
+});
 
 // ==================== Atom 1.0 ====================
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,760 +1,0 @@
-/**
- * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
- * guardians. One place to add and manage wards, a message centre for
- * incoming help requests and adoption traffic, and its own push channel
- * (alert types 'help' and 'guardian', web-push slice reused).
- *
- * Everything is scoped to a site the logged-in user OWNS: the guardian acts
- * as one of their own actors (?site=slug picks one when they own several).
- * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
- */
-import express from 'express';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { requireAuth } from '../middleware/auth.js';
-import AP from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
-import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
-import { emojiName } from '../services/NoteRender.js';
-
-const router = express.Router();
-const __dir = path.dirname(fileURLToPath(import.meta.url));
-
-/** The acting site: ?site=slug when owned, else the user's first site. */
-function siteForUser(req) {
-  const userId = req.session.user.id;
-  const want = String(req.query.site || req.body?.site || '').trim();
-  if (want) {
-    const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
-    if (s) return s;
-  }
-  return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
-}
-
-/** Everything the dashboard shows, one shape for page and API. */
-function uiStrings(L) {
-  const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
-    'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
-    'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
-    'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
-    // The per-ward panel: everything about one child in one place.
-    'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
-    'panel_follow', 'panel_follow_empty', 'follow_out_line', 'panel_posts', 'panel_posts_empty',
-    'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
-    // Releasing a ward: a deliberate two-step answer, never one click.
-    'release_title', 'release_effect', 'release_local', 'release_step_down',
-    'release_last', 'release_unknown', 'release_yes', 'release_no',
-    // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
-    'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
-    'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
-    'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
-    // A gated-setting proposal from a fellow guardian (5.6).
-    'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
-    'play_propose', 'play_on', 'play_off',
-    // The status of a proposal this guardian sent (5.6).
-    'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
-    'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
-    'panel_guards_far',
-    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
-    // de drempel erbij. De namen volgen de catalogus in gated.js.
-    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_externalThreads',
-    // De twee richtingen van §5.3, met woorden die niet op elkaar lijken:
-    // "Volgverzoeken" komt naar het kind toe, "Zelf iemand volgen" gaat ervan
-    // weg. Zonder dat verschil in de tekst zijn de rijen niet uit elkaar te
-    // houden zodra ze naast elkaar staan (shaer-p729).
-    'gate_follows', 'gate_following',
-    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
-    'gate_unknown', 'gate_always', 'gate_threshold', 'gate_threshold_unknown',
-    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
-    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
-    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
-    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days', 'help_former_ward',
-  'warn_reversible', 'warn_irreversible', 'warn_unknown', 'warn_tally_elsewhere', 'warn_decides', 'warn_not_last', 'warn_go', 'warn_back',
-    'help_archive', 'help_archive_hide', 'panel_history',
-    // Het logboek (§4.2): onbekende soorten vallen terug op hun ruwe naam.
-    'log_show', 'log_hide', 'evr_not_a_teapot',
-    'ev_offer_rejected', 'ev_offer_refused', 'ev_committed', 'ev_guardian_left',
-    'ev_coguardian_left', 'ev_gated_outcome', 'ev_lapse_opened',
-    'gate_propose_open', 'gate_propose_close', 'gate_default_off',
-    'gate_images', 'gate_messages', 'gate_compose', 'gate_replies', 'gate_music', 'gate_quoteCards', 'gate_asked',
-    'gate_customEmoji', 'gate_publicProfile', 'gate_accountMove', 'gate_independence',
-    'gate_unavailable', 'gate_planned_note', 'gates_summary', 'gates_show', 'gates_hide'];
-  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
-  s.wave = i18nT(L, 'guardian.wave');
-  s.waved = i18nT(L, 'guardian.waved');
-  return s;
-}
-
-function dashboardState(site, L) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  // EEN weg naar de hulpvragen (Barts 429-jacht, 9-8): dit scherm had een
-  // eigen kopie van de queue-query, met een afkap op 50 -- dus de fix die open
-  // vragen nooit meer afkapt (shaer-6wt) ging aan het paneel voorbij, en juist
-  // de guardian met een caseload zag oude open vragen wegvallen. Nu dezelfde
-  // bron als de apps: open vragen volledig, geschiedenis afgekapt.
-  const helpItems = Guardianship.queues.helpItemsFor(site.slug).map((h) => ({
-    ...h,
-    // The dashboard is built in the browser, so it gets the body finished: the
-    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
-    // and a link to the post it is about; both belong in the card.
-    body_html: renderNoteBody(h, L),
-    name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
-    // In the site's own timezone, the same as everywhere else in Klonkt. The
-    // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
-    when_text: formatDateTime(h.published || h.created_at),
-  }));
-  return {
-    site: site.slug,
-    me,
-    // Committed wards, each carrying the gated settings a guardian may change.
-    // `embeds` is null for a ward we do not host: that setting lives on the
-    // ward's own server, so we show it as not-adjustable rather than lying.
-    // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
-    // their availability; null for a remote ward, whose server tracks it.
-    wards: Guardianship.listWards(site.slug).map((w) => ({
-      ...w,
-      embeds: wardEmbedSetting(w.other_uri),
-      playback: wardPlaybackSetting(w.other_uri),
-      guardians: Guardianship.queues.wardGuardianStatuses(w.other_uri),
-      // What THIS guardian proposed for this ward and how it stands (5.6):
-      // open, accepted, rejected, or expired when the window ran out and the
-      // ward's server had nothing to write home. The answer is a real
-      // Accept/Reject from the ward's server, not a guess from here.
-      proposals: Guardianship.gated.listSent(site.slug, w.other_uri).map((p) => ({
-        feature: p.feature, value: !!p.value, created: p.created_at,
-        status: Guardianship.gated.sentStatus(p, Date.now()),
-      })),
-      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
-      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
-      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
-      gates: Guardianship.queues.wardGates(site.slug, w.other_uri),
-    })),
-    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
-    // Running lapses (3.6.3) this guardian or its local wards are party to.
-    lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
-    // Gated-setting proposals another guardian opened on a ward we share
-    // (5.6), forwarded here by the ward's server. Without answering these the
-    // threshold is never met and the proposal simply expires.
-    gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
-      id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
-      // Wat er blijft hangen als dit doorgaat (shaer-nf9). Alleen bij OPENZETTEN:
-      // dichtzetten laat niets nieuws door en hoeft dus niet gewaarschuwd te
-      // worden -- een waarschuwing die overal staat wordt nergens gelezen.
-      consequence: r.value ? Guardianship.gated.gateConsequence(r.feature) : null,
-      // Maakt JOUW antwoord dit af (shaer-8vt)? De telling loopt op de server van
-      // het kind, dus dit is het enige wat we erover weten -- en zonder dat
-      // weet niemand dat hij de doorslag geeft.
-      decisive: r.decisive !== 0,
-    })),
-    help: helpItems,
-    strings: uiStrings(L),
-  };
-}
-
-
-// ── The PWA page ─────────────────────────────────────────────────────────
-router.get('/', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  const L = resolveLang(req);
-  if (!site) return res.status(404).send('No site for this account.');
-  const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
-  // This standalone PWA page is rendered directly (not through renderPage), so
-  // the CSP nonce must be injected here — otherwise strict-dynamic blocks
-  // guardian.js and the whole dashboard is dead (buttons do nothing).
-  res.render('pages/guardian', {
-    state: dashboardState(site, L),
-    sites,
-    lang: L,
-    t: (k, v) => i18nT(L, k, v),
-    cspNonce: res.locals.cspNonce,
-  }, (err, html) => {
-    if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
-    res.send(injectCspNonce(html, res.locals.cspNonce));
-  });
-});
-
-// ── JSON state for refreshes ─────────────────────────────────────────────
-/**
- * De staat van het paneel, desgewenst als LANGE POLL (Barts opdracht, 9-8).
- *
- * Zonder `wait` gedraagt de route zich exact zoals altijd. Met `wait` blijft het
- * antwoord hangen tot er iets gebeurt dat de guardian moet verwerken, of tot de
- * tijd om is -- dan een lege 304.
- *
- * EERST KIJKEN, DAN WACHTEN. Veranderde er iets tussen het vorige antwoord en
- * dit verzoek, dan is de merksteen nu al anders en gaat het antwoord METEEN de
- * deur uit. Zou je eerst gaan wachten, dan blijft nieuws dat net in dat gaatje
- * viel vijfentwintig seconden liggen -- en juist bij een hulpvraag is dat de
- * verkeerde vertraging.
- *
- * WAKKER OP ALLES. De guardianship-module zendt veertien soorten gebeurtenissen
- * uit en die wekken allemaal (wakeGuardian); daarnaast wekt de tijdlijn (onNews),
- * want de berichten van je wards staan in ditzelfde scherm.
- */
-router.get('/api/state', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const stuur = () => AP.sendMaybe304(req, res, dashboardState(site, resolveLang(req)), { contentType: 'application/json' });
-
-  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
-  const merk = req.headers['if-none-match'];
-  if (!wachtS || !merk) return stuur();
-
-  // Is er nu al iets anders? Dan niet wachten.
-  const nu = AP.etagFor(JSON.stringify(dashboardState(site, resolveLang(req))));
-  if (nu !== merk) return stuur();
-
-  await new Promise((klaar) => {
-    let af = false;
-    const eind = () => { if (af) return; af = true; clearTimeout(t); offG(); offN(); klaar(); };
-    const offG = AP.onGuardian(site.slug, eind);
-    const offN = AP.onNews(site.slug, eind);
-    const t = setTimeout(eind, wachtS * 1000);
-    // Hing de client op, dan houdt niemand dit antwoord meer vast.
-    res.on('close', eind);
-  });
-  if (res.writableEnded) return undefined;
-  return stuur();
-});
-
-// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
-//    its wards, so their posts (incl. followers-only) are DELIVERED to the
-//    guardian's inbox → timeline. The follow is the mechanism; no new fetch.
-//    First contact also backfills the ward's recent PUBLIC posts as a cold
-//    start so the corner is not empty before delivery catches up.
-function ensureWardConnections(site) {
-  let wards;
-  try { wards = Guardianship.listWards(site.slug); } catch { return; }
-  for (const w of wards) {
-    const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
-      .get(site.slug, w.other_uri);
-    if (already) continue;
-    // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
-    AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
-    // Cold start: pull recent public posts now so oma sees something at once.
-    AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
-  }
-}
-
-// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
-//    guardian watches, it does not publish (Robins besluit).
-router.get('/api/feed', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const L = resolveLang(req);
-  ensureWardConnections(site);
-  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
-  // Only show the wards you actually guard (the timeline can hold more).
-  const items = AP.getTimeline(site.slug, 60, 0)
-    .filter((p) => wardUris.has(p.author_uri))
-    .map((p) => ({
-      id: p.id,
-      author: p.author_handle || p.author_name || p.author_uri,
-      authorUri: p.author_uri,   // the grouping key: which child's panel this belongs in
-      authorName: p.author_name,
-      authorIcon: p.author_icon,
-      content: p.content,
-      url: p.url,
-      published: p.published || p.created_at,
-      when_text: formatDateTime(p.published || p.created_at),
-      cw: p.cw || null,
-      // Zelfde valkuil als in note-body.ejs: kapotte json gooit, maar geldige json
-      // van het verkeerde type niet. Zonder deze wacht neemt één vreemde note van
-      // een remote server het hele guardian-paneel mee, en dat is precies het
-      // scherm dat het moet doen als er iets aan de hand is.
-      media: (() => { try { const m = JSON.parse(p.media_json || '[]'); return Array.isArray(m) ? m : []; } catch { return []; } })(),
-      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
-      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
-      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
-      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
-      // een client die nog uit de cache draait.
-      body_html: renderNoteBody(p, L),
-    }));
-  res.json({ items, following: wardUris.size });
-});
-
-// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
-//    approve. Ward and guardian are co-located on the family Klonkt here, so
-//    the guardian reads its wards' pending follows locally.
-function wardSlugsOf(site) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  return Guardianship.listWards(site.slug)
-    .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
-    .filter(Boolean);
-}
-
-router.get('/api/follow-requests', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const items = [];
-  const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
-  // wardUri is the grouping key for the per-ward panel: the handle is for
-  // reading, the URI is what identifies the child across both cases below.
-  // Local wards (guardian co-located): read the pending follows directly.
-  for (const w of wardSlugsOf(site)) {
-    for (const f of Guardianship.follows.listForWard(w.slug)) {
-      items.push({ id: f.id, direction: 'incoming', ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
-    }
-    // §5.3 andersom (shaer-p729): wat dit kind zelf heeft gevraagd. Stond hier
-    // niet, dus een guardian met een LOKALE ward zag uitgaande verzoeken in de
-    // PWA helemaal niet -- ze wachtten op iemand die er nooit naar keek.
-    for (const o of Guardianship.outgoing.listForWard(w.slug)) {
-      items.push({ id: o.id, direction: 'outgoing', ward: `@${w.slug}@${host}`, wardUri: w.uri, target: o.target_handle || o.target_uri, remote: false, created: o.created_at });
-    }
-  }
-  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
-  for (const rev of Guardianship.follows.listReviews(site.slug)) {
-    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
-    // De richting stond in de tabel en werd hier weggelaten. Zonder haar leest
-    // een uitgaand verzoek als een inkomend: de follower IS dan de ward, dus de
-    // kaart zei "je kind wil je kind volgen" en het doel viel weg.
-    const uitgaand = rev.direction === 'outgoing';
-    items.push({
-      id: rev.id, direction: uitgaand ? 'outgoing' : 'incoming',
-      ward: wardName, wardUri: rev.ward_uri,
-      follower: uitgaand ? undefined : (rev.follower_handle || rev.follower_uri),
-      target: uitgaand ? (rev.target_handle || rev.target_uri) : undefined,
-      followerIcon: uitgaand ? undefined : rev.follower_icon,
-      remote: true, created: rev.created_at,
-    });
-  }
-  res.json({ items });
-});
-
-// Het logboek (§4.2): wat er is gebeurd, met de reden erbij. GEEN wachtrij --
-// hier staat niets dat om een antwoord vraagt, en daarom hoort het ingeklapt.
-// Het bestaat omdat een weigering anders alleen te merken was doordat er iets
-// uit een lijst verdween, en "het is weg" vertelt een ward niet waarom.
-router.get('/api/events', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  res.json({ items: AP.listGuardianEvents(site.slug, 50) });
-});
-
-router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
-
-  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
-  // which tallies quorum and returns the Accept(Follow) to the follower.
-  const review = Guardianship.follows.getReview(site.slug, req.params.id);
-  if (review) {
-    try { await AP.sendFollowDecision(site, review, decision); }
-    catch { return res.status(502).json({ error: 'delivery' }); }
-    Guardianship.follows.removeReview(site.slug, req.params.id);
-    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
-  }
-
-  // Local ward: decide directly (quorum on this instance).
-  const pending = Guardianship.follows.getPending(req.params.id);
-  if (!pending) return res.status(404).json({ error: 'gone' });
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
-  // Acting from the dashboard is an answer (3.6), and the quorum runs over
-  // the available set (3.5): both applied here, the same as over the wire.
-  Guardianship.availability.oneAnswer(me, Date.now());
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
-  try {
-    if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-    else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-  } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
-  res.json({ ok: true, outcome: r.outcome });
-});
-
-// ── §5.3, the other direction (shaer-p729): the ward wants to follow SOMEONE,
-//    and the guardians decide. Same quorum arithmetic and the same availability
-//    rules as the inbound gate above; only the question is turned around, which
-//    is why it gets its own endpoint rather than a flag on that one.
-router.post('/api/outgoing-follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
-
-  const pending = Guardianship.outgoing.getPending(req.params.id);
-  if (!pending) return res.status(404).json({ error: 'gone' });
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
-  Guardianship.availability.oneAnswer(me, Date.now());
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.outgoing.decide(pending.id, me, decision, guardians);
-  try {
-    // Only on approval does anything leave the building. A refusal is a local
-    // fact: the follow was never sent, so there is nothing out there to undo
-    // and nobody to inform that a child asked about them.
-    if (r.outcome === 'approved') await AP.performApprovedFollow(r.follow);
-  } catch { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
-  res.json({ ok: true, outcome: r.outcome });
-});
-
-// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
-//    guardian to a ward. A private direct note, never a feed post. Warmth
-//    without publishing (Robins besluit).
-router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const wardUri = String(req.body?.ward || '').trim();
-  // Only wave at a ward you actually guard.
-  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
-  if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
-  const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
-  const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
-  if (!r) return res.status(502).json({ error: 'delivery' });
-  res.json({ ok: true, delivered: r.delivered });
-});
-
-// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
-// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
-// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
-// loopt, zodat niemand denkt dat de ander het al doet.
-//
-// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
-// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
-// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
-// een ward: nooit een window.confirm.
-router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
-  const noteUri = String(req.body?.note || '').trim();
-  const wardUri = String(req.body?.ward || '').trim();
-  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
-  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
-  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
-  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
-
-  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
-  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
-  // degene die klikt hoort niet te liegen omdat een andere server traag is.
-  // MET onze eigen handle. Die stond hier op null, en "door wie" was juist de
-  // hele vraag van deze bead: een binnengekomen markering draagt de handle van
-  // de afzender wel, dus onze EIGEN rij was de enige zonder naam. Op het scherm
-  // viel dat terug op de kale URI.
-  Guardianship.help.record(noteUri, me, kind, AP.deriveHandle(me));
-
-  // DE MEDE-GUARDIANS, en dit ging mis (shaer-lgo, gevonden 11-8 met @mee).
-  //
-  // Hier stond listGuardians(wardUri.replace(/.*\/ap\/users\//, '')): de staart
-  // van de URI als slug. listGuardians kent alleen relaties van LOKALE sites,
-  // dus voor een ward elders leverde dat altijd een lege lijst -- en juist die
-  // ward is het hele punt, want een ward op je eigen instance heeft geen
-  // federatie nodig. De markering ging dus alleen naar het kind en nooit naar
-  // de andere guardian. Precies de faalstand waar deze bead voor bestaat:
-  // iedereen denkt dat de ander het oppakt.
-  //
-  // Erger nog: had er toevallig een lokale site met die naam bestaan, dan
-  // waren het DIENS guardians geweest.
-  //
-  // existingGuardiansOf kende de goede weg al -- lokaal opzoeken, en anders
-  // shaer:guardians uit de actor van de ward. Die stond alleen niet aan deze
-  // route vast.
-  const anderen = await Guardianship.existingGuardiansOf(wardUri).catch(() => []);
-  const ontvangers = [wardUri, ...anderen].filter((u) => u && u !== me);
-  const r = await AP.deliverDirectNote(site, {
-    recipients: ontvangers,
-    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
-    helpMark: { kind, noteUri },
-  }).catch(() => null);
-  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
-  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
-  // stilte.
-  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
-});
-
-// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
-//    the Shaer apps use (one path, one behavior).
-router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const handle = String(req.body?.handle || '').trim();
-  if (!handle) return res.status(400).json({ error: 'empty_handle' });
-  const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
-  if (!wardUri) return res.status(404).json({ error: 'not_found' });   // the handle does not resolve to an account
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const r = await AP.ingestOutboxActivity(site, req.session.user, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
-  });
-  // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
-  // offer is recorded and delivery is retried in the background.
-  if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
-  res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
-});
-
-// ── Answer an offer (co-guardian accept/reject, or the candidate's final
-//    "complete"). All three are a C2S Accept/Reject on the offer id; the
-//    handshake module decides when it commits (§3.1).
-// ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ──
-// One direct note with shaer:away and an endTime to every ward, the same path
-// Shaer takes over C2S, and the only path: a ward on this instance receives
-// that note through the loopback and applies the absence in its own inbox
-// handler, exactly as a ward elsewhere does. This route used to write the
-// local wards itself as well, which meant the wire version could break without
-// anyone here noticing.
-router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
-  if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
-  const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
-  if (!wards.length) return res.status(409).json({ error: 'no_wards' });
-  const until = Date.now() + days * 24 * 3600 * 1000;
-  const L = resolveLang(req);
-  const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
-  const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
-  if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' });
-  res.json({ ok: true, until });
-});
-
-// ── Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian ────────
-// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
-// A local ward opens directly; a remote ward gets the proposal delivered,
-// because the ward's server is the one that tallies and enforces.
-router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const ward = String(req.body?.ward || '').trim();
-  const target = String(req.body?.target || '').trim();
-  if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const r = await AP.ingestOutboxActivity(site, req.session.user, {
-    type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
-  });
-  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
-  res.json({ ok: true, lapse: r.id });
-});
-
-// ── Answer a forwarded gated-setting proposal (FEP-633c 5.6) ─────────────
-// The decision belongs to the ward's server, so the answer travels there as an
-// Accept/Reject on the offer id, exactly like a gated follow's decision.
-router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
-  if (!review) return res.status(404).json({ error: 'gone' });
-  const agree = req.body?.answer !== 'reject';
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const activity = {
-    id: `${me}#gated-${Date.now().toString(36)}`,
-    type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
-  };
-  try { await AP.deliverToActor(site, review.ward_uri, activity); }
-  catch { return res.status(502).json({ error: 'delivery' }); }
-  Guardianship.gated.removeGatedReview(site.slug, review.id);
-  res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
-});
-
-router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const offerId = String(req.body?.offer || '').trim();
-  const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
-  if (!offerId) return res.status(400).json({ error: 'empty_offer' });
-  const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
-  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
-  res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
-});
-
-// ── PWA assets served no-cache, so an update is never masked by the 1-year
-//    /assets cache or a stuck install (that was the whole "nothing works after
-//    a deploy" bug). Small files; the browser revalidates and gets a 304 when
-//    unchanged, the fresh file when changed.
-function pwaAsset(rel, type) {
-  return (req, res) => {
-    res.set('Cache-Control', 'no-cache');
-    res.type(type);
-    res.sendFile(path.join(__dir, '..', 'assets', rel));
-  };
-}
-router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
-router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
-
-// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
-/**
- * What actually happens if this guardian releases this ward?
- *
- * Releasing is not one action but two very different ones, and the difference
- * is the number of guardians the child has left (FEP-633c):
- *   - more than one → §3.3, you step down and the child stays a ward;
- *   - you are the last → §3.4, that is emancipation, and the FEP is explicit
- *     that no single guardian decides it alone (three consenting adults, or a
- *     majority plus two witnesses).
- * On top of that, today's release is LOCAL: the Undo is not federated yet
- * (relations.js, fase 4), so the ward's server keeps listing this guardian.
- * A guardian pressing the button would otherwise believe the child is released.
- *
- * Answered on demand rather than in the dashboard state: for a ward we do not
- * host this reaches out to that ward's server, and nobody should pay for that
- * on every refresh.
- */
-router.get('/wards/release-check', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.query.uri || '').trim();
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const local = !!base && uri.startsWith(`${base}/`);
-  let guardians = null;   // null = we could not find out; say so rather than guess
-  if (local) {
-    const slug = uri.replace(/\/+$/, '').split('/').pop();
-    try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
-  } else {
-    const doc = await AP.fetchActor(uri).catch(() => null);
-    const g = doc && doc['shaer:guardians'];
-    if (Array.isArray(g)) guardians = g.length;
-    else if (typeof g === 'string') guardians = 1;
-    else if (g && Array.isArray(g.items)) guardians = g.items.length;
-    else if (doc) guardians = 0;   // the actor answered and names no guardians
-  }
-  res.json({
-    guardians,
-    last: guardians === null ? null : guardians <= 1,
-    local,
-  });
-});
-
-// ── The fellow guardians of a ward, wherever it lives ─────────────────────
-// A guardian looking at a ward's panel should see who else holds a seat: that
-// is the child's safety net, and "dit kind woont op een andere server" is not
-// an answer. For a local ward the availability rides along (we do that
-// bookkeeping). For a remote ward we read the PUBLIC membership from its
-// actor document (shaer:guardians, §2.1) and nothing more: availability is
-// the ward's server's private ledger (§3.6.1) and stays there. Fetched on
-// panel-open rather than into the dashboard, so one slow remote server does
-// not hold the whole screen hostage.
-router.get('/wards/guardians', requireAuth, async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.query.uri || '').trim();
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return res.status(403).json({ error: 'not_my_ward' });
-  }
-  const local = Guardianship.queues.wardGuardianStatuses(uri);
-  if (local) return res.json({ local: true, guardians: local });
-  const doc = await AP.fetchActor(uri).catch(() => null);
-  let g = doc && doc['shaer:guardians'];
-  if (g && Array.isArray(g.items)) g = g.items;             // a Collection
-  const guardians = (Array.isArray(g) ? g : (typeof g === 'string' ? [g] : []))
-    .filter((x) => typeof x === 'string')
-    .map((u) => {
-      try { const p = new URL(u); return { uri: u, handle: `@${p.pathname.replace(/\/+$/, '').split('/').pop()}@${p.host}` }; }
-      catch { return { uri: u, handle: u }; }
-    });
-  res.json({ local: false, guardians });
-});
-
-router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.body?.uri || '').trim();
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  // Ending a guardianship is an Undo of the Relationship that travels to the
-  // ward and the other guardians (§3.2), not a local delete. Same call the
-  // Guardian apps reach over C2S, so the two cannot drift apart.
-  const r = await Guardianship.endGuardianship(site, uri);
-  if (r.status >= 400) return res.status(r.status).json({ error: r.error });
-  res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
-});
-
-/**
- * The external-embeds setting of a ward we host: true/false when a guardian has
- * decided, null when it is still on auto (which means off for a ward) or when
- * the ward lives elsewhere and the setting is not ours to show.
- */
-function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
-/** The playback gate of a ward we host (5.6): the heavier sibling. */
-function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
-
-function wardGateSetting(uri, column) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
-  const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
-  const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
-  if (!row) return null;
-  return row.v === null || row.v === undefined ? false : row.v === 1;
-}
-
-// ── Gated feature: may this ward see external (non-fediverse) embeds? ──
-// The first real gated setting (FEP-633c §5-style). The gate itself is applied
-// server-side when the feed is serialised, so this endpoint is the only way it
-// can move, and only a committed guardian of THAT ward may move it.
-router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  // Niet meer alleen embeds/playback: elke gate uit de catalogus met een kolom
-  // is voorstelbaar (8-8, "maak ze allemaal functioneel"). De oude regel
-  // HERSCHREEF een onbekende feature stilletjes naar externalEmbeds -- een
-  // voorstel voor de ene poort dat op de andere landt is precies het soort
-  // fout dat een guardian nooit mag overkomen. Onbekend wordt nu geweigerd.
-  const feature = String(req.body?.feature || 'shaer:externalEmbeds');
-  if (!Guardianship.gated.featureColumn(feature)) return res.status(400).json({ error: 'unknown_feature' });
-  req.body = { ...req.body, feature };
-  return proposeGated(req, res);
-});
-function proposeGated(req, res) {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  // De hele afweging staat in AP.proposeGate, zodat de apps langs dezelfde weg
-  // kunnen voorstellen (shaer-8ru). Deze route is nog maar de PWA-deur ernaartoe.
-  const uit = AP.proposeGate(site, req.body?.uri, req.body?.feature, req.body?.allow === true);
-  const { status, ...rest } = uit;
-  return res.status(status === 200 ? 200 : status).json(rest);
-}
-
-// ── The installable identity: own scope so the Guardian corner installs as
-//    its own app next to the site PWA.
-router.get('/manifest.webmanifest', (req, res) => {
-  const site = res.locals.site;
-  res.set('Cache-Control', 'no-cache');
-  res.json({
-    id: `klonkt-guardian-${site?.slug || 'guardian'}`,
-    name: 'Klonkt Guardian',
-    short_name: 'Guardian',
-    description: 'Ward management and help requests for guardians.',
-    scope: '/guardian/',
-    start_url: '/guardian?source=pwa',
-    display: 'standalone',
-    display_override: ['standalone', 'minimal-ui'],
-    orientation: 'any',
-    background_color: '#141a24',
-    theme_color: '#ff6b35',
-    lang: site?.language || 'nl',
-    icons: [
-      { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
-    ],
-  });
-});
-
-// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
-router.get('/icon.svg', (req, res) => {
-  const svg = `<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
-  <rect width="64" height="64" rx="14" fill="#ff6b35"/>
-  <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
-</svg>`;
-  res.set('Content-Type', 'image/svg+xml');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(svg);
-});
-
-// Losse guardian-accounts (guardian-lite: /invite + /join, user + site met
-// guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar;
-// zo'n account was de laatste multi-user-rest en zette bovendien andermans
-// wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een
-// verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een
-// eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie.
-
-export default router;
Index: src/routes/lang.js
===================================================================
--- src/routes/lang.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,31 +1,0 @@
-// Visitor language choice: /lang/:code sets the interface language in the session
-// and redirects back to where you came from. (Content stays in the author's language.)
-import express from 'express';
-import { SUPPORTED } from '../services/i18n.js';
-import db from '../config/database.js';
-
-const router = express.Router();
-
-router.get('/lang/:code', (req, res) => {
-  const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
-  if (req.session) req.session.lang = code;
-  // Logged in? Also save the choice on the account so it follows the user
-  // across devices/sessions (not just this session cookie).
-  if (req.session && req.session.user && req.session.user.id) {
-    try {
-      db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
-      req.session.user.lang = code;
-    } catch { /* lang column missing on an old DB → session-only, no breakage */ }
-  }
-  // Safe back URL: internal path only (no open redirect).
-  let back = (typeof req.query.r === 'string') ? req.query.r : '';
-  if (!back.startsWith('/') || back.startsWith('//')) {
-    try {
-      const u = new URL(req.get('referer') || '');
-      back = u.pathname + (u.search || '');
-    } catch { back = '/'; }
-  }
-  res.redirect(back || '/');
-});
-
-export default router;
Index: src/routes/linkbio.js
===================================================================
--- src/routes/linkbio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,61 +1,0 @@
-/**
- * Link-in-bio + click stats (premium feature #6).
- *
- *   GET /links          -> Linktree-style page with the site's profile_links
- *   GET /links/go/:i     -> counts the click (per url) and redirects to the external URL
- *
- * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the
- * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats).
- * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the
- * site's own profile_links. Hub: via /user/:slug/links.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { PLATFORMS } from '../services/PlatformIcons.js';
-
-const router = express.Router();
-
-function parseLinks(site) {
-  if (!site || !site.profile_links) return [];
-  try { return JSON.parse(site.profile_links) || []; } catch { return []; }
-}
-
-router.get('/links', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const links = parseLinks(site).map((l, i) => {
-    const meta = PLATFORMS[l.platform] || {};
-    return { i, url: l.url, platform: l.platform, label: meta.label || l.platform, svg: meta.svg || '', brand: meta.brand || '' };
-  });
-  renderPage(req, res, 'pages/linkbio', {
-    pageTitle: (site.title || '') + ' — links',
-    bodyClass: 'on-linkbio',
-    lbLinks: links,
-  });
-});
-
-router.get('/links/go/:i', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const links = parseLinks(site);
-  const idx = parseInt(req.params.i, 10);
-  const link = (Number.isInteger(idx) && idx >= 0) ? links[idx] : null;
-  if (!link || !link.url) return next();
-  const url = String(link.url);
-  // Only external http(s) or mailto links (no open redirect / javascript:).
-  if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
-  try {
-    db.prepare(
-      `INSERT INTO link_clicks (site_id, url, clicks, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP)
-       ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
-    ).run(site.id, url);
-  } catch { /* counting must never break the redirect */ }
-  res.redirect(302, url);
-});
-
-export default router;
Index: src/routes/newsletter.js
===================================================================
--- src/routes/newsletter.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,90 +1,0 @@
-/**
- * Newsletter — public side (premium feature #1).
- *
- *   GET  /nieuwsbrief                      -> sign-up form (premium; 404 otherwise)
- *   POST /nieuwsbrief                      -> subscribe (double opt-in if SMTP configured)
- *   GET  /nieuwsbrief/bevestigen/:token    -> confirm opt-in
- *   GET  /nieuwsbrief/uitschrijven/:token  -> unsubscribe (ALWAYS allowed)
- *
- * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase).
- * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase).
- */
-
-import express from 'express';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { addSubscriber, confirm, unsubscribe } from '../services/SubscriberService.js';
-
-const router = express.Router();
-
-function esc(s) {
-  return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
-}
-function fullUrl(req, siteUrlBase, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (siteUrlBase || '') + p;
-}
-function show(req, res, state, extra = {}) {
-  renderPage(req, res, 'pages/newsletter', {
-    pageTitle: 'Nieuwsbrief' + (res.locals.site ? ' — ' + res.locals.site.title : ''),
-    bodyClass: 'on-newsletter',
-    nlState: state,
-    ...extra,
-  });
-}
-
-router.get('/nieuwsbrief', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  if (!res.locals.site) return next();
-  const fan = req.session && req.session.user;
-  const prefill = (fan && fan.email && fan.email.includes('@')) ? fan.email : '';
-  show(req, res, 'form', { nlPrefill: prefill });
-});
-
-router.post('/nieuwsbrief', async (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const email = (req.body.email || '').trim();
-  const doubleOptin = mailerConfigured();
-  const r = addSubscriber(site.id, email, 'widget', { doubleOptin });
-  if (!r.ok) return show(req, res, r.error === 'invalid_email' ? 'invalid' : 'error', { nlPrefill: email });
-
-  if (r.status === 'pending') {
-    // Double opt-in: send the confirmation email.
-    const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
-    const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
-    try {
-      await sendMail({
-        to: email,
-        subject: 'Bevestig je inschrijving — ' + (site.title || 'nieuwsbrief'),
-        text: 'Bevestig je inschrijving op de nieuwsbrief van ' + (site.title || '') + ':\n' + link +
-              '\n\nNiet aangevraagd? Negeer deze mail. Uitschrijven: ' + unsub,
-        html: '<p>Bevestig je inschrijving op de nieuwsbrief van <strong>' + esc(site.title) + '</strong>:</p>' +
-              '<p><a href="' + link + '">Inschrijving bevestigen</a></p>' +
-              '<p style="color:#888;font-size:12px">Niet aangevraagd? Negeer deze mail. ' +
-              '<a href="' + unsub + '">Uitschrijven</a></p>',
-      });
-    } catch (e) {
-      return show(req, res, 'smtperror');
-    }
-    return show(req, res, 'check', { nlEmail: email });
-  }
-  return show(req, res, 'done', { nlEmail: email });
-});
-
-router.get('/nieuwsbrief/bevestigen/:token', (req, res, next) => {
-  if (!premiumUnlocked()) return next();
-  const ok = confirm(req.params.token);
-  show(req, res, ok ? 'confirmed' : 'badtoken');
-});
-
-// Unsubscribe is always allowed (even if the premium layer is later disabled): a
-// subscriber must always be able to opt out. Not premium-gated.
-router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
-  const ok = unsubscribe(req.params.token);
-  show(req, res, ok ? 'unsubbed' : 'badtoken');
-});
-
-export default router;
Index: src/routes/oauth.js
===================================================================
--- src/routes/oauth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,156 +1,0 @@
-/**
- * OAuth 2.0 routes for ActivityPub Client-to-Server (native/web clients).
- *
- *   POST /oauth/register            dynamic client registration (RFC 7591 subset)
- *   GET  /.well-known/oauth-authorization-server   server metadata (RFC 8414)
- *   GET  /oauth/authorize           consent screen (session-authenticated)
- *   POST /oauth/authorize           user grants → redirect back with ?code
- *   POST /oauth/token               code + PKCE verifier → bearer token
- *
- * Auth model: PUBLIC clients + PKCE only (see OAuthService). The consent screen
- * reuses Klonkt's normal login session; the token it mints is scoped to one
- * user + one of their sites.
- */
-import express from 'express';
-import db from '../config/database.js';
-import OAuth from '../services/OAuthService.js';
-import { requireAuth } from '../middleware/auth.js';
-import { renderPage } from '../middleware/render.js';
-import PermissionsService from '../services/PermissionsService.js';
-import { apEnabled } from '../services/SettingsService.js';
-
-const router = express.Router();
-router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-
-// Sites this user may post as (owner or co-admin). The consent screen lists these.
-function manageableSites(user) {
-  return db.prepare('SELECT id, slug, title, owner_id FROM sites ORDER BY created_at')
-    .all()
-    .filter((s) => PermissionsService.canAdminSite(user, s));
-}
-
-// Append query params to a redirect URI WITHOUT re-serializing it: native custom
-// schemes (com.shaer.app:/cb) get mangled by new URL().toString() (→ //cb/), and
-// RFC 6749 §4.1.2 says to append to the registered URI as-is. The URI is already
-// validated against the registered set before we ever call this.
-function redirectWith(redirectUri, params) {
-  const q = Object.entries(params)
-    .filter(([, v]) => v !== undefined && v !== null && v !== '')
-    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
-    .join('&');
-  const sep = redirectUri.includes('?') ? '&' : '?';
-  return q ? `${redirectUri}${sep}${q}` : redirectUri;
-}
-
-// Hand control back to the client at redirect_uri + params. For a web client
-// (http/https) a plain 302 is right. For a NATIVE custom scheme
-// (com.klonkt.shaer:/oauth) a 302 is unreliable: mobile browsers routinely drop
-// a server redirect to a custom scheme (no user gesture). So we serve a tiny
-// interstitial that both auto-forwards AND offers a tap link — a tap is a user
-// gesture that launches the app on Android, and iOS's ASWebAuthenticationSession
-// intercepts either navigation. Same page for allow and deny (neutral copy).
-function finishRedirect(res, redirectUri, params) {
-  const target = redirectWith(redirectUri, params);
-  if (/^https?:\/\//i.test(redirectUri)) return res.redirect(target);
-  const attr = target.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
-  return res.type('html').send(`<!doctype html>
-<html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<meta http-equiv="refresh" content="0;url=${attr}">
-<title>Return to the app</title>
-<style>body{font-family:system-ui,-apple-system,sans-serif;background:#111;color:#eee;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center}
-.box{padding:1.5rem}p{color:#aaa;line-height:1.5}a.btn{display:inline-block;margin-top:1.2rem;padding:.85rem 1.7rem;border-radius:12px;background:#5A32E6;color:#fff;text-decoration:none;font-weight:700}</style>
-</head><body><div class="box">
-<p>Almost done. If the app doesn't open by itself:</p>
-<a class="btn" href="${attr}">Open the app</a>
-</div>
-<script>location.replace(${JSON.stringify(target)});</script>
-</body></html>`);
-}
-
-// Bounce back to the client with an OAuth error (RFC 6749 §4.1.2.1) when we have
-// a validated redirect_uri; otherwise render a plain error (open-redirect guard).
-function authError(res, redirectUri, state, error, desc) {
-  if (redirectUri) return finishRedirect(res, redirectUri, { error, error_description: desc, state });
-  return res.status(400).json({ error, error_description: desc });
-}
-
-// ── RFC 8414: server metadata ────────────────────────────────────────────
-router.get('/.well-known/oauth-authorization-server', (req, res) => {
-  const base = baseUrl(req);
-  res.type('application/json').json({
-    issuer: base,
-    authorization_endpoint: `${base}/oauth/authorize`,
-    token_endpoint: `${base}/oauth/token`,
-    registration_endpoint: `${base}/oauth/register`,
-    response_types_supported: ['code'],
-    grant_types_supported: ['authorization_code'],
-    code_challenge_methods_supported: ['S256'],
-    token_endpoint_auth_methods_supported: ['none'],
-    scopes_supported: ['c2s'],
-  });
-});
-
-// ── RFC 7591: dynamic client registration ────────────────────────────────
-router.post('/oauth/register', (req, res) => {
-  const out = OAuth.registerClient({ client_name: req.body.client_name, redirect_uris: req.body.redirect_uris });
-  if (out.error) return res.status(400).json(out);
-  return res.status(201).json(out);
-});
-
-// ── Authorization: consent screen ────────────────────────────────────────
-router.get('/oauth/authorize', requireAuth, (req, res) => {
-  const { client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope, state } = req.query;
-  const client = OAuth.getClient(client_id);
-  // Pre-redirect validation errors must NOT bounce to an unvalidated URI.
-  if (!client) return res.status(400).json({ error: 'invalid_client' });
-  if (!client.redirect_uris.includes(String(redirect_uri || ''))) return res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri not registered' });
-  if (response_type !== 'code') return authError(res, redirect_uri, state, 'unsupported_response_type');
-  if (code_challenge_method !== 'S256' || !code_challenge) return authError(res, redirect_uri, state, 'invalid_request', 'PKCE S256 required');
-
-  const sites = manageableSites(req.session.user);
-  if (!sites.length) return authError(res, redirect_uri, state, 'access_denied', 'no manageable sites for this account');
-
-  return renderPage(req, res, 'pages/oauth-consent', {
-    pageTitleKey: 'oauth.title', bodyClass: 'on-special',
-    client, sites, params: { client_id, redirect_uri, code_challenge, scope: scope || 'c2s', state: state || '' },
-  });
-});
-
-router.post('/oauth/authorize', requireAuth, (req, res) => {
-  const { client_id, redirect_uri, code_challenge, scope, state, site_slug, decision } = req.body;
-  const client = OAuth.getClient(client_id);
-  if (!client || !client.redirect_uris.includes(String(redirect_uri || ''))) {
-    return res.status(400).json({ error: 'invalid_request', error_description: 'client/redirect mismatch' });
-  }
-  if (decision !== 'allow') return authError(res, redirect_uri, state, 'access_denied');
-
-  const site = db.prepare('SELECT id, slug, owner_id FROM sites WHERE slug = ?').get(String(site_slug || ''));
-  if (!site || !PermissionsService.canAdminSite(req.session.user, site)) {
-    return authError(res, redirect_uri, state, 'access_denied', 'not allowed to post as this site');
-  }
-  const out = OAuth.createCode({
-    clientId: client_id, userId: req.session.user.id, siteSlug: site.slug,
-    redirectUri: redirect_uri, codeChallenge: code_challenge, scope,
-  });
-  if (out.error) return authError(res, redirect_uri, state, out.error, out.error_description);
-  return finishRedirect(res, redirect_uri, { code: out.code, state });
-});
-
-// ── Token exchange ───────────────────────────────────────────────────────
-router.post('/oauth/token', (req, res) => {
-  res.set('Cache-Control', 'no-store');
-  if (req.body.grant_type !== 'authorization_code') {
-    return res.status(400).json({ error: 'unsupported_grant_type' });
-  }
-  const out = OAuth.exchangeCode({
-    code: req.body.code, client_id: req.body.client_id,
-    redirect_uri: req.body.redirect_uri, code_verifier: req.body.code_verifier,
-  });
-  if (out.error) return res.status(400).json(out);
-  return res.json(out);
-});
-
-export default router;
Index: src/routes/og.js
===================================================================
--- src/routes/og.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,29 +1,0 @@
-/**
- * GET /og/:slug.png — themed Open Graph card for a site (1200x630 PNG).
- * Generated from the site's palette + accent (see OgImageService), cached.
- * Used as the default og:image so every site has a branded social preview.
- */
-import express from 'express';
-import db from '../config/database.js';
-import { ogImageFor } from '../services/OgImageService.js';
-
-const router = express.Router();
-
-router.get('/:slug.png', (req, res) => {
-  let site;
-  try {
-    site = db.prepare(
-      'SELECT slug, title, tagline, description, palette, accent, theme_override, og_theme FROM sites WHERE slug = ?'
-    ).get(req.params.slug);
-  } catch { /* db error → 404 below */ }
-  if (!site) return res.status(404).end();
-
-  const png = ogImageFor(site);
-  if (!png) return res.status(404).end(); // resvg unavailable → no card (graceful)
-
-  res.set('Content-Type', 'image/png');
-  res.set('Cache-Control', 'public, max-age=86400');
-  return res.send(png);
-});
-
-export default router;
Index: src/routes/openwebauth.js
===================================================================
--- src/routes/openwebauth.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,245 +1,0 @@
-/**
- * OpenWebAuth (FEP-61cf), de drie plekken waar de stroom ons raakt.
- *
- *   POST /owa/login   de bezoeker geeft zijn adres, wij sturen hem naar huis
- *   ALL  /owa/token   zijn server vraagt ondertekend om een token (stap 2/3)
- *   GET  /owa/logout  weer anoniem
- *
- * Plus de middleware onderaan, die op ELK verzoek naar `?owt=` en `?zid=` kijkt.
- *
- * Zie OpenWebAuthService voor de stroom als geheel en voor de drie aanvallen
- * die de FEP beschrijft.
- */
-import express from 'express';
-import * as AP from '../services/ActivityPubService.js';
-import * as OWA from '../services/OpenWebAuthService.js';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { owaMagicLimiter } from '../middleware/rate-limit.js';
-
-const router = express.Router();
-
-/** Waar de bezoeker weer uitkomt. Altijd binnen onze eigen origin. */
-function returnUrlFor(req, path) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Alleen een pad, nooit iets wat de bezoeker meegaf als volledige URL: dat is
-  // hoe een aanmeldformulier een open redirect wordt.
-  let p = String(path || '/');
-  if (!p.startsWith('/') || p.startsWith('//')) p = '/';
-  return base + p;
-}
-
-/**
- * De handtekening staat hier in `Authorization`, niet in `Signature`.
- *
- * De FEP is er stellig over: "An OpenWebAuth signed request must have an
- * Authorization header starting with the word Signature". De rest van de
- * fediverse (en dus AP.verifyRequest) leest de `Signature`-header, zoals
- * Mastodon die stuurt. Twee gewoontes voor hetzelfde ding.
- *
- * Zonder deze vertaling zou elke ECHTE client -- Hubzilla, (streams), Forte --
- * hier een 401 krijgen terwijl hij alles goed deed, en zou pas de eerste
- * interop-proef dat aan het licht brengen.
- *
- * Geen mutatie van req: verifyRequest leest maar vier velden, dus we geven een
- * kopie mee. Zo blijft wat de rest van de keten ziet ongewijzigd.
- */
-function metSignatureHeader(req) {
-  const auth = String((req.headers && req.headers.authorization) || '');
-  if (req.headers && req.headers.signature) return req;      // al in de gewone vorm
-  if (!/^signature\s+/i.test(auth)) return req;              // niets te vertalen
-  return {
-    method: req.method,
-    originalUrl: req.originalUrl,
-    rawBody: req.rawBody,
-    headers: { ...req.headers, signature: auth.replace(/^signature\s+/i, '') },
-  };
-}
-
-/**
- * Stap 2/3: de home instance vraagt ondertekend om een token.
- *
- * GET EN POST, want de FEP zegt dat sommige home instances een POST met een
- * willekeurig lijf sturen.
- *
- * De verificatie is niet nieuw geschreven: AP.verifyRequest() doet dit al voor
- * de inbox, inclusief het vastpinnen van de sleutel op de herkomst van de actor
- * (anders host je een document met andermans id naast je eigen sleutel), een
- * replay-venster op Date, en een verplichte digest zodra er een lijf is. Eén
- * implementatie van "is deze aanvraag echt van wie hij zegt".
- */
-router.all('/owa/token', async (req, res) => {
-  let verified = null;
-  try { verified = await AP.verifyRequest(metSignatureHeader(req)); } catch { verified = null; }
-  if (!verified || !verified.id) {
-    return res.status(401).json({ success: false });
-  }
-  const pem = verified.publicKey && verified.publicKey.publicKeyPem;
-  if (!pem) return res.status(400).json({ success: false });
-
-  try {
-    const token = OWA.issueToken(verified.id);
-    // Versleuteld met ZIJN publieke sleutel: alleen de server die de bijbehorende
-    // prive-sleutel heeft kan hem lezen. Daarmee bewijst de teruggave dat de
-    // ondertekenaar ook echt die actor beheert, en niet alleen zijn document kent.
-    return res.json({ success: true, encrypted_token: OWA.encryptTokenFor(token, pem) });
-  } catch (e) {
-    console.warn('[owa] token uitgeven mislukte:', e && e.message);
-    return res.status(500).json({ success: false });
-  }
-});
-
-/**
- * Stap 1: de bezoeker typt zijn adres en wij sturen hem naar zijn eigen server.
- *
- * We slaan hier NIETS op over wie hij zegt te zijn. Dat is opzet: pas het
- * ingewisselde token bepaalt de identiteit (zie de impersonatie-aanval in de
- * FEP). Een `zid`/handle is niet meer dan een routeringshint.
- */
-router.post('/owa/login', async (req, res) => {
-  const terug = returnUrlFor(req, req.body && req.body.next);
-  const found = await OWA.discoverRedirectEndpoint(req.body && req.body.handle);
-  if (!found) {
-    const u = new URL(terug);
-    u.searchParams.set('owa_error', '1');
-    return res.redirect(u.toString());
-  }
-  return res.redirect(OWA.buildRedirect(found.endpoint, terug));
-});
-
-/**
- * /magic — de HOME-kant: onze gebruiker bewijst zich bij een andere site.
- *
- * Hier zijn de rollen omgedraaid. Een doelsite stuurt onze ingelogde gebruiker
- * hierheen; wij halen daar ondertekend een token op, ontsleutelen het met onze
- * eigen prive-sleutel en sturen hem terug met ?owt=. Dit is de enige plek waar
- * die sleutel nodig is -- en meteen de reden dat alleen een echte instance deze
- * kant kan spelen.
- *
- * WELKE IDENTITEIT? Op Klonkt is de fediverse-identiteit de SITE, niet het
- * account. Wie één site heeft gaat meteen door; wie er meer heeft kiest er een,
- * want ondertekenen en ontsleutelen kunnen alleen met een sleutel die hij ook
- * echt beheert.
- *
- * EN ER IS EEN TUSSENSCHERM, met opzet. De FEP waarschuwt onder "Information
- * leakage": OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die
- * erom vraagt, desnoods zonder dat je iets merkt. Deze omweg langs je eigen
- * server is het enige moment waarop je kunt zeggen: deze site niet.
- */
-function eigenSites(user) {
-  if (!user || !user.id) return [];
-  return db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY is_primary DESC, created_at ASC')
-    .all(user.id);
-}
-
-/** De doelsite waar dit heen gaat, alleen om te TONEN. Beslissen doet bdest. */
-function doelHost(bdest) { try { return new URL(bdest).host; } catch { return ''; } }
-
-router.get('/magic', (req, res) => {
-  const bdest = OWA.fromBdest(req.query && req.query.bdest);
-  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
-
-  // Niet ingelogd? Dan eerst hier inloggen, en daarna terug naar dit scherm --
-  // met de bdest nog intact, anders is de hele stroom weg.
-  if (!(req.session && req.session.user)) {
-    const terug = '/magic?owa=1&bdest=' + encodeURIComponent(String(req.query.bdest));
-    return res.redirect('/auth/login?next=' + encodeURIComponent(terug));
-  }
-
-  const sites = eigenSites(req.session.user);
-  if (!sites.length) return res.status(403).type('text/plain').send('geen eigen actor om mee te tekenen');
-
-  return renderPage(req, res, 'pages/owa-consent', {
-    pageTitle: 'Aanmelden bij ' + doelHost(bdest.href),
-    bodyClass: 'on-special',
-    owaHost: doelHost(bdest.href),
-    owaBdest: String(req.query.bdest),
-    owaSites: sites,
-  });
-});
-
-/**
- * De gebruiker zei ja. Nu pas gaan we tekenen.
- *
- * De open-redirect-verdediging van deze kant zit in discoverTokenEndpoint(): de
- * FEP zegt dat we bij een mislukte ontdekking NIET naar bdest mogen doorsturen,
- * want dan is /magic het doorgeefluik. Vandaar dat elke fout hieronder een
- * foutpagina geeft en geen redirect.
- */
-router.post('/magic', owaMagicLimiter, async (req, res) => {
-  const bdest = OWA.fromBdest(req.body && req.body.bdest);
-  if (!bdest) return res.status(400).type('text/plain').send('bad bdest');
-  if (!(req.session && req.session.user)) return res.status(401).type('text/plain').send('niet ingelogd');
-
-  // De gekozen site moet er een van HEM zijn: anders tekent hij met andermans
-  // sleutel, en dat is precies het gat dat je hier niet wilt.
-  const sites = eigenSites(req.session.user);
-  const gekozen = sites.find((s) => s.slug === String(req.body.slug || '')) || sites[0];
-  if (!gekozen) return res.status(403).type('text/plain').send('geen eigen actor');
-
-  const endpoint = await OWA.discoverTokenEndpoint(bdest.href);
-  if (!endpoint) return res.status(502).type('text/plain').send('die site biedt geen OpenWebAuth aan');
-
-  try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const keys = AP.getOrCreateKeys(gekozen.slug);
-    const keyId = AP.actorId(base, gekozen.slug) + '#main-key';
-    const versleuteld = await OWA.requestToken(endpoint, { keyId, privatePem: keys.private_pem });
-    if (!versleuteld) return res.status(502).type('text/plain').send('geen token gekregen');
-    const token = OWA.decryptToken(versleuteld, keys.private_pem);
-    if (!token) return res.status(502).type('text/plain').send('token onleesbaar');
-    const terug = new URL(bdest.href);
-    terug.searchParams.set('owt', token);
-    return res.redirect(terug.toString());
-  } catch (e) {
-    console.warn('[owa] /magic mislukte:', e && e.message);
-    return res.status(502).type('text/plain').send('aanmelden mislukte');
-  }
-});
-
-/** Weer anoniem. Raakt een eventuele lokale sessie niet aan. */
-router.get('/owa/logout', (req, res) => {
-  if (req.session) delete req.session.owa;
-  res.redirect(returnUrlFor(req, req.query && req.query.next));
-});
-
-/**
- * Op elk verzoek: is er een token ingewisseld, of wil iemand de stroom starten?
- *
- * `owt` BEPAALT de identiteit. `zid` start hooguit de stroom en wordt verder
- * genegeerd -- dat onderscheid IS de impersonatie-verdediging uit de FEP.
- */
-export function owaMiddleware(req, res, next) {
-  if (req.method !== 'GET' || !req.query) return next();
-
-  if (req.query.owt) {
-    let actor = null;
-    try { actor = OWA.redeemToken(req.query.owt); } catch { actor = null; }
-    if (actor) req.session.owa = { actor, at: Date.now() };
-    // Het token uit de URL halen: hij is toch al opgebruikt, en zo blijft hij
-    // niet in de geschiedenis, de titelbalk of een gedeelde link staan.
-    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
-    u.searchParams.delete('owt');
-    u.searchParams.delete('zid');
-    return res.redirect(u.pathname + (u.search || ''));
-  }
-
-  if (req.query.zid && !OWA.guestActor(req)) {
-    const u = new URL(req.originalUrl, process.env.PUBLIC_BASE_URL || 'https://localhost');
-    u.searchParams.delete('zid');
-    const terug = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '') + u.pathname + (u.search || '');
-    // Geen await in een middleware-keten die verder synchroon is: bij een fout
-    // gaat de bezoeker gewoon door naar de pagina, alleen zonder inlog.
-    OWA.discoverRedirectEndpoint(req.query.zid)
-      .then((found) => {
-        if (found) return res.redirect(OWA.buildRedirect(found.endpoint, terug));
-        return res.redirect(terug);
-      })
-      .catch(() => res.redirect(terug));
-    return;
-  }
-
-  return next();
-}
-
-export default router;
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,137 +1,0 @@
-/**
- * Paid posts (klonkt-demo-aki) slice 3: the patron link + passkey flow.
- * Cookie-less throughout: the OAuth state and the WebAuthn challenge travel in
- * signed blobs (CryptoBox), never a session.
- *
- * GET  /paid/link?post=<slug>  -> redirect to Patreon authorize
- * GET  /paid/callback          -> verify patron, render the passkey page
- * POST /paid/register          -> verify the passkey, store the entitlement
- */
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { signBlob, verifyBlob, cryptoBoxReady } from '../services/CryptoBox.js';
-import PaidPatreon from '../services/PaidPatreonService.js';
-import Passkey from '../services/PasskeyService.js';
-
-const router = express.Router();
-const AUTHORIZE = 'https://www.patreon.com/oauth2/authorize';
-
-const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-
-// The feature is only live when premium is on, secrets can be encrypted, and the
-// owner has connected a campaign.
-function ready(req, res) {
-  const site = res.locals.site;
-  if (!site) { res.status(404).end(); return null; }
-  if (!premiumUnlocked() || !cryptoBoxReady()) { res.status(404).end(); return null; }
-  const cfg = PaidPatreon.getOwnerConfig(site.id);
-  if (!cfg || !cfg.clientId || !cfg.campaignId) { res.status(404).end(); return null; }
-  return { site, cfg };
-}
-
-// Step 1: send the visitor to Patreon.
-router.get('/link', (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const slug = String(req.query.post || '').trim();
-  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
-  if (!post || !post.paid) return res.redirect((res.locals.siteUrlBase || '') + '/' + (slug || ''));
-  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
-  const state = signBlob({ purpose: 'patron', siteId: r.site.id, cents, post: post.slug }, 900);
-  const url = `${AUTHORIZE}?response_type=code&client_id=${encodeURIComponent(r.cfg.clientId)}`
-    + `&redirect_uri=${encodeURIComponent(baseUrl(req) + '/paid/callback')}`
-    + `&scope=${encodeURIComponent('identity identity.memberships')}`
-    + `&state=${encodeURIComponent(state)}`;
-  res.redirect(url);
-});
-
-// Step 2: Patreon returns. Verify the patron; if a supporter at the right tier,
-// render the passkey-creation page.
-router.get('/callback', async (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const payload = verifyBlob(String(req.query.state || ''));
-  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
-    return renderPage(req, res, 'pages/paid-result', {
-      pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'expired',
-    });
-  }
-  const patronUrl = PaidPatreon.patreonUrl(r.site.id);
-  const code = String(req.query.code || '');
-  if (req.query.error || !code) {
-    return renderPage(req, res, 'pages/paid-result', { pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
-  }
-  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
-  const cents = membership ? (membership.cents || 0) : 0;
-  const active = membership && membership.status === 'active_patron';
-  if (!active || cents < payload.cents) {
-    return renderPage(req, res, 'pages/paid-result', {
-      pageTitleKey: 'pres.t', bodyClass: 'on-post', ok: false,
-      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post, patronUrl,
-    });
-  }
-  // Supporter at the right tier. Hand out registration options + a signed blob
-  // carrying the challenge and the proven cents; the passkey page returns both.
-  const options = await Passkey.registrationOptions(baseUrl(req), r.site.slug);
-  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
-  renderPage(req, res, 'pages/paid-passkey', {
-    pageJs: 'paid-passkey',
-    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
-    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
-  });
-});
-
-// Step 3: verify the passkey and store the pseudonymous entitlement.
-router.post('/register', express.json({ limit: '64kb' }), async (req, res) => {
-  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
-  const { response, blob } = req.body || {};
-  const payload = verifyBlob(String(blob || ''));
-  if (!payload || payload.purpose !== 'reg' || payload.siteId !== r.site.id) {
-    return res.status(400).json({ error: 'bad_challenge' });
-  }
-  const cred = await Passkey.verifyRegistration(baseUrl(req), response, payload.challenge);
-  if (!cred) return res.status(400).json({ error: 'verify_failed' });
-  Passkey.storeEntitlement({
-    credentialId: cred.credentialId, siteId: r.site.id, publicKey: cred.publicKey,
-    counter: cred.counter, transports: cred.transports, minCents: payload.cents,
-  });
-  res.json({ ok: true });
-});
-
-// Step 4 (unlock): hand out authentication options for a passkey assertion.
-router.get('/challenge', async (req, res) => {
-  const r = ready(req, res); if (!r) return;
-  const slug = String(req.query.post || '').trim();
-  const post = slug ? db.prepare('SELECT slug, paid, paid_min_cents FROM posts WHERE site_id = ? AND slug = ?').get(r.site.id, slug) : null;
-  if (!post || !post.paid) return res.status(404).json({ error: 'not_paid' });
-  const cents = post.paid_min_cents || PaidPatreon.defaultMinCents(r.site.id);
-  const options = await Passkey.authenticationOptions(baseUrl(req));
-  const blob = signBlob({ purpose: 'auth', siteId: r.site.id, cents, post: post.slug, challenge: options.challenge }, 300);
-  res.json({ options, blob });
-});
-
-// Verify the assertion, check the entitlement, and return the full post body in
-// the SAME response. No unlock token becomes state (design decision).
-router.post('/unlock', express.json({ limit: '64kb' }), async (req, res) => {
-  const r = ready(req, res); if (!r) return res.status(404).json({ error: 'unavailable' });
-  const { response, blob } = req.body || {};
-  const payload = verifyBlob(String(blob || ''));
-  if (!payload || payload.purpose !== 'auth' || payload.siteId !== r.site.id) return res.status(400).json({ error: 'bad_challenge' });
-  const credId = response && response.id;
-  const ent = credId ? Passkey.getEntitlement(credId, r.site.id) : null;
-  if (!ent) return res.status(403).json({ error: 'no_entitlement' });      // unknown/expired passkey
-  if ((ent.min_cents || 0) < payload.cents) return res.status(403).json({ error: 'tier' });
-  const vr = await Passkey.verifyAssertion(baseUrl(req), response, payload.challenge, ent);
-  if (!vr) return res.status(400).json({ error: 'verify_failed' });
-  Passkey.bumpCounter(credId, vr.newCounter);
-  const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
-  if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
-  // Hand back a short-lived, single-post unlock capability. The client reloads
-  // the real post page with it (?u=), so the post renders through its normal
-  // template: correct layout, scoped styles, working audio. Not a cookie and
-  // not stored: a 120s signed blob that lives only in that one URL.
-  const token = signBlob({ purpose: 'unlocked', siteId: r.site.id, post: post.slug }, 120);
-  res.json({ ok: true, redirect: `${res.locals.siteUrlBase || ''}/${encodeURIComponent(post.slug)}?u=${encodeURIComponent(token)}` });
-});
-
-export default router;
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/posts.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,11 +3,9 @@
 import path from 'path';
 import fs from 'fs';
+import { fileURLToPath } from 'url';
 import multer from 'multer';
-import ejs from 'ejs';
 import db from '../config/database.js';
-import { POST_TYPES, KEUZE_TYPES } from '../config/post-types.js';
-import { requireAuth, requireSiteManager, isViewer } from '../middleware/auth.js';
+import { requireAuth } from '../middleware/auth.js';
 import { renderPage } from '../middleware/render.js';
-import { recordPageview, recordPostView } from '../services/StatsService.js';
 import PermissionsService from '../services/PermissionsService.js';
 import MarkdownService from '../services/MarkdownService.js';
@@ -15,46 +13,15 @@
 import AudioEmbedService from '../services/AudioEmbedService.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { audioEnabled } from '../config/features.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { toWebp } from '../services/ImageWebpService.js';
-import VideoCoverService from '../services/VideoCoverService.js';
-import ActivityPubService from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
-import { verifyBlob } from '../services/CryptoBox.js';
-import { postEntry } from '../services/PostAccessService.js';
-import * as OWA from '../services/OpenWebAuthService.js';
-import MusicMeta from '../services/MusicMeta.js';
-import { mediaDir } from '../config/paths.js';
-
-const POST_IMAGES_DIR = mediaDir('POST_IMAGES_PATH', 'post-images');
+import { signUrl } from '../services/AudioStreamService.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const POST_IMAGES_DIR = path.resolve(
+  process.env.POST_IMAGES_PATH ||
+  path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
+);
 fs.mkdirSync(POST_IMAGES_DIR, { recursive: true });
 
 const ALLOWED_IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
 const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
-
-// Rich replies: media dropped/pasted into the reply editor. Images, audio and
-// video, stored as-is (no transcode; a reply attachment is not a track).
-const REPLY_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
-fs.mkdirSync(REPLY_MEDIA_DIR, { recursive: true });
-const ALLOWED_REPLY_MEDIA_EXT = new Set([
-  '.jpg', '.jpeg', '.png', '.webp', '.gif',
-  '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav',
-  '.mp4', '.webm', '.mov',
-]);
-const MAX_REPLY_MEDIA_BYTES = 32 * 1024 * 1024;
-const replyMediaUpload = multer({
-  storage: multer.diskStorage({
-    destination: (req, file, cb) => cb(null, REPLY_MEDIA_DIR),
-    filename: (req, file, cb) => cb(null, `${uuid()}${path.extname(file.originalname).toLowerCase()}`),
-  }),
-  limits: { fileSize: MAX_REPLY_MEDIA_BYTES },
-  fileFilter: (req, file, cb) => {
-    const ext = path.extname(file.originalname).toLowerCase();
-    if (!ALLOWED_REPLY_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
-    cb(null, true);
-  },
-});
 
 const imageStorage = multer.diskStorage({
@@ -77,25 +44,5 @@
 });
 
-// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
-// A second post with the same title is NOT rejected ("already exists"),
-// but automatically gets a free suffix. exceptId = the post being updated
-// (allowed to keep its own slug).
-function uniqueSlug(siteId, base, exceptId = null) {
-  let candidate = base;
-  let n = 2;
-  for (;;) {
-    const row = exceptId
-      ? db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(siteId, candidate, exceptId)
-      : db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(siteId, candidate);
-    if (!row) return candidate;
-    candidate = `${base}-${n++}`;
-  }
-}
-
 const router = express.Router();
-
-// Feed page size for "Load more" (Solo, News, Messages, Cirkel). 72 is divisible
-// by 2/3/4 so every grid column count ends on a full row.
-const FEED_PAGE = 72;
 
 // ==================== UPLOAD IMAGE (cover or content) ====================
@@ -103,40 +50,9 @@
 // insert a markdown ![](url) into content.
 router.post('/posts/upload-image', requireAuth, (req, res) => {
-  imageUpload.single('image')(req, res, async (err) => {
+  imageUpload.single('image')(req, res, (err) => {
     if (err) return res.status(400).json({ error: err.message });
     if (!req.file) return res.status(400).json({ error: 'No file' });
-    const name = toWebp(req.file);
-    const url = '/media/post-images/' + name;
-    // An animated WebP cover → also make a muted loop MP4 (Safari plays it smoothly where the
-    // animated WebP is janky on iOS). Best-effort; on failure we just return the still image.
-    // The editor stores `video` in the hidden cover_video_url field for the cover.
-    let video = null;
-    try {
-      const src = path.join(POST_IMAGES_DIR, name);
-      if (VideoCoverService.isAnimatedWebp(src)) {
-        const r = await VideoCoverService.animatedWebpToVideo(src, POST_IMAGES_DIR, path.basename(name, path.extname(name)) + '-v');
-        if (r) video = '/media/post-images/' + path.basename(r.videoPath);
-      }
-    } catch { /* keep the still image */ }
-    res.json({ url, video, size: req.file.size, mime: req.file.mimetype });
-  });
-});
-
-// Rich replies: media for a reply (image/audio/video). Returns { url, mediaType, name }
-// exactly as the editor's attachments JSON wants it; deliverReply re-validates.
-router.post('/posts/upload-reply-media', requireSiteManager, (req, res) => {
-  replyMediaUpload.single('media')(req, res, (err) => {
-    if (err) return res.status(400).json({ error: err.message });
-    if (!req.file) return res.status(400).json({ error: 'No file' });
-    const mime = String(req.file.mimetype || '');
-    if (!/^(image|audio|video)\//.test(mime)) {
-      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
-      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
-    }
-    res.json({
-      url: '/media/reply-media/' + req.file.filename,
-      mediaType: mime,
-      name: String(req.file.originalname || '').slice(0, 120),
-    });
+    const url = '/media/post-images/' + req.file.filename;
+    res.json({ url, size: req.file.size, mime: req.file.mimetype });
   });
 });
@@ -145,12 +61,7 @@
   'auth', 'admin', 'login', 'register', 'logout',
   'archive', 'search', 'account', 'sites', 'comments',
-  'posts', 'media', 'audio', 'forum',
-  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'posts', 'media', 'audio', 'prutter', 'forum',
+  'tag', 'type', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
-  'authorize_interaction', 'fediverse', 'news', 'following', 'notifications', 'blocking',
-  'paid', 'push', 'guardian',
-  // De meeslepende leesweergave. Gereserveerd
-  // omdat een bericht met deze slug de route anders zou overschaduwen.
-  'read',
 ]);
 
@@ -171,29 +82,4 @@
 }
 
-// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
-const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
-// Parse the editor's poll fields into the poll_json we store on the post (which
-// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
-// options or the poll checkbox is off). endTime is set from the chosen duration
-// (default 1 day) so the Scheduler can close it.
-function parsePollForm(body) {
-  if (!body || !body.poll_enabled) return null;
-  const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
-  const options = [];
-  const seen = new Set();
-  for (const o of raw) {
-    const name = String(o == null ? '' : o).trim().slice(0, 100);
-    if (!name) continue;
-    const key = name.toLowerCase();
-    if (seen.has(key)) continue; seen.add(key);
-    options.push({ name });
-    if (options.length >= 8) break;
-  }
-  if (options.length < 2) return null;
-  const dur = parseInt(body.poll_duration, 10);
-  const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
-  return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
-}
-
 // ==================== HOME (Posts list) ====================
 router.get('/', (req, res) => {
@@ -218,42 +104,19 @@
   `).all(site.id);
 
-  // Regular posts: anything with pinned = 0. Paged in blocks of 72 (Load more).
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const rows = db.prepare(`
+  // Regular posts: anything with pinned = 0
+  const posts = db.prepare(`
     SELECT p.*, u.username as author_username
     FROM posts p JOIN users u ON p.author_id = u.id
     WHERE p.site_id = ? AND p.status = 'published' AND p.pinned = 0
     ORDER BY p.published_at DESC
-    LIMIT ? OFFSET ?
-  `).all(site.id, FEED_PAGE + 1, offset);
-  const hasMore = rows.length > FEED_PAGE;
-  const posts = rows.slice(0, FEED_PAGE);
-  const moreBase = res.locals.siteUrlBase || '';
-
-  if (append) {
-    return renderPage(req, res, 'partials/home-append', {
-      posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-      readerItems: readerItems(site, posts, req),
-    });
-  }
-
-  recordPageview(site.id, req);
-
-  // FEP-7628 slice 3: this account moved. A visitor who lands here deserves
-  // the same signpost the fediverse gets — one big link to the new address.
-  const movedTo = site.moved_to && /^https?:\/\//i.test(String(site.moved_to)) ? String(site.moved_to) : null;
+    LIMIT 30
+  `).all(site.id);
+
   renderPage(req, res, 'pages/home', {
     pinnedPosts,
     posts,
-    readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    movedTo,
-    movedToLabel: movedTo ? (ActivityPubService.actorDisplay(site.slug, movedTo).handle || movedTo) : null,
     pageTitle: site.title,
     socialDescr: site.description || site.tagline || '',
     bodyClass: 'on-home',
-    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
-    pageJs: 'read tape',
   });
 });
@@ -268,6 +131,4 @@
 
   renderPage(req, res, 'pages/post-edit', {
-    // post-edit neemt de playlist-editor op.
-    pageJs: 'post-edit playlist-editor',
     post: {
       id: uuid(),
@@ -277,5 +138,4 @@
     },
     isNew: true,
-    keuzeTypes: KEUZE_TYPES,
     pageTitle: 'New post',
     bodyClass: 'on-special',
@@ -284,59 +144,4 @@
 
 // ==================== CREATE POST ====================
-// ── Per-post audio federation ──────────────────────────────────────────────
-// "Share audio on the fediverse" is a per-post choice in the editor, but the underlying
-// flag is per track (audio_tracks.fedi_open — it gates the file + drives the AS2 Audio
-// attachment). NB: the file gate is per file, so opening a track in one post makes its file
-// fetchable for every post that reuses it.
-// ONE-WAY: opening is permanent. Once the file has federated it's out there — re-gating
-// would be false security (remote copies keep the URL), so we never write fedi_open back to 0.
-function setAudioFediOpen(siteId, content, open) {
-  if (!open) return; // never close — see one-way note above
-  const c = content || '';
-  try {
-    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id = ? AND site_id = ?').run(m[1], siteId);
-    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND album = ?').run(siteId, m[1].trim());
-    // playlists.id is a GLOBAL key, so the site filter has to sit on the tracks: without it a
-    // post on site A embedding site B's playlist would open B's files — permanently.
-    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE site_id = ? AND id IN (SELECT track_id FROM playlist_tracks WHERE playlist_id = ?)').run(siteId, m[1]);
-  } catch { /* non-fatal */ }
-}
-// True when the post references hosted audio AND all of it is currently fedi_open (drives the
-// editor checkbox's initial state).
-function postAudioFediOpen(siteId, content) {
-  const c = content || '';
-  if (!/\[\[(track|album|playlist):/i.test(c)) return false;
-  let total = 0, open = 0;
-  const tally = (r) => { if (r && r.media_id) { total++; if (r.fedi_open) open++; } };
-  try {
-    for (const m of c.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) tally(db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], siteId));
-    for (const m of c.matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare('SELECT fedi_open, media_id FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL').all(siteId, m[1].trim())) tally(r);
-    for (const m of c.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.fedi_open, t.media_id FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL').all(m[1])) tally(r);
-  } catch { /* non-fatal */ }
-  return total > 0 && open === total;
-}
-
-// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
-// source (used by the editor + re-rendering), content_rendered holds the linkified render the
-// page serves. Called after every create/edit. Non-fatal: the render route falls back to
-// baking on the fly if this ever fails.
-function cacheRenderedContent(postId, rawContent) {
-  const raw = rawContent || '';
-  // 1. Immediate + synchronous: bake #hashtags + URLs so the post renders enriched at once.
-  try {
-    db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
-      .run(ActivityPubService.bakePostContent(raw), postId);
-  } catch (e) { /* fallback bake in the render route keeps display correct */ }
-  // 2. Async: resolve @mentions (webfinger, once) and re-store, WITHOUT blocking the save
-  //    response — a moment later the post's @mentions are clickable too. A slow/dead remote
-  //    server can't stall the save; on failure the sync bake from step 1 stands.
-  ActivityPubService.bakePostContentWithMentions(raw)
-    .then((html) => {
-      try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(html, postId); }
-      catch (e) { /* keep the sync bake */ }
-    })
-    .catch(() => { /* keep the sync bake */ });
-}
-
 router.post('/posts/create', requireAuth, (req, res) => {
   const site = res.locals.site;
@@ -344,23 +149,6 @@
     return res.status(403).send('No permission');
   }
-  // Verhuisd = niet meer schrijven. Dit moet HIER staan en niet pas bij
-  // deliverCreate: die weigert alleen de bezorging, waarna de post gewoon in de
-  // database belandt met een object-URI op een adres dat je hebt opgezegd. Dan
-  // lijkt het gelukt, staat het er, en sterft het met het domein. Precies de
-  // halve toestand die dit slot moet voorkomen.
-  if (ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd naar ' + ActivityPubService.movedLock(site).movedTo
-      + '. Nieuwe berichten maak je daar. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.');
-  }
 
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -370,5 +158,5 @@
 
   // Generate slug from title if empty
-  let finalSlug = (slug || title || '')
+  const finalSlug = (slug || title || '')
     .toLowerCase()
     .replace(/[^a-z0-9]+/g, '-')
@@ -376,45 +164,31 @@
 
   if (!finalSlug) return res.status(400).send('Title or slug required');
-  if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
-
-  // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
-  finalSlug = uniqueSlug(site.id, finalSlug);
-
-  const finalType = POST_TYPES.has(type) ? type : 'post';
-  const pollJson = parsePollForm(req.body);   // AS2 Question definition, or null
+  if (RESERVED_SLUGS.has(finalSlug)) return res.status(400).send('That slug is reserved');
+
+  // Uniqueness check
+  const existing = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?').get(site.id, finalSlug);
+  if (existing) return res.status(400).send('A post with that slug already exists');
+
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : 'post';
   const postId = uuid();
   const now = new Date().toISOString();
-  let finalStatus = status || 'draft';
-  let publishedAt = finalStatus === 'published' ? now : null;
-  // Release planning: published + a future publish_at -> 'scheduled'
-  // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
+  const finalStatus = status || 'draft';
+  const publishedAt = finalStatus === 'published' ? now : null;
 
   db.prepare(`
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
+      status, cover_image_url, pinned, tags, type, noindex,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
     title || finalSlug, cleanContent, excerpt || '',
-    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     now, now, publishedAt
   );
-  cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   if (finalStatus === 'published') {
@@ -424,14 +198,4 @@
       ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
     } catch (e) { /* FTS index issues are non-fatal */ }
-
-    // ActivityPub: federate a freshly published post to followers. fan_only → delivered
-    // to followers but addressed followers-only (option A: "fans" = your fedi followers).
-    if (status === 'published') {
-      ActivityPubService.deliverCreate(site, {
-        id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-        published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
-      }).catch(() => { /* best-effort */ });
-    }
   }
 
@@ -465,17 +229,7 @@
   }
 
-  // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
-  let pollLocked = false;
-  try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
-
   renderPage(req, res, 'pages/post-edit', {
-    // Zelfde modules als de nieuw-route hierboven: zonder deze regel laadt de
-    // editor niet, en dan wist een opslag de post (shaer-5s1, de beet van 7-8).
-    pageJs: 'post-edit playlist-editor',
     post,
     isNew: false,
-    keuzeTypes: KEUZE_TYPES,
-    pollLocked,
-    fediOpenAudio: postAudioFediOpen(site.id, post.content),
     pageTitle: 'Edit: ' + (post.title || 'Untitled'),
     bodyClass: 'on-special',
@@ -497,32 +251,9 @@
   }
 
-  // Verhuisd: een BESTAANDE post bewerken mag nog -- daar wil je juist "ik ben
-  // verhuisd naar ..." in kunnen zetten, en die URI bestaat al. Een concept
-  // alsnog publiceren mag niet: dat is nieuwe inhoud op een adres dat je hebt
-  // opgezegd.
-  if (post.status !== 'published' && String(req.body.status || '') === 'published'
-      && ActivityPubService.movedLock(site).locked) {
-    return res.status(409).send('Dit account is verhuisd. Publiceren doe je op '
-      + ActivityPubService.movedLock(site).movedTo + '. Bestaande berichten bewerken kan hier wel.');
-  }
-
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
-  const fanOnly = req.body.fan_only ? 1 : 0;
-  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
-  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
-  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
-  const nsfw = req.body.nsfw ? 1 : 0;
-  const cw = (req.body.content_warning || '').trim().slice(0, 200);
-  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
-  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
-  const finalType = POST_TYPES.has(type) ? type : (post.type || 'post');
-
-  // A poll that has already received votes is frozen (you can still edit the surrounding
-  // post, but not the options) — changing options after votes would scramble the tally and
-  // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
-  const hasVotes = !!(post.poll_json && (() => { try { return db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id); } catch { return false; } })());
-  const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
+  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
+  const finalType = validTypes.has(type) ? type : (post.type || 'post');
 
   // Sanitize before storage — same pipeline as create.
@@ -532,7 +263,8 @@
   if (newSlug && newSlug !== post.slug) {
     const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
-    const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
-    // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
-    finalSlug = uniqueSlug(site.id, safe, post.id);
+    if (RESERVED_SLUGS.has(cleaned)) return res.status(400).send('That slug is reserved');
+    const conflict = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ? AND id != ?').get(site.id, cleaned, post.id);
+    if (conflict) return res.status(400).send('Slug already taken');
+    finalSlug = cleaned;
   }
 
@@ -546,33 +278,18 @@
   }
 
-  // Release planning: published + future publish_at -> 'scheduled'.
-  let publishAt = null;
-  const pa = Date.parse(req.body.publish_at || '');
-  if (req.body.schedule_enabled && finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
-    finalStatus = 'scheduled';
-    publishAt = new Date(pa).toISOString();
-    publishedAt = null;
-  }
-
   db.prepare(`
     UPDATE posts SET
       title = ?, content = ?, excerpt = ?, status = ?,
-      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
-      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
+      cover_image_url = ?, pinned = ?, tags = ?,
+      type = ?, noindex = ?,
       slug = ?, published_at = ?, updated_at = ?
     WHERE id = ?
   `).run(
     title, cleanContent, excerpt, finalStatus,
-    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
+    cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
+    finalType, noindex ? 1 : 0,
     finalSlug, publishedAt, now, post.id
   );
-  cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
-  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
-
-  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
-  // BEFORE federating, so the Update/Create note carries the right Audio attachments.
-  setAudioFediOpen(site.id, cleanContent, req.body.fedi_open_audio);
 
   // Update FTS
@@ -586,29 +303,4 @@
   } catch (e) { /* FTS issues non-fatal */ }
 
-  // ActivityPub: federate edits to followers. A post that BECOMES published →
-  // Create (new post); an already-published post that's edited → Update (so
-  // Mastodon refreshes its cached copy). fan_only → followers-only (option A).
-  if (finalStatus === 'published') {
-    const apPost = {
-      id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
-    };
-    // Op een verhuisd account mag een BESTAANDE post nog bewerkt worden -- daar
-    // wil je juist "ik ben verhuisd naar ..." in kunnen zetten, en die URI
-    // bestaat al. Wat niet mag is een concept alsnog publiceren: dat is nieuwe
-    // inhoud op een adres dat je hebt opgezegd. deliverCreate/deliverUpdate
-    // weigeren zelf ook, dit voorkomt alleen de lokale halve toestand.
-    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
-    else ActivityPubService.deliverUpdate(site, apPost).catch(() => { /* best-effort */ });
-  }
-
-  // Pin/unpin/reorder → push Add/Remove activities so followers' instances update the
-  // pinned order immediately (reliable, unlike re-fetching the cached featured collection).
-  if ((post.pinned || 0) !== parsePinnedRank(pinned)) {
-    const unpinned = (post.pinned || 0) > 0 && parsePinnedRank(pinned) === 0 ? [post.id] : [];
-    ActivityPubService.resyncFeaturedPins(site, unpinned).catch(() => { /* best-effort */ });
-  }
-
   res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
 });
@@ -626,11 +318,4 @@
   if (!PermissionsService.canDeletePost(req.session.user, post, site)) {
     return res.status(403).send('No permission');
-  }
-
-  // ActivityPub: tell followers the post is gone (Delete + Tombstone) if it was
-  // federated (any published post now federates — fan_only goes followers-only).
-  // Fire before the row is removed — we still have post.id (= the Note id).
-  if (post.status === 'published') {
-    ActivityPubService.deliverDelete(site, post).catch(() => { /* best-effort */ });
   }
 
@@ -686,36 +371,31 @@
 });
 
-// Local likes/favourites are removed — engagement is fediverse-only now
-// (the ⭐ on a post likes via the fediverse). No post_likes, no /favorieten.
-
-// Newer/Older neighbours across ALL posts in feed order. Shared by the full
-// post render and the fan gate (premium fan_only) so navigation is consistent
-// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
-// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
-// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
-// Dezelfde berichten, klaar voor de leesweergave.
-//
-// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
-// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
-// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
-//
-// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
-// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
-function readerItems(site, rows, req) {
-  const viewer = OWA.viewerFor(req, site, { unlockedSlug: null });
-  return rows.map((post) => ({
-    post,
-    entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
-  }));
-}
-
-export function renderPostBodyHtml(site, post, req) {
-  let html = (post.content_rendered != null && post.content_rendered !== '')
-    ? post.content_rendered
-    : ActivityPubService.bakePostContent(post.content || '');
-  if (audioEnabled()) {
+// ==================== VIEW POST (last route â€” catches /:slug) ====================
+router.get('/:slug', (req, res, next) => {
+  if (RESERVED_SLUGS.has(req.params.slug)) return next();
+
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site not found');
+
+  const post = db.prepare(`
+    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
+    FROM posts p JOIN users u ON p.author_id = u.id
+    WHERE p.site_id = ? AND p.slug = ?
+  `).get(site.id, req.params.slug);
+
+  if (!post) return res.status(404).send('Post not found');
+
+  // Permission to view: published OR (logged in + can edit)
+  if (post.status !== 'published') {
+    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+    if (!canEdit) return res.status(403).send('Not published');
+  }
+
+  // Render content. Content is now user-authored HTML (already sanitized on
+  // save). The pipeline still adds autoembed iframes and shortcode embeds:
+  //   stored HTML → autoembed → [[track]]/[[album]]/[[playlist]] → response
+  let html = post.content || '';
   if (site.enable_audio_player !== 0) {
     html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
     html = AudioEmbedService.embedExternalLinkShortcodes(html);
 
@@ -726,6 +406,5 @@
       const placeholders = trackIds.map(() => '?').join(',');
       const rows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.cover_url, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.id IN (${placeholders})
@@ -734,5 +413,5 @@
       html = AudioEmbedService.embedTrackShortcodes(html, (id) => {
         const r = byId.get(id);
-        if (!r) return null;
+        if (!r || !r.filename) return null;
         return {
           id: r.id,
@@ -740,10 +419,5 @@
           artist: r.artist,
           cover: r.cover_url,
-          credit: r.credit || '',
-          license: r.license || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
-          url: r.filename ? audioUrl(r.filename) : '',  // '' = link-only track
+          url: signUrl(r.filename).url,
         };
       });
@@ -755,6 +429,5 @@
       const placeholders = albumNames.map(() => '?').join(',');
       const albumRows = db.prepare(`
-        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position,
-               t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+        SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.position, m.filename
         FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
         WHERE t.site_id = ? AND t.album IN (${placeholders})
@@ -763,15 +436,11 @@
       const byAlbum = new Map();
       for (const r of albumRows) {
-        // Link-only tracks (no file) remain in the album overview (url '').
+        if (!r.filename) continue;
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
-          id: r.id,
-          url: r.filename ? audioUrl(r.filename) : '',
+          url: signUrl(r.filename).url,
           title: r.title || 'Untitled',
           artist: r.artist || '',
           cover: r.cover_url || '',
-          link_spotify: r.link_spotify || '',
-          link_youtube: r.link_youtube || '',
-          link_soundcloud: r.link_soundcloud || '',
         });
       }
@@ -795,855 +464,9 @@
       const isAdmin = req.session?.user?.role === 'god';
       html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
-        return PlaylistService.get(site.id, id, audioUrl);
+        return PlaylistService.get(site.id, id, signUrl);
       }, { isAdmin });
     }
   }
-  } else {
-    // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
-    // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
-    // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
-    html = AudioEmbedService.autoembed(html);
-    html = AudioEmbedService.embedMediaShortcodes(html);
-    html = AudioEmbedService.embedExternalLinkShortcodes(html);
-    html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  }
-  return html;
-}
-
-// A short public teaser for a paid post: its excerpt, else the first ~280 chars
-// of the (stripped) content. Shared by the web gate and federation.
-function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  // Only the FIRST paragraph: a paid teaser must never spill later content.
-  const html = String((post && post.content) || '');
-  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
-  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
-}
-
-// De muziek van een betaalde post op de poortpagina zelf.
-//
-// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
-// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
-// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
-// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
-// kant op (Robin, 24-8). De muur staat om de tekst.
-//
-// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
-// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
-// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
-// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
-// Half open is hier dus dicht.
-//
-// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
-// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
-// niet lekken -- dezelfde regel als bij readerItems.
-export function paidOpenAudioHtml(site, post, req) {
-  if (!postAudioFediOpen(site.id, post.content)) return '';
-  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
-  if (!codes.length) return '';
-  const alleenMuziek = codes.join('\n');
-  try {
-    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
-  } catch { return ''; /* geen speler is geen kapotte poort */ }
-}
-
-function postNeighbors(site, post) {
-  const ordered = db.prepare(`
-    SELECT id, slug, title, pinned FROM posts
-    WHERE site_id = ? AND status = 'published'
-    ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
-  `).all(site.id);
-  const idx = ordered.findIndex((p) => p.id === post.id);
-  const newerPost = idx > 0 ? ordered[idx - 1] : null;
-  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
-  if (newerPost) newerPost._urlBase = '';
-  if (olderPost) olderPost._urlBase = '';
-  return { newerPost, olderPost };
-}
-
-// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
-// Standard fediverse "reply from your own server" landing endpoint. A post page
-// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
-// composes a reply that federates back to that post.
-router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.query.uri || '').toString();
-  const sent = !!req.query.sent;
-  const followed = !!req.query.followed;
-  const voted = !!req.query.voted;
-  const reported = !!req.query.reported;
-  let target = null, followTarget = null;
-  if (!sent && !followed && !voted && !reported && uri) {
-    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
-    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
-    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
-  }
-  renderPage(req, res, 'pages/authorize-interaction', {
-    pageJs: 'authorize-interaction reply-editor',
-    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
-    bodyClass: 'on-special',
-    uri,
-    target,
-    followTarget,
-    sent,
-    followed,
-    voted: !!req.query.voted,
-    reported: !!req.query.reported,
-    liked: !!req.query.liked,
-    boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
-    siteTitle: site ? site.title : '',
-  });
-});
-
-// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
-// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
-router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
-});
-
-// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
-router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const actorUri = (req.body.actor_uri || '').toString();
-  const reason = (req.body.reason || '').toString();
-  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
-});
-
-// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
-router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).liked;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
-      .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
-    ActivityPubService.setReaction(site.slug, uri, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
-// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
-router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => {
-        if (!note) return;
-        const id = note.object_uri || uri;
-        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
-          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
-          // de Cirkel verschijnt.
-          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
-      })
-      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
-    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
-    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
-    // note, zodat de post ook in je tijdlijn belandt.
-    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// Follow a remote actor from your own site (when the target is a profile, not a post).
-router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  if (!site || !uri) return res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
-  // Afwachten in plaats van wegsturen: ligt het verzoek bij de guardians, dan
-  // moet dat op het scherm staan (shaer-p729). "followed=1" terwijl er niets
-  // gebeurd is, is precies de leugen die de poort waardeloos maakt.
-  ActivityPubService.followActor(site, uri)
-    .then((r) => res.redirect('/authorize_interaction?' + (r && r.held ? 'held=1' : 'followed=1') + '&uri=' + encodeURIComponent(uri)))
-    .catch((e) => {
-      console.warn('[AP] remote follow failed:', e.message);
-      res.redirect('/authorize_interaction?error=1&uri=' + encodeURIComponent(uri));
-    });
-});
-
-router.post('/authorize_interaction', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  const language = (req.body.language || '').toString();
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    // Resolve + deliver in the background so Send responds instantly.
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
-      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
-  }
-  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
-});
-
-// Manage / delete your own outbound fediverse replies (site owner only).
-// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
-// The old /fediverse (manage) and /notifications pages redirect here.
-router.get('/messages', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = page.length > FEED_PAGE;
-  const items = page.slice(0, FEED_PAGE);
-  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
-  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
-  // Only stamp "seen" on the first page load (not on Load-more appends).
-  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  // FEP-633c: pending guardianship offers TO this account (I am the ward)
-  // show as a special message with an accept button (Robins besluit: the kid
-  // answers in its own Klonkt; safety is out-of-band by the guardians).
-  const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
-  const guardianOffers = (site
-    ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
-    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
-  renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages reply-editor', items, seenAt,
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// The kid answers a guardianship offer from Berichten: the same C2S
-// Accept/Reject pipeline the Shaer apps use (one path, one behavior).
-router.post('/messages/guardianship', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
-  const offer = String(req.body.offer || '').trim();
-  if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
-  try {
-    // Same C2S Accept/Reject the apps use; the handshake module records the
-    // ward's accept and (once the candidate returns the handle) commits.
-    const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
-    if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
-  } catch { /* fall through */ }
-  res.redirect(back + '?error=guardianship');
-});
-// A ward answers a guardian's wave without publishing: a canned private note
-// back to the sender (FEP-633c §5, shaer:wave reply). Same direct-note leg.
-router.post('/messages/quick-reply', requireSiteManager, express.urlencoded({ extended: false }), async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  const to = String(req.body.to || '').trim();
-  const text = String(req.body.text || '').trim().slice(0, 200);
-  // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
-  // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
-  // zonder JS blijft het formulier gewoon posten en omleiden.
-  const viaFetch = req.get('X-Requested-With') === 'fetch';
-  const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
-  if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
-  try {
-    const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
-    if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
-  } catch { /* fall through */ }
-  return mis('quickreply');
-});
-
-// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
-// bepaalt de draad zelf (zie groupConversations → replyTo):
-//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
-//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
-//   - hangt hij aan een persoon, dan is het een direct bericht terug.
-// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
-// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
-// als enige stuurt).
-router.post('/messages/reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const back = `${res.locals.siteUrlBase || ''}/messages`;
-  if (!site) return res.status(404).send('Site required');
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  const language = String(req.body.language || '');
-  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
-  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
-
-  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
-  const postSlug = String(req.body.post_slug || '');
-  const toActor = String(req.body.to || '');
-  try {
-    if (interactionId && postSlug) {
-      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
-      const parent = ActivityPubService.getInteractionById(interactionId);
-      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
-      // formulier een antwoord onder andermans draad kunnen hangen.
-      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
-      });
-    } else if (/^https?:\/\//i.test(toActor)) {
-      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
-      if (!r) return res.redirect(back + '?error=reply_failed');
-    } else {
-      return res.redirect(back + '?error=reply_target');
-    }
-  } catch (e) {
-    console.warn('[AP] reply from Berichten failed:', e.message);
-    return res.redirect(back + '?error=reply_failed');
-  }
-  res.redirect(back + '?success=reply_sent');
-});
-
-router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
-    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
-// object URI so re-delivery and thread-crawling never bring it back. Works for private
-// notes too (acts on the local copy; no remote fetch involved).
-router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
-    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
-// locally stored object/actor URIs, so it also works for private notes that
-// authorize_interaction cannot fetch (401/404).
-router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
-    if (tgt && (tgt.objectUri || tgt.actorUri)) {
-      try {
-        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
-        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
-      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
-    }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
-router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
-  if (site && (text.trim() || html.trim())) {
-    try {
-      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
-        html, language: String(req.body.language || ''),
-      });
-    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// ==================== FEDIVERSE CLIENT: home timeline + following ====================
-// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
-// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
-function timelineEmbedHtml(html) {
-  if (!html) return null;
-  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
-  while ((m = re.exec(html))) {
-    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
-    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
-    if (!p) {
-      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
-      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
-      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
-      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-      if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-      continue;
-    }
-    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
-    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
-  }
-  return null;
-}
-
-// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
-// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
-// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
-function klonktAudioEmbed(html, url) {
-  if (!html || !url || html.indexOf('🎵') < 0) return null;
-  let u; try { u = new URL(url); } catch { return null; }
-  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
-  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
-  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
-  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
-  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
-  return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
-}
-
-/**
- * FEP-633c §5.3-style gated feature: may this account see previews of links
- * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
- *
- * Applied at SERVE time on every surface, the way the app's inbox read already
- * does it (routes/activitypub.js): a card the client merely hides has still
- * been delivered.
- */
-function gateEmbeds(site, rows) {
-  if (!site || !rows.length) return rows;
-  if (embedsAllowedFor(site)) return rows;
-  return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
-}
-
-function isWardSite(site) {
-  try { return !!site && Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
-}
-function embedsAllowedFor(site) {
-  return !site || Guardianship.externalEmbedsAllowed(site.external_embeds, isWardSite(site));
-}
-/**
- * May a third-party PLAYER run inside this page? (FEP-633c 5.6, the heavier
- * sibling of the preview gate.) This was the hole: the player iframe is built
- * from the note's content by timelineEmbedHtml, on a path that never touched
- * gateEmbeds. A ward whose guardians had allowed nothing still got the full
- * YouTube player on the web, while the app showed nothing at all: the heavy
- * thing open, the light thing shut. Playback also requires the preview gate,
- * because you cannot play what you may not see.
- */
-function playbackAllowedFor(site) {
-  if (!site) return true;
-  if (!embedsAllowedFor(site)) return false;
-  return Guardianship.externalPlaybackAllowed(site.external_playback, isWardSite(site));
-}
-
-router.get('/news', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const cspOrigins = new Set();
-  // Fetch one extra to know whether a "Load more" button belongs on this page.
-  const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
-  const hasMore = rows.length > FEED_PAGE;
-  // Players (a third party's engine inside our page) ride the playback gate;
-  // a Klonkt site's own audio embed is ours and stays.
-  const mayPlay = playbackAllowedFor(site);
-  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
-    let embedHtml = mayPlay ? timelineEmbedHtml(p.content) : null;
-    let content = p.content;
-    let embedUrl = null;
-    if (!embedHtml) {
-      const k = klonktAudioEmbed(p.content, p.url);
-      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
-    }
-    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
-    // top-level "open the player" link that works even when a browser shield/CSP blocks
-    // the cross-site iframe (a full-page navigation is not a cross-site frame).
-    let poll = null;
-    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
-    return { ...p, content, embedHtml, embedUrl, poll };
-  });
-  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
-  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
-  if (cspOrigins.size) {
-    const csp = res.getHeader('Content-Security-Policy');
-    if (csp) {
-      const extra = [...cspOrigins].join(' ');
-      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
-    }
-  }
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  renderPage(req, res, 'pages/news', {
-    pageJs: 'news',
-    pageTitle: 'News', bodyClass: 'on-special',
-    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
-// Connect = who you follow + who follows you, merged into one page with direction
-// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
-// separate Following/Followers pages, which redirect here so old links keep working.
-router.get('/connect', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
-  // FEP-633c §2: the ward always sees who guards it, and §3.6 how available
-  // each of them is. Connect is where "who am I connected to" belongs; a
-  // guardian is the one connection a ward should never have to hunt for.
-  // Owner-only by construction: this page is the owner's.
-  const guardianHandle = (uri, cached) => {
-    if (cached && cached.charAt(0) === '@') return cached;
-    try { const u = new URL(uri); return `@${u.pathname.split('/').filter(Boolean).pop()}@${u.host}`; }
-    catch { return uri; }
-  };
-  const gStatus = site ? Object.fromEntries(
-    Guardianship.availability.statusesFor(site.slug, Guardianship.listGuardians(site.slug).map((g) => g.other_uri), Date.now())
-      .map((s) => [s.id, s]),
-  ) : {};
-  const myGuardians = (site ? Guardianship.listGuardians(site.slug) : [])
-    .map((g) => ({
-      uri: g.other_uri,
-      handle: guardianHandle(g.other_uri, g.other_handle),
-      availability: (gStatus[g.other_uri] || {})['shaer:availability'] || 'active',
-      awayUntil: (gStatus[g.other_uri] || {})['shaer:awayUntil'] || null,
-    }));
-  // De eigenaarspoort: openstaande volgverzoeken, alleen buiten voogdij.
-  // Een ward-follow beslissen de guardians — die tonen we hier dus NIET,
-  // anders is deze pagina een deur naast hun poort.
-  const followRequests = (site && !myGuardians.length)
-    ? Guardianship.follows.listForWard(site.slug) : [];
-  renderPage(req, res, 'pages/connect', {
-    pageTitle: 'Connect', bodyClass: 'on-special',
-    connections, myGuardians, followRequests,
-    approveFollowers: !!(site && site.approve_followers),
-    // Na een verhuizing staat de uitgaande kant op slot. Dat hoort te blijken
-    // VOORDAT je op een knop drukt, niet daarna uit een foutmelding.
-    movedTo: ActivityPubService.movedLock(site).movedTo,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-
-router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (!site) return res.redirect(`${base}/connect`);
-  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
-  return res.redirect(`${base}/connect?` + (ok
-    ? 'success=' + encodeURIComponent('Volger verwijderd')
-    : 'error=' + encodeURIComponent('Volger niet gevonden')));
-});
-
-// De poort zelf aan- of uitzetten, op de plek waar de verzoeken toch al
-// staan (Robins wens, 18-8: "op de connect is logischer").
-router.post('/connect/approve-followers', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (site) {
-    db.prepare('UPDATE sites SET approve_followers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-      .run(req.body.on ? 1 : 0, site.id);
-  }
-  return res.redirect(`${base}/connect`);
-});
-
-// De eigenaarspoort beslist (Robins wens, 18-8): accepteer of weiger een
-// volgverzoek dat door approve_followers is vastgehouden. Bewust NIET voor
-// wards — daar beslissen de guardians, en deze route weigert dan hard, zodat
-// hij geen sluiproute naast die poort wordt.
-router.post('/follow-requests/:decision', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  const { decision } = req.params;
-  if (!site || !['approve', 'deny'].includes(decision)) return res.redirect(`${base}/connect`);
-  if (Guardianship.listGuardians(site.slug).length) {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Volgverzoeken lopen via je guardians'));
-  }
-  const pending = Guardianship.follows.getPending(String(req.body.id || ''));
-  if (!pending || pending.ward_slug !== site.slug || pending.status !== 'pending') {
-    return res.redirect(`${base}/connect?error=` + encodeURIComponent('Verzoek niet gevonden'));
-  }
-  if (decision === 'approve') await ActivityPubService.acceptGatedFollow(pending);
-  else await ActivityPubService.rejectGatedFollow(pending);
-  Guardianship.follows.remove(pending.id);
-  return res.redirect(`${base}/connect?success=` + encodeURIComponent(
-    decision === 'approve' ? 'Volger geaccepteerd' : 'Verzoek geweigerd'));
-});
-
-router.post('/news/follow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const handle = (req.body.handle || '').toString();
-  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
-  if (site && handle.trim()) {
-    try {
-      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
-      // 'moved' is geen mislukking maar een weigering met een reden, en die reden
-      // hoort de gebruiker te lezen. "Volgen mislukt" laat hem zoeken naar een
-      // storing die er niet is.
-      if (r && r.error === 'moved') q = 'error=' + encodeURIComponent(`Dit account is verhuisd naar ${r.movedTo}. Volgen doe je daarvandaan.`);
-      else if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
-      // Een DERDE uitkomst, niet gelukt en niet mislukt (shaer-p729). "Je volgt
-      // nu X" zeggen terwijl het verzoek bij de guardians ligt is de leugen die
-      // deze poort waardeloos maakt: het kind denkt dat het gebeurd is.
-      else if (r && r.held) q = 'success=' + encodeURIComponent(r.status === 'denied' ? 'Je guardians hebben dit geweigerd' : 'Je verzoek ligt bij je guardians');
-      else {
-        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
-      }
-    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
-  }
-  res.redirect('/following?' + q);
-});
-
-// ── Je volglijst meenemen ─────────────────────────────────────────
-//
-// Zonder dit was verhuizen halfslachtig: de Move vertelt je VOLGERS waar je heen
-// ging, maar niets vertelde JOU wie jij volgde. Die lijst stond alleen in de
-// database die je achterlaat.
-router.get('/news/following.csv', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const { followingCsv } = await import('../services/ArchiveExportService.js');
-  const csv = site ? followingCsv(site.slug) : null;
-  if (!csv) return res.redirect('/connect?error=' + encodeURIComponent('Je volgt nog niemand'));
-  res.set('Content-Type', 'text/csv; charset=utf-8');
-  res.set('Content-Disposition', `attachment; filename="following-${site.slug}.csv"`);
-  // Privé: dit is de lijst van wie jij volgt, niets voor een cache onderweg.
-  res.set('Cache-Control', 'private, no-store');
-  res.send(csv);
-});
-
-// Een bestand OF geplakte tekst. Multer leest een multipart-formulier, en dat
-// bevat allebei: het bestandsveld en het tekstveld. In het geheugen, niet op
-// schijf: dit is een lijstje adressen van een paar kilobyte dat na het lezen
-// niets meer te zoeken heeft op de server.
-const followingCsvUpload = multer({
-  storage: multer.memoryStorage(),
-  limits: { fileSize: 512 * 1024, files: 1 },
-}).single('csvfile');
-
-router.post('/news/following/import', requireSiteManager, followingCsvUpload, async (req, res) => {
-  const site = res.locals.site;
-  // Een geupload bestand wint van het plakveld: wie een bestand kiest bedoelt dat.
-  const csv = (req.file && req.file.buffer)
-    ? req.file.buffer.toString('utf8').replace(/^﻿/, '')   // BOM eraf; Excel zet die erin
-    : ((req.body && req.body.csv) || '');
-  // Terug naar waar je vandaan kwam. Sinds 14-8 staat dit formulier op
-  // /admin/migrate (Robin: alle migratie-opties bij elkaar); terugspringen naar
-  // Connect is dan desorienterend. Alleen een eigen pad, geen open redirect.
-  const terug = /^\/[A-Za-z0-9/_-]*$/.test(String(req.body.next || '')) ? String(req.body.next) : '/connect';
-  if (!site || !String(csv).trim()) return res.redirect(terug + '?error=' + encodeURIComponent('Geen lijst ontvangen'));
-
-  const { importFollowing } = await import('../services/ArchiveImportService.js');
-  // followActor als followFn: die doet de webfinger, stuurt de Follow en zet
-  // auto_boost meteen goed. Zo blijft er één pad naar een volgrelatie.
-  const r = await importFollowing(site, csv, {
-    followFn: async (s, adres, uitgelicht) => {
-      const uit = await ActivityPubService.followActor(s, adres, !!uitgelicht);
-      // followActor meldt een fout als VELD, niet als exception. Zonder deze
-      // vertaling telde een onvindbaar account gewoon als geslaagd mee.
-      if (uit && uit.error) throw new Error(uit.error);
-      return true;
-    },
-  });
-
-  const delen = [`${r.gevolgd} gevolgd`];
-  if (r.overgeslagen) delen.push(`${r.overgeslagen} overgeslagen`);
-  if (r.mislukt.length) {
-    const namen = r.mislukt.slice(0, 3).map((m) => m.adres).join(', ');
-    delen.push(`${r.mislukt.length} mislukt (${namen}${r.mislukt.length > 3 ? '…' : ''})`);
-  }
-  // Terug naar /connect: daar staat het blok, /following is de oude pagina.
-  res.redirect(terug + '?' + (r.mislukt.length ? 'error=' : 'success=') + encodeURIComponent(delen.join(', ')));
-});
-
-router.post('/news/unfollow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
-  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
-});
-
-// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
-router.post('/news/autoboost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
-  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
-});
-
-// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
-// no banner); no-JS → redirect back.
-router.post('/news/like', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).liked;
-    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'like', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
-router.post('/news/boost', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getReaction(site.slug, note).boosted;
-    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
-    if (on) {
-      // Fire-and-forget: re-resolve the note so the cached row is refreshed
-      // (cover/content) — boosting again heals a stale copy from EVERY boost
-      // path, not just the interact page.
-      ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
-        .catch(() => { /* best-effort */ });
-    }
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
-router.post('/news/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
-  res.redirect('/news');
-});
-
-// Notifications inbox (new followers + replies/likes/boosts on your posts).
-router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-// Blocking / defederation (owner-only).
-router.get('/blocking', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
-  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
-});
-
-router.post('/blocking/add', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  let q = 'success=' + encodeURIComponent('Geblokkeerd');
-  if (site) {
-    try {
-      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
-      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
-      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
-    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
-  }
-  const ref = req.get('Referer') || '';
-  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
-});
-
-router.post('/blocking/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()).catch(() => {}); } catch (e) { /* ignore */ } }
-  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
-});
-
-// ==================== VIEW POST (last route â€” catches /:slug) ====================
-router.get('/:slug', (req, res, next) => {
-  if (RESERVED_SLUGS.has(req.params.slug)) return next();
-
-  const site = res.locals.site;
-  if (!site) return next(); // -> nette 404 catch-all
-
-  const post = db.prepare(`
-    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
-    FROM posts p JOIN users u ON p.author_id = u.id
-    WHERE p.site_id = ? AND p.slug = ?
-  `).get(site.id, req.params.slug);
-
-  if (!post) return next(); // unknown slug -> clean 404 catch-all
-
-  // Permission to view: published OR (logged in + can edit)
-  if (post.status !== 'published') {
-    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-    if (!canEdit) return res.status(403).send('Not published');
-  }
-
-  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
-  // is not the owner/editor. Checked BEFORE the fan gate: a post that is both
-  // fan_only and paid unlocks with a passkey, not with a Klonkt-login, so the
-  // paid gate wins (otherwise anonymous visitors land on the login gate and
-  // never see the unlock button).
-  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-  // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
-  // supporter render the FULL post through this normal template (correct layout,
-  // scoped styles, working audio). Short-lived signed blob, single post, not a
-  // cookie and not stored.
-  const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
-  const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
-  if (post.paid && !canEditThis && !_unlocked) {
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    const pgAudio = paidOpenAudioHtml(site, post, req);
-    return renderPage(req, res, 'pages/paid-gate', {
-    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
-      pageTitle: post.title || 'Voor supporters',
-      bodyClass: 'on-special',
-      pgTitle: post.title || '',
-      pgTeaser: paidTeaser(post),
-      pgAudio,
-      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
-      pgSlug: post.slug,
-      pgPatronUrl: paidPatronUrl(site.id),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Fan-only preview (premium #3): full content only for logged-in fans.
-  // Anonymous visitors get a clean login gate instead of the content (the title/
-  // teaser may still appear elsewhere as a teaser).
-  // Een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn EN deze site
-  // volgt, is precies wie fan_only bedoelde. Die hoeft geen poort te zien.
-  const _fediVolger = OWA.isFollowerOf(site.slug, OWA.guestActor(req));
-  if (post.fan_only && !(req.session && req.session.user) && !_fediVolger) {
-    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
-    // stuck on the fan gate but can keep browsing.
-    const { newerPost, olderPost } = postNeighbors(site, post);
-    return renderPage(req, res, 'pages/fan-gate', {
-      pageTitle: post.title || 'Alleen voor fans',
-      bodyClass: 'on-special',
-      fgTitle: post.title || '',
-      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
-      owaError: !!(req.query && req.query.owa_error),
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Statistics: count the view (skips admins + unpublished own-preview).
-  if (post.status === 'published') recordPostView(post, req);
-
-  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
-  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
-  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
-  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
-  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
-  post.content_html = renderPostBodyHtml(site, post, req);
+  post.content_html = html;
 
   if (post.tags) {
@@ -1653,13 +476,39 @@
   }
 
-  // Native comments removed: social interaction is fediverse-only (see the
-  // "From the fediverse" section below).
+  // Comments: top-level + replies. Two-pass build: fetch all approved
+  // comments for the post, then group replies under their parent.
+  const commentRows = db.prepare(`
+    SELECT c.id, c.parent_comment_id, c.content, c.status, c.created_at,
+           c.author_id, u.username AS author_username, u.avatar_url AS author_avatar
+    FROM comments c JOIN users u ON u.id = c.author_id
+    WHERE c.post_id = ? AND c.status = 'approved'
+    ORDER BY c.created_at ASC
+  `).all(post.id);
+  const topLevel = [];
+  const repliesById = new Map();
+  for (const c of commentRows) {
+    if (c.parent_comment_id) {
+      if (!repliesById.has(c.parent_comment_id)) repliesById.set(c.parent_comment_id, []);
+      repliesById.get(c.parent_comment_id).push(c);
+    } else {
+      topLevel.push(c);
+    }
+  }
+  for (const c of topLevel) c.replies = repliesById.get(c.id) || [];
+  const totalComments = commentRows.length;
 
   // Prev / next chronological (kept for back-compat — "post-nav" feature
   // below the article still uses these as a simple linear navigation).
-  const urlBaseFor = () => '';
-
-  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
-  const { newerPost, olderPost } = postNeighbors(site, post);
+  const prevPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at < ? AND id != ?
+    ORDER BY published_at DESC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
+
+  const nextPost = db.prepare(`
+    SELECT slug, title FROM posts
+    WHERE site_id = ? AND status = 'published' AND published_at > ? AND id != ?
+    ORDER BY published_at ASC LIMIT 1
+  `).get(site.id, post.published_at, post.id);
 
   // ── Related posts: same-tag matching with recency fallback ─────
@@ -1667,8 +516,9 @@
   // Excluding self via `id != ?`.
   const candidates = db.prepare(`
-    SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning
+    SELECT id, slug, title, cover_image_url, published_at, tags
     FROM posts
     WHERE site_id = ? AND status = 'published' AND id != ?
-    ORDER BY published_at DESC LIMIT 50
+    ORDER BY published_at DESC
+    LIMIT 50
   `).all(site.id, post.id);
 
@@ -1707,32 +557,50 @@
   }
   // Strip the internal _overlap field before sending to view
-  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
-
-  // Inbound fediverse activity (threaded) for this post.
-  let fediverse = { thread: [], likeCount: 0, announceCount: 0, total: 0 };
-  try {
-    const _apBase = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-    fediverse = ActivityPubService.getInteractions(post.id, _apBase, site);
-    // Stale-while-revalidate: render from cache now; refresh the remote thread in the
-    // background (TTL-gated, non-blocking) so undelivered replies-to-replies fill in next view.
-    if (res.locals.apEnabled !== false) ActivityPubService.maybeCrawlThread(post.id);
-  } catch { /* non-fatal */ }
-  // Owner/admin of this site may reply back to a fediverse interaction.
-  const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
-  // Avatar for our own (outbound) fediverse replies = the site's profile photo.
-  const siteAvatar = (site && site.profile_photo) ? site.profile_photo : null;
+  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => rest);
+
+  // ── Pinned navigation: prev/next pinned post ───────────────────
+  // Only meaningful if the current post is pinned. We order by
+  // published_at DESC (newest pinned first) — same as the homepage feed.
+  // Pinned navigation: prev/next pinned post by RANK (not by date).
+  // - prev (← back to) = post with smaller rank, i.e. higher in stack
+  // - next (→ forward) = post with larger rank, i.e. lower in stack
+  // BOVENAAN appears when current is rank 1 (no rank 0 above);
+  // ONDERAAN appears when current is the highest rank (no further down).
+  let prevPinnedPost = null;
+  let nextPinnedPost = null;
+  let pinnedTopOfStack = false;
+  let pinnedBottomOfStack = false;
+  if (post.pinned > 0) {
+    // The rank one step UP the stack (towards #1)
+    prevPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned < ? AND id != ?
+      ORDER BY pinned DESC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    // The rank one step DOWN the stack (away from #1)
+    nextPinnedPost = db.prepare(`
+      SELECT slug, title FROM posts
+      WHERE site_id = ? AND status = 'published' AND pinned > 0
+        AND pinned > ? AND id != ?
+      ORDER BY pinned ASC LIMIT 1
+    `).get(site.id, post.pinned, post.id) || null;
+
+    pinnedTopOfStack    = !prevPinnedPost;  // already rank #1 (or nothing higher)
+    pinnedBottomOfStack = !nextPinnedPost;  // nothing further down the stack
+  }
 
   renderPage(req, res, 'pages/post', {
-    pageJs: 'post reply-editor tape',
     post,
-    poll: ActivityPubService.ownPollView(post),
-    newerPost,
-    olderPost,
+    prevPost,
+    nextPost,
     relatedPosts,
-    fediverse,
-    canManageSite,
-    siteAvatar,
-    postHasPlayableAudio: ActivityPubService.hasPlayableAudio(post.content || '', site.id),
-    musicLd: MusicMeta.build((process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''), site, post),
+    prevPinnedPost,
+    nextPinnedPost,
+    pinnedTopOfStack,
+    pinnedBottomOfStack,
+    comments: topLevel,
+    totalComments,
     pageTitle: post.title + ' - ' + site.title,
     socialDescr: post.excerpt || '',
@@ -1742,57 +610,3 @@
 });
 
-// ── Reply back to a fediverse interaction (site owner/admin only) ──
-router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (parent && parent.post_id === post.id && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    try {
-      await ActivityPubService.deliverReply(site, {
-        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions,
-        language: (req.body.language || '').toString(),
-      });
-    } catch (e) { console.warn('[AP] reply send failed:', e.message); }
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
-// Owner likes/boosts a fediverse comment on their own post — directly as the
-// site, no "your server" detour (mirrors /fedi-reply).
-router.post('/posts/:slug/fedi-react', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
-  if (!post) return res.status(404).send('Not found');
-  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
-  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
-  if (parent && parent.post_id === post.id && parent.object_uri) {
-    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
-    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
-    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
-    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
-    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
-    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
-    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
-      .catch((e) => console.warn('[AP] reaction failed:', e.message));
-    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
-    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
-    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
-    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
-    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
-    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
-    else ActivityPubService.setInteractionLiked(parent.id, on);
-  }
-  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
-});
-
 export default router;
-export { postNeighbors };
Index: src/routes/prutter.js
===================================================================
--- src/routes/prutter.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/prutter.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,137 @@
+/**
+ * Prutter — Direct Messaging routes.
+ *
+ * GET  /prutter                   -> inbox (list of your conversations on THIS site)
+ * GET  /prutter/new?to=<username> -> start (or resume) a conversation, redirects to /prutter/:id
+ * GET  /prutter/:id               -> conversation view (messages + send form)
+ * POST /prutter/:id/send          -> send a message (HTMX-friendly response)
+ *
+ * Scoping: conversations are per-site (PrutterService.getOrCreateConversation
+ * uses res.locals.site.id). Robin's quote: "Prutter = DM (per .com domain)".
+ *
+ * Per-site toggle: site.enable_prutter == 0 -> 404 the whole feature.
+ *
+ * No anonymous DMs — always requires login.
+ */
+
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { requireAuth } from '../middleware/auth.js';
+import PermissionsService from '../services/PermissionsService.js';
+
+const router = express.Router();
+
+const MAX_MESSAGE_LEN = 2000;
+
+function siteAllowsDM(req, res) {
+  const site = res.locals.site;
+  if (!site) return false;
+  return site.enable_prutter !== 0;
+}
+
+// Middleware: require both auth + Prutter enabled
+function requirePrutter(req, res, next) {
+  if (!siteAllowsDM(req, res)) return res.status(404).send('Prutter not enabled on this site');
+  return requireAuth(req, res, next);
+}
+
+// ==================== INBOX ====================
+router.get('/', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conversations = prutter.getUserConversations(req.session.user.id);
+
+  // Filter to only conversations on THIS site (Prutter scope is per-site)
+  const siteId = res.locals.site.id;
+  const scoped = conversations.filter(c => c.site_id === siteId);
+
+  renderPage(req, res, 'pages/prutter-inbox', {
+    pageTitle: 'Prutter',
+    bodyClass: 'on-special',
+    conversations: scoped,
+  });
+});
+
+// ==================== START / RESUME CONVERSATION ====================
+router.get('/new', requirePrutter, (req, res) => {
+  const targetUsername = (req.query.to || '').toString().trim();
+  if (!targetUsername) {
+    return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
+  }
+  const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
+  if (!target) return res.status(404).send('User not found');
+  if (target.id === req.session.user.id) {
+    return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
+  }
+
+  const prutter = req.app.locals.prutter;
+  const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
+  res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
+});
+
+// ==================== CONVERSATION VIEW ====================
+router.get('/:id', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
+  if (!conv) return res.status(404).send('Conversation not found');
+
+  // Auth: must be a participant
+  const me = req.session.user.id;
+  if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
+
+  // Scope: this conversation must belong to the resolved site
+  if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
+
+  // Other party
+  const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
+  const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
+
+  // Messages (oldest first for natural reading order)
+  const messages = prutter.getMessages(conv.id, 200, 0).reverse();
+
+  // Mark inbound messages as read
+  prutter.markAsRead(conv.id, me);
+
+  renderPage(req, res, 'pages/prutter-conversation', {
+    pageTitle: 'Prutter — ' + (other?.username || ''),
+    bodyClass: 'on-special',
+    conversation: conv,
+    other,
+    messages,
+  });
+});
+
+// ==================== SEND MESSAGE ====================
+router.post('/:id/send', requirePrutter, (req, res) => {
+  const prutter = req.app.locals.prutter;
+  const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
+  if (!conv) return res.status(404).send('Not found');
+
+  const me = req.session.user.id;
+  if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
+  if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
+
+  const content = (req.body.content || '').toString().trim();
+  if (!content) return res.status(400).send('Empty');
+  if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
+
+  const message = prutter.sendMessage(conv.id, me, content);
+
+  // HTMX request → return the single rendered message HTML, appended to the thread
+  if (req.headers['hx-request']) {
+    return res.send(
+      `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
+      `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
+      `</li>`
+    );
+  }
+
+  res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
+});
+
+function escapeHtml(s) {
+  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+export default router;
Index: src/routes/push.js
===================================================================
--- src/routes/push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,66 +1,0 @@
-/**
- * Web Push (docs/webpush-design.md) slice 2: enable/disable + test.
- * The public VAPID key is public by design (it only identifies this server to
- * the browser's push service); everything that touches a subscription is a
- * logged-in action. Web Push delivery itself is cookie-less.
- */
-import express from 'express';
-import db from '../config/database.js';
-import { requireAuth } from '../middleware/auth.js';
-import Push from '../services/PushService.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
-
-const router = express.Router();
-
-// A subscription row is personal: only its creator may touch it.
-function ownRow(endpoint, userId) {
-  if (!endpoint) return null;
-  const row = db.prepare('SELECT endpoint, user_id FROM push_subscriptions WHERE endpoint = ?').get(String(endpoint));
-  return row && row.user_id === userId ? row : null;
-}
-
-router.get('/vapid', async (req, res) => {
-  const key = await Push.publicKey();
-  if (!key) return res.status(503).json({ error: 'push_unavailable' });
-  res.json({ publicKey: key });
-});
-
-router.post('/subscribe', requireAuth, express.json({ limit: '16kb' }), async (req, res) => {
-  if (!(await Push.pushReady())) return res.status(503).json({ error: 'push_unavailable' });
-  const s = req.body && req.body.subscription;
-  const keys = s && s.keys;
-  const ok = Push.saveSubscription({
-    endpoint: s && s.endpoint, userId: req.session.user.id,
-    p256dh: keys && keys.p256dh, auth: keys && keys.auth,
-    alertTypes: req.body.alerts || null,
-    uaLabel: String(req.body.uaLabel || '').slice(0, 120) || null,
-  });
-  if (!ok) return res.status(400).json({ error: 'bad_subscription' });
-  res.json({ ok: true });
-});
-
-router.post('/unsubscribe', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
-  if (!row) return res.status(404).json({ error: 'not_found' });
-  Push.deleteSubscription(row.endpoint);
-  res.json({ ok: true });
-});
-
-router.post('/alerts', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  const row = ownRow(req.body && req.body.endpoint, req.session.user.id);
-  if (!row) return res.status(404).json({ error: 'not_found' });
-  Push.updateAlerts(row.endpoint, req.session.user.id, req.body.alerts || {});
-  res.json({ ok: true });
-});
-
-// A test ping to all of the caller's own devices (bypasses alert prefs).
-router.post('/test', requireAuth, async (req, res) => {
-  const L = resolveLang(req);
-  const sent = await Push.notifyUser(req.session.user.id, {
-    type: 'test', title: i18nT(L, 'push.n_test_t'),
-    body: i18nT(L, 'push.n_test_b'), url: '/admin/push',
-  });
-  res.json({ ok: true, sent });
-});
-
-export default router;
Index: src/routes/search.js
===================================================================
--- src/routes/search.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/search.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,15 +1,14 @@
 /**
- * GET /search?q=...          -> full results page
- * GET /search/suggest?q=...  -> compact JSON for live results in the overlay
+ * GET /search?q=...
  *
- * Searches the current site across:
- *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
- *   2. Tracks (audio_tracks) on title / artist / album.
- *   3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
- *   4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
- *      the available ones.
+ * Queries the posts_fts virtual table (FTS5) for the current site.
+ * Search is restricted to published posts of the resolved site.
  *
- * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
- * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
+ * FTS5 quirks handled:
+ *   - Empty / whitespace-only query: render the form with no results.
+ *   - User input is wrapped in double quotes so FTS5 treats it as a phrase
+ *     (avoids syntax errors from special chars like "OR", parentheses, etc.).
+ *   - Snippet() builds the highlighted excerpt; we keep markup minimal so
+ *     the EJS view can wrap the matches in <mark>.
  */
 
@@ -17,165 +16,68 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { getSetting } from '../services/SettingsService.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
 
 const router = express.Router();
 
-function buildFtsQuery(q) {
-  const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
-  if (!terms.length) return null;
-  return terms.map((t) => '"' + t + '"*').join(' ');
+// Wrap user input as a single FTS5 phrase. Strip embedded double-quotes so
+// the wrapping stays balanced. FTS5 phrase queries are forgiving and avoid
+// the operator-soup pitfalls of bare user input.
+function asPhrase(q) {
+  return '"' + q.replace(/"/g, '') + '"';
 }
 
-function likeArg(q) {
-  return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
-}
-
-function cleanSnippet(html, excerpt) {
-  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-  const s = (html || '')
-    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
-    .replace(/\[\[[^\]]*?\]\]/g, ' ')
-    .replace(/\[\[|\]\]/g, ' ')
-    .replace(/[#>*_`~]+/g, ' ')
-    .replace(/\s+/g, ' ')
-    .trim();
-  if (!s || /^[…\s]*$/.test(s)) return esc((excerpt || '').slice(0, 160));
-  return s;
-}
-
-// ── Core: search all sources for one site. `lim` caps results per group
-//    (small for live suggestions, large for the full page). ──────────────────
-function searchSite(req, res, rawQ, lim) {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  const urlFor = (slug) => `/${slug}`;
-  // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat).
-  const lang = resolveLang(req);
-  const t = (k) => i18nT(lang, k);
-
-  const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
-  if (!site || !rawQ) return out;
-  const like = likeArg(rawQ);
-
-  // 1. Posts (FTS5)
-  const ftsQuery = buildFtsQuery(rawQ);
-  if (ftsQuery) {
-    try {
-      out.results = db.prepare(`
-        SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
-               snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
-        FROM posts_fts
-        JOIN posts p ON p.id = posts_fts.post_id
-        JOIN users u ON u.id = p.author_id
-        WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
-        ORDER BY score ASC LIMIT ?
-      `).all(ftsQuery, site.id, lim.posts);
-      out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
-    } catch (err) { out.queryError = err.message; }
-  }
-
-  // 2. Tracks
-  try {
-    const trackRows = db.prepare(`
-      SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
-      FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
-      WHERE t.site_id = @site
-        AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
-      ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
-    `).all({ site: site.id, like, lim: lim.tracks });
-    const playable = trackRows.filter((t) => t.filename);
-    let posts = [];
-    if (playable.length) {
-      posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
-    }
-    const postUrlForTrack = (tr) => {
-      let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
-      if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
-      if (!hit) {
-        const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
-        if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
-      }
-      return hit ? urlFor(hit.slug) : null;
-    };
-    out.tracks = playable.map((tr) => ({
-      id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
-      cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
-    }));
-  } catch (err) { if (!out.queryError) out.queryError = err.message; }
-
-  // 3. Events (agenda) — only when the agenda is publicly enabled.
-  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
-    try {
-      out.events = db.prepare(`
-        SELECT date, time, city, venue, country FROM shows
-        WHERE site_id = @site
-          AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
-             OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
-        ORDER BY date ASC LIMIT @lim
-      `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
-        date: e.date, time: e.time || '',
-        where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
-        url: base + '/shows',
-      }));
-    } catch (err) { if (!out.queryError) out.queryError = err.message; }
-  }
-
-  // 4. Pages — curated, available ones only; matched against the (translated) name.
-  const ql = rawQ.toLowerCase();
-  const candidates = [
-    { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
-    { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
-    { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
-    { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
-    { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
-  ];
-  out.pages = candidates
-    .filter((c) => c.on)
-    .map((c) => ({ label: t(c.key), url: c.url }))
-    .filter((c) => c.label.toLowerCase().includes(ql))
-    .slice(0, lim.pages);
-
-  return out;
-}
-
-// ── Full results page ────────────────────────────────────────────────────────
 router.get('/', (req, res) => {
   const site = res.locals.site;
-  if (!site) return res.status(404).send('No site');
   const rawQ = (req.query.q || '').toString().trim();
 
+  if (!site) return res.status(404).send('No site');
+
+  // Empty query — render the page with the form and no results.
   if (!rawQ) {
     return renderPage(req, res, 'pages/search', {
-      pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
-      results: [], tracks: [], events: [], pages: [], total: 0,
+      pageTitle: 'Search',
+      bodyClass: 'on-special',
+      query: '',
+      results: [],
+      total: 0,
     });
   }
 
-  const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
-  const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
+  let results = [];
+  let total = 0;
+  let queryError = null;
+
+  try {
+    // FTS5 join → posts table, filter by site + published
+    results = db.prepare(`
+      SELECT
+        p.slug,
+        p.title,
+        p.excerpt,
+        p.published_at,
+        u.username AS author_username,
+        snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
+        bm25(posts_fts) AS score
+      FROM posts_fts
+      JOIN posts p ON p.id = posts_fts.post_id
+      JOIN users u ON u.id = p.author_id
+      WHERE posts_fts MATCH ?
+        AND p.site_id = ?
+        AND p.status = 'published'
+      ORDER BY score ASC
+      LIMIT 50
+    `).all(asPhrase(rawQ), site.id);
+
+    total = results.length;
+  } catch (err) {
+    queryError = err.message;
+  }
+
   renderPage(req, res, 'pages/search', {
-    pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
-    results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
-    total, queryError: r.queryError,
-  });
-});
-
-// ── Live suggestions (JSON) ──────────────────────────────────────────────────
-router.get('/suggest', (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
-  const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
-  if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
-
-  const urlFor = (slug) => `/${slug}`;
-  const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
-  res.json({
-    posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
-    tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
-    events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
-    pages: r.pages,
+    pageTitle: `Search: ${rawQ}`,
+    bodyClass: 'on-special',
+    query: rawQ,
+    results,
+    total,
+    queryError,
   });
 });
Index: src/routes/shows.js
===================================================================
--- src/routes/shows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,77 +1,0 @@
-/**
- * Show agenda + notify-me (premium feature #8) — public side.
- *
- *   GET  /shows         -> upcoming gigs + "keep me posted" form
- *   POST /shows/notify  -> subscribe to show announcements (subscribers, source
- *                          'notify'; double opt-in if SMTP configured)
- *
- * The notify confirm/unsubscribe reuses the generic subscriber links
- * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows.
- */
-
-import express from 'express';
-import db from '../config/database.js';
-import { renderPage } from '../middleware/render.js';
-import { premiumUnlocked } from '../services/PatreonService.js';
-import { mailerConfigured, sendMail } from '../config/mailer.js';
-import { addSubscriber } from '../services/SubscriberService.js';
-import { getSetting } from '../services/SettingsService.js';
-
-const router = express.Router();
-
-// Agenda is opt-in: only accessible once the admin has enabled it.
-function agendaOn() { return getSetting('agenda_enabled') === '1'; }
-
-function esc(s) { return String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
-function fullUrl(req, p) {
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  return base + (req.res.locals.siteUrlBase || '') + p;
-}
-function upcoming(siteId) {
-  const today = new Date().toISOString().slice(0, 10);
-  return db.prepare('SELECT * FROM shows WHERE site_id = ? AND date >= ? ORDER BY date ASC, time ASC').all(siteId, today);
-}
-
-router.get('/shows', (req, res, next) => {
-  if (!premiumUnlocked() || !agendaOn()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  renderPage(req, res, 'pages/shows', {
-    pageTitle: 'Agenda — ' + (site.title || ''),
-    bodyClass: 'on-shows',
-    shows: upcoming(site.id),
-    notifyState: req.query.ok ? 'done' : (req.query.check ? 'check' : null),
-  });
-});
-
-router.post('/shows/notify', async (req, res, next) => {
-  if (!premiumUnlocked() || !agendaOn()) return next();
-  const site = res.locals.site;
-  if (!site) return next();
-  const email = (req.body.email || '').trim();
-  const doubleOptin = mailerConfigured();
-  const r = addSubscriber(site.id, email, 'notify', { doubleOptin });
-  if (!r.ok) {
-    return renderPage(req, res, 'pages/shows', {
-      pageTitle: 'Agenda', bodyClass: 'on-shows', shows: upcoming(site.id),
-      notifyState: 'error', notifyMsg: r.error === 'invalid_email' ? 'Controleer je e-mailadres.' : 'Er ging iets mis.',
-    });
-  }
-  if (r.status === 'pending') {
-    const link = fullUrl(req, '/nieuwsbrief/bevestigen/' + r.token);
-    const unsub = fullUrl(req, '/nieuwsbrief/uitschrijven/' + r.token);
-    try {
-      await sendMail({
-        to: email,
-        subject: 'Bevestig — show-updates van ' + (site.title || ''),
-        text: 'Bevestig dat je show-aankondigingen wilt ontvangen: ' + link + '\n\nUitschrijven: ' + unsub,
-        html: '<p>Bevestig dat je show-aankondigingen van <strong>' + esc(site.title) + '</strong> wilt ontvangen:</p>' +
-              '<p><a href="' + link + '">Bevestigen</a></p><p style="color:#888;font-size:12px"><a href="' + unsub + '">Uitschrijven</a></p>',
-      });
-    } catch { return res.redirect((res.locals.siteUrlBase || '') + '/shows'); }
-    return res.redirect((res.locals.siteUrlBase || '') + '/shows?check=1');
-  }
-  res.redirect((res.locals.siteUrlBase || '') + '/shows?ok=1');
-});
-
-export default router;
Index: src/routes/tags.js
===================================================================
--- src/routes/tags.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/tags.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -22,5 +22,5 @@
   try {
     posts = db.prepare(`
-      SELECT DISTINCT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+      SELECT DISTINCT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
                       p.published_at, u.username AS author_username
       FROM posts p, json_each(p.tags) j
@@ -35,5 +35,5 @@
     // Fall back to a LIKE match if json_each isn't available for some reason
     posts = db.prepare(`
-      SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+      SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
              p.published_at, u.username AS author_username
       FROM posts p JOIN users u ON u.id = p.author_id
Index: src/routes/types.js
===================================================================
--- src/routes/types.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/types.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -8,10 +8,8 @@
 import express from 'express';
 import db from '../config/database.js';
-import { POST_TYPES } from '../config/post-types.js';
 import { renderPage } from '../middleware/render.js';
 
 const router = express.Router();
-// Een lijst, drie gebruikers: zie config/post-types.js.
-const VALID_TYPES = POST_TYPES;
+const VALID_TYPES = new Set(['post', 'foto', 'video', 'audio']);
 
 router.get('/:type', (req, res) => {
@@ -23,5 +21,5 @@
 
   const posts = db.prepare(`
-    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url,
+    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url,
            p.published_at, p.type, u.username AS author_username
     FROM posts p JOIN users u ON u.id = p.author_id
Index: src/routes/users.js
===================================================================
--- src/routes/users.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/users.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -27,5 +27,5 @@
 
   const posts = db.prepare(`
-    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.cover_video_url, p.published_at
+    SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.published_at
     FROM posts p
     WHERE p.author_id = ? AND p.site_id = ? AND p.status = 'published'
Index: src/server.js
===================================================================
--- src/server.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/server.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,6 +1,6 @@
 /**
- * Klonkt Beta — server bootstrap
+ * PrutFolio v1 — server bootstrap
  *
- * Personal multi-site platform — Node + SQLite + htmx.
+ * Persoonlijk multi-site platform forked van PrutCMS v9 (PHP, file-based).
  * Stack: Express + better-sqlite3 + EJS + htmx + ws.
  */
@@ -13,16 +13,12 @@
 import path from 'path';
 import fs from 'fs';
-import crypto from 'crypto';
 import { fileURLToPath } from 'url';
 import http from 'http';
 import db, { initializeDatabase } from './config/database.js';
-import { startScheduler } from './services/Scheduler.js';
 import { SqliteSessionStore } from './services/SqliteSessionStore.js';
-import { ensurePrimarySite } from './services/ensurePrimarySite.js';
-import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js';
+import PrutterService from './services/PrutterService.js';
+import { WebSocketServer } from 'ws';
+
 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
-import { isViewer } from './middleware/auth.js';
-import { renderPage } from './middleware/render.js';
-import { audioEnabled } from './config/features.js';
 import authRoutes from './routes/auth.js';
 import accountRoutes from './routes/account.js';
@@ -30,11 +26,11 @@
 import adminAudioRoutes from './routes/admin-audio.js';
 import adminPlaylistsRoutes from './routes/admin-playlists.js';
-import adminListenersRoutes from './routes/admin-listeners.js';
 import adminSitesRoutes from './routes/admin-sites.js';
 import adminUsersRoutes from './routes/admin-users.js';
-import adminSettingsRoutes from './routes/admin-settings.js';
-import adminSeoRoutes from './routes/admin-seo.js';
+import adminCommentsRoutes from './routes/admin-comments.js';
+import prutterRoutes from './routes/prutter.js';
 import audioRoutes from './routes/audio.js';
 import searchRoutes from './routes/search.js';
+import commentsRoutes from './routes/comments.js';
 import tagsRoutes from './routes/tags.js';
 import typesRoutes from './routes/types.js';
@@ -42,133 +38,47 @@
 import feedRoutes from './routes/feed.js';
 import postsRoutes from './routes/posts.js';
-import paidRoutes from './routes/paid.js';
-import langRoutes from './routes/lang.js';
-import adminUpdatesRoutes from './routes/admin-updates.js';
-import adminPatreonRoutes from './routes/admin-patreon.js';
-import adminStatsRoutes from './routes/admin-stats.js';
-import adminPaidRoutes from './routes/admin-paid.js';
-import adminPushRoutes from './routes/admin-push.js';
-import pushRoutes from './routes/push.js';
-import guardianRoutes from './routes/guardian.js';
-import adminMediaRoutes from './routes/admin-media.js';
-import adminMigrateRoutes from './routes/admin-migrate.js';
-import circleRoutes from './routes/circle.js';
-import epkRoutes from './routes/epk.js';
-import newsletterRoutes from './routes/newsletter.js';
-import adminNewsletterRoutes from './routes/admin-newsletter.js';
-import downloadRoutes from './routes/download.js';
-import linkbioRoutes from './routes/linkbio.js';
-import embedRoutes from './routes/embed.js';
-import showsRoutes from './routes/shows.js';
-import adminShowsRoutes from './routes/admin-shows.js';
-import adminEpkRoutes from './routes/admin-epk.js';
-import changelogRoutes from './routes/changelog.js';
-import ogRoutes from './routes/og.js';
-import apRoutes from './routes/activitypub.js';
-import owaRoutes, { owaMiddleware } from './routes/openwebauth.js';
-import oauthRoutes from './routes/oauth.js';
-import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
-
-// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
-// and persist it next to the database, so it stays stable across restarts and
-// updates. This lets Docker / bare-Node installs run with zero manual config.
+
 if (!process.env.SESSION_SECRET) {
-  const dataDir = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
-  const secretFile = path.join(dataDir, '.session-secret');
-  try { process.env.SESSION_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); } catch { /* not yet generated */ }
-  if (!process.env.SESSION_SECRET) {
-    fs.mkdirSync(dataDir, { recursive: true });
-    process.env.SESSION_SECRET = crypto.randomBytes(32).toString('hex');
-    fs.writeFileSync(secretFile, process.env.SESSION_SECRET, { mode: 0o600 });
-    console.log(`🔑 Generated a SESSION_SECRET (stored in ${secretFile})`);
-  }
-}
-
-// A SESSION_SECRET that was explicitly set in the env must still be strong in prod.
-if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
-  console.error('❌ FATAL: SESSION_SECRET is too weak for production (set a longer, random one in .env)');
+  console.error('❌ FATAL: SESSION_SECRET is required');
   process.exit(1);
 }
 
+if (process.env.NODE_ENV === 'production' && process.env.SESSION_SECRET.length < 32) {
+  console.error('❌ FATAL: SESSION_SECRET too weak for production');
+  process.exit(1);
+}
+
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const PORT = process.env.PORT || 3000;
-// Interface to bind. Default 0.0.0.0 (needed for Docker port-forwarding). Behind a
-// reverse proxy on the same host, set HOST=127.0.0.1 so the app is NOT reachable
-// directly from the internet (only via the proxy) — see README/install docs.
-const HOST = process.env.HOST || '0.0.0.0';
 const isDev = process.env.NODE_ENV !== 'production';
 
 const app = express();
 const server = http.createServer(app);
-
-// Per-request CSP nonce for the strict script-src (nonce + strict-dynamic). Must be set
-// before helmet builds the CSP header below. The nonce is injected into every <script> tag
-// at render time (see middleware/render.js injectCspNonce).
-app.use((req, res, next) => { res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); next(); });
-
-// HSTS. The default ships a plain long max-age — safe on ANY domain. includeSubDomains +
-// preload are aggressive (they affect the operator's OTHER subdomains and can get their
-// domain baked into browsers near-permanently), so they're opt-in via HSTS_STRICT=1 — set
-// only on domains you fully own (e.g. the klonkt.com fleet). Self-hosters get the safe default.
-// NB: Helmet defaults includeSubDomains to true, so the safe default must disable it explicitly.
-const hstsOptions = { maxAge: 31536000, includeSubDomains: false, preload: false };
-if (process.env.HSTS_STRICT === '1') { hstsOptions.includeSubDomains = true; hstsOptions.preload = true; }
 
 app.use(helmet({
   contentSecurityPolicy: {
     directives: {
-      defaultSrc: ["'none'"],
-      // Strict CSP: a per-request nonce + 'strict-dynamic' (no 'unsafe-inline', no broad host
-      // sources — securityheaders/Observatory flag those). Trusted (nonce'd) scripts may load
-      // further scripts, which covers htmx-swapped inline scripts AND the external player APIs
-      // that embed-player.js injects (YouTube/SoundCloud/Spotify). The nonce is added to every
-      // <script> tag at render time (middleware/render.js injectCspNonce).
-      scriptSrc: [
-        "'strict-dynamic'",
-        (req, res) => `'nonce-${res.locals.cspNonce}'`,
+      defaultSrc: ["'self'"],
+      scriptSrc: ["'self'", "'unsafe-inline'"],
+      styleSrc: ["'self'", "'unsafe-inline'"],
+      imgSrc: ["'self'", "data:", "https:"],
+      connectSrc: ["'self'", "wss:", "ws:"],
+      mediaSrc: ["'self'", "https:"],
+      fontSrc: ["'self'"],
+      frameSrc: [
+        "'self'",
+        "https://open.spotify.com",
+        "https://w.soundcloud.com",
+        "https://bandcamp.com",
+        "https://embed.music.apple.com",
+        "https://www.youtube-nocookie.com",
+        "https://player.vimeo.com",
       ],
-      // No inline event handlers anywhere: every on* attribute was moved to a
-      // delegated data-* handler (the shared script in shell.ejs), so inline
-      // handlers are blocked entirely — this closes the last 'unsafe-inline' in
-      // the script directives.
-      scriptSrcAttr: ["'none'"],
-      styleSrc: ["'self'", "'unsafe-inline'"],
-      // blob: required for the image editor (Cropper) — it displays the chosen
-      // photo via URL.createObjectURL(blob:…). Without blob: the CSP silently
-      // blocks the <img> → empty edit window. (media-src already has blob: for audio.)
-      imgSrc: ["'self'", "data:", "https:", "blob:"],
-      connectSrc: ["'self'", "wss:", "ws:", "https://*.spotifycdn.com", "https://*.scdn.co"],
-      // blob: is required for the audio player — it fetch()es track bytes and
-      // plays from a blob: object URL (Spotify-style). Without blob: here the
-      // CSP silently blocks <audio>.src = blob:… → the player fires 'error' and
-      // auto-skips every track. 'self'/https: do NOT imply blob:.
-      mediaSrc: ["'self'", "https:", "blob:"],
-      fontSrc: ["'self'"],
-      // Embeds (platform players + cross-site Klonkt audio players) are framed broadly:
-      // ANY https origin, so embeds work in any context (feed, htmx/PWA nav, public pages).
-      // The sensitive /authorize_interaction page tightens frame-src back to 'self' in
-      // renderPage — it shows untrusted remote content next to the interact buttons.
-      frameSrc: ["'self'", "https:"],
-      // default-src is 'none' (deny by default), so resource types that were implicitly covered
-      // by the old default-src 'self' must be listed explicitly: the PWA manifest and the
-      // service worker. (base-uri/form-action/frame-ancestors/object-src 'none' come from
-      // Helmet's defaults; img/style/connect/media/font/frame are set above.)
-      manifestSrc: ["'self'"],
-      workerSrc: ["'self'", "blob:"],
     },
   },
-  hsts: hstsOptions,
+  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
   frameguard: { action: 'sameorigin' },
-  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+  referrerPolicy: { policy: 'no-referrer-when-downgrade' },
 }));
-
-// Permissions-Policy: disable powerful features Klonkt never uses (camera, microphone,
-// geolocation) and opt out of the Topics API. Features that embeds legitimately need
-// (autoplay, fullscreen, encrypted-media, picture-in-picture) are left at their default
-// allowlist, so YouTube/Spotify/SoundCloud players keep working.
-app.use((req, res, next) => {
-  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
-  next();
-});
 
 app.set('view engine', 'ejs');
@@ -182,40 +92,5 @@
 // Without this, Express sees req.protocol === 'http' and won't issue secure
 // cookies — sessions never persist past the redirect after login.
-// Trust proxy hoort bij WAAR JE DRAAIT, niet bij dev/prod (Barts 429-jacht,
-// 9-8): klonkt-dev draait NODE_ENV=development ACHTER Caddy, en zonder trust
-// proxy was req.ip voor elk verzoek 127.0.0.1 -- de hele wereld plus de
-// honderd kudde-daemons deelden EEN rate-limit-emmer van 300/min. De kudde
-// leegde hem, en Barts refresh kreeg 'Too many requests' terwijl de live-lus
-// aan dezelfde 429's verhongerde. TRUST_PROXY=1 zet hem aan waar een proxy
-// voor de deur staat; kaal-op-poort blijft hem uit laten, want een direct
-// bereikbare server die X-Forwarded-For vertrouwt laat iedereen zijn eigen
-// IP kiezen -- en daarmee de limiter omzeilen.
-if (!isDev || process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
-
-// Collapse leading duplicate slashes in the path. A reverse proxy that proxies with
-// `RewriteRule ^(.*)$ http://localhost:3000/$1` (Apache [P]) sends "//" for the root and
-// "//path" for sub-paths (the captured $1 keeps its leading slash) → Express matches no
-// route → the whole site 404'd behind such a proxy. Normalising here makes Klonkt resilient
-// to that common reverse-proxy setup. (Only the leading slashes; the query string is intact.)
-app.use((req, res, next) => {
-  if (req.url.startsWith('//')) req.url = req.url.replace(/^\/+/, '/');
-  next();
-});
-
-// Create/migrate the schema BEFORE anything touches the DB: the session store
-// queries the `sessions` table on construction, so on a fresh install the tables
-// must exist first (otherwise: "no such table: sessions" → crash loop on first boot).
-initializeDatabase();
-startScheduler(); // release planning: publish scheduled posts when publish_at is reached
-startDeliveryWorker(); // retry failed fediverse deliveries with backoff
-// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
-// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
-// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
-migrateReactions();
-selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
-
-// Safety net: guarantee that there is always a primary site (solo/hub/circle).
-// Idempotent — does nothing if a site already exists or there is no admin yet.
-ensurePrimarySite();
+if (!isDev) app.set('trust proxy', 1);
 
 // Session middleware extracted into a variable so the WebSocket upgrade
@@ -237,59 +112,16 @@
 
 app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
-
-// On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
-// (cached on disk), so the browser doesn't jaggily shrink a high-res cover for the grid/
-// list. Mounted BEFORE the /media static so it catches the thumb path first.
-app.get('/media/thumb/:w/*', async (req, res) => {
-  const w = parseInt(req.params.w, 10);
-  const rel = req.params[0] || '';
-  if (!THUMB_SIZES.has(w)) return res.status(400).end();
-  let file = null;
-  try { file = await getThumbnail(rel, w); } catch { /* fall through to original */ }
-  if (!file) {
-    // Generation unavailable/failed → serve the original instead of 404'ing.
-    return res.redirect(302, '/media/' + rel.split('/').map(encodeURIComponent).join('/'));
-  }
-  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
-  res.type('webp');
-  res.sendFile(file);
-});
-
-// Signed remote-image proxy: downscale a REMOTE avatar/image (SSRF-safe via safeFetch)
-// to a cached WebP, so line-art fediverse avatars don't render jagged. Only HMAC-signed
-// URLs (produced by the avatar() view helper) are accepted — not an open resizer.
-app.get('/img/a/:w', async (req, res) => {
-  const w = parseInt(req.params.w, 10);
-  const url = typeof req.query.u === 'string' ? req.query.u : '';
-  const sig = typeof req.query.s === 'string' ? req.query.s : '';
-  if (!THUMB_SIZES.has(w) || !verifyImg(url, w, sig)) return res.status(400).end();
-  let file = null;
-  try { file = await getRemoteThumbnail(url, w); } catch { /* fall through to original */ }
-  if (!file) return res.redirect(302, url); // fetch/downscale failed → let the browser load the remote original
-  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=604800');
-  res.type('webp');
-  res.sendFile(file);
-});
-
-app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
-  // Public media (post covers, avatars) must be cross-origin embeddable by other
-  // Klonkt sites in their CIRCLE. Helmet sets CORP=same-origin by default, which
-  // causes the browser to block those images (the file arrives, but the browser
-  // refuses to render it). Set cross-origin explicitly for /media.
-  setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'),
-  // An upload never changes under its name (unique filenames; a new upload is
-  // a new name), so say so. Without this the default is max-age=0 and every
-  // platform image-loader may re-ask for every image on every screen: Shaer's
-  // cards visibly re-loaded what the previous view had just shown. The thumbs
-  // and the avatar proxy already declared this; the originals were the one
-  // place that forgot.
-  maxAge: isDev ? 0 : '1y',
-  immutable: !isDev,
-}));
-
-// (Removed) TWA / digital-asset-links — only needed for the APK/TWA variant.
-// Klonkt is PWA-only; assetlinks.json is no longer served.
+app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media'));
+
+// P64 — TWA / digital-asset-links: must be served at /.well-known/assetlinks.json
+// at the site root with Content-Type: application/json. Without this Android
+// shows the URL bar inside the installed PrutFolio app.
+app.get('/.well-known/assetlinks.json', (req, res) => {
+  res.type('application/json').sendFile(
+    path.join(__dirname, 'assets', '.well-known', 'assetlinks.json')
+  );
+});
+
+initializeDatabase();
 
 // Bundle HTMX: copy from node_modules into our own assets dir so we can serve
@@ -310,19 +142,7 @@
 })();
 
-// ActivityPub: WebFinger + /ap/* (site-agnostic, resolves the site by slug).
-app.use(apRoutes);
-// OpenWebAuth (FEP-61cf): het token-endpoint en het inlogformulier.
-app.use(owaRoutes);
-// En op elk GET-verzoek kijken of er een token wordt ingewisseld (?owt=) of een
-// stroom gestart (?zid=). Na de sessie, want het resultaat gaat IN de sessie;
-// voor de pagina's, want een poort verderop moet de uitkomst al kunnen zien.
-app.use(owaMiddleware);
-// ActivityPub C2S: OAuth 2.0 (native/web clients). Site-agnostic; the consent
-// screen picks which site the token can post as.
-app.use(oauthRoutes);
-
-// Themed OG cards (/og/:slug.png) — resolve the site by slug themselves, so they
-// run before resolveSite and need no site context.
-app.use('/og', ogRoutes);
+// Singleton PrutterService — routes get it via req.app.locals.prutter.
+const prutter = new PrutterService(db);
+app.locals.prutter = prutter;
 
 app.use(resolveSite);
@@ -330,97 +150,16 @@
 app.use(loadTheme);
 
-// ActivityPub content negotiation on the human URLs: an AP request (Accept:
-// application/activity+json) to a profile/post URL is redirected to its /ap/*
-// representation — same URL serves HTML to browsers, AP-JSON to servers (this is
-// how Mastodon resolves a pasted profile/post URL). Gated on apWants() so normal
-// browser requests pay nothing.
-app.use((req, res, next) => {
-  if (req.method !== 'GET' || !apWants(req)) return next();
-  const site = res.locals.site;
-  if (!site || !site.slug) return next();
-  const seg = req.path.replace(/^\/+|\/+$/g, '');
-  if (seg === '') return res.redirect(302, `/ap/users/${encodeURIComponent(site.slug)}`);
-  if (!seg.includes('/')) {
-    try {
-      const post = db.prepare(
-        "SELECT id FROM posts WHERE site_id = ? AND slug = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
-      ).get(site.id, seg);
-      if (post) return res.redirect(302, `/ap/notes/${post.id}`);
-    } catch { /* fall through to normal HTML handling */ }
-  }
-  return next();
-});
-
-// Lightweight CSRF defense: reject cross-origin state-mutating requests.
-// Same-origin forms + HTMX send a matching Origin; missing Origin is allowed
-// through (non-browser clients). sameSite:'lax' on the session cookie is the
-// second layer. (Does not apply to GET/HEAD/OPTIONS.)
-app.use((req, res, next) => {
-  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
-  const origin = req.get('origin');
-  if (!origin) return next(); // no Origin → no browser CSRF vector
-  let originHost;
-  try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
-  // Behind a reverse proxy the raw Host is the backend bind (e.g. localhost:3000, when the
-  // proxy doesn't preserve it — common with Apache .htaccess proxying), so also accept the
-  // operator-configured PUBLIC_BASE_URL host and the proxy's X-Forwarded-Host. Both are
-  // operator/proxy-controlled and can't be forged via a victim's browser, so this is safe.
-  const allowedHosts = [req.get('host'), req.get('x-forwarded-host')];
-  if (process.env.PUBLIC_BASE_URL) { try { allowedHosts.push(new URL(process.env.PUBLIC_BASE_URL).host); } catch { /* ignore bad config */ } }
-  if (!allowedHosts.includes(originHost)) return res.status(403).send('Cross-origin request geweigerd');
-  next();
-});
-
-// Viewer accounts: may view everything (including Admin), change nothing. This is
-// the ONLY write gate — fail-closed, before all route handlers. Every state-mutating
-// method is rejected (the login POST sets the session after this guard, so it is
-// not affected). Instead of raw 403 text we render a clean page (or, for HTMX,
-// a swapped-in message).
-app.use((req, res, next) => {
-  const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
-  if (mutating && isViewer(req.session?.user)) {
-    if (req.headers['hx-request'] === 'true') {
-      // htmx doesn't swap on 4xx; send 200 + retarget so the message appears in #pcms-main.
-      res.setHeader('HX-Retarget', '#pcms-main');
-      res.setHeader('HX-Reswap', 'innerHTML');
-      res.status(200);
-    } else {
-      res.status(403);
-    }
-    return renderPage(req, res, 'pages/viewer-blocked', {
-      pageTitle: 'Kijker-modus',
-      bodyClass: 'on-special',
-    });
-  }
-  next();
-});
-
 app.use('/auth', authRoutes);
 app.use('/account', accountRoutes);
-// NB: /notifications is the fediverse notifications page (in postsRoutes). The old
-// user-notifications route was removed — it collided with the fedi route after the
-// /meldingen -> /notifications rename, and the user-notifications system is dead.
-if (audioEnabled()) {
-  app.use('/admin/audio', adminAudioRoutes);
-  app.use('/admin/playlists', adminPlaylistsRoutes);
-  app.use('/admin/listeners', adminListenersRoutes);
-}
-app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too)
-app.use('/admin/migrate', adminMigrateRoutes); // posts + media naar/van een andere Klonkt
+app.use('/admin/audio', adminAudioRoutes);
+app.use('/admin/playlists', adminPlaylistsRoutes);
 app.use('/admin/sites', adminSitesRoutes);
 app.use('/admin/users', adminUsersRoutes);
-app.use('/admin/settings', adminSettingsRoutes);
-app.use('/admin/seo', adminSeoRoutes);
-app.use('/admin/updates', adminUpdatesRoutes);
-app.use('/admin/patreon', adminPatreonRoutes);
-app.use('/admin/stats', adminStatsRoutes);
-app.use('/admin/paid', adminPaidRoutes);
-app.use('/admin/push', adminPushRoutes);
-app.use('/admin/newsletter', adminNewsletterRoutes);
-app.use('/admin/shows', adminShowsRoutes);
-app.use('/admin/epk', adminEpkRoutes);
+app.use('/admin/comments', adminCommentsRoutes);
 app.use('/admin', adminRoutes);
-if (audioEnabled()) app.use('/audio', audioRoutes);
+app.use('/prutter', prutterRoutes);
+app.use('/audio', audioRoutes);
 app.use('/search', searchRoutes);
+app.use('/comments', commentsRoutes);
 app.use('/tag', tagsRoutes);
 app.use('/type', typesRoutes);
@@ -428,16 +167,4 @@
 // Feed/sitemap routes are mounted at root because they're at well-known paths
 app.use('/', feedRoutes);
-app.use('/', circleRoutes); // /cirkel-feed (solo: next() -> postsRoutes)
-app.use('/', epkRoutes); // /pers perskit (premium; niet-premium: next() -> 404)
-app.use('/', newsletterRoutes); // /nieuwsbrief in/uitschrijven (premium; niet-premium: next())
-if (audioEnabled()) app.use('/', downloadRoutes); // /downloads + /download/:id (audio; lite: uit)
-app.use('/', linkbioRoutes); // /links link-in-bio + klikstats (premium)
-if (audioEnabled()) app.use('/', embedRoutes); // /embed inbedbare audiospeler (audio; lite: uit)
-app.use('/', showsRoutes); // /shows agenda + notify-me (premium)
-app.use('/', changelogRoutes); // /changelog publieke release-/wijzigingen-pagina
-app.use('/', langRoutes); // /lang/:code — interface-taal kiezen (vóór de catch-all)
-app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
-app.use('/push', pushRoutes);   // web-push subscribe/test (before the /:slug catch-all)
-app.use('/guardian', guardianRoutes);   // the Guardian PWA (FEP-633c): losse guardians, meekijken, follow-gating, wave, invite (was guardian2, v1 verwijderd)
 app.use('/', postsRoutes);
 
@@ -456,15 +183,12 @@
   const startUrl = (base || '') + '/?source=pwa';
 
-  // A stable identity per site so installs don't collide (Chromium uses `id`).
-  // NB: changing the id orphans existing PWA installs (no migration carries an
-  // install across an id change) — anyone who already installed the site as a
-  // PWA will need to reinstall once. Data stays server-side, so nothing is lost.
-  const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt';
+  // A stable identity per site so installs don't collide (Chromium uses `id`)
+  const idBase = site?.slug ? `prutfolio-${site.slug}` : 'prutfolio';
 
   res.set('Cache-Control', 'no-cache');
   res.json({
     id: idBase,
-    name: site?.title || 'Klonkt',
-    short_name: (site?.title || 'Klonkt').slice(0, 12),
+    name: site?.title || 'PrutFolio',
+    short_name: (site?.title || 'PrutFolio').slice(0, 12),
     description: site?.description || site?.tagline || '',
     scope,
@@ -474,5 +198,5 @@
     orientation: 'any',
     background_color: '#1a1a17',
-    theme_color: site?.accent || '#e8b04b',
+    theme_color: site?.accent || '#c2410c',
     lang: site?.language || 'nl',
     icons: [
@@ -489,12 +213,13 @@
 // content there with a forgiving content-type since modern browsers accept it.
 function _renderFavicon(res, accent) {
-  const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#e8b04b';
-  // Site mark: rounded square in the site accent + bold white 'K' (Klonkt)
+  const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#c2410c';
+  // Simple PrutFolio mark: a rounded square in the site accent + lowercase 'p'
+  // (display font is server-side unavailable, so we use a generic serif fallback)
   const svg = `<?xml version="1.0" encoding="UTF-8"?>
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
   <rect width="64" height="64" rx="14" fill="${safeAccent}"/>
-  <text x="50%" y="50%" dy="0.35em" text-anchor="middle"
-        font-family="Arial, Helvetica, sans-serif"
-        font-size="42" font-weight="800" fill="#fff">K</text>
+  <text x="50%" y="50%" dy="0.36em" text-anchor="middle"
+        font-family="Georgia, 'Times New Roman', serif"
+        font-size="44" font-weight="700" fill="#fff">p</text>
 </svg>`;
   res.set('Content-Type', 'image/svg+xml');
@@ -516,5 +241,5 @@
   res.set('Cache-Control', 'no-cache');
   res.send(`
-const CACHE_VERSION = 'pcms-v19-' + new Date().toISOString().split('T')[0];
+const CACHE_VERSION = 'pcms-v10-' + new Date().toISOString().split('T')[0];
 self.addEventListener('install', e => {
   e.waitUntil(caches.open(CACHE_VERSION).then(c => c.addAll(['/'])));
@@ -527,69 +252,7 @@
   self.clients.claim();
 });
-// ONLY intercept navigations (HTML pages) for an offline fallback.
-// Do NOT touch images, CSS, JS or /media — let the browser handle those natively.
-// Otherwise a failed network fetch could fall back to an empty cache match
-// (undefined) and "break" an image on a normal refresh (hard reload bypasses
-// the SW, which is why that case worked fine).
 self.addEventListener('fetch', e => {
   if (e.request.method !== 'GET') return;
-  if (e.request.mode !== 'navigate') return; // page loads only
-  // Same-origin ONLY. A cross-origin navigate request is an <iframe> embed
-  // (YouTube/Spotify/SoundCloud …) — routing those through the SW yields an
-  // opaque/altered response the iframe cannot render → blank embeds in the
-  // installed PWA (which is always SW-controlled). Let the browser load them.
-  try { if (new URL(e.request.url).origin !== self.location.origin) return; } catch (err) { return; }
-  e.respondWith(
-    // { cache: 'no-store' }: go to the network for the page, bypassing the browser's
-    // HTTP cache, so an online visitor ALWAYS gets the fresh site and never a
-    // heuristically-cached copy served through the SW. The cache is only a
-    // last-resort offline fallback (the .catch below).
-    fetch(e.request, { cache: 'no-store' }).then(resp => {
-      // Network-first: always serve fresh when online. Also refresh the '/' offline
-      // fallback with the homepage we just served, so a later cold start on a flaky or
-      // offline connection no longer shows the stale install-time snapshot ("old data
-      // on first PWA load").
-      try {
-        if (resp && resp.ok && new URL(e.request.url).pathname === '/') {
-          const copy = resp.clone();
-          e.waitUntil(caches.open(CACHE_VERSION).then(c => c.put('/', copy)).catch(() => {}));
-        }
-      } catch (err) { /* ignore cache refresh failures */ }
-      return resp;
-    }).catch(() => caches.match('/').then(r => r || Response.error()))
-  );
-});
-// Web push (docs/webpush-design.md): payload is JSON {type,title,body,url},
-// encrypted end-to-end to this browser (RFC 8291). Show it; click opens url.
-self.addEventListener('push', e => {
-  let d = {};
-  try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
-  const title = d.title || 'Klonkt';
-  e.waitUntil(Promise.all([
-    self.registration.showNotification(title, {
-      body: d.body || '',
-      icon: '/favicon.svg',
-      badge: '/favicon.svg',
-      tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
-      data: { url: d.url || '/' },
-    }),
-    // Wek ook een pagina die al openstaat. De push IS het teken dat er iets
-    // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog
-    // eens doen -- en die tweede zou alleen werken zolang de app open is,
-    // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen.
-    self.clients.matchAll({ type: 'window', includeUncontrolled: true })
-      .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); })
-      .catch(() => { /* geen open venster: niets te wekken */ }),
-  ]));
-});
-self.addEventListener('notificationclick', e => {
-  e.notification.close();
-  const url = (e.notification.data && e.notification.data.url) || '/';
-  e.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
-    for (const c of list) {
-      if (new URL(c.url).origin === self.location.origin && 'focus' in c) { c.navigate(url); return c.focus(); }
-    }
-    return clients.openWindow(url);
-  }));
+  e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
 });
   `);
@@ -608,29 +271,67 @@
 
 app.use((req, res) => {
-  res.status(404);
-  // Clean, mobile-friendly 404 via the shell (viewport + nav + site theme).
-  // Falls back to bare HTML if rendering unexpectedly fails.
-  try {
-    return renderPage(req, res, 'pages/404', {
-      pageTitle: '404 — niet gevonden',
-      bodyClass: 'on-special on-404',
+  res.status(404).send(`
+    <div style="font-family:system-ui;max-width:500px;margin:4rem auto;text-align:center;padding:2rem;">
+      <h1 style="font-size:5rem;margin:0;color:#c33;">404</h1>
+      <p>Not found</p>
+      <a href="/" style="color:#c2410c;">← Home</a>
+    </div>
+  `);
+});
+
+// ==================== WebSocket: Prutter real-time ====================
+// Authenticate via the existing session cookie. We reuse sessionMiddleware
+// during the HTTP upgrade so req.session is populated; if no user, abort.
+const wss = new WebSocketServer({ noServer: true });
+
+server.on('upgrade', (req, socket, head) => {
+  if (req.url !== '/ws/prutter') {
+    socket.destroy();
+    return;
+  }
+  // Run session middleware on the upgrade request.
+  // (Express's middleware accepts (req, res, next); we pass a stub res.)
+  const stubRes = { setHeader: () => {}, getHeader: () => undefined, on: () => {}, end: () => {} };
+  sessionMiddleware(req, stubRes, () => {
+    if (!req.session?.user) {
+      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
+      socket.destroy();
+      return;
+    }
+    wss.handleUpgrade(req, socket, head, (ws) => {
+      ws.userId = req.session.user.id;
+      wss.emit('connection', ws, req);
     });
-  } catch (e) {
-    return res.send('<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><div style="font-family:system-ui;max-width:500px;margin:4rem auto;text-align:center;padding:2rem"><h1 style="font-size:4rem;margin:0">404</h1><p>Pagina niet gevonden</p><a href="/">← Home</a></div>');
+  });
+});
+
+wss.on('connection', (ws) => {
+  prutter.addConnection(ws.userId, ws);
+  ws.on('close', () => prutter.removeConnection(ws.userId, ws));
+  ws.on('error', () => prutter.removeConnection(ws.userId, ws));
+  // Optional: ping every 30s to keep connections alive through proxies
+  ws.isAlive = true;
+  ws.on('pong', () => { ws.isAlive = true; });
+});
+const wsPing = setInterval(() => {
+  for (const ws of wss.clients) {
+    if (ws.isAlive === false) { ws.terminate(); continue; }
+    ws.isAlive = false;
+    try { ws.ping(); } catch {}
   }
-});
-
-server.listen(PORT, HOST, () => {
-  const baseUrl = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+}, 30000);
+if (wsPing.unref) wsPing.unref();
+
+server.listen(PORT, () => {
   console.log('');
-  console.log('🪶 Klonkt');
-  console.log(`   ${baseUrl || `http://localhost:${PORT}`}`);
-  if (baseUrl) console.log(`   (bound to ${HOST}:${PORT})`);
+  console.log('🪶 PrutFolio v1 — alpha');
+  console.log(`   http://localhost:${PORT}`);
   console.log('');
   console.log(`   ✓ Security: Helmet, CSP, secure sessions`);
   console.log(`   ✓ Privacy:  Self-hosted fonts, no third-party requests`);
   console.log(`   ✓ Layout:   v9 editorial feel (top nav, profile header)`);
-  console.log(`   ✓ Auth:     wachtwoord (beheer) + Google (luisteraars) / logout`);
+  console.log(`   ✓ Auth:     login / register / logout`);
   console.log(`   ✓ Posts:    create / edit / view / archive`);
+  console.log(`   ✓ Realtime: WebSocket server ready (Prutter)`);
   console.log('');
   console.log(`   Mode: ${isDev ? 'development' : 'PRODUCTION'}`);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,4350 +1,0 @@
-/**
- * ActivityPubService — Klonkt as a real ActivityPub actor (fediverse bridge).
- *
- * Phase 1 (this file): the PUBLISH/discoverable side.
- *   - per-site RSA keypair (Mastodon-compatible HTTP Signatures; separate from
- *     the Ed25519 keys used by the lighter Cirkels v1)
- *   - builders for the Actor document, Note objects and the Outbox collection
- *   - apWants(): HTTP content-negotiation helper (activity+json vs HTML)
- *
- * The interactive side (inbox: Follow/Accept, signature verify, delivery to
- * followers) lands in the next step and is tested live against Mastodon.
- *
- * AP actor URLs live under /ap/* so they never clash with the human pages:
- *   actor   = <base>/ap/users/<slug>
- *   inbox   = <actor>/inbox      outbox = <actor>/outbox
- *   note    = <base>/ap/notes/<postId>
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import db, { NU_ISO, isoSql } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import AudioEmbedService from './AudioEmbedService.js';
-import EmbedResolver from './EmbedResolver.js';
-import Push from './PushService.js';
-import { t as i18nT } from './i18n.js';
-import Blocklist from './BlocklistService.js';
-import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts, hashtagTags, buildHashtagList, pagedCollection, PAGINA_GROOTTE, artiestUrl } from './ap-core.js';
-// Stap 3 van de opsplitsing (shaer-drc): het transport -- de SSRF-poort, de
-// sleutels, HTTP Signatures, de bezorging met wachtrij en de ondertekende
-// GET -- woont in ap-transport.js. Hier her-geëxporteerd zodat elke bestaande
-// importeur blijft werken, hetzelfde patroon als de Guardianship-exports onderaan.
-import {
-  safeFetch, getOrCreateKeys, deliver, fetchActor,
-  enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
-  anySigningSlug, verifyRequest, signedGetHeaders, signedGetJson, apGetJson,
-} from './ap-transport.js';
-export {
-  safeFetch, getOrCreateKeys, deliver, fetchActor,
-  enqueueDelivery, deliverWithRetry, processDeliveryQueue, startDeliveryWorker,
-  verifyRequest, signedGetHeaders, signedGetJson,
-};
-// Stap 4 (shaer-drc): de C2S-inname woont in ap-c2s.js. Die is een coordinator
-// en krijgt zijn werktuigen uit de dienstlaag onderaan dit bestand via
-// wireC2S -- de regel blijft dat een module NOOIT uit dit bestand importeert.
-import { ingestOutboxActivity, wireC2S } from './ap-c2s.js';
-export { ingestOutboxActivity };
-// Stap 5 (shaer-drc): de leeskant van de tijdlijn woont in ap-timeline.js.
-// tlStmts komt mee terug omdat de SCHRIJVERS (inbox, backfill, self-heal,
-// upsertBoostedNote) hier wonen; wireTimeline krijgt onderaan zijn ene
-// werktuig uit het reactiecluster.
-import {
-  tlStmts, wireTimeline,
-  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
-  readMarkers, markRead, unreadPerConversation, getDirectMessages,
-  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
-  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
-  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
-} from './ap-timeline.js';
-export {
-  getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
-  readMarkers, markRead, unreadPerConversation, getDirectMessages,
-  isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
-  stripEmojiTags, timelineEmojis, extractObjectLinkTags, timelineObjectLinks,
-  extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote,
-};
-// Stap 6 (shaer-drc): het reactiecluster woont in ap-reactions.js. Dat
-// importeert tlStmts zelf statisch uit ap-timeline; alleen movedLock gaat er
-// onderaan via wireReactions in.
-import {
-  wireReactions,
-  setMyReaction, getMyReactions,
-  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
-  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
-  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
-} from './ap-reactions.js';
-export {
-  setMyReaction, getMyReactions,
-  markBoosted, unmarkBoosted, markLiked, unmarkLiked,
-  migrateReactions, canonicalReactionUri, getReaction, getReactionsFor,
-  setReaction, getTimelineReaction, upsertBoostedNote, boostedCount,
-};
-// Stap 7 (shaer-drc): de volgwinkel woont in ap-following.js. fwStmts komt
-// mee terug voor de Accept-tak van de inbox en de verhuizing (FEP-7628);
-// wireFollowing krijgt onderaan zijn zes werktuigen.
-import {
-  fwStmts, wireFollowing,
-  webfingerResolve, listFollowing, setAutoBoost,
-  followActor, resolveRemoteActor, unfollowActor,
-} from './ap-following.js';
-export {
-  webfingerResolve, listFollowing, setAutoBoost,
-  followActor, resolveRemoteActor, unfollowActor,
-};
-// Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll,
-// applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en
-// de backfill, maar blijven naar buiten toe prive zoals ze waren.
-import {
-  wirePolls,
-  parsePoll, applyPollToNote, recordPollBallot,
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
-  voteOnPoll, voteOnRemotePoll,
-} from './ap-polls.js';
-export {
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
-  voteOnPoll, voteOnRemotePoll,
-};
-// Stap 9 (shaer-drc): de inbox woont in ap-inbox.js. De schakelkast krijgt
-// onderaan zijn vierendertig werktuigen via wireInbox.
-import { handleInbox, wireInbox } from './ap-inbox.js';
-export { handleInbox };
-// Stap 10 (shaer-drc): de Cirkel woont in ap-cirkel.js. Geen wire: hij leest
-// alleen db.
-import { autoBoostCount, getCirkelPosts, getCirkelMembers } from './ap-cirkel.js';
-export { autoBoostCount, getCirkelPosts, getCirkelMembers };
-// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
-export { AP_CONTEXT, actorId, noteId, guessMediaType };
-// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
-// vandaan kwam, zodat elke bestaande aanroep blijft werken.
-import { luisteraars } from './music/index.js';
-import { TRACK_KOLOMMEN,
-  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
-  buildTrackAudio, buildTrackCollection, buildTrackCreate, trackUri, buildMixtapeObject, postMusicType,
-  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-  licentieUri, channelCategory,
-} from './music/index.js';
-export {
-  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
-  buildTrackAudio, buildTrackCollection, buildTrackCreate,
-  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-};
-
-
-// Short random suffix so two activity ids minted in the same millisecond (e.g.
-// parallel saves) don't collide and get deduped by a receiver.
-const rid = () => crypto.randomBytes(4).toString('hex');
-
-// Keep only http(s) URLs — drops javascript:/data:/etc so a remote actor can't
-// smuggle a dangerous scheme into a stored href/src (rendered in owner-only views).
-
-const MAX_OUTBOX = 20;
-// Cache-buster for the music listen-link → forces Mastodon to re-crawl a FRESH
-// (square) player card. Bump this whenever the twitter:player card dimensions change.
-const FEDI_CARD_VER = '2';
-
-// ── content negotiation ───────────────────────────────────────────
-// True when the caller wants ActivityPub JSON rather than the HTML page.
-export function apWants(req) {
-  const a = String(req.headers.accept || '').toLowerCase();
-  return a.includes('application/activity+json') ||
-         (a.includes('application/ld+json') && a.includes('activitystreams'));
-}
-
-const AP_CONTENT_TYPE = 'application/activity+json; charset=utf-8';
-/**
- * Hetzelfde antwoord als de vorige keer? Dan 304 (Barts punt, 9-8).
- *
- * De inbox doet dit al met `since` + `wait`, en de guardian-wachtrijen niet: die
- * stuurden bij elke verversing de hele lijst terug, ook als er niets veranderd
- * was. Bij honderd wards is dat 217 KB JSON die de telefoon opnieuw moet
- * parsen -- over de lijn valt het mee (2,8 KB gzip), maar het OPBOUWEN van
- * veertienhonderd objecten is wat je merkt.
- *
- * EEN INHOUDS-ETAG, geen cursor. Een cursor vraagt een tweede beschrijving van
- * wanneer iets "veranderd" is, en die kan uit de pas gaan lopen met wat er
- * werkelijk in het antwoord staat; een hash van het antwoord zelf kan dat per
- * definitie niet. De server bouwt het antwoord nog steeds (26 ms) -- wat we
- * besparen is de overdracht en het parsen.
- *
- * NOOIT 304 OP EEN LEEG ANTWOORD. Dezelfde les als de '0'-uitzondering bij de
- * inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
- * die hash ook stabiel, en zou een client voor eeuwig 304 krijgen op niets.
- */
-export function etagFor(body) {
-  return `"${crypto.createHash('sha256').update(body).digest('base64url').slice(0, 27)}"`;
-}
-
-export function sendMaybe304(req, res, obj, { cacheControl, contentType } = {}) {
-  const body = JSON.stringify(obj);
-  const leeg = !obj || (Array.isArray(obj.orderedItems) && obj.orderedItems.length === 0);
-  res.set('Vary', 'Authorization');
-  if (!leeg) {
-    const tag = etagFor(body);
-    res.set('ETag', tag);
-    if (req.headers['if-none-match'] === tag) return res.status(304).end();
-  }
-  res.type(contentType || AP_CONTENT_TYPE);
-  // `no-cache` betekent NIET "niet bewaren": de client bewaart het antwoord en
-  // vraagt elke keer of het nog klopt. Precies wat we willen -- zonder dit
-  // stuurt een browser geen If-None-Match en is de ETag decoratie.
-  res.set('Cache-Control', cacheControl || 'private, no-cache');
-  return res.send(body);
-}
-
-export function sendAP(res, obj, cacheControl) {
-  res.type(AP_CONTENT_TYPE);
-  // A per-caller (e.g. guardian-widened) view must not be publicly cached.
-  res.set('Cache-Control', cacheControl || 'public, max-age=120');
-  res.send(JSON.stringify(obj));
-}
-
-// ── document builders ─────────────────────────────────────────────
-
-
-/** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
- *  een Link of een array van beide zijn; dit is de enige plek die dat weet. */
-function pickLink(url, test) {
-  const links = Array.isArray(url) ? url : (url ? [url] : []);
-  for (const l of links) {
-    const href = safeUrl(typeof l === 'string' ? l : (l && l.href));
-    const mt = (l && typeof l === 'object' && l.mediaType) || '';
-    if (href && test(mt)) return { href, mediaType: mt };
-  }
-  return null;
-}
-
-/**
- * De `url` van de actor als kanaal (shaer-0nh): de webpagina en, als die er is,
- * de RSS-feed ernaast.
- *
- * De RSS-link gaat er ALLEEN in voor de site waar de instance op gepind staat.
- * Sinds hub-modus verdween serveert routes/feed.js `/feed.xml` van de primaire
- * site en bestaat `/user/<slug>` niet meer als route; een feed-link voor een
- * andere site zou naar de verkeerde feed wijzen. Liever een link minder dan een
- * link die iemand anders' muziek belooft.
- */
-export function channelUrls(base, site) {
-  const isPrimair = site.slug === site.primary_slug;
-  const pagina = `${base}/${isPrimair ? '' : 'user/' + encodeURIComponent(site.slug)}`;
-  const uit = [{ type: 'Link', href: pagina, mediaType: 'text/html' }];
-  if (isPrimair) uit.push({ type: 'Link', href: `${base}/feed.xml`, mediaType: 'application/rss+xml' });
-  return uit;
-}
-
-
-/**
- * Wat de tijdlijn van een binnengekomen object nodig heeft, PER SOORT: de
- * inhoud-HTML, de bijlagen voor media_json, en de link van het item.
- *
- * Eén plek, zodat een nieuwe soort erbij een tak is en geen speurtocht. De
- * Krant rendert media_json al naar soort -- audio/* wordt een speler -- dus een
- * track komt vanzelf als echte speler binnen zonder dat de weergave iets van
- * Funkwhale hoeft te weten.
- */
-/**
- * De waarschuwingstekst van een object, of niets.
- *
- * `summary` IS in AS2 een SAMENVATTING -- "a natural language summarization of
- * the object". Dat Mastodon dat veld hergebruikt als waarschuwing is Mastodons
- * conventie, en die zet er `sensitive` bij. Zonder `sensitive` is een summary
- * dus gewoon een samenvatting.
- *
- * WordPress + ActivityPub stuurt daar de EXCERPT van een artikel in, netjes
- * afgekapt voor Mastodon. Wij lazen dat als waarschuwing en verborgen de post
- * daarmee achter zijn eigen eerste alinea (Barts melding, 13-8:
- * europeanpirates.eu). Niemand krijgt dan te zien wat er staat, en de
- * waarschuwing waarschuwt nergens voor.
- */
-export function contentWarning(o) {
-  if (!o || !o.sensitive) return null;
-  const s = typeof o.summary === 'string' ? o.summary.trim() : '';
-  return s || null;
-}
-
-export function timelineFields(o) {
-  // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
-  // is het de kaart-afbeelding van een player-post), bij een Audio altijd,
-  // want daar IS het de albumhoes.
-  const hoes = () => {
-    if (!o.image) return null;
-    const im = Array.isArray(o.image) ? o.image[0] : o.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    return iu ? { url: iu, type: (im && im.mediaType) || 'image/jpeg' } : null;
-  };
-
-  if (o.type === 'Audio') {
-    const geluid = pickLink(o.url, (mt) => /^audio\//i.test(mt));
-    // De webpagina van de track. Zonder mediaType is dat de veilige aanname:
-    // er een speler op zetten zou een HTML-pagina als geluid aanbieden.
-    const pagina = pickLink(o.url, (mt) => /^text\/html/i.test(mt)) || pickLink(o.url, (mt) => !mt);
-    const atts = [];
-    const h = hoes(); if (h) atts.push(h);              // eerst kijken, dan luisteren
-    if (geluid) atts.push({ url: geluid.href, type: geluid.mediaType || 'audio/mpeg' });
-    // Een Audio heeft geen `content`; de titel is wat er te lezen valt. Door de
-    // sanitizer, want hij komt van een vreemde server.
-    return {
-      html: o.name ? HtmlSanitizerService.sanitize(`<p>${o.name}</p>`) : '',
-      atts,
-      url: pagina ? pagina.href : null,
-    };
-  }
-
-  // Een ARTIKEL heeft een titel, en die is het eerste wat je wilt zien. Zonder
-  // dit kwam een WordPress-post binnen als kale body: de titel zit in `name` en
-  // die gooiden we weg, terwijl de excerpt in `summary` ten onrechte als
-  // waarschuwing dienstdeed. Nu allebei goed -- en dit is dezelfde greep die
-  // resolveRemoteNote al doet voor niet-Note-objecten, dus de tijdlijn en het
-  // antwoordpad zeggen eindelijk hetzelfde.
-  if (o.type && o.type !== 'Note' && typeof o.name === 'string' && o.name.trim()) {
-    const kop = `<p><strong>${HtmlSanitizerService.escape ? HtmlSanitizerService.escape(o.name) : o.name}</strong></p>`;
-    const atts = mediaFromNote(o);
-    const pagina = pickLink(o.url, (mt) => !mt || /html/i.test(mt));
-    return {
-      html: HtmlSanitizerService.sanitize(kop + (o.content || '')),
-      atts,
-      url: pagina ? pagina.href : null,
-    };
-  }
-
-  // Note / Question -- ongewijzigd gedrag.
-  const atts = (Array.isArray(o.attachment) ? o.attachment : [])
-    .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
-    .filter((m) => m.url);
-  if (!atts.some((m) => !m.type || /image/i.test(m.type))) {
-    const h = hoes(); if (h) atts.push(h);
-  }
-  const pagina = pickLink(o.url, () => true);
-  return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
-}
-
-/**
- * De site achter een library-uri, of null. Zelfde strengheid als localSlugOf:
- * de uri moet met ONZE basis beginnen en de site moet bestaan -- anders levert
- * andermans /library met dezelfde padstaart hier een volger op onze naam op.
- */
-function libraryOwnerSlug(uri) {
-  const u = String(uri || '');
-  if (!u.endsWith('/library')) return null;
-  return localSlugOf(u.slice(0, -'/library'.length));
-}
-
-export function buildActor(base, site) {
-  const id = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  // FEP-633c §5.3: a ward's follows are gated (guardians approve), so the actor
-  // MUST advertise manuallyApprovesFollowers:true — otherwise a follower's server
-  // (Mastodon) assumes auto-accept and shows "Following" while we hold it pending.
-  const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-  const actor = {
-    '@context': AP_CONTEXT,
-    id,
-    type: 'Person',
-    preferredUsername: site.slug,
-    name: site.title || site.slug,
-    summary: site.tagline || site.description || '',
-    // Een Link-ARRAY in plaats van een kale string (shaer-0nh): zo adverteert
-    // een kanaal zichzelf, en zo vindt een podcast-app de feed. De text/html
-    // staat VOORAAN, want een lezer die maar één url verwacht pakt de eerste --
-    // dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.
-    url: channelUrls(base, site),
-    ...(channelCategory(site) ? { category: channelCategory(site) } : {}),
-    // …and the same honesty for the OWNER gate (Robins wens, 18-8): a site
-    // with approve_followers on holds follows pending until the owner decides.
-    manuallyApprovesFollowers: isWard || !!site.approve_followers,
-    discoverable: true,
-    inbox: `${id}/inbox`,
-    outbox: `${id}/outbox`,
-    followers: `${id}/followers`,
-    following: `${id}/following`,
-    featured: `${id}/featured`,
-    // AS2-kern `streams`: "supplementary Collections which may be of
-    // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
-    // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
-    streams: [`${id}/tracks`, `${id}/playlists`],
-    // AP §5.6: the private blocked collection (owner-only GET). The server
-    // list is the source of truth for Shaer's "in Orbit"; clients keep no
-    // separate state.
-    blocked: `${id}/blocked`,
-    // FEP-1580: de vertaaltabel van een verhuizing plus de Moves die hem
-    // rechtvaardigen. Deze twee staan er ALTIJD, ook leeg, en dat is met opzet:
-    // de FEP wijst er apart op dat "een verhuizing zonder objecten" en "een
-    // server die dit niet kent" anders niet uit elkaar te houden zijn.
-    migration: `${id}/migration`,
-    moves: `${id}/moves`,
-    // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
-    // (guardianship module owns these).
-    ...Guardianship.guardianshipActorProps(id, site.slug),
-    // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
-    // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
-    // (RFC 7591) is discovered via /.well-known/oauth-authorization-server, not here.
-    endpoints: {
-      sharedInbox: `${base}/ap/inbox`,
-      oauthAuthorizationEndpoint: `${base}/oauth/authorize`,
-      oauthTokenEndpoint: `${base}/oauth/token`,
-      uploadMedia: `${id}/uploadMedia`,
-    },
-    publicKey: {
-      id: `${id}#main-key`,
-      owner: id,
-      publicKeyPem: keys.public_pem,
-    },
-  };
-  if (site.profile_photo) {
-    const u = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
-    actor.icon = { type: 'Image', url: u };
-  }
-  // Account creation date — shown by Mastodon + read by indexers (additive, standard AS2).
-  if (site.created_at) { try { actor.published = new Date(site.created_at).toISOString(); } catch { /* skip bad date */ } }
-  // FEP-7628: former identities this account claims. The OLD server checks for
-  // exactly this back-reference before it will move followers here, so the
-  // list must be on the public actor, not tucked away in settings.
-  try {
-    const aka = JSON.parse(site.ap_aliases || '[]');
-    if (Array.isArray(aka)) {
-      const clean = aka.filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u) && u !== id);
-      if (clean.length) actor.alsoKnownAs = clean;
-    }
-  } catch { /* skip malformed ap_aliases */ }
-  // FEP-7628 slice 3: this account moved. The old actor stays online AS A
-  // SIGNPOST — that is the whole point of keeping it: whoever missed the Move
-  // activity (offline server, later visitor) still learns where we went by
-  // fetching us. Per the FEP the moved actor "should be considered inactive",
-  // and publishers should stop delivering here.
-  if (site.moved_to && /^https?:\/\//i.test(String(site.moved_to))) actor.movedTo = String(site.moved_to);
-  // Zie movedLock() verderop: het serveren van movedTo is de ENE helft, het
-  // stilzetten van de uitgaande kant de andere.
-  // De MusicBrainz-koppeling van de artiest (shaer-mbz). Alleen als hij ZELF
-  // gekozen heeft -- er staat niets als er niets gekoppeld is, want een lege
-  // of geraden verwijzing is erger dan geen.
-  //
-  // schema:sameAs en niet alsoKnownAs: dat laatste is in AS2 voor vroegere
-  // identiteiten van dezelfde actor, en FEP-7628 leunt erop bij een verhuizing.
-  // Een MBID hier neerzetten zou een verhuizing kunnen laten mislukken.
-  const mbUrl = artiestUrl(site.mb_artist_id);
-  if (mbUrl) actor.sameAs = mbUrl;
-  // Profile links → PropertyValue rows: Mastodon/PeerTube/WordPress-ActivityPub render these as
-  // profile metadata (rel=me enables link-back verification). Additive; ignored by simpler receivers.
-  try {
-    const links = JSON.parse(site.profile_links || '[]');
-    if (Array.isArray(links) && links.length) {
-      const esc = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-      const rows = links
-        .filter((l) => l && l.url && /^https?:/i.test(l.url))
-        .map((l) => ({
-          type: 'PropertyValue',
-          name: esc(l.platform || 'Link'),
-          value: `<a href="${esc(l.url).replace(/"/g, '&quot;')}" rel="me nofollow noopener" target="_blank">${esc(String(l.url).replace(/^https?:\/\//, ''))}</a>`,
-        }));
-      if (rows.length) actor.attachment = rows;
-    }
-  } catch { /* skip malformed profile_links */ }
-  return actor;
-}
-
-// Does a post's audio shortcodes reference at least one PLAYABLE (file-backed)
-// track? Link-only tracks (external Spotify/YouTube, media_id NULL) don't count —
-// they have no Klonkt-hosted audio to embed, so no player card / cover-suppression.
-export function hasPlayableAudio(content, siteId) {
-  if (!content || !/\[\[(track|album|playlist):/i.test(content)) return false;
-  try {
-    for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT media_id FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.media_id) return true; }
-    for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL LIMIT 1').get(siteId, m[1].trim())) return true; }
-    for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { if (db.prepare('SELECT 1 FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL LIMIT 1').get(m[1])) return true; }
-  } catch { /* non-fatal */ }
-  return false;
-}
-
-// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
-// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
-// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
-// later body mutation can't affect it.
-//
-// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
-// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
-function openAudioAttachments(base, site, post) {
-  const openAudio = [];
-  if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const seenA = new Set();
-  const addRow = (r) => {
-    const fn = r.filename || (r.storage_path || '').split('/').pop();
-    if (!fn || seenA.has(fn)) return; seenA.add(fn);
-    const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
-    // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
-    // Mastodon renders it as the artwork thumbnail on its native audio player.
-    const art = abs(r.cover_url || post.cover_image_url || null);
-    if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
-    openAudio.push(a);
-  };
-  const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
-  try {
-    for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
-    for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
-    for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
-  } catch { /* non-fatal */ }
-  return openAudio;
-}
-
-// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
-// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
-// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
-// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
-// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
-// niet voor, en de hub gooide zo'n bandje daarom stil weg.
-//
-// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
-// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
-// ziet. Wie het type wel kent, vindt het bandje als geheel.
-//
-// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
-// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
-function mixtapeAttachment(base, site, post) {
-  try {
-    const soort = postMusicType(post.content || '', site.id);
-    if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
-    const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
-      .get(soort.collectie.id, site.id);
-    if (!pl) return null;
-    return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
-  } catch { return null; /* een bandje minder is geen kapotte post */ }
-}
-
-// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
-export function buildNote(base, site, post, opts = {}) {
-  // Replies are Notes too. buildNote is the single entry point for ALL Notes; a reply is
-  // (for now) the simple flavor: pre-baked content, no title/cover/image/audio/embed
-  // machinery, addressed to the parent actor + thread. This early branch keeps that output
-  // byte-identical to the old buildReplyNote. When rich replies land (images/audio/embeds),
-  // this branch collapses and replies flow through the full post pipeline below. `post` here
-  // is the ap_outbox reply row (id, in_reply_to, content, post_slug, created_at, to_actor).
-  if (opts.isReply) {
-    const meR = actorId(base, site.slug);
-    // Rich replies: attachments column (JSON [{url, mediaType, name}]) → AS2
-    // attachment array with absolute URLs and the matching object type.
-    let replyAtt;
-    try {
-      const list = post.attachments ? JSON.parse(post.attachments) : [];
-      if (Array.isArray(list) && list.length) {
-        replyAtt = list.map((a) => ({
-          type: a.mediaType.startsWith('image/') ? 'Image' : a.mediaType.startsWith('audio/') ? 'Audio' : 'Video',
-          mediaType: a.mediaType,
-          url: /^https?:/i.test(a.url) ? a.url : `${base}${a.url}`,
-          name: a.name || undefined,
-        }));
-      }
-    } catch { /* malformed attachments never block the Note */ }
-    return {
-      id: noteId(base, post.id),
-      type: 'Note',
-      attributedTo: meR,
-      inReplyTo: post.in_reply_to || undefined,
-      content: post.content,
-      // Reply language (rich replies): the AS2 language map next to `content`.
-      contentMap: post.language ? { [post.language]: post.content } : undefined,
-      attachment: replyAtt,
-      url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined,
-      published: toISO(post.created_at),
-      // A direct note (private mention, shaer-tqc) addresses ONLY its
-      // recipients: no Public anywhere, so it cannot be boosted and never
-      // shows in public timelines (the Mastodon DM model).
-      to: post.visibility === 'direct'
-        ? (JSON.parse(post.to_actors || '[]'))
-        : (post.to_actor ? [post.to_actor] : [PUBLIC]),
-      // Followers-only reply ('friends', shaer detail-view Reply): the parent
-      // author (in `to`) + our followers, but NO Public — it does not federate
-      // into open discovery. Default reply stays quiet-public (Public in cc).
-      cc: post.visibility === 'direct' ? []
-        : post.visibility === 'friends' ? [`${meR}/followers`]
-          : [PUBLIC, `${meR}/followers`],
-      // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
-      ...Guardianship.helpRequestProps(post),
-      ...Guardianship.waveProps(post),
-      ...Guardianship.awayProps(post),
-      // FEP-633c §2.2: object hint that the author is a ward.
-      ...Guardianship.hasGuardiansProps(site.slug),
-      tag: [
-        ...mentionTags(post.content),
-        ...hashtagTags(base, post.content),
-      ],
-    };
-  }
-  const id = noteId(base, post.id);
-  const aId = actorId(base, site.slug);
-  const human = `${base}/${encodeURIComponent(post.slug)}`;
-  // Mastodon ignores a Note's `name`, so put the title INTO the content (bold
-  // first line) — the standard blog→fediverse convention. post.content is
-  // already sanitized HTML; the title is plain text, so escape it.
-  const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-  const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
-
-  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
-  // content, so nothing leaks past the paywall. Images stay home too.
-  //
-  // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
-  // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
-  // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
-  // en die nummers federeren toch al los als eigen Audio-objecten met hun
-  // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
-  // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
-  // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
-  // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
-  // dient de betaalde post beter dan vier weesnummers.
-  if (post.paid) {
-    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-    const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
-    const rawTeaser = String(post.excerpt || '').trim()
-      || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
-    const openBijlagen = openAudioAttachments(base, site, post);
-    const band = mixtapeAttachment(base, site, post);
-    // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
-    // elk nummer gesloten is, is een lege doos met een titel erop.
-    if (band && openBijlagen.length) openBijlagen.push(band);
-    return {
-      '@context': AP_CONTEXT,
-      id,
-      type: 'Note',
-      attributedTo: aId,
-      content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
-      url: human,
-      published: toISO(post.published_at || post.created_at || Date.now()),
-      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
-      to: [PUBLIC],
-      cc: [`${aId}/followers`],
-      tag: [...hashtagTags(base, post.content)],
-      replies: `${id}/replies`,
-      // DE WAARSCHUWING REIST MEE (Barts melding, 15-8). Deze vroege return liet
-      // `sensitive` en `summary` vallen, want die worden pas na de gewone tak
-      // gezet. Gevolg: een betaalde post met een waarschuwing ging ZONDER die
-      // waarschuwing de deur uit -- en de teaser is publiek, dus juist die had
-      // hem nodig. Een gevoelige teaser zonder vlag is erger dan geen teaser.
-      sensitive: !!post.nsfw,
-      ...(post.nsfw ? { summary: post.content_warning || 'Gevoelige inhoud' } : {}),
-      ...Guardianship.hasGuardiansProps(site.slug),
-    };
-  }
-
-  // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
-  // the cover + any inline <img>, make absolute, then strip <img> from the content
-  // to avoid duplicate rendering on clients that DO keep them.
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
-  const playable = hasPlayableAudio(post.content || '', site && site.id);
-  // A post with an external embed (Spotify/YouTube/SoundCloud/Vimeo/Bandcamp/Apple) should let
-  // Mastodon render the embed's player CARD. Mastodon shows EITHER media attachments OR a link
-  // card, never both — so when the post has an embed link we skip the image attachments so the
-  // card wins. (On Klonkt nothing changes: the cover + the embed player still render.)
-  const hasEmbed = (() => {
-    const c = post.content || '';
-    if (/\[\[embed:/i.test(c)) return true;
-    for (const m of c.matchAll(/https?:\/\/[^\s"'<>]+/gi)) if (AudioEmbedService.detectProvider(m[0])) return true;
-    return false;
-  })();
-  // Link-only tracks (external Spotify/YouTube/SoundCloud, no hosted file): collect their links
-  // so we federate them — Mastodon cards the first (its player), the rest show as clickable links
-  // — instead of a bare "listen on site" link, and we suppress the cover so the card can show.
-  const trackEmbedLinks = (() => {
-    if (playable) return [];
-    const out = [];
-    try {
-      for (const m of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-        const r = db.prepare('SELECT media_id, link_spotify, link_youtube, link_soundcloud FROM audio_tracks WHERE id = ?').get(m[1]);
-        if (r && !r.media_id) for (const u of [r.link_spotify, r.link_youtube, r.link_soundcloud]) if (u && /^https?:\/\//i.test(u)) out.push(u);
-      }
-    } catch { /* non-fatal */ }
-    return [...new Set(out)].slice(0, 6);
-  })();
-  const noImages = playable || hasEmbed || trackEmbedLinks.length > 0; // suppress images → let the player/embed card show
-  const urls = [];
-  // Posts with PLAYABLE hosted audio suppress image attachments so Mastodon renders
-  // the player CARD (twitter:player) instead of the cover — media attachment and
-  // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
-  // keeps its cover (no player card to show).
-  // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
-  // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
-  // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
-  // Media a C2S composer attached (shaer-j3uh): federate with their REAL
-  // mediaType, because the extension map below knows no audio and would call
-  // an m4a an Image. Pushed BEFORE the covers: a C2S video doubles as the
-  // cover video, and the URL-dedupe keeps the FIRST entry, which must be the
-  // one that knows its type and poster. Images also live inline in the
-  // content, so the dedupe keeps those single too.
-  try {
-    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
-      if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType, poster: a.poster ? abs(a.poster) : null });
-    }
-  } catch { /* malformed never blocks the Note */ }
-  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
-  else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
-  let body = post.content || '';
-  // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
-  // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
-  // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
-  // as the attachment description.
-  if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
-    const tag = m[0];
-    const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
-    if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
-    const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
-    urls.push({ url: abs(src), name: alt });
-  }
-  body = body.replace(/<img\b[^>]*>/gi, '');
-  // Video and audio tags leave the federated content the same way (30-7):
-  // they ride as AS2 attachments (c2s_attachments), and the tag itself
-  // carries a RELATIVE /media src that is dead everywhere but our own web.
-  // Leaving it in showed every remote reader a broken player above the
-  // working one. The web keeps its tags: this strip is federation-only.
-  body = body.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, '').replace(/<video\b[^>]*\/?>/gi, '');
-  body = body.replace(/<audio\b[^>]*>[\s\S]*?<\/audio>/gi, '').replace(/<audio\b[^>]*\/?>/gi, '');
-  // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately
-  // gates audio (the /audio/stream URL has friction), and shipping it as an AP
-  // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead,
-  // replace the shortcodes with a "🎵 listen on the site" link so the post invites
-  // a click-through to the protected player (discovery without leaking the file).
-  const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-  // Elke titel met zijn track-id erbij, zodat hij hieronder een EIGEN link
-  // krijgt naar #track-<id> op de postpagina (shaer-38y). Zonder id was dit een
-  // vetgedrukte opsomming waar je niets mee kon: vijf namen en een enkele
-  // "listen on"-link naar de post als geheel. Elke track heeft daar al een
-  // anker -- direct ingesloten, in een album of in een playlist -- dus dit
-  // wijst naar precies het nummer waar de naam bij hoort.
-  const audioLabels = [];
-  try {
-    const zien = new Set();
-    const voegToe = (id, titel) => {
-      const t = String(titel || '').trim();
-      if (!t) return;
-      const sleutel = id || ('naam:' + t);
-      if (zien.has(sleutel)) return;
-      zien.add(sleutel);
-      audioLabels.push({ id: id || null, titel: t });
-    };
-    // In de volgorde van de POST: een enkele scan over alle drie de vormen,
-    // zodat de opsomming leest zoals de post is neergezet.
-    for (const m of body.matchAll(/\[\[(track|album|playlist):([^\]]+)\]\]/gi)) {
-      const soort = m[1].toLowerCase(), waarde = m[2].trim();
-      if (soort === 'track') {
-        const r = db.prepare('SELECT id, title FROM audio_tracks WHERE id = ?').get(waarde);
-        if (r) voegToe(r.id, r.title);
-      } else if (soort === 'album') {
-        const rs = db.prepare('SELECT id, title FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY rowid').all(site.id, waarde);
-        if (rs.length) for (const r of rs) voegToe(r.id, r.title);
-        else voegToe(null, waarde);            // album zonder tracks: dan maar de naam
-      } else {
-        for (const r of db.prepare('SELECT t.id, t.title FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? ORDER BY pt.position').all(waarde)) voegToe(r.id, r.title);
-      }
-    }
-  } catch { /* non-fatal */ }
-  const openAudio = openAudioAttachments(base, site, post);
-  // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
-  // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
-  // speler van. Bakken we ze eerst om, dan komt er een tekstlink aan en is de
-  // speler weg. Onherstelbaar bovendien: het bakken STRIPT de shorthand en
-  // plakt achteraan hooguit VIER titels, dus een album van tien nummers
-  // overleeft het niet.
-  //
-  // Dezelfde regel als bij de outbox en de tracks: wie ondertekend vraagt
-  // namens de actor waar wij naartoe verhuisd zijn, krijgt onze eigen kijk.
-  if (!opts.rauweInhoud) body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
-  // renders its OWN preview/player card (YouTube/Spotify/SoundCloud/etc) instead
-  // of federating the raw shortcode text.
-  body = body.replace(/\[\[embed:([^\]]+)\]\]/gi, (mm, raw) => {
-    const u = esc(raw.trim().replace(/&amp;/g, '&'));
-    return `<p><a href="${u}">${u}</a></p>`;
-  });
-  if (hadAudio && !opts.rauweInhoud) {
-    // Elke titel als eigen link naar zijn anker; een titel zonder id (een
-    // albumnaam zonder tracks) blijft gewone tekst.
-    const lbl = audioLabels.slice(0, 4)
-      .map((a) => (a.id ? `<a href="${human}#track-${esc(a.id)}">${esc(a.titel)}</a>` : esc(a.titel)))
-      .join(', ');
-    if (trackEmbedLinks.length) {
-      // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
-      // player), the rest render as clickable links — the fediverse-native "embed + links".
-      body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong>` : ''}</p>`;
-      for (const u of trackEmbedLinks) { const eu = esc(u); body += `<p><a href="${eu}">${eu}</a></p>`; }
-    } else {
-      // For playable posts, append a version param to the listen-link so Mastodon
-      // sees a NEW card URL and re-crawls it (fresh SQUARE player card) instead of
-      // reusing the cached landscape one. Invisible: the link TEXT stays clean, the
-      // page ignores the param. Bump FEDI_CARD_VER when the card dimensions change.
-      const listenHref = playable ? `${human}?fc=${FEDI_CARD_VER}` : human;
-      body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${listenHref}">listen on ${esc(site.title || 'the site')}</a></p>`;
-    }
-  }
-  // Klonkt renders post content with white-space:pre-wrap, so raw newlines ARE line
-  // breaks on the site. Mastodon (plain HTML) collapses whitespace and would drop them,
-  // so convert newlines to <br> for the federated copy (content already made with
-  // shift+enter uses <br> and has no \n → this is a no-op there).
-  body = body.replace(/\r?\n/g, '<br>');
-  body = linkHashtags(base, body); // link inline #hashtags in the post body too
-  body = linkUrls(body);           // bare URLs → clickable links on the federated copy
-  // Append the tags-field hashtags to the content so Mastodon renders them as clickable
-  // hashtags (a Hashtag that's only in the `tag` array isn't shown inline). CamelCase
-  // multi-word tags; skip any already present inline in the body.
-  {
-    const inlineTags = new Set(hashtagTags(base, body).map((h) => h.name.slice(1).toLowerCase()));
-    const addSeen = new Set();
-    const tagLinks = normalizeTags(post.tags).map(tagParts).filter(Boolean)
-      .filter((p) => !inlineTags.has(p.slug) && !addSeen.has(p.slug) && addSeen.add(p.slug))
-      .map((p) => `<a href="${base}/tag/${encodeURIComponent(p.slug)}" class="mention hashtag" rel="tag">#${p.label}</a>`);
-    if (tagLinks.length) body += `<p>${tagLinks.join(' ')}</p>`;
-  }
-  const seen = new Set();
-  const attachment = urls.filter((x) => x && x.url)
-    .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
-    .map((x) => { const mt = x.mt || guessMediaType(x.url); // the stored type wins; the extension map is the fallback
-      const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
-      const a = { type: ty, mediaType: mt, url: x.url };
-      if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
-      if (x.poster) a.icon = { type: 'Image', url: x.poster }; // the video's still (shaer-zowq)
-      return a; });
-  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
-
-  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
-  const tape = mixtapeAttachment(base, site, post);
-  if (tape) attachment.push(tape);
-
-  // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
-  // present when the content was already mention-linked (deliverCreate/Update resolve them
-  // at send time); a plain buildNote (outbox/notes) yields none.
-  const _mentionTags = mentionTags(body);
-  const _mentionCc = _mentionTags.map((t) => t.href);
-
-  const note = {
-    id,
-    type: 'Note',
-    attributedTo: aId,
-    content: titleHtml + body,
-    url: human,
-    published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
-    // fan_only = "fans only" → followers-only visibility (delivered to your followers
-    // but not addressed to Public, so Mastodon shows it only to them and can't boost it).
-    to: (post.fan_only || post.ap_visibility === 'quiet') ? [`${aId}/followers`] : [PUBLIC],
-    // Mentioned actors (from inline @user@host links the caller resolved) are addressed in cc
-    // so Mastodon notifies them; empty unless the content was mention-linked (delivery time).
-    cc: [...new Set([
-      ...(post.ap_visibility === 'quiet' ? [PUBLIC] : []),          // quiet public: Public in cc, not to
-      ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
-      ..._mentionCc])],
-    tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content, post)],
-    replies: `${id}/replies`,
-    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
-    // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
-    sensitive: !!post.nsfw,
-  };
-  // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
-  Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
-  // FEP-044f: this post quotes a fediverse object. Emit it the way the network
-  // actually reads it, and address the quoted author so they get told.
-  applyQuoteProps(note, post.quote_uri, post.quote_actor);
-  if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
-  if (attachment.length) note.attachment = attachment;
-  // When the cover attachment is suppressed (hosted audio OR an external embed/link-only track →
-  // so Mastodon shows the player/link card, not media), still expose the cover via AS2 `image` so
-  // card/grid consumers (the Klonkt Cirkel/News feed) can show it. Mastodon ignores a Note's
-  // `image`, so its card is unaffected — but a Klonkt receiver reads it (handleInbox o.image).
-  if (post.cover_image_url && noImages) {
-    const cov = abs(post.cover_image_url);
-    if (cov) { note.image = { type: 'Image', mediaType: guessMediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
-  }
-  // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
-  // (/embed) so a client that honours embedUrl can show an inline player WITHOUT ever
-  // getting the audio file — the anti-steal posture is untouched. `embedUrl` is a real
-  // standard field name (not a Klonkt invention); if Mastodon's apps honour it on a Note we
-  // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
-  if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
-  // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
-  // language from its key for the timeline language filter + the translate button. Emitted
-  // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
-  if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
-  // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
-  // reuses the note's content/addressing/tags, then swaps the type and strips media.
-  const ownPoll = parseOwnPoll(post.poll_json);
-  if (ownPoll) applyPollToNote(note, post.id, ownPoll);
-  return note;
-}
-
-// All reply note URIs on a local post (inbound fediverse replies + our own
-// outbound replies) — backs the Note's `replies` Collection so remote servers
-// can fetch the whole thread.
-export function getReplyUris(base, postId) {
-  const out = [];
-  try {
-    for (const r of db.prepare("SELECT object_uri FROM ap_interactions WHERE kind = 'reply' AND post_id = ? AND object_uri != '' ORDER BY created_at").all(postId)) out.push(r.object_uri);
-    for (const r of db.prepare('SELECT id FROM ap_outbox WHERE post_id = ? ORDER BY rowid').all(postId)) out.push(`${base}/ap/notes/${r.id}`);
-  } catch { /* non-fatal */ }
-  return out;
-}
-
-// Notifications "seen" tracking → a real bell badge. Stored per site in app_settings.
-export function markNotificationsSeen(slug) {
-  try {
-    db.prepare("INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP")
-      .run(`fedi_notif_seen:${slug}`, new Date().toISOString());
-  } catch { /* non-fatal */ }
-}
-export function countUnseenNotifications(slug) {
-  try {
-    const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
-    const seen = row ? Date.parse(row.value) : 0;
-    let n = 0;
-    for (const it of getNotifications(slug, 50)) { if (Date.parse(it.created_at) > seen) n++; }
-    return n;
-  } catch { return 0; }
-}
-// The seen-watermark itself (ms epoch, 0 = never marked) — the Messages page reads it
-// BEFORE marking seen, so it can render unread dots on the items newer than last visit.
-export function notificationsSeenAt(slug) {
-  try {
-    const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(`fedi_notif_seen:${slug}`);
-    return row ? (Date.parse(row.value) || 0) : 0;
-  } catch { return 0; }
-}
-
-// Messages = the unified inbox (Reacties + Meldingen merged, decision Robin+Bart 2026-07-16):
-// every notification PLUS your own outbound replies ('sent', with edit/delete via their
-// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
-// one grouped item (actors list + count) so activity doesn't drown out conversations.
-/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
- *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
- *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
-function outboxMediaJson(attachments) {
-  if (!attachments) return null;
-  try {
-    const list = JSON.parse(attachments);
-    if (!Array.isArray(list) || !list.length) return null;
-    const media = list
-      .filter((a) => a && a.url)
-      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
-    return media.length ? JSON.stringify(media) : null;
-  } catch { return null; }
-}
-
-export function getMessages(slug, limit, offset) {
-  const off = Math.max(0, offset || 0);
-  const lim = limit || 60;
-  // The stream is grouped (consecutive likes/boosts collapse), so paging is done by
-  // recomputing the whole stream top-down and slicing [off, off+lim] — stable across
-  // pages. Fetch a buffer past off+lim so grouping-shrinkage can't hide a full page.
-  const need = off + lim + 100;
-  const items = getNotifications(slug, need);
-  try {
-    for (const m of listOutbox(slug).slice(0, need)) {
-      items.push({
-        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
-        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
-        editable: m.editable, language: m.language, created_at: m.created_at,
-        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
-        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
-        // media uit media_json met een `type`, terwijl ap_outbox ze als
-        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
-        // een foto die JIJ meestuurde als kale tekst binnen.
-        media_json: outboxMediaJson(m.attachments),
-      });
-    }
-  } catch { /* ignore */ }
-  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
-  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
-  // zei alleen een slug, dus vullen we ze in één query aan.
-  try {
-    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
-    if (missing.length) {
-      const rows = db.prepare(
-        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
-           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
-      ).all(...missing, slug);
-      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
-      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
-    }
-  } catch { /* zonder titel valt de draad terug op de slug */ }
-  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
-  const out = [];
-  for (const it of items) {
-    const prev = out[out.length - 1];
-    if ((it.type === 'like' || it.type === 'announce') && prev && prev.type === it.type
-        && prev.post_slug === it.post_slug) {
-      prev.actors = prev.actors || [prev.name || prev.handle || '?'];
-      prev.actors.push(it.name || it.handle || '?');
-      prev.count = (prev.count || 1) + 1;
-      continue;
-    }
-    out.push(it);
-  }
-  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
-  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
-  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
-  // gesprekken, niet door losse zinnen.
-  return groupConversations(out).slice(off, off + lim);
-}
-
-// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
-// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
-// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
-// moest dus tussen chips heen en weer, terwijl het één draad is.
-const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
-
-/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
- *
- *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
- *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
- *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
- *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
- *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
- *     loopt per tegenpartij.
- *
- *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
- *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
- *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
- *  door.
- */
-export function threadKey(it) {
-  if (!it || !CONV_TYPES.has(it.type)) return null;
-  if (it.post_slug) return `post:${it.post_slug}`;
-  let who = it.handle || it.to_handle || '';
-  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
-  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
-  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
-  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
-  if (!who && it.to_actors) {
-    try {
-      const first = JSON.parse(it.to_actors)[0];
-      if (first) who = deriveHandle(first);
-    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
-  }
-  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
-  return norm ? `actor:${norm}` : null;
-}
-
-/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
- *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
- *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
- *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
- *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
- */
-export function groupConversations(items) {
-  const threads = new Map();
-  const out = [];
-  for (const it of items || []) {
-    const key = threadKey(it);
-    if (!key) { out.push(it); continue; }
-    let t = threads.get(key);
-    if (!t) {
-      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
-      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
-      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
-      threads.set(key, t);
-      out.push(t);
-    }
-    t.messages.push(it);
-    // De context bij de draad: gaat het over een post, dan hoort de link
-    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
-    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
-    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
-    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
-    // dan kan het ontvangen antwoord eronder de echte titel niet meer
-    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
-    if (it.post_slug) {
-      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
-      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
-    }
-  }
-  for (const t of threads.values()) {
-    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
-    t.count = t.messages.length;
-    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
-    const seen = new Set();
-    for (const m of t.messages) {
-      if (m.type === 'sent') continue;
-      const h = m.handle || m.name;
-      if (!h || seen.has(h)) continue;
-      seen.add(h);
-      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
-    }
-    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
-    // onbeantwoord bericht leest.
-    t.mine = t.messages.some((m) => m.type === 'sent');
-    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
-    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
-    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
-    // een direct bericht aan de tegenpartij.
-    const inkomend = t.messages.filter((m) => m.type !== 'sent');
-    const laatste = inkomend[inkomend.length - 1];
-    t.replyTo = {
-      interactionId: (laatste && laatste.interactionId) || null,
-      postSlug: (t.post && t.post.slug) || null,
-      actorUri: (laatste && (laatste.actorUri || laatste.url))
-        || (t.messages.find((m) => m.to_actor) || {}).to_actor
-        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
-    };
-  }
-  return out;
-}
-
-export function buildCreate(base, site, post, opts = {}) {
-  const note = buildNote(base, site, post, opts);
-  return {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create',
-    type: 'Create',
-    actor: actorId(base, site.slug),
-    published: note.published,
-    to: note.to,
-    cc: note.cc,
-    object: note,
-  };
-}
-
-
-/**
- * De outbox: wat deze actor heeft uitgebracht. Posts EN tracks (shaer-0nh,
- * stap 4).
- *
- * WAAROM HIER EN NIET IN EEN BEZORGING. Een kanaal-lezer HAALT de outbox op --
- * zo heb ik zelf Funkwhales kanaal uitgelezen. Een Create(Audio) ook naar de
- * inboxen van volgers duwen zou schade doen: Mastodon neemt Audio aan als
- * statustype, dus bij een album-post zou dezelfde muziek twee keer in hun
- * tijdlijn komen -- een keer als bijlage bij de Note, en dan nog N keer los.
- * De post is het bericht, de outbox is de discografie.
- *
- * Door elkaar op datum, nieuwste eerst, zodat de outbox één verhaal vertelt in
- * plaats van twee lijstjes achter elkaar.
- *
- * De tracks komen als ARGUMENT binnen, net als de posts, en worden hier
- * uitdrukkelijk NIET zelf opgehaald. De route beslist wie wat mag zien -- een
- * geblokkeerde bezoeker krijgt daar een lege outbox, en een bouwer die stiekem
- * zijn eigen database bevraagt zou dwars door die deur heen leveren.
- */
-/**
- * Een PAGINA van de outbox, in SQL (shaer-sk4).
- *
- * De outbox mengt twee bronnen: posts en open tracks, gevlochten op datum. Een
- * offset over die twee kan niet met twee losse queries -- je weet niet hoeveel
- * van elk er in pagina drie horen. Vandaar een UNION met de datum als sleutel,
- * daar de LIMIT/OFFSET overheen, en pas dan de rijen zelf ophalen.
- *
- * Wat er stond was geen paginering maar een KAP: de route haalde twintig posts
- * en hield daarvan twintig items over. Alles daarvoor was niet op een volgende
- * pagina maar helemaal onbereikbaar.
- *
- * @param {boolean} fanOnly  mag de lezer ook de fans-only posts zien?
- */
-export function outboxSlice(siteId, { fanOnly = false, offset = 0, limit = MAX_OUTBOX } = {}) {
-  const fanClause = fanOnly ? '' : 'AND (p.fan_only IS NULL OR p.fan_only = 0)';
-  const unie = `
-    SELECT 'post' AS soort, p.id AS id, ${isoSql('COALESCE(p.published_at, p.created_at)')} AS wanneer
-      FROM posts p WHERE p.site_id = ? AND p.status = 'published' ${fanClause}
-    UNION ALL
-    SELECT 'track', t.id, t.created_at
-      FROM audio_tracks t WHERE t.site_id = ? AND t.fedi_open = 1`;
-  let rijen = [], totaal = 0;
-  try {
-    totaal = db.prepare(`SELECT COUNT(*) n FROM (${unie})`).get(siteId, siteId).n;
-    rijen = db.prepare(`SELECT soort, id FROM (${unie}) ORDER BY wanneer DESC LIMIT ? OFFSET ?`)
-      .all(siteId, siteId, limit, Math.max(0, offset));
-  } catch { return { posts: [], tracks: [], totaal: 0 }; }
-
-  const postIds = rijen.filter((r) => r.soort === 'post').map((r) => r.id);
-  const trackIds = rijen.filter((r) => r.soort === 'track').map((r) => r.id);
-  const gaten = (n) => Array.from({ length: n }, () => '?').join(',');
-  const posts = postIds.length ? db.prepare(
-    // fan_only en ap_visibility MOETEN mee. buildNote adresseert hierop, en
-    // zonder deze twee kolommen is post.fan_only altijd undefined: elke
-    // fan-only post ging dan de outbox uit met to: as:Public, terwijl hij
-    // alleen aan vrienden geserveerd wordt. Een volger kreeg dus een
-    // vrienden-post met een publiek etiket erop, en die mag hij dan publiek
-    // boosten. Gevonden tijdens de FEP-1580 end-to-end test (shaer-fuyo).
-    // EN paid + excerpt, om exact dezelfde reden (Barts melding, 15-8). Zonder
-    // `paid` is post.paid hier `undefined`, dan slaat buildNote zijn redactie
-    // over en gaat de VOLLEDIGE tekst van een betaalde post de outbox uit. Zo
-    // kwam een post via een hub-actor gewoon te lezen. `excerpt` moet mee omdat
-    // de teaser daaruit komt; zonder dat veld valt hij terug op de eerste
-    // alinea van precies de tekst die verborgen hoort te blijven.
-    //
-    // Dit is een KOLOMMENLIJST, en die faalt stil: een vergeten kolom is
-    // `undefined` en niet een fout. Wie hier een veld toevoegt waar buildNote
-    // op beslist, moet het HIER ook toevoegen.
-    `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
-            c2s_attachments, quote_json, embed_json, published_at, created_at,
-            fan_only, ap_visibility, paid, paid_min_cents
-       FROM posts WHERE id IN (${gaten(postIds.length)})`).all(...postIds) : [];
-  const tracks = trackIds.length ? db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-       FROM audio_tracks t JOIN media m ON m.id = t.media_id
-      WHERE t.id IN (${gaten(trackIds.length)})`).all(...trackIds) : [];
-  return { posts, tracks, totaal };
-}
-
-export function buildOutbox(base, site, posts, tracks = [], { page = false, totalItems, alGesneden = false, rauweInhoud = false } = {}) {
-  const id = `${actorId(base, site.slug)}/outbox`;
-  const wanneer = (x) => Date.parse(x && x.published ? x.published : 0) || 0;
-  const items = [
-    ...(posts || []).map((p) => buildCreate(base, site, p, { rauweInhoud })),
-    // Eén zoekopdracht voor alle tracks samen, niet per stuk.
-    ...(() => {
-      const posts = (tracks || []).length && site.id ? trackHostPosts(site.id) : null;
-      return (tracks || []).map((r) => buildTrackCreate(base, site, r, { hostPosts: posts }));
-    })(),
-  ]
-    .sort((a, b) => wanneer(b) - wanneer(a))
-    .slice(0, alGesneden ? Infinity : MAX_OUTBOX);
-  // WAT HIER NOG NIET GEPAGINEERD IS, en dat hoort genoemd (shaer-sk4): deze
-  // lijst is al door de route op twintig rijen afgekapt, dus pagina 2 is leeg.
-  // Echt doorbladeren vraagt een LIMIT/OFFSET in SQL -- en dat is hier lastiger
-  // dan bij volgers, want posts en tracks worden op DATUM door elkaar gevlochten
-  // en komen uit twee tabellen. Dat vraagt een UNION met een offset erover, geen
-  // tweede slice. De vorm klopt nu wel: pagina 2 zegt eerlijk dat hij leeg is en
-  // biedt geen `next` aan, in plaats van pagina 1 nog eens te geven.
-  // GEPAGINEERD, ook al past alles op een pagina (Funkwhale, 11-8).
-  //
-  // Hun serializer weigerde onze outbox met "first: This field is required" en
-  // "last: This field is required". AS2 EIST ze niet -- een collectie mag zijn
-  // items inline dragen -- maar bijna iedereen pagineert, en een lezer die de
-  // paginaweg volgt liep hier dood. Dit is de eerste concrete reden die we
-  // hoorden waarom er niets van ons binnenkwam.
-  //
-  // De items blijven WEL inline op de wortel. Shaer bouwt zijn feed daaruit, en
-  // wie hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Er is
-  // precies een pagina, dus first en last wijzen naar dezelfde.
-  return pagedCollection(id, items, { page, totalItems, alGesneden });
-}
-
-// Public callers get a count-only collection (privacy). The authenticated
-// account owner (a C2S bearer scoped to this site) gets the real actor URIs via
-// `items`, so their own client can build a friends list.
-export function buildFollowers(base, site, count, items = null, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/followers`;
-  // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
-}
-
-// The accounts this site follows — count only, mirroring buildFollowers. The spec lists
-// `following` as a standard actor property; Hubzilla/Friendica + crawlers expect it.
-export function buildFollowing(base, site, count, items = null, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/following`;
-  // count-only for the public; full for the owner
-  return pagedCollection(id, items || [], { totalItems: items ? items.length : (count || 0), page });
-}
-
-// Pinned posts → the actor's `featured` collection. Mastodon reads this and shows
-// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
-// rank; embedded as full Notes so a remote server doesn't need extra fetches.
-export function buildFeatured(base, site, posts, { page = false } = {}) {
-  const id = `${actorId(base, site.slug)}/featured`;
-  const items = (posts || []).map((p) => buildNote(base, site, p));
-  return pagedCollection(id, items, { page });
-}
-
-// ── Playlist als AP-collectie (shaer-ayc, stap 1 van het Funkwhale-spoor) ──
-// Een playlist heeft, anders dan een album-als-tekstveld, een id — dus kan hij
-// een stabiele URI dragen en federeren. De vorm is bewust kaal AS2: een
-// OrderedCollection van Audio-objecten, dezelfde rijvorm die een post als
-// attachment meestuurt, zodat elke client die post-audio al speelt dit ook
-// speelt.
-//
-// De poortregel verandert hier NIET: alleen fedi_open-tracks staan erin, met
-// echte bestands-URL. Een gated track is niet "een rij zonder url" maar
-// afwezig — wie de collectie leest ziet het open deel en kan niet aftellen
-// hoeveel er achter de poort staat. totalItems telt daarom ook alleen het
-// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
-// niet over wat wij thuis in de kast hebben.
-
-// ── followers store (lazy stmts) ──────────────────────────────────
-let _insF, _updFDisp, _delF, _listF, _cntF;
-function fStmts() {
-  if (!_insF) {
-    _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
-    _updFDisp = db.prepare('UPDATE ap_followers SET name = COALESCE(?, name), handle = COALESCE(?, handle), icon = COALESCE(?, icon) WHERE slug = ? AND actor_uri = ?');
-    _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
-    _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
-    _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
-  }
-  return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
-}
-export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
-
-// Followers with delivery health, for the management list. Never-delivered accounts
-// first, then oldest successful delivery first — i.e. the cleanup candidates on top.
-export function listFollowers(slug) {
-  return db.prepare(
-    `SELECT id, actor_uri, inbox, shared_inbox, created_at, last_delivery_at, last_error_at
-     FROM ap_followers WHERE slug = ?
-     ORDER BY (last_delivery_at IS NULL) DESC, last_delivery_at ASC, created_at ASC`
-  ).all(slug);
-}
-// Manually drop a follower after a check (a still-live account would have to re-follow).
-/**
- * Een volger verwijderen, en het hem ook LATEN WETEN (Robin, 21-8).
- *
- * Reject(Follow) is het standaardsignaal voor "je volgt me niet meer": de
- * andere kant ruimt de relatie dan op in plaats van te blijven denken dat hij
- * volgt. Zonder dit merkte de hub niets -- die bleef als volger in zijn eigen
- * boeken staan terwijl er nooit meer iets werd bezorgd.
- *
- * Verwijderen gaat altijd door; de melding is een gunst en mag mislukken.
- */
-export function removeFollower(slug, id) {
-  const rij = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? AND id = ?').get(slug, id);
-  const info = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND id = ?').run(slug, id);
-  if (info.changes > 0 && rij && rij.actor_uri) meldNietLangerVolger(slug, rij.actor_uri);
-  return info.changes > 0;
-}
-
-/** Reject(Follow) naar een ex-volger; faalt stil, want de relatie is al weg. */
-export function meldNietLangerVolger(slug, actorUri) {
-  try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const me = actorId(base, slug);
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-    if (!site) return;
-    const reject = {
-      '@context': AP_CONTEXT,
-      id: `${me}#reject-follow-${Date.now()}-${rid()}`,
-      type: 'Reject',
-      actor: me,
-      to: [actorUri],
-      object: { type: 'Follow', actor: actorUri, object: me },
-    };
-    deliverToActor(site, actorUri, reject)
-      .then((r) => console.log('[AP] Reject(Follow)', slug, '→', actorUri, r && r.delivered ? 'bezorgd' : 'niet bezorgd'))
-      .catch(() => {});
-  } catch { /* nooit blokkerend */ }
-}
-
-// Best cached display for an actor URI, across the caches Klonkt already fills:
-// followers (now with name/icon), following, interactions, timeline, mentions.
-// Falls back to a handle derived from the URI. Display info is not sensitive.
-export function actorDisplay(slug, uri) {
-  const ok = (r) => r && (r.name || r.icon);
-  try {
-    let r = db.prepare('SELECT name, handle, icon FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT name, handle, icon FROM ap_following WHERE slug = ? AND actor_uri = ?').get(slug, uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_interactions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT author_name AS name, author_handle AS handle, author_icon AS icon FROM ap_timeline WHERE author_uri = ? AND (author_name IS NOT NULL OR author_icon IS NOT NULL) LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-    r = db.prepare('SELECT actor_name AS name, actor_handle AS handle, actor_icon AS icon FROM ap_mentions WHERE actor_uri = ? AND (actor_name IS NOT NULL OR actor_icon IS NOT NULL) ORDER BY created_at DESC LIMIT 1').get(uri);
-    if (ok(r)) return { name: r.name, handle: r.handle || deriveHandle(uri), icon: r.icon };
-  } catch { /* ignore */ }
-  return { name: null, handle: deriveHandle(uri), icon: null };
-}
-
-// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
-// Pure and testable; the route sets the response headers around it.
-export function prefersEnriched(preferHeader) {
-  return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
-}
-
-// AS2 actor reference with display, for the owner C2S followers/following view.
-// preferredUsername = the local part of the handle; name = the set display name.
-export function buildActorRef(slug, uri) {
-  const d = actorDisplay(slug, uri);
-  const user = d.handle && d.handle[0] === '@' ? d.handle.slice(1).split('@')[0] : null;
-  const out = { id: uri, type: 'Person' };
-  if (d.name) out.name = d.name;
-  if (user) out.preferredUsername = user;
-  if (d.icon) out.icon = { type: 'Image', url: d.icon };
-  return out;
-}
-
-// The site's OWN display info in the same shape as `shaer:author` on timeline
-// entries. The owner's app reads its own posts from the outbox, which carried
-// no author info, so every card but your own had a byline (Robins melding,
-// 30-7: geen header van self op eigen posts).
-export function selfAuthor(base, site) {
-  const out = {
-    name: site.title || site.slug,
-    handle: `@${site.slug}@${String(base).replace(/^https?:\/\//, '')}`,
-    url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
-  };
-  if (site.profile_photo) {
-    out.icon = /^https?:/.test(site.profile_photo) ? site.profile_photo : `${base}${site.profile_photo.startsWith('/') ? '' : '/'}${site.profile_photo}`;
-  }
-  return out;
-}
-
-// Merge who-you-follow (ap_following, rich display) with who-follows-you (ap_followers,
-// delivery health) into ONE connections list, keyed by actor_uri. Each entry gets a
-// direction (following →, follower ←, mutual ↔) and, for accounts we deliver to, an
-// `unreachable` flag (never delivered, or last attempt failed after the last success) so
-// the view can split dead connections into their own section. Powers the Connect page.
-export function listConnections(slug) {
-  const byUri = new Map();
-  for (const f of listFollowing(slug)) {
-    byUri.set(f.actor_uri, {
-      actor_uri: f.actor_uri, name: f.name || null, handle: f.handle || null,
-      icon: f.icon || null, url: f.url || null, auto_boost: f.auto_boost ? 1 : 0,
-      status: f.status || null, following: true, follower: false,
-      last_delivery_at: null, last_error_at: null, follower_id: null,
-    });
-  }
-  for (const fo of listFollowers(slug)) {
-    const e = byUri.get(fo.actor_uri);
-    if (e) { e.follower = true; e.last_delivery_at = fo.last_delivery_at; e.last_error_at = fo.last_error_at; e.follower_id = fo.id; }
-    else byUri.set(fo.actor_uri, {
-      actor_uri: fo.actor_uri, name: null, handle: null, icon: null, url: null,
-      auto_boost: 0, status: null, following: false, follower: true,
-      last_delivery_at: fo.last_delivery_at, last_error_at: fo.last_error_at, follower_id: fo.id,
-    });
-  }
-  return [...byUri.values()].map((e) => {
-    e.direction = (e.following && e.follower) ? 'mutual' : (e.following ? 'following' : 'follower');
-    e.unreachable = e.follower && (!e.last_delivery_at || (!!e.last_error_at && (!e.last_delivery_at || e.last_error_at > e.last_delivery_at)));
-    return e;
-  });
-}
-
-// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
-let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
-// ── moderation tombstones (ap_rejected_objects) ───────────────────
-// A reply the owner removed stays removed: its object URI is tombstoned and
-// checked at ingest AND by the thread-crawler (else thread-filling would
-// re-fetch it). Owner moderation acts on the LOCAL copy, so it also works for
-// private notes that authorize_interaction can't fetch (401/404).
-let _insRj, _hasRj;
-function rjStmts() {
-  if (!_insRj) {
-    _insRj = db.prepare('INSERT OR IGNORE INTO ap_rejected_objects (object_uri, post_id, reason) VALUES (?,?,?)');
-    _hasRj = db.prepare('SELECT 1 FROM ap_rejected_objects WHERE object_uri = ?');
-  }
-  return { ins: _insRj, has: _hasRj };
-}
-export function isRejectedObject(uri) {
-  if (!uri) return false;
-  try { return !!rjStmts().has.get(String(uri)); } catch { return false; }
-}
-// Owner removes an incoming reply: tombstone + delete. Tenancy-scoped: the
-// interaction's post must belong to the caller's site.
-export function rejectInteraction(site, interactionId, reason) {
-  if (!site || !site.slug) return { error: 'forbidden' };
-  const row = iStmts().getI.get(interactionId);
-  if (!row) return { error: 'not_found' };
-  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
-    .get(row.post_id, site.slug);
-  if (!owns) return { error: 'forbidden' };
-  if (row.object_uri) { try { rjStmts().ins.run(row.object_uri, row.post_id, reason || 'removed by site owner'); } catch { /* non-fatal */ } }
-  db.prepare('DELETE FROM ap_interactions WHERE id = ?').run(interactionId);
-  console.log('[AP] interaction removed by owner', site.slug, row.object_uri || row.actor_uri);
-  return { ok: true, object_uri: row.object_uri || null, actor_uri: row.actor_uri || null };
-}
-// Stored URIs of an interaction (tenancy-scoped) → feed sendReport for flagging
-// from the local copy (works for private notes; no remote fetch needed to target).
-export function interactionReportTarget(site, interactionId) {
-  if (!site || !site.slug) return null;
-  const row = iStmts().getI.get(interactionId);
-  if (!row) return null;
-  const owns = db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = (SELECT id FROM sites WHERE slug = ?)')
-    .get(row.post_id, site.slug);
-  if (!owns) return null;
-  return { objectUri: row.object_uri || null, actorUri: row.actor_uri || null };
-}
-
-// AP addressing → visibility: 'public' | 'unlisted' | 'followers' | 'direct'.
-// Mastodon-conventie: Public in `to` = public, Public in `cc` = unlisted, een
-// followers-collectie zonder Public = followers-only, anders direct (DM). Public
-// kan als volledige URI, 'as:Public' of 'Public' voorkomen (JSON-LD shorthands).
-export function noteVisibility(o) {
-  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : []));
-  const isPub = (u) => u === PUBLIC || u === 'as:Public' || u === 'Public';
-  const to = arr(o && o.to).map(String);
-  const cc = arr(o && o.cc).map(String);
-  if (to.some(isPub)) return 'public';
-  if (cc.some(isPub)) return 'unlisted';
-  if ([...to, ...cc].some((u) => /\/followers\/?$/.test(u))) return 'followers';
-  return 'direct';
-}
-
-/**
- * Does this note belong in the home timeline (de Krant)?
- *
- * Only if it is a POST. A direct note is addressed to named people, so it is a
- * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
- * guardian's wave. Those are stored as mentions instead and surface in
- * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
- */
-export function belongsInTimeline(o) {
-  if (!o || !o.id || o.inReplyTo) return false;
-  return noteVisibility(o) !== 'direct';
-}
-
-function iStmts() {
-  if (!_insI) {
-    _insI = db.prepare(`INSERT OR IGNORE INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, parent_uri, visibility, emoji_json, actor_emoji_json, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
-    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
-    _listI = db.prepare('SELECT id, kind, object_uri, parent_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at, acted_boost, acted_like, visibility, emoji_json, actor_emoji_json FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
-    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
-    _insO = db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
-    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
-  }
-  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
-}
-
-export function getInteractionById(id) { return iStmts().getI.get(id); }
-export function setInteractionBoosted(id, on) {
-  db.prepare('UPDATE ap_interactions SET acted_boost = ? WHERE id = ?').run(on ? 1 : 0, id);
-}
-export function setInteractionLiked(id, on) {
-  db.prepare('UPDATE ap_interactions SET acted_like = ? WHERE id = ?').run(on ? 1 : 0, id);
-}
-
-const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
-// Extract our local post id from a note URL, but only if it's ours (base match).
-// One host, two spellings (Barts WebFinger-les, 2-8): a URL the client hands
-// back may carry the punycoded host (every URL parser silently punycodes)
-// while PUBLIC_BASE_URL carries the typed one. WHATWG URL does the IDNA, so
-// compare origins in ASCII and never the bytes the client happened to send.
-function asciiOrigin(u) {
-  try { const x = new URL(String(u)); return `${x.protocol}//${x.host}`.toLowerCase(); } catch { return null; }
-}
-function isOwnUrl(u) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return false;
-  const a = asciiOrigin(u);
-  return !!a && a === asciiOrigin(base);
-}
-function postIdFromNoteUrl(url, base) {
-  const s = String(url || '');
-  // ASCII origins, not startsWith: xn--zz9h.example IS 🩵.example, and a
-  // byte comparison read our own note as a stranger's.
-  if (base) { const a = asciiOrigin(s); if (!a || a !== asciiOrigin(base)) return null; }
-  const m = s.match(/\/ap\/notes\/([^/?#]+)/);
-  return m ? decodeURIComponent(m[1]) : null;
-}
-export function deriveHandle(actorUri) {
-  try { const u = new URL(actorUri); const seg = u.pathname.split('/').filter(Boolean).pop() || ''; return `@${seg}@${u.host}`; } catch { return String(actorUri || ''); }
-}
-function actorInfo(doc, actorUri) {
-  let host = ''; try { host = new URL(actorUri).host; } catch { /* keep empty */ }
-  const handle = doc && doc.preferredUsername ? `@${doc.preferredUsername}@${host}` : deriveHandle(actorUri);
-  const icon = doc && doc.icon ? (doc.icon.url || (Array.isArray(doc.icon) && doc.icon[0] && doc.icon[0].url)) : null;
-  const name = (doc && (doc.name || doc.preferredUsername)) || handle;
-  // Een AS2 `url` mag een ARRAY van Links zijn -- onze eigen buildActor doet
-  // dat (profiel + RSS), en een oudere consument stringde die array tot
-  // "[object Object],[object Object]" in de mention-hrefs van een hulpvraag
-  // (Barts vondst, 8-8). pickLink kiest de html-Link; de kale string blijft
-  // de gewone weg, en de actor-id de terugval.
-  const profiel = (doc && Array.isArray(doc.url))
-    ? ((pickLink(doc.url, (mt) => !mt || /html/i.test(mt)) || {}).href || safeUrl(doc.id || actorUri))
-    : safeUrl((doc && (doc.url || doc.id)) || actorUri);
-  return {
-    name,
-    handle,
-    url: profiel || null,
-    icon: safeUrl(icon) || null,
-    // FEP-9098 custom emojis in the display name (":shortcode:"), so the byline
-    // renders them. Only computed when the name actually has a shortcode.
-    emojis: /:[A-Za-z0-9_+-]+:/.test(name) ? actorNameEmojis(doc) : undefined,
-  };
-}
-
-// Map ":shortcode:" → image url from an actor doc's Emoji tags (for a custom-
-// emoji display name). Undefined when there are none.
-function actorNameEmojis(doc) {
-  const arr = doc && Array.isArray(doc.tag) ? doc.tag : (doc && doc.tag ? [doc.tag] : []);
-  const out = {};
-  for (const t of arr) {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Emoji' || typeof t.name !== 'string' || !t.icon) continue;
-    const u = t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url);
-    if (u) out[t.name] = u;
-  }
-  return Object.keys(out).length ? out : undefined;
-}
-
-// Given an inReplyTo note URL, find which local post the thread belongs to + the
-// note being replied to (parent), so a reply-to-a-comment can be nested.
-function findThreadTarget(inReplyTo, base) {
-  if (!inReplyTo) return null;
-  const seg = postIdFromNoteUrl(inReplyTo, base); // our /ap/notes/<id> segment (if ours)
-  if (seg && localPostExists(seg)) return { post_id: seg, parent_uri: inReplyTo };
-  if (seg) {
-    try { const o = db.prepare('SELECT post_id FROM ap_outbox WHERE id = ?').get(seg); if (o && o.post_id) return { post_id: o.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
-  }
-  try { const row = db.prepare("SELECT post_id FROM ap_interactions WHERE object_uri = ? AND kind = 'reply' LIMIT 1").get(inReplyTo); if (row && row.post_id) return { post_id: row.post_id, parent_uri: inReplyTo }; } catch { /* ignore */ }
-  return null;
-}
-
-// Drop the leading @mention(s) a federated reply carries (the person being replied to),
-// so a comment reads "dope tekening ouwe" instead of "@jason@jasonhacky.nl dope …".
-// Keeps a leading <p> wrapper; handles mention <a> links and plain-text @user@domain.
-export function stripLeadingMentions(html) {
-  if (!html) return html;
-  let s = String(html);
-  s = s.replace(/^(\s*<p[^>]*>)?\s*(?:<a\b[^>]*>\s*@[^<]+<\/a>[  ]*)+/i, (m, p) => p || '');
-  s = s.replace(/^(\s*<p[^>]*>)?\s*(?:@[\w.-]+(?:@[\w.-]+)?[  ]+)+/i, (m, p) => p || '');
-  return s;
-}
-
-// View-ready threaded view of a post's fediverse activity (inbound replies +
-// our outbound replies, nested), plus like/boost counts.
-export function getInteractions(postId, base, site) {
-  const s = iStmts();
-  // Privacy: a followers-only or direct (DM) reply is addressed to people, not to the
-  // public web, so it must NOT render in the public thread. It still reaches the owner
-  // via notifications (post context + reference included there). Legacy rows without a
-  // visibility value are treated as public. Likes/boosts stay counted (count-only).
-  const rows = s.list.all(postId).filter((r) =>
-    r.kind !== 'reply' || !(r.visibility === 'followers' || r.visibility === 'direct'));
-  const baseClean = (base || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const postNoteId = baseClean ? `${baseClean}/ap/notes/${postId}` : null;
-  // Our own (outbound) replies show the SITE identity for everyone (not "You").
-  let host = ''; try { host = new URL(baseClean).host; } catch { /* ignore */ }
-  const siteName = (site && (site.title || site.slug)) || '';
-  const siteHandle = (site && site.slug && host) ? `@${site.slug}@${host}` : '';
-  const siteUrl = baseClean ? `${baseClean}/` : '';
-  const siteIcon = (site && site.profile_photo) || null;
-
-  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
-  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
-  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
-  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
-  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
-  const mijnSleutel = new Map();
-  for (const r of rows) {
-    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
-  }
-  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
-  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
-
-  const nodes = [];
-  for (const r of rows) {
-    if (r.kind !== 'reply') continue;
-    const ik = mijnReactie(r.object_uri);
-    nodes.push({
-      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
-      actor_uri: r.actor_uri,
-      actor_name: r.actor_name, actor_handle: r.actor_handle, actor_url: r.actor_url,
-      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
-      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: ik.boosted, acted_like: ik.liked,
-      children: [],
-    });
-  }
-  for (const o of s.listO.all(postId)) {
-    nodes.push({
-      noteId: baseClean ? `${baseClean}/ap/notes/${o.id}` : o.id, parent: o.in_reply_to || null,
-      mine: true, outboxId: o.id, content: stripLeadingMentions(o.content), created_at: o.created_at,
-      media: (() => { try { return o.attachments ? JSON.parse(o.attachments) : []; } catch { return []; } })(),
-      actor_name: siteName, actor_handle: siteHandle, actor_url: siteUrl, actor_icon: siteIcon,
-      children: [],
-    });
-  }
-
-  const byId = new Map(nodes.map((n) => [n.noteId, n]));
-  // Conversation partners per node (u02, the reply editor's mentions bar): the
-  // node's author plus the ancestor authors up the chain. Our own nodes are
-  // skipped (we do not mention ourselves), deduped by actor, capped at 8.
-  for (const n of nodes) {
-    const seen = new Set();
-    const list = [];
-    let cur = n, guard = 0;
-    while (cur && guard++ < 12 && list.length < 8) {
-      if (!cur.mine && cur.actor_uri && !seen.has(cur.actor_uri)) {
-        seen.add(cur.actor_uri);
-        list.push({
-          uri: cur.actor_uri,
-          url: cur.actor_url || cur.actor_uri,
-          handle: cur.actor_handle || deriveHandle(cur.actor_uri),
-        });
-      }
-      cur = cur.parent ? byId.get(cur.parent) : null;
-    }
-    n.participants = list;
-  }
-  const isTop = (n) => !n.parent || n.parent === postNoteId || !byId.has(n.parent);
-  const tops = [];
-  for (const n of nodes) {
-    if (isTop(n)) { tops.push(n); continue; }
-    let anc = n, guard = 0;
-    while (!isTop(anc) && guard++ < 12) anc = byId.get(anc.parent);
-    anc.children.push(n);
-  }
-  const byTime = (a, b) => new Date(a.created_at) - new Date(b.created_at);
-  tops.sort(byTime).forEach((t) => t.children.sort(byTime));
-
-  return {
-    thread: tops,
-    likeCount: rows.filter((r) => r.kind === 'like').length,
-    announceCount: rows.filter((r) => r.kind === 'announce').length,
-    total: nodes.length,
-  };
-}
-
-const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
-// Which of OUR sites are named in a note's Mention tags? Only hrefs on our own base count
-// (an /ap/users/<slug> path on a remote host is someone else's actor), and the slug must be
-// an existing site. Deduped.
-export function localMentionSlugs(tags, base) {
-  if (!base) return [];
-  const out = [], seen = new Set();
-  for (const t of (Array.isArray(tags) ? tags : (tags ? [tags] : []))) {
-    if (!t || t.type !== 'Mention' || typeof t.href !== 'string') continue;
-    if (!t.href.startsWith(base + '/ap/users/')) continue;
-    const slug = slugFromActorUrl(t.href);
-    if (!slug || seen.has(slug)) continue; seen.add(slug);
-    try { if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(slug)) out.push(slug); } catch { /* ignore */ }
-  }
-  return out;
-}
-
-// ── Authorized fetch for a single Note (2-8) ─────────────────────
-// Who may read this post's Note over AP GET? 'public' needs nobody;
-// friends-only (fan_only, Shaer's DEFAULT) needs a verified follower;
-// 'direct' is addressed to people and is never served over a GET at all.
-export function noteAudience(post) {
-  if (!post) return 'direct';
-  if (post.ap_visibility === 'direct') return 'direct';
-  if (post.fan_only || post.ap_visibility === 'friends') return 'followers';
-  return 'public';
-}
-// A follower earns the friends-only Note; a blocked actor gets the same
-// nothing as a stranger (the standing rule: a blocked actor's signed fetch
-// earns the empty set, gated server-side at serialisation).
-export function mayReadNote(site, post, actorUri) {
-  const aud = noteAudience(post);
-  if (aud === 'public') return true;
-  if (aud === 'direct' || !site || !actorUri) return false;
-  // FEP-1580: dezelfde regel als in outboxAudience, en hier net zo hard nodig.
-  // De outbox geeft de LIJST vrij; zonder deze tak strandt de doelinstantie
-  // alsnog op elke losse Note die niet publiek is.
-  if (isMoveTarget(site.slug, actorUri)) return true;
-  try {
-    const blocked = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'actor' AND target = ?").get(site.slug, actorUri);
-    if (blocked) return false;
-    let host = null; try { host = new URL(actorUri).host; } catch { /* geen host, geen domein-block */ }
-    if (host) {
-      const dom = db.prepare("SELECT 1 FROM ap_blocks WHERE slug = ? AND kind = 'domain' AND target = ?").get(site.slug, host);
-      if (dom) return false;
-    }
-    return !!db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(site.slug, actorUri);
-  } catch { return false; }
-}
-
-
-// ── Web push to the owner (docs/webpush-design.md, slice 3) ─────────
-// Fire-and-forget: a notification must never block or break inbox processing.
-function pushEvent(slug, event) {
-  try { Push.notifySite(slug, event).catch(() => {}); } catch { /* never throw */ }
-  wakeNews(slug);   // long-poll waiters (Robins verzoek, 31-7): same moments as push
-}
-
-// ── Long-poll on news (Robins verzoek, 31-7) ─────────────────────
-// The app holds GET /ap/users/:slug/inbox/wait open; the moment anything
-// push-worthy lands for that account (a message, a reply, a wave, a help
-// request) every waiter is woken and the app re-reads its feed. In-process
-// on purpose: one Klonkt is one process, and a waiter is one callback.
-const _newsWaiters = new Map();   // slug -> Set<cb>
-export function onNews(slug, cb) {
-  let set = _newsWaiters.get(slug);
-  if (!set) { set = new Set(); _newsWaiters.set(slug, set); }
-  set.add(cb);
-  return () => { set.delete(cb); if (!set.size) _newsWaiters.delete(slug); };
-}
-/**
- * Wachters op het Guardian-paneel (Barts opdracht, 9-8).
- *
- * APART VAN onNews, en dat is met opzet. `news` gaat over de tijdlijn; dit gaat
- * over alles wat een guardian te VERWERKEN krijgt -- een aanbod, een
- * volgverzoek, een gate-voorstel, een hulpvraag, een lapse. De guardianship-
- * module zendt daar al veertien soorten voor uit; die gingen alleen naar push,
- * en push kiest bewust maar een handvol. Het paneel moet ze allemaal weten.
- *
- * Een wachter wordt EEN keer gewekt en daarna vergeten: het antwoord dat volgt
- * is de nieuwe waarheid, en de client komt terug met een nieuwe wachter.
- */
-const _guardWaiters = new Map();   // slug -> Set<cb>
-export function onGuardian(slug, cb) {
-  let set = _guardWaiters.get(slug);
-  if (!set) { set = new Set(); _guardWaiters.set(slug, set); }
-  set.add(cb);
-  return () => { set.delete(cb); if (!set.size) _guardWaiters.delete(slug); };
-}
-export function wakeGuardian(slug) {
-  const set = _guardWaiters.get(slug);
-  if (!set || !set.size) return;
-  const cbs = [...set];
-  set.clear();
-  _guardWaiters.delete(slug);
-  for (const cb of cbs) { try { cb(); } catch { /* een wachter mag de rest nooit breken */ } }
-}
-
-export function wakeNews(slug) {
-  const set = _newsWaiters.get(slug);
-  if (!set || !set.size) return;
-  const cbs = [...set];
-  set.clear();
-  _newsWaiters.delete(slug);
-  for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } }
-}
-// Path prefix for a site's pages. One instance is one owner, so the site
-// lives at the root; kept as a function because the push URLs read like
-// `${pushPrefix(slug)}/messages` all over this file.
-function pushPrefix() { return ''; }
-// Notification language: the site's content language (fallback: instance default).
-function pushLang(slug) {
-  try { const r = db.prepare('SELECT language FROM sites WHERE slug = ?').get(slug); return (r && r.language) || process.env.KLONKT_DEFAULT_LANG || 'nl'; } catch { return 'nl'; }
-}
-// Site slug, target URL and title for a post-scoped notification.
-function pushPostCtx(postId) {
-  try {
-    const r = db.prepare('SELECT p.slug AS post, p.title, s.slug AS site FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ?').get(postId);
-    if (!r) return null;
-    return { site: r.site, title: r.title || r.post, url: `${pushPrefix(r.site)}/${r.post}#fediverse` };
-  } catch { return null; }
-}
-
-// ── Op slot na een verhuizing (FEP-7628) ──────────────────────────
-//
-// Een verhuisd account serveert `movedTo` en is daarmee dood verklaard. Toch kon
-// je er gewoon op posten, volgen, liken en reageren, en dat federeerde vrolijk
-// de wereld in. Drie dingen gaan daar mis:
-//
-//   - Nieuwe posts krijgen een object-URI op een adres dat je hebt opgezegd. Die
-//     URI's overleven het domein niet, en de reacties erop ook niet.
-//   - Je volgers zijn al verhuisd, dus je post in het niets terwijl het lijkt of
-//     je post.
-//   - Een server die je movedTo ziet EN tegelijk verse activiteit van dat adres
-//     krijgt, krijgt tegenstrijdige signalen over de verhuizing.
-//
-// Daarom staat de poort op de UITGAANDE kant en niet op de knoppen: een
-// C2S-client (Shaer) praat rechtstreeks met deze functies en zou anders zo langs
-// een verborgen knop lopen. De UI volgt de poort, niet andersom.
-//
-// WAT DICHT GAAT: posten, reageren, volgen, liken, boosten, stemmen, en een
-// tweede verhuizing.
-// WAT OPEN BLIJFT: alles wat de wegwijzer draagt (de actor, webfinger, je
-// bestaande posts, de outbox), alles inkomend (reacties op oude posts blijven
-// binnenkomen en leesbaar), je eigen beheer (archief exporteren, volglijst
-// downloaden), en ontvolgen -- opruimen mag altijd.
-// Rapporteren blijft OOK open: dat is een veiligheidsklep, geen inhoud maken.
-//
-// OMKEERBAAR: `moved_to` leegmaken heft het slot op. Een verhuizing kan mislukken
-// en dan moet je terug kunnen.
-export function movedLock(site) {
-  const to = site && site.moved_to && /^https?:\/\//i.test(String(site.moved_to))
-    ? String(site.moved_to) : null;
-  return to ? { locked: true, movedTo: to } : { locked: false, movedTo: null };
-}
-
-/** Weigering in de vorm die de aanroepers al kennen: een object met `error`. */
-function movedRefusal(site, wat) {
-  const l = movedLock(site);
-  if (!l.locked) return null;
-  console.warn('[AP] geweigerd, dit account is verhuisd:', wat, '→', l.movedTo);
-  return { error: 'moved', movedTo: l.movedTo };
-}
-
-// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
-// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
-export async function deliverCreate(site, post) {
-  if (movedLock(site).locked) { console.warn('[AP] Create niet bezorgd, account verhuisd:', site && site.slug); return; }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  // Resolve inline @user@host mentions → link them in the note + collect their inboxes, so a
-  // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
-  const mres = await resolveMentionsInText(base, post.content || '');
-  let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
-  // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
-  // and remember it on the post, so buildNote (sync, also used by the outbox)
-  // never has to fetch. The quoted author's inbox joins the delivery set: that
-  // IS the notification.
-  const quoteInboxes = [];
-  // EERST BAKKEN, dan pas linken zoeken (shaer-k3f, gevonden op het toestel):
-  // firstExternalUrl leest <a href>-ankers, en de web-editor bakt die er bij
-  // het opslaan al in -- maar een post uit de APP is platte tekst waarin de
-  // URL nog geen anker is. Zonder deze bak zag het C2S-pad dus nooit een link
-  // en kreeg een app-post nooit een kaart, terwijl de preview hem net wel
-  // beloofd had.
-  const gebakken = bakePostContent(post2.content || '');
-  if (post2.quote_uri === undefined || post2.quote_uri === null) {
-    const q = await resolveOwnQuote(gebakken);
-    if (q) {
-      try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
-      post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
-    }
-  }
-  // De kaart op de eigen post (shaer-k3f), langs dezelfde pijplijn als een
-  // binnenkomende: een fediverse-quote wordt een quote-snapshot, anders
-  // probeert de link een externe kaart. VOOR de vroege return hieronder, want
-  // ook een post zonder volgers hoort zijn kaart te krijgen -- de app leest
-  // hem uit de outbox, niet uit een bezorging. Best-effort en eenmalig: wat
-  // hier niet lukt blijft een kale link, precies wat het was.
-  if (!post2.quote_json && !post2.embed_json) {
-    try {
-      if (post2.quote_uri) {
-        const qj = await resolveQuoteByUri(post2.quote_uri);
-        if (qj) { db.prepare('UPDATE posts SET quote_json = ? WHERE id = ?').run(qj, post.id); post2 = { ...post2, quote_json: qj }; }
-      } else {
-        const ej = await resolveExternalEmbed(gebakken);
-        if (ej) { db.prepare('UPDATE posts SET embed_json = ? WHERE id = ?').run(ej, post.id); post2 = { ...post2, embed_json: ej }; }
-      }
-    } catch { /* een kaart is nooit een blokkade voor de post zelf */ }
-  }
-  if (post2.quote_actor) {
-    const a = await fetchActor(post2.quote_actor).catch(() => null);
-    const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
-    if (inbox) quoteInboxes.push(inbox);
-  }
-  const followers = fStmts().list.all(site.slug);
-  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
-  if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${actorId(base, site.slug)}#main-key`;
-  const create = buildCreate(base, site, post2);
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem);
-}
-
-// On a new Follow, send that follower our most recent posts as Create so their
-// timeline shows our history (Mastodon does not backfill on follow). Oldest-first
-// so they sort into the follower's timeline at their original dates.
-async function backfillNewFollower(base, slug, inbox) {
-  if (!base || !slug || !inbox) return;
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) return;
-  // Deze lijst filterde op fan_only maar NIET op paid, en haalde `paid` ook niet
-  // op -- dus stond post.paid op undefined, sloeg buildNote zijn redactie over,
-  // en duwden we bij ELKE nieuwe volger twintig posts de deur uit met de
-  // volledige tekst van de betaalde erbij. Een push, dus onherroepelijk: het
-  // staat daarna in hun inbox. Zelfde reden voor ap_visibility, dat hier
-  // helemaal ontbrak: een friends- of direct-post hoort niet in een backfill.
-  // (Barts melding, 15 augustus 2026.)
-  const recent = db.prepare(
-    `SELECT id, slug, title, excerpt, content, cover_image_url, cover_video_url, nsfw, content_warning,
-            c2s_attachments, published_at, created_at, fan_only, ap_visibility, paid, paid_min_cents
-     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND IFNULL(ap_visibility, 'public') = 'public'
-     ORDER BY ${isoSql('COALESCE(published_at, created_at)')} DESC LIMIT 20`
-  ).all(site.id).reverse();
-  if (!recent.length) return;
-  const keys = getOrCreateKeys(slug);
-  const keyId = `${actorId(base, slug)}#main-key`;
-  for (const p of recent) {
-    try { await deliver(inbox, buildCreate(base, site, p), keyId, keys.private_pem); } catch { /* best-effort */ }
-    await new Promise((r) => setTimeout(r, 150));
-  }
-  console.log('[AP] backfilled', recent.length, 'posts to new follower of', slug);
-}
-
-// Tell followers a post is gone (Delete + Tombstone) so it's removed from their feeds.
-/**
- * Delete(Tombstone) voor een van onze EIGEN objecten, naar alle volgers.
- *
- * De romp staat apart omdat een post niet het enige is dat wij de draad op
- * sturen. Een track is een eersterangs Audio-object met een eigen id
- * (shaer-0nh), en die werd bij verwijderen nergens aangekondigd: de rij ging
- * weg, het object ging 404 en elke server die hem had geindexeerd hield hem
- * voor altijd. Op de hub kwam dat op 21-8 aan het licht als een track die naar
- * een dode URL wees.
- *
- * Het object-id komt van de aanroeper. Dat moet ook wel: bij verwijderen is de
- * rij vaak al weg, dus er valt niets meer op te zoeken.
- */
-export async function deliverObjectDelete(site, objectId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !objectId) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const del = {
-    '@context': AP_CONTEXT,
-    id: `${objectId}#delete-${Date.now()}-${rid()}`,
-    type: 'Delete',
-    actor: me,
-    to: [PUBLIC],
-    object: { id: objectId, type: 'Tombstone' },
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem);
-}
-
-export async function deliverDelete(site, post) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !post || !post.id) return;
-  return deliverObjectDelete(site, noteId(base, post.id));
-}
-
-/**
- * Zelfde voor een track. Roep dit aan VOOR het verwijderen van de rij, net als
- * bij een post: daarna is `id` er nog wel maar de context niet meer.
- */
-export async function deliverTrackDelete(site, trackId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !trackId) return;
-  return deliverObjectDelete(site, trackUri(base, site, trackId));
-}
-
-// Tell followers an already-published post changed (Update + edited Note) so
-// Mastodon refreshes the cached copy (e.g. after fixing content).
-export async function deliverUpdate(site, post) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !post || !post.id) return;
-  const mres = await resolveMentionsInText(base, post.content || ''); // link mentions + collect inboxes
-  const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
-  const followers = fStmts().list.all(site.slug);
-  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
-  if (!inboxes.length) return;
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const note = buildNote(base, site, post2);
-  note.updated = new Date().toISOString();
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${noteId(base, post.id)}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: note.cc,
-    object: note,
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-}
-
-// Tell followers the ACTOR changed (Update + Person) so Mastodon re-processes the
-// account AND re-fetches the featured (pinned) collection — there is no standard
-// "featured changed" activity, so this is how a pin/unpin propagates promptly.
-export async function deliverActorUpdate(site) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${me}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
-    object: buildActor(base, site),
-  };
-  for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-}
-
-// Reliably set the pinned order on followers' instances via Add/Remove activities
-// (how Mastodon itself federates pins) — pushed to the inbox + processed immediately,
-// unlike the featured COLLECTION which Mastodon caches with sticky StatusPins.
-// Mastodon's Add skips an already-pinned status, so we REMOVE every pin first, wait,
-// then ADD in rank-DESCENDING order (rank 1 added LAST → newest StatusPin → shown first,
-// because Mastodon displays pins newest-first). `alsoRemove` = ids to unpin too.
-// Serialize pin-resyncs per site: two concurrent /save calls would otherwise interleave
-// their Remove -> wait -> Add sequences and scramble the StatusPin order on Mastodon. A
-// resync already in flight for a site coalesces later requests into ONE rerun after it
-// finishes (accumulating their extra unpins), so rapid saves don't pile up N full resyncs.
-const _pinResync = new Map(); // slug -> { promise, pending, pendingRemove:Set, site }
-export function resyncFeaturedPins(site, alsoRemove = []) {
-  if (!site || !site.slug) return Promise.resolve();
-  const slug = site.slug;
-  const running = _pinResync.get(slug);
-  if (running) {
-    running.pending = true;
-    running.site = site; // use the latest site object on the rerun
-    for (const id of alsoRemove) running.pendingRemove.add(id);
-    return running.promise;
-  }
-  const state = { promise: null, pending: false, pendingRemove: new Set(), site };
-  state.promise = (async () => {
-    let extra = alsoRemove;
-    for (;;) {
-      try { await doResyncFeaturedPins(state.site, extra); }
-      catch (e) { console.warn('[AP] pin resync failed:', e.message); }
-      if (!state.pending) break;
-      state.pending = false;
-      extra = [...state.pendingRemove];
-      state.pendingRemove = new Set();
-    }
-    _pinResync.delete(slug);
-  })();
-  _pinResync.set(slug, state);
-  return state.promise;
-}
-
-// The actual resync work — do NOT call directly; go through resyncFeaturedPins() above so
-// it stays serialized per site.
-async function doResyncFeaturedPins(site, alsoRemove = []) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return;
-  const followers = fStmts().list.all(site.slug);
-  if (!followers.length) return;
-  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const keys = getOrCreateKeys(site.slug);
-  const me = actorId(base, site.slug);
-  const keyId = `${me}#main-key`;
-  const featured = `${me}/featured`;
-  const note = (id) => noteId(base, id);
-  const pinned = db.prepare(
-    `SELECT id FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
-       AND pinned IS NOT NULL AND pinned > 0
-     ORDER BY pinned DESC, ${isoSql('COALESCE(published_at, created_at)')} ASC LIMIT 20`
-  ).all(site.id);
-  const removeIds = [...new Set([...pinned.map((p) => p.id), ...alsoRemove])];
-  // 1. Remove every current pin so Mastodon can recreate them in order.
-  for (const id of removeIds) {
-    const rm = { '@context': AP_CONTEXT, id: `${me}#rm-${id}-${Date.now()}-${rid()}`, type: 'Remove', actor: me, object: note(id), target: featured, to: [PUBLIC] };
-    for (const inbox of inboxes) deliver(inbox, rm, keyId, keys.private_pem).catch(() => { /* best-effort */ });
-  }
-  if (!pinned.length) { console.log('[AP] unpinned all featured for', site.slug); return; }
-  await new Promise((r) => setTimeout(r, 5000)); // let the Removes land first
-  // 2. Add in rank-DESC order, gaps so each StatusPin gets an increasing created_at.
-  for (const p of pinned) {
-    const add = { '@context': AP_CONTEXT, id: `${me}#add-${p.id}-${Date.now()}-${rid()}`, type: 'Add', actor: me, object: note(p.id), target: featured, to: [PUBLIC], cc: [`${me}/followers`] };
-    for (const inbox of inboxes) deliver(inbox, add, keyId, keys.private_pem).catch(() => { /* best-effort */ });
-    await new Promise((r) => setTimeout(r, 2000));
-  }
-  console.log('[AP] resynced', pinned.length, 'featured pins for', site.slug);
-}
-
-// ── outbound replies (Klonkt → fediverse) ─────────────────────────
-const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-const toISO = (v) => { if (!v) return new Date().toISOString(); const s = String(v); const d = new Date(/[TZ]/.test(s) ? s : s.replace(' ', 'T') + 'Z'); return isNaN(d) ? new Date().toISOString() : d.toISOString(); };
-
-// Build one of OUR outbound reply Notes from an ap_outbox row.
-// Turn #hashtags in reply text into Mastodon-style hashtag links (clickable + federated).
-function linkHashtags(base, html) {
-  // Prefix: start / whitespace / '>' / opening bracket — "(#tag" is a tag too. NO quote
-  // chars in this class: a quote precedes attribute values (alt="#…"), which must not match.
-  return String(html || '').replace(/(^|[\s>([{])#([\p{L}\p{M}\p{N}_]+)/gu, (m, pre, tag) =>
-    `${pre}<a href="${base}/tag/${encodeURIComponent(tag.toLowerCase())}" class="mention hashtag" rel="tag">#${tag}</a>`);
-}
-// Auto-link bare http(s) URLs in already-safe HTML (federated copies). Splits on existing
-// <a>…</a> so a linked URL is never wrapped twice; requires start/whitespace/'>' before the
-// URL so attribute values (src="https://…") never match. Trailing sentence punctuation stays
-// outside the link (Mastodon-style).
-function linkUrls(html) {
-  const parts = String(html || '').split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi);
-  for (let i = 0; i < parts.length; i++) {
-    if (/^<a\b/i.test(parts[i])) continue; // already a link → leave as-is
-    parts[i] = parts[i].replace(/(^|[\s>([{])(https?:\/\/[^\s<]+?)([.,;:!?)\]»]*)(?=$|[\s<])/g,
-      (m, pre, url, trail) => `${pre}<a href="${url.replace(/"/g, '%22')}" rel="nofollow noopener" target="_blank">${url}</a>${trail}`);
-  }
-  return parts.join('');
-}
-// Linkify inline #hashtags and bare URLs in BODY html for on-site DISPLAY, using the
-// EXACT same rules as the federated copy (linkHashtags/linkUrls), so the website and the
-// Mastodon copy agree instead of the website showing raw text. Idempotent: existing
-// <a>…</a> (editor links, embeds, shortcode buttons) are split out and left untouched, so
-// nothing is double-wrapped. Pass base='' → root-relative /tag/<slug> links.
-export function linkifyBody(base, html) {
-  const withTags = String(html || '')
-    .split(/(<a\b[^>]*>[\s\S]*?<\/a>)/gi)
-    .map((seg) => (/^<a\b/i.test(seg) ? seg : linkHashtags(base, seg)))
-    .join('');
-  return linkUrls(withTags);
-}
-
-// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
-// at save and cached in posts.content_rendered, so page views serve it statically instead of
-// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
-// resolve @mentions here too (webfinger once at save instead of per page view).
-export function bakePostContent(source) {
-  return linkifyBody('', source || '');
-}
-
-// Step 2: the full bake, incl. @mention links. Resolves @user@host via webfinger ONCE (the
-// same resolver the federated copy uses) and bakes the profile links into content_rendered,
-// so page views never do a per-view lookup. Unresolvable handles stay plain text; on any
-// failure it degrades to the sync #hashtag/URL bake. Async (webfinger) → callers run it off
-// the save response so the request never blocks on a slow/dead remote server.
-export async function bakePostContentWithMentions(source) {
-  const withHashUrls = bakePostContent(source);
-  try { const m = await resolveMentionsInText('', withHashUrls); return m.html; }
-  catch { return withHashUrls; }
-}
-
-// Extract the AP Hashtag tag objects from already-linked reply content.
-
-// Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
-// normalizeTags en tagParts staan sinds shaer-38y in ap-core: music/ heeft ze
-// ook nodig en mag hier niet uit importeren.
-// A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
-// name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
-// slug/href stays lowercase ("livemusic").
-// Merge a post's tags field + the #hashtags linked inline in its body into one deduped
-// Hashtag tag list (with hrefs to our /tag page).
-// hashtagTags en buildHashtagList staan sinds shaer-38y in ap-core: music/
-// heeft dezelfde lijst nodig en mag hier niet uit importeren.
-
-// Extract Mention tag objects from already-linked content (class="u-url mention").
-function mentionTags(content) {
-  const tags = [], seen = new Set();
-  // The link href is the human profile URL; the actor URI (for the Mention tag) is in data-actor.
-  const re = /<a href="[^"]*" class="u-url mention" data-actor="([^"]+)">@([^<]+)<\/a>/gi;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const href = m[1];
-    if (seen.has(href)) continue; seen.add(href);
-    tags.push({ type: 'Mention', href, name: '@' + m[2] });
-  }
-  return tags;
-}
-// Resolve inline @user@domain mentions in reply/post text → link them (href = actor URI)
-// and collect the mentioned actors' inboxes so they get notified. Best-effort per mention.
-async function resolveMentionsInText(base, html) {
-  const inboxes = [];
-  const handles = new Set();
-  // Prefix also allows opening brackets — "(@user@host + me)" is a mention too (real-world
-  // miss: a bracketed mention federated as plain text and its target was never notified).
-  const re = /(^|[\s>([{])@([\p{L}\p{M}\p{N}_.-]+@[\p{L}\p{M}\p{N}.-]+)/gu;
-  let m;
-  while ((m = re.exec(html || ''))) handles.add(m[2]);
-  let out = String(html || '');
-  for (const h of handles) {
-    let actorUri = null;
-    try { actorUri = await webfingerResolve('@' + h); } catch { actorUri = null; }
-    if (!actorUri) continue;
-    const actor = await fetchActor(actorUri).catch(() => null);
-    const inbox = actor && ((actor.endpoints && actor.endpoints.sharedInbox) || actor.inbox);
-    if (inbox) inboxes.push(inbox);
-    const profileUrl = actorInfo(actor, actorUri).url || actorUri; // human profile page → the link href
-    const esc = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-    out = out.replace(new RegExp('(^|[\\s>([{])@' + esc + '(?![\\p{L}\\p{M}\\p{N}_.-])', 'gu'),
-      (full, pre) => `${pre}<a href="${profileUrl}" class="u-url mention" data-actor="${actorUri}">@${h}</a>`);
-  }
-  return { html: out, inboxes };
-}
-
-export function buildReplyNote(base, site, row) {
-  // Thin delegate: replies are built by buildNote (the single Note entry point) in reply mode.
-  return buildNote(base, site, row, { isReply: true });
-}
-
-// The account's own outbound notes (replies and direct messages) as AS2
-// Notes, newest first. The C2S inbox read serves these alongside the
-// timeline: without them your own reply existed everywhere EXCEPT in your
-// own app (Robins melding, 30-7: "replyen werkt nog niet"; het antwoord
-// stond op de server maar de app kreeg het nooit terug, dus je probeerde
-// het opnieuw en liep in de duplicate-guard).
-export function getSentNotes(base, site, limit = 60) {
-  return db.prepare('SELECT * FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC LIMIT ?')
-    .all(site.slug, limit)
-    .map((row) => buildReplyNote(base, site, row));
-}
-
-// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
-export function getOutboxNote(base, id) {
-  const row = iStmts().getO.get(id);
-  if (!row) return null;
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
-  if (!site) return null;
-  return buildReplyNote(base, site, row);
-}
-
-// The direct-note leg (ward call-for-help) lives in the guardianship module
-// (src/services/guardianship/delivery.js); wired with our AP helpers at the
-// bottom of this file. Re-exported so every existing caller keeps working.
-export const c2sVisibility = Guardianship.c2sVisibility;
-export const deliverDirectNote = Guardianship.deliverDirectNote;
-
-// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
-// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
-/**
- * Een gate-voorstel de deur uit (FEP-633c 5.6, shaer-8ru).
- *
- * STOND IN routes/guardian.js en kon daar alleen door de PWA aangeroepen worden.
- * De apps moeten hetzelfde kunnen, en een tweede implementatie ernaast zou een
- * tweede weg naar hetzelfde besluit zijn -- precies de fout die we vandaag bij
- * de antwoordpoort hebben rechtgezet, toen de innamepoort alleen in C2S bleek te
- * zitten en het webpad eromheen liep. Een pad dus.
- *
- * EEN WEG, waar de ward ook woont (Robins regel, 29-7): voorstellen over de
- * lijn en de server van de ward laat tellen. Co-locatie verandert alleen het
- * transport -- deliverToActor lust een lokale ontvanger terug door dezelfde
- * inbox. De oude kortsluiting boekte de stem hier meteen, en zo bleef het
- * remote-pad een maand stuk zonder dat iemand het merkte.
- */
-export function proposeGate(site, wardUri, feature, allow) {
-  const uri = String(wardUri || '').trim();
-  if (!uri) return { status: 400, error: 'empty_uri' };
-  if (!Guardianship.gated.featureColumn(feature)) return { status: 400, error: 'unknown_feature' };
-  // Alleen een guardian van dit kind. Zonder deze regel zou iedereen met een
-  // token een instelling van een vreemd kind kunnen aanvragen.
-  if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
-    return { status: 403, error: 'not_your_ward' };
-  }
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-  const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
-  // Ons eigen spoor van wat we stuurden: de server van de ward antwoordt op deze
-  // Offer zodra het besluit valt, en dat antwoord heeft een rij nodig om in te
-  // landen. Het is ook het enige waardoor het scherm van de voorsteller meer kan
-  // zeggen dan een knoptekst.
-  Guardianship.gated.recordSent(offerId, site.slug, uri, feature, allow);
-  deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
-  const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
-  const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null;
-  return { status: 200, ok: true, allow, state: 'open', offerId, ...(progress || { federated: true }) };
-}
-
-export async function deliverReply(site, { postId, postSlug, parent, text, html, language, attachments, mentions, visibility }) {
-  const _mv = movedRefusal(site, 'reply'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  // Rich replies: `html` is the reply editor's HTML (sanitized here); `text` is
-  // the plain-text fallback (no-JS path, C2S `source`). Either may carry the reply.
-  const richClean = html ? HtmlSanitizerService.sanitize(String(html)) : '';
-  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
-  // Attachments: only OUR OWN uploads (/media/... paths, no remote URLs — the
-  // upload route is the sole producer), image/audio/video only, max 4.
-  const media = (Array.isArray(attachments) ? attachments : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
-  // A media-only reply (no text) is a valid reply.
-  if (!base || !site || !site.slug || !parent || (!String(text || '').trim() && !rich && !media.length)) return null;
-  // DE POORT STAAT HIER en niet alleen in de outbox (shaer-r4c). routes/posts.js
-  // roept deliverReply op drie plekken rechtstreeks aan -- de eigen webinterface
-  // van Klonkt gaat dus nooit langs ingestOutboxActivity. Een poort die alleen in
-  // C2S staat is een poort met een deur ernaast.
-  //
-  // Dit is het knooppunt dat beide paden delen. De reddingsboei komt hier niet
-  // langs: een hulpvraag is altijd direct en loopt via deliverDirectNote, dus de
-  // boei blijft open zonder dat daar een uitzondering voor nodig is.
-  {
-    const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-    if (!Guardianship.wardGateAllowed(site.gate_replies, isWard)) return null;
-  }
-  const me = actorId(base, site.slug);
-  // u02, the mentions bar: `mentions` undefined = legacy behavior (mention the
-  // parent author). An ARRAY (possibly empty) = the kept conversation partners
-  // exactly as the bar shows them; the mention prefix, the Mention tags (via
-  // mentionTags over the content) and the delivery targets all follow it.
-  const kept = Array.isArray(mentions)
-    ? mentions
-      .filter((m) => m && typeof m.uri === 'string' && /^https?:\/\//i.test(m.uri))
-      .slice(0, 8)
-      .map((m) => ({
-        uri: m.uri,
-        url: (typeof m.url === 'string' && /^https?:\/\//i.test(m.url)) ? m.url : m.uri,
-        handle: String(m.handle || deriveHandle(m.uri)).slice(0, 120),
-      }))
-    : null;
-  const mentionAnchor = (uri, url, h) => {
-    const disp = h && h[0] === '@' ? h : '@' + (h || '');
-    return `<a href="${escHtml(url || uri)}" class="u-url mention" data-actor="${escHtml(uri)}">${escHtml(disp)}</a> `;
-  };
-  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
-  const mention = kept
-    ? kept.map((k) => mentionAnchor(k.uri, k.url, k.handle)).join('')
-    : (parent.actor_uri ? mentionAnchor(parent.actor_uri, parent.actor_url, handle) : '');
-  // Who the stored reply is "to": the parent when kept, else the first kept chip.
-  const parentKept = !kept || kept.some((k) => k.uri === parent.actor_uri);
-  const toActorUri = parentKept ? (parent.actor_uri || null) : (kept[0] ? kept[0].uri : null);
-  const toHandle = parentKept ? handle : (kept[0] ? kept[0].handle : null);
-  let content;
-  let mres;
-  if (rich) {
-    // Same enrichment pipeline as the plain path (mentions/hashtags/URLs), on
-    // sanitized editor HTML. The parent mention goes inline into the first
-    // paragraph (Mastodon convention), or becomes its own leading one.
-    mres = await resolveMentionsInText(base, rich);
-    const processed = linkUrls(linkHashtags(base, mres.html));
-    if (processed.startsWith('<p>')) {
-      content = processed.replace('<p>', `<p>${mention}`);            // inline in the first paragraph
-    } else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) {
-      content = `<p>${mention}</p>${processed}`;                      // block content: own leading paragraph
-    } else {
-      content = `<p>${mention}${processed}</p>`;                      // bare inline text: one paragraph together
-    }
-  } else {
-    const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-    mres = await resolveMentionsInText(base, body); // link inline @mentions + collect their inboxes
-    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
-  }
-  const replyLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
-  // Dedup: skip if the exact same reply was already sent (double-submit guard).
-  // Attachments count toward "the same": two media-only replies share content.
-  const mediaJson = media.length ? JSON.stringify(media) : null;
-  // A duplicate is idempotent success, not an error: it answers with the
-  // EXISTING id. Returning without one made the C2S ingest say 502
-  // reply_failed on a double-submit (Robins schermafdruk, 30-7), so a retry
-  // of a reply the app never showed looked like the reply itself failing.
-  const dup = db.prepare('SELECT id FROM ap_outbox WHERE site_slug = ? AND IFNULL(in_reply_to, \'\') = ? AND content = ? AND IFNULL(attachments, \'\') = IFNULL(?, \'\') LIMIT 1')
-    .get(site.slug, parent.object_uri || '', content, mediaJson);
-  if (dup) { console.log('[AP] outreply skipped (duplicate)'); return { duplicate: true, id: dup.id, delivered: 0 }; }
-  const id = crypto.randomUUID();
-  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, toActorUri, toHandle, content, replyLang, mediaJson);
-  // Followers-only reply (shaer detail-view): mark the row so buildNote drops
-  // Public from cc. Default (undefined/'public'/'quiet') stays quiet-public.
-  if (visibility === 'friends') { try { db.prepare('UPDATE ap_outbox SET visibility = ? WHERE id = ?').run('friends', id); } catch { /* ignore */ } }
-  const row = iStmts().getO.get(id);
-  const note = buildReplyNote(base, site, row);
-  const create = {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create', type: 'Create', actor: me,
-    published: note.published, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${me}#main-key`;
-  const inboxes = new Set();
-  // Everyone the mentions bar kept gets pinged; legacy path = the parent only.
-  const mentionTargets = kept ? kept.map((k) => k.uri) : (parent.actor_uri ? [parent.actor_uri] : []);
-  for (const uri of mentionTargets) {
-    const a = await fetchActor(uri).catch(() => null);
-    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
-  }
-  if (parent.threadInbox) inboxes.add(parent.threadInbox); // back-compat (single)
-  (parent.threadInboxes || []).forEach((i) => inboxes.add(i)); // whole ancestor chain
-  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-  mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the reply
-  inboxes.delete(`${me}/inbox`);       // never deliver to ourselves (already in ap_outbox)
-  inboxes.delete(`${base}/ap/inbox`);  // (our own shared inbox) → avoids a self-duplicate
-  let delivered = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) {
-    let ok = false;
-    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, create); // durable: retry a briefly-offline recipient (was silently dropped)
-  }
-  console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
-  return { id, content, delivered };
-}
-
-// attributedTo may be a string, an object {id}, or an ARRAY — e.g. a PeerTube Video is
-// attributed to [Person (account), Group (channel)]. Pick a usable actor URI (prefer Person).
-function actorUriOf(att) {
-  if (!att) return null;
-  if (typeof att === 'string') return att;
-  if (Array.isArray(att)) {
-    const person = att.find((a) => a && typeof a === 'object' && a.type === 'Person' && a.id);
-    if (person) return person.id;
-    for (const a of att) { if (typeof a === 'string') return a; if (a && a.id) return a.id; }
-    return null;
-  }
-  return att.id || null;
-}
-
-// Resolve a remote post URL (any fediverse/Klonkt post) into a reply target.
-// Returns a parent-shaped object usable by deliverReply(), or null.
-// The server's own note, built straight from the DB. resolveRemoteNote used
-// to fetch EVERYTHING over HTTPS, including notes living right here: a
-// hairpin fetch fails on home setups (a Klonkt on a Mac behind a tunnel), the
-// /ap/notes route rightly hides friends-only posts, and a punycode-spelled
-// own URL read as remote on a byte comparison. For the authenticated C2S
-// caller none of those walls apply; the DB is one prepare() away.
-// `forSlug` is that caller: only the post's own site gets its non-public
-// notes on this shortcut (public ones anyone, same as the route serves).
-function localNoteObject(url, forSlug) {
-  if (!isOwnUrl(url)) return null;
-  const m = String(url).match(/\/ap\/notes\/([^/?#]+)/);
-  if (!m) return null;
-  const id = decodeURIComponent(m[1]);
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const post = db.prepare("SELECT * FROM posts WHERE id = ? AND status = 'published'").get(id);
-  if (post) {
-    const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-    if (!site) return null;
-    const nonPublic = post.fan_only || post.ap_visibility === 'friends' || post.ap_visibility === 'direct';
-    if (nonPublic && (!forSlug || forSlug !== site.slug)) return null;
-    return buildNote(base, site, post);
-  }
-  return getOutboxNote(base, id);   // our own outbound replies
-}
-// The own actor document, same shortcut, same reason.
-function localActorObject(uri) {
-  if (!isOwnUrl(uri)) return null;
-  const m = String(uri).match(/\/ap\/users\/([^/?#]+)/);
-  const site = m ? db.prepare('SELECT * FROM sites WHERE slug = ?').get(decodeURIComponent(m[1])) : null;
-  return site ? buildActor((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site) : null;
-}
-
-// ── De thread onder een post (shaer-tqz) ───────────────────────────
-//
-// Klonkt is hier een TOLK, geen archief (Barts besluit, 7-8): de antwoorden
-// worden opgehaald op het moment dat iemand kijkt en daarna weer vergeten.
-// Geen tabel, geen migratie -- wie replies bewaart van elke post die iemand
-// tegenkomt, laat de omvang van zijn database bepalen door surfgedrag. Dat is
-// de AFWIJKING, niet de norm: Mastodon serveert /context uit zijn eigen
-// database, en dat verdient zich daar terug omdat honderden mensen de cache
-// delen. Een Klonkt-instance is de server van één persoon.
-//
-// Waarom dit niet in de app kan: de replies-collectie van een vreemde server
-// eist in secure mode een ONDERTEKEND verzoek, en de sleutel staat hier en kan
-// hier niet weg. Voor de opgaande inReplyTo-keten komt de app weg met een
-// ongetekende GET (mist er een, jammer); voor een thread van dertig is "de
-// helft doet het niet" geen resultaat.
-//
-// Je krijgt hier NOOIT de hele thread: een replies-collectie bevat alleen wat
-// die ene server gezien heeft. De UI hoort "wat de bron weet" te tonen en geen
-// volledigheid te suggereren.
-// NIET hetzelfde als maybeCrawlThread verderop: die kruipt de thread onder je
-// EIGEN posts af en bewaart de antwoorden in ap_interactions (dat zijn de
-// jouwe, die horen te blijven). Dit hier is voor een post van een ANDER die je
-// tegenkomt, en bewaart niets.
-const THREAD_VIEW_LIMIT = 30;
-const THREAD_VIEW_TTL_MS = 120_000;
-const THREAD_VIEW_CACHE_MAX = 200;
-const threadViewCache = new Map();   // `${slug}|${uri}` -> { at, out } -- geheugen, weg bij herstart
-
-/** Eén pagina items uit een AS2-collectie, welke spelling hij ook koos. */
-function collectionItems(coll) {
-  if (!coll || typeof coll !== 'object') return [];
-  const arr = coll.orderedItems || coll.items;
-  return Array.isArray(arr) ? arr : [];
-}
-
-/**
- * De directe antwoorden op één note, genormaliseerd voor de C2S-lezer.
- *
- * ALLEEN ophalen en normaliseren; de poorten zitten in de route. De
- * kringfilter (de dichte stand van shaer:externalThreads) woont in
- * filterThreadToCircle en de beeld/muziek/emoji-poorten in
- * gateAttachments/stripEmojiTags -- per verzoek, buiten deze cache om, want de
- * stand van een poort mag hier niet twee minuten bevriezen. Geblokkeerde
- * actors zijn een andere categorie en verdwijnen WEL hier, zonder telling:
- * een blokkade is onzichtbaar, ook als getal.
- */
-export async function getThread(slug, objectUri) {
-  const key = `${slug}|${objectUri}`;
-  const hit = threadViewCache.get(key);
-  if (hit && Date.now() - hit.at < THREAD_VIEW_TTL_MS) return hit.out;
-
-  // De status waarmee de BRON antwoordde op de note zelf. 401/403/404/410 is
-  // een besluit van die server (niet gedeeld, of weg); alles daarbuiten -- ook
-  // een stuk netwerk dat wegviel -- is een storing. De route moet dat verschil
-  // kunnen zeggen, anders wijst de melding naar de verkeerde partij.
-  let sourceStatus = 0;
-  const get = (u) => localNoteObject(u, slug) || signedGetJson(slug, u, (st) => { sourceStatus = st; });
-  const note = await get(objectUri);
-  const repliesRef = note && note.replies;
-  let coll = null;
-  if (typeof repliesRef === 'string') coll = await signedGetJson(slug, repliesRef);
-  else if (repliesRef && typeof repliesRef === 'object') {
-    coll = collectionItems(repliesRef).length || repliesRef.first ? repliesRef
-      : (repliesRef.id ? await signedGetJson(slug, repliesRef.id) : repliesRef);
-  }
-  // De pagina-wandeling van collectReplyItems, maar met behoud van INLINE
-  // objecten (die niet opnieuw opgehaald hoeven). Niet "de eerste pagina":
-  // Mastodon serveert `first` als inline-pagina met LEGE items en een `next`
-  // waar de antwoorden echt staan -- wie alleen de eerste pagina leest, ziet
-  // op elke Mastodon-post een leeg gesprek. Dat was precies Barts melding
-  // (8-8, reacties op een vreemde post). Eigen posts maskeerden het: die
-  // gaan door de lokale kortsluiting en hebben orderedItems meteen vol.
-  let items = [];
-  let node = coll;
-  if (node && node.first && !collectionItems(node).length) {
-    node = typeof node.first === 'string' ? await signedGetJson(slug, node.first) : node.first;
-  }
-  let pages = 0;
-  while (node && pages++ < 3 && items.length < THREAD_VIEW_LIMIT) {
-    items.push(...collectionItems(node));
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await signedGetJson(slug, node.next) : node.next;
-  }
-  items = items.slice(0, THREAD_VIEW_LIMIT);
-
-  // Alles tegelijk in plaats van om de beurt: dertig vreemde servers na elkaar
-  // afwachten is een halve minuut kijken naar een spinner.
-  const objs = await Promise.all(items.map(async (it) => {
-    const o = typeof it === 'string' ? await get(it) : (it && it.object && typeof it.object === 'object' ? it.object : it);
-    return (o && o.id && o.attributedTo) ? o : null;
-  }));
-
-  const kept = [];
-  for (const o of objs) {
-    if (!o) continue;
-    const actorUri = actorUriOf(o.attributedTo);
-    if (!actorUri || isBlockedAny(actorUri)) continue;   // een blokkade telt niet mee
-    kept.push({ o, actorUri });
-  }
-
-  // Bylines: één fetch per unieke auteur, niet één per antwoord.
-  const authors = new Map();
-  await Promise.all([...new Set(kept.map((k) => k.actorUri))].map(async (uri) => {
-    authors.set(uri, localActorObject(uri) || await signedGetJson(slug, uri).catch(() => null));
-  }));
-
-  const notes = kept.map(({ o, actorUri }) => ({
-    id: o.id,
-    type: 'Note',
-    // De ingesloten actor (shaer-nmw): de byline hoort in attributedTo, waar
-    // elke AP-lezer hem zoekt, en niet in een eigen property ernaast.
-    attributedTo: actorObject(actorUri, actorInfo(authors.get(actorUri), actorUri)),
-    inReplyTo: (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || objectUri,
-    content: HtmlSanitizerService.sanitize(String(o.content || '').slice(0, 50_000)),
-    url: safeUrl(typeof o.url === 'string' ? o.url : (o.url && o.url.href)) || undefined,
-    published: typeof o.published === 'string' ? o.published : undefined,
-    sensitive: !!o.sensitive,
-    summary: (contentWarning(o) || '').slice(0, 500) || undefined,
-    attachment: (() => {
-      const arr = Array.isArray(o.attachment) ? o.attachment : (o.attachment ? [o.attachment] : []);
-      const out = arr.map((a) => ({ type: 'Document', mediaType: (a && a.mediaType) || undefined, url: safeUrl(a && a.url), name: (a && typeof a.name === 'string') ? a.name.slice(0, 1500) : undefined }))
-        .filter((a) => a.url);
-      return out.length ? out.slice(0, 8) : undefined;
-    })(),
-    // FEP-9098: de custom emoji van het antwoord (":shortcode:" -> plaatje).
-    // Zonder deze tags rendert een reply van een Mastodon-account zijn emoji
-    // als kale tekst (Barts punt, 8-8). Alleen naam + geschoond icoon-adres
-    // gaan door; de rest van de vreemde tag-array blijft achter.
-    tag: (() => {
-      const j = extractEmojiTags(o.tag);
-      if (!j) return undefined;
-      const out = JSON.parse(j)
-        .map((t) => ({ type: 'Emoji', name: t.name, icon: { type: 'Image', url: safeUrl(t.icon && (t.icon.url || (Array.isArray(t.icon) && t.icon[0] && t.icon[0].url))) } }))
-        .filter((t) => t.icon.url)
-        .slice(0, 30);
-      return out.length ? out : undefined;
-    })(),
-  })).sort((a, b) => String(a.published || '').localeCompare(String(b.published || '')));
-
-  const out = { notes, found: !!note, sourceStatus };
-  threadViewCache.set(key, { at: Date.now(), out });
-  if (threadViewCache.size > THREAD_VIEW_CACHE_MAX) {
-    const oldest = [...threadViewCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
-    if (oldest) threadViewCache.delete(oldest[0]);
-  }
-  return out;
-}
-
-/**
- * De thread gefilterd op de kring die de guardians al kennen (gevolgd of
- * volgend) -- de dichte stand van shaer:externalThreads. PER VERZOEK, buiten de
- * threadcache om: een poort die de guardians net dichtzetten mag niet nog twee
- * minuten open nawerken uit een cache. Wat er buiten valt wordt GETELD, nooit
- * stil weggelaten.
- */
-export function filterThreadToCircle(slug, notes) {
-  const circle = new Set();
-  try { for (const r of db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted'").all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
-  try { for (const r of db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ?').all(slug)) circle.add(r.actor_uri); } catch { /* geen tabel */ }
-  const kept = [], out = { hidden: 0 };
-  for (const n of notes) {
-    // actorUriOf, niet n.attributedTo: sinds de byline ingesloten meegaat is
-    // dat een OBJECT en zou een kale vergelijking hier stil alles wegfilteren
-    // -- een ward met een lege thread en nergens een foutmelding.
-    if (circle.has(actorUriOf(n.attributedTo))) kept.push(n);
-    else out.hidden += 1;
-  }
-  out.notes = kept;
-  return out;
-}
-
-export async function resolveRemoteNote(url, opts = {}) {
-  if (!/^https?:\/\//i.test(String(url || ''))) return null;
-  // With `asSlug` the fetches are SIGNED as that local actor. An anonymous
-  // GET can only read public notes; a friends-only note (Shaer's default!)
-  // rightly refuses it, which made every reply to a friend's post fail while
-  // a reply to your own public post worked (Robins melding, 30-7). Signed,
-  // the other server sees WHO asks and serves what the friendship earns.
-  const get = (u) => (opts.asSlug ? signedGetJson(opts.asSlug, u) : fetchActor(u).catch(() => null));
-  const note = localNoteObject(url, opts.asSlug) || await get(url); // own DB first, then AP GET
-  if (!note || !note.id) return null;
-  const att = note.attributedTo;
-  const actorUri = actorUriOf(att);
-  if (!actorUri) return null;
-  const actor = localActorObject(actorUri) || await get(actorUri);
-  const ai = actorInfo(actor, actorUri);
-  // Is what we're replying to a post (or a comment) on one of OUR posts? If so,
-  // link our reply to that local post so it shows nested in the post thread.
-  const localTgt = findThreadTarget(note.id, (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''));
-  // Walk the WHOLE reply chain upward (comment → parent comment → … → root post)
-  // and collect every ancestor author's inbox, so each participant's server —
-  // including the original post's author — receives + threads our reply.
-  const threadInboxes = [];
-  const seenInbox = new Set();
-  let cursor = note.inReplyTo, guard = 0;
-  while (cursor && guard++ < 6) {
-    const url = typeof cursor === 'string' ? cursor : (cursor && cursor.id);
-    if (!url) break;
-    const pn = localNoteObject(url, opts.asSlug) || await get(url);
-    if (!pn) break;
-    const pa = actorUriOf(pn.attributedTo);
-    if (pa && pa !== actorUri) {
-      const paDoc = await get(pa);
-      const inbox = paDoc && ((paDoc.endpoints && paDoc.endpoints.sharedInbox) || paDoc.inbox);
-      if (inbox && !seenInbox.has(inbox)) { seenInbox.add(inbox); threadInboxes.push(inbox); }
-    }
-    cursor = pn.inReplyTo; // climb to the next ancestor
-  }
-  // For non-Note objects (PeerTube Video, Article, …) the meaningful label is `name` (the
-  // title); prepend it so the reply page shows what you're replying to (sanitize cleans it).
-  let rawHtml = String(note.content || '').replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
-  if (note.name && note.type && note.type !== 'Note') rawHtml = `<p><strong>${note.name}</strong></p>` + rawHtml;
-  const images = (Array.isArray(note.attachment) ? note.attachment : [])
-    .filter((a) => a && a.url && (!a.mediaType || /^image\//i.test(a.mediaType)))
-    .map((a) => safeUrl(a.url)).filter(Boolean);
-  // A Klonkt hosted-audio post strips its cover from `attachment` (so Mastodon
-  // shows the player card, not a loose image) and puts it in `image` instead.
-  // Same fallback as mediaFromNote() so a boosted music post keeps its cover.
-  if (!images.length && note.image) {
-    const im = Array.isArray(note.image) ? note.image[0] : note.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    if (iu) images.push(iu);
-  }
-  return {
-    object_uri: safeUrl(note.id) || note.id,
-    actor_uri: actorUri,
-    actor_url: ai.url,
-    actor_handle: ai.handle,
-    actor_name: ai.name,
-    actor_icon: ai.icon,
-    url: note.url || url,
-    content: HtmlSanitizerService.sanitize(rawHtml),       // full, sanitized
-    sensitive: !!note.sensitive,                            // remote CW → blur in the Cirkel
-    cw: contentWarning(note) || '',
-    images,
-    // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
-    // image-only for the interact page preview; a boosted video-only post (Loops)
-    // lost its media entirely because upsertBoostedNote only saw `images`.
-    media: mediaFromNote(note),
-    threadInboxes,                                          // every ancestor author's inbox
-    localPostId: localTgt ? localTgt.post_id : '',          // our post this belongs to (if any)
-    poll: parsePoll(note),                                  // a Question → its options/counts (else null)
-    preview: HtmlSanitizerService.toPlainText(note.content || '').slice(0, 240),
-  };
-}
-
-// List a site's own outbound fediverse replies (for the manage/delete view).
-// The plain editable text of a stored reply (unwrap links → their text, <br> → newline)
-// so the manage view can prefill an edit box; the mention is re-added on save.
-function outboxEditableText(content) {
-  return String(content || '')
-    .replace(/<br\s*\/?>/gi, '\n')
-    .replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1')
-    .replace(/<[^>]+>/g, '')
-    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
-    .trim();
-}
-export function listOutbox(siteSlug) {
-  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
-  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
-  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
-  // elkaar in "Verzonden" en "Gesprekken".
-  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
-    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
-}
-
-// Delete one of our outbound replies: send Delete(Tombstone) to recipients + remove it.
-export async function deliverOutboxDelete(site, outboxId) {
-  const row = iStmts().getO.get(outboxId);
-  if (!row || row.site_slug !== site.slug) return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (base) {
-    const me = actorId(base, site.slug);
-    const nid = noteId(base, row.id);
-    const del = { '@context': AP_CONTEXT, id: `${nid}#delete-${Date.now()}-${rid()}`, type: 'Delete', actor: me, to: [PUBLIC], object: { id: nid, type: 'Tombstone' } };
-    const keys = getOrCreateKeys(site.slug);
-    const inboxes = new Set();
-    if (row.to_actor) { const a = await fetchActor(row.to_actor).catch(() => null); if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox); }
-    for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-    for (const inbox of [...inboxes].filter(Boolean)) {
-      try { const st = await deliver(inbox, del, `${me}#main-key`, keys.private_pem); if (st >= 200 && st < 300) continue; } catch { /* queue below */ }
-      enqueueDelivery(site.slug, inbox, del); // durable: a failed comment-delete now retries (was silently dropped)
-    }
-  }
-  db.prepare('DELETE FROM ap_outbox WHERE id = ?').run(outboxId);
-  return true;
-}
-
-// Edit one of our outbound replies: rewrite the stored content (mention re-added + #tags
-// re-linked) and send an Update(Note) so recipients refresh their cached copy.
-export async function deliverOutboxUpdate(site, outboxId, newText, opts = {}) {
-  const row = iStmts().getO.get(outboxId);
-  if (!row || row.site_slug !== site.slug) return false;
-  const text = String(newText || '').trim();
-  // Rich edit: same sanitize + enrichment pipeline as deliverReply.
-  const richClean = opts.html ? HtmlSanitizerService.sanitize(String(opts.html)) : '';
-  const rich = richClean && HtmlSanitizerService.toPlainText(richClean).trim() ? richClean : '';
-  if (!text && !rich) return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return false;
-  const me = actorId(base, site.slug);
-  const toActor = row.to_actor ? await fetchActor(row.to_actor).catch(() => null) : null;
-  const toProfile = row.to_actor ? (actorInfo(toActor, row.to_actor).url || row.to_actor) : '';
-  const _h = row.to_handle || deriveHandle(row.to_actor);
-  const toHandle = _h && _h[0] === '@' ? _h : '@' + (_h || '');
-  // An edit must not drop co-mentions (u02): reuse the OLD content's leading
-  // mention anchors (the bar's kept list at send time) when present; only fall
-  // back to rebuilding the single to_actor mention for legacy rows.
-  const oldPrefix = (String(row.content || '')
-    .match(/^\s*(?:<p[^>]*>)?\s*((?:<a\b[^>]*class="u-url mention"[^>]*>\s*@[^<]+<\/a>[\s ]*)+)/i) || [])[1] || '';
-  const mention = oldPrefix || (row.to_actor
-    ? `<a href="${escHtml(toProfile)}" class="u-url mention" data-actor="${escHtml(row.to_actor)}">${escHtml(toHandle)}</a> ` : '');
-  let content;
-  let mres;
-  if (rich) {
-    mres = await resolveMentionsInText(base, rich);
-    const processed = linkUrls(linkHashtags(base, mres.html));
-    if (processed.startsWith('<p>')) content = processed.replace('<p>', `<p>${mention}`);
-    else if (/^<(blockquote|ul|ol|pre|h[1-6]|div|hr)\b/i.test(processed)) content = `<p>${mention}</p>${processed}`;
-    else content = `<p>${mention}${processed}</p>`;
-  } else {
-    mres = await resolveMentionsInText(base, escHtml(text).replace(/\r?\n/g, '<br>'));
-    content = `<p>${mention}${linkUrls(linkHashtags(base, mres.html))}</p>`;
-  }
-  // Language may be updated with the edit; attachments always survive untouched.
-  const newLang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(opts.language || '')) ? opts.language : null;
-  db.prepare('UPDATE ap_outbox SET content = ?, language = COALESCE(?, language) WHERE id = ?').run(content, newLang, outboxId);
-  const note = buildReplyNote(base, site, iStmts().getO.get(outboxId));
-  note.updated = new Date().toISOString();
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${note.id}#update-${Date.now()}-${rid()}`, type: 'Update', actor: me,
-    published: note.published, updated: note.updated, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const inboxes = new Set();
-  if (toActor) inboxes.add((toActor.endpoints && toActor.endpoints.sharedInbox) || toActor.inbox);
-  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
-  mres.inboxes.forEach((i) => inboxes.add(i)); // people @mentioned inline in the edit
-  inboxes.delete(`${me}/inbox`); inboxes.delete(`${base}/ap/inbox`);
-  let delivered = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) {
-    let ok = false;
-    try { const st = await deliver(inbox, update, `${me}#main-key`, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, update); // durable: retry the edit later (was silently dropped)
-  }
-  console.log('[AP] outreply edit', site.slug, 'delivered', delivered);
-  return { ok: true, content, delivered };
-}
-
-
-
-// Store the author's display-name emoji map (from actorInfo().emojis) on a
-// timeline row, so the byline can render a ":shortcode:" name. No-op when the
-// name has no custom emoji (the common case).
-function storeAuthorEmoji(id, slug, ai) {
-  if (!ai || !ai.emojis || !Object.keys(ai.emojis).length) return;
-  try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(ai.emojis), id, slug); } catch { /* ignore */ }
-}
-
-// A display-name emoji map (actorInfo().emojis) → JSON to store, or null.
-function emojiJsonOf(map) { return (map && Object.keys(map).length) ? JSON.stringify(map) : null; }
-
-
-
-// ── Self-heal: re-sync the fediverse cache (ap_timeline) after a DRASTIC update ──
-// Runs ONCE per SELFHEAL_VERSION bump — NOT on every boot. Re-fetches each cached
-// note and refreshes content + media (recovers covers/edits that were delivered
-// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
-// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 22; // v22: summary is pas een waarschuwing MET sensitive, en een artikel houdt zijn titel
-async function fetchNoteAP(url) {
-  try {
-    const r = await fetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (r.status === 404 || r.status === 410) return 404;
-    if (r.ok) return await r.json();
-  } catch { /* unreachable */ }
-  return null;
-}
-function mediaFromNote(note) {
-  const atts = (Array.isArray(note.attachment) ? note.attachment : []).map((a) => {
-    const m = { url: safeUrl(a && a.url), type: (a && a.mediaType) || '' };
-    // A federated video may carry its poster as an AS2 icon (shaer-zowq).
-    const iconUrl = a && a.icon && safeUrl(typeof a.icon === 'string' ? a.icon : a.icon.url);
-    if (iconUrl && /^video\//i.test(m.type)) m.poster = iconUrl;
-    return m;
-  }).filter((m) => m.url);
-  if (!atts.some((m) => !m.type || /image/i.test(m.type)) && note.image) {
-    const im = Array.isArray(note.image) ? note.image[0] : note.image;
-    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
-    if (iu) atts.push({ url: iu, type: (im && im.mediaType) || 'image/jpeg' });
-  }
-  return JSON.stringify(atts);
-}
-
-// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
-// own posts quotes a fediverse object, say so in the shapes the network really
-// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
-// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
-// third form. All three point at the same object, which is what every reader
-// expects. The quoted author goes in `cc`, because being quoted without being
-// told is exactly the rudeness this FEP is trying to design away.
-export function applyQuoteProps(note, quoteUri, quoteActor) {
-  if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
-  note.quote = quoteUri;
-  note.quoteUrl = quoteUri;
-  note['_misskey_quote'] = quoteUri;
-  note.tag = [...(note.tag || []), {
-    type: 'Link',
-    mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
-    href: quoteUri,
-    rel: ['https://misskey-hub.net/ns#_misskey_quote'],
-    name: quoteUri,
-  }];
-  if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
-    note.cc = [...new Set([...(note.cc || []), quoteActor])];
-  }
-  return note;
-}
-
-// The first external (non-fediverse) link in a note, resolved to the same card
-// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
-// third-party frame inside a kid-safe app is a hole you cannot close again, so
-// the embed carries an image and a title and nothing executable.
-// Returns the JSON to store, or null when there is nothing worth showing.
-export async function resolveExternalEmbed(html) {
-  const first = firstExternalUrl(html);
-  if (!first) return null;
-  const io = EmbedResolver.liveIO({
-    safeFetch,
-    detectProvider: (u) => AudioEmbedService.detectProvider(u),
-    fetchActor,
-    actorInfo,
-  });
-  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
-  // 'ap' is handled by the quote path; a bare 'link' is not worth a card.
-  if (!card || card.kind === 'ap' || card.kind === 'link') return null;
-  const thumb = (card.media || []).find((m) => m && m.url);
-  if (!thumb && !card.title) return null;
-  // Title, provider and author name come from a third party. Store them as
-  // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
-  // be the one that remembers to escape. A card is a card, not an essay.
-  const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
-  return JSON.stringify({
-    url: card.url,
-    kind: card.kind,                       // 'provider' | 'oembed'
-    provider: plain(card.provider),
-    title: plain(card.title),
-    author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
-    media: thumb ? [thumb] : [],           // thumbnail only, no html/iframe
-  });
-}
-
-/**
- * Does our own post link to a fediverse object? Returns { uri, actor } when the
- * first external link resolves to a quotable AP object, else null. Runs once at
- * publish time; the answer is stored on the post.
- */
-export async function resolveOwnQuote(html) {
-  const first = firstExternalUrl(html);
-  if (!first) return null;
-  const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
-  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
-  if (!card || card.kind !== 'ap' || !card.id) return null;
-  return { uri: card.id, actor: card.attributedTo || null };
-}
-
-/**
- * De composer-preview (shaer-k3f): één URL langs exact dezelfde pijplijn als
- * publiceren, zodat wat de preview toont ook is wat de post krijgt. Twee
- * uitkomsten, hoogstens een gevuld: een AP-object wordt een quote-snapshot,
- * een externe link probeert een kaart. Beide als JSON-string, dezelfde vorm
- * als de kolommen -- de route serveert ze door timelineQuote/timelineEmbed en
- * de gate, net als de tijdlijn.
- */
-export async function previewCard(url) {
-  if (!/^https?:\/\//i.test(String(url || ''))) return {};
-  const html = `<a href="${String(url).replace(/"/g, '&quot;')}">x</a>`;
-  const q = await resolveOwnQuote(html);
-  if (q && q.uri) {
-    const quoteJson = await resolveQuoteByUri(q.uri).catch(() => null);
-    if (quoteJson) return { quoteJson };
-  } else {
-    const embedJson = await resolveExternalEmbed(html).catch(() => null);
-    if (embedJson) return { embedJson };
-  }
-  return {};
-}
-
-/** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
-export function firstExternalUrl(html) {
-  if (!html || typeof html !== 'string') return null;
-  for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) {
-    const tag = m[0];
-    if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue;
-    const href = m[1];
-    if (/^https?:\/\//i.test(href)) return href;
-  }
-  return null;
-}
-
-/** The stored external-embed card, for the C2S read. */
-// ── Standaardvormen in plaats van eigen dialect (shaer-nmw) ───────
-//
-// Robins waarschuwing: geen Klonkt/Shaer-dialect schrijven waar AS2 of een FEP
-// het al regelt. Vier eigen properties hadden een standaard naast zich staan,
-// en deze helpers zijn die standaard -- een definitie per vorm, zodat de tien
-// plekken die ze emitten niet elk hun eigen variant krijgen.
-//
-// De oude shaer:-velden blijven er voorlopig NAAST staan. Een app in het veld
-// leest ze nog, en een leeg scherm is een duurdere fout dan een dubbel veld;
-// ze gaan eruit als de clients om zijn (tweede helft van shaer-nmw).
-
-/** FEP-9098 Emoji-tags uit een {shortcode: url}-kaart. */
-function emojiTagsFromMap(emojis) {
-  const uit = Object.entries(emojis || {})
-    .filter(([naam, url]) => naam && url)
-    .map(([naam, url]) => ({ type: 'Emoji', name: naam, icon: { type: 'Image', url } }));
-  return uit.length ? uit : undefined;
-}
-
-/**
- * Een actor als INGESLOTEN OBJECT voor `attributedTo` / `actor`.
- *
- * AS2 staat toe dat attributedTo een object is in plaats van een URI, en dan
- * heeft ELKE client er wat aan -- niet alleen de onze, die er shaer:author
- * naast kreeg. `preferredUsername` is de lokale naam; een lezer leidt de handle
- * af uit die naam plus de host van de id, precies zoals wij serverkant ook
- * doen. Weten we niets van de persoon, dan blijft het de kale URI: een leeg
- * object zou beweren dat we hem kennen.
- */
-export function actorObject(uri, info) {
-  if (!uri) return undefined;
-  const iets = info && (info.name || info.handle || info.icon || info.url);
-  if (!iets) return uri;
-  const o = { id: uri, type: 'Person' };
-  if (info.name) o.name = info.name;
-  const lokaal = String(info.handle || '').replace(/^@/, '').split('@')[0];
-  if (lokaal) o.preferredUsername = lokaal;
-  if (info.icon) o.icon = { type: 'Image', url: info.icon };
-  if (info.url) o.url = info.url;
-  const tags = emojiTagsFromMap(info.emojis);
-  if (tags) o.tag = tags;
-  return o;
-}
-
-/**
- * De linkkaart als AS2 `preview` (core: "identifies an entity that provides a
- * preview of this object"). Een Page met url, name en image IS een kaart; daar
- * hoefde shaer:embed nooit voor te bestaan.
- *
- * Wat WEL van ons blijft is de spelerpagina: dat die alleen meegaat als de
- * guardians de poort openden is FEP-633c-gedrag en heeft geen AS2-tegenhanger.
- */
-export function previewObject(embedJson, { playback = false } = {}) {
-  const e = timelineEmbed(embedJson, { playback });
-  if (!e) return undefined;
-  const thumb = (e.media || []).find((m) => m && m.url);
-  const p = { type: 'Page', url: e.url };
-  if (e.title) p.name = e.title;
-  if (thumb) p.image = { type: 'Image', url: thumb.url };
-  if (e.author && (e.author.name || e.author.handle)) {
-    p.attributedTo = { type: 'Person', name: e.author.name || e.author.handle };
-  }
-  if (e['shaer:playerUrl']) p['shaer:playerUrl'] = e['shaer:playerUrl'];
-  if (e['shaer:playable']) p['shaer:playable'] = e['shaer:playable'];
-  return p;
-}
-
-/**
- * De geciteerde post als OBJECT in `quote` (FEP-044f staat toe dat quote het
- * object zelf is, niet alleen een URI). De opgeslagen momentopname wordt hier
- * een echte Note, met de auteur als ingesloten actor -- dus geen tweede eigen
- * property voor iets dat de FEP al kan.
- */
-export function quoteObject(quoteJson) {
-  const q = timelineQuote(quoteJson);
-  if (!q) return undefined;
-  const note = { type: 'Note', id: q.url, url: q.url };
-  if (q.content) note.content = q.content;
-  if (q.published) note.published = q.published;
-  if (q.author) {
-    note.attributedTo = actorObject(q.author.url || q.url, {
-      name: q.author.name, handle: q.author.handle, icon: q.author.icon,
-    });
-  }
-  const media = (q.media || []).filter((m) => m && m.url)
-    .map((m) => ({ type: 'Document', mediaType: m.type || undefined, url: m.url }));
-  if (media.length) note.attachment = media;
-  const tags = emojiTagsFromMap(q.emojis);
-  if (tags) note.tag = tags;
-  return note;
-}
-
-export function timelineEmbed(embedJson, { playback = false } = {}) {
-  try {
-    const e = embedJson ? JSON.parse(embedJson) : null;
-    if (!e || typeof e !== 'object' || !e.url) return undefined;
-    // The player URL is served ONLY when the playback gate is open (FEP-633c
-    // 5.6). Deciding it here keeps the provider knowledge in one place: the
-    // client never needs a list of hosts, it just plays what it is handed.
-    // Privacy-enhanced variants only: nocookie for YouTube, the instance's own
-    // player for PeerTube. Without one the card stays a thumbnail.
-    const player = playback ? playerUrlFor(e.url) : null;
-    if (player) return { ...e, 'shaer:playerUrl': player };
-    // The gate is shut and there IS something behind it. Saying so costs
-    // nothing (the card already shows a video thumbnail) and saves the child
-    // from tapping a card that will never answer: the app can explain instead
-    // of doing nothing. It stays a statement of fact, never a way in.
-    return playerUrlFor(e.url) ? { ...e, 'shaer:playable': true } : e;
-  } catch { return undefined; }
-}
-
-/** The embeddable player for a URL, or null when we will not frame it. */
-export function playerUrlFor(url) {
-  if (typeof url !== 'string') return null;
-  let p = null;
-  try { p = AudioEmbedService.detectProvider(url); } catch { p = null; }
-  if (p && p.provider === 'youtube' && p.id) return `https://www.youtube-nocookie.com/embed/${p.id}?rel=0&modestbranding=1&playsinline=1`;
-  if (p && p.provider === 'vimeo' && p.id) return `https://player.vimeo.com/video/${p.id}`;
-  // PeerTube is decentralised, so it is matched by its watch-URL shape rather
-  // than a provider list. Host chars are validated before it is inlined.
-  const pt = url.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-  if (pt) return `https://${pt[1]}/videos/embed/${pt[2]}`;
-  return null;
-}
-
-// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
-// snapshot { url, author{name,handle,icon}, content, published, media } so the
-// client can render it as a nested card instead of a bare link. Best-effort and
-// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
-// to the object-link chip. The content goes through the same sanitiser as every
-// other note, so the kid-safe guarantees hold.
-async function resolveQuote(note) {
-  const url = quoteHrefOf(note);
-  if (!url) return null;
-  return resolveQuoteByUri(url);
-}
-
-/** Hetzelfde snapshot, maar vanaf een kale URI: eigen posts en de
- *  composer-preview (shaer-k3f) kennen alleen de link, niet de tag-vorm. */
-async function resolveQuoteByUri(url) {
-  const q = await apGetJson(url);
-  if (!q || typeof q !== 'object') return null;
-  const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
-    : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
-  const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
-  // The quoted post's own FEP-9098 emojis, so :shortcode: renders in the card.
-  const emojis = {};
-  try {
-    for (const e of JSON.parse(extractEmojiTags(q.tag) || '[]')) {
-      const u = e.icon && (e.icon.url || (Array.isArray(e.icon) && e.icon[0] && e.icon[0].url));
-      if (typeof e.name === 'string' && u) emojis[e.name] = u;
-    }
-  } catch { /* ignore */ }
-  let media = []; try { media = JSON.parse(mediaFromNote(q)); } catch { /* ignore */ }
-  const snapshot = {
-    url: safeUrl(q.url || q.id || url) || url,
-    author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
-    content: HtmlSanitizerService.sanitize(q.content || ''),
-    published: q.published || null,
-    media,
-    emojis: Object.keys(emojis).length ? emojis : undefined,
-  };
-  return JSON.stringify(snapshot);
-}
-
-/**
- * The card under a post: a fediverse quote (FEP-044f) when the note has one,
- * otherwise an external link preview. Both render as the SAME card, so only one
- * of the two is ever stored. Returns {column, json} or null.
- *
- * Both halves reach out over the network, which is why every caller runs this
- * out of band: an inbox answer must never wait on a third party.
- */
-async function resolveCard(o) {
-  if (quoteHrefOf(o)) {
-    const qj = await resolveQuote(o);
-    return qj ? { column: 'quote_json', json: qj } : null;
-  }
-  const ej = await resolveExternalEmbed(o && o.content);
-  return ej ? { column: 'embed_json', json: ej } : null;
-}
-
-// AP-native catch-up: pull an actor's standard `outbox` collection and merge their recent
-// top-level posts into the timeline for `slug`. Push (Create delivery) cannot backfill
-// history-from-before-you-followed or a delivery that was missed while you were down;
-// reading the outbox is the spec-conform way to catch up. PULL ONLY — sends nothing.
-export async function backfillFromOutbox(slug, actorUri, limit = 20) {
-  try {
-    if (!slug || !actorUri) return 0;
-    const actor = await fetchActor(actorUri);
-    if (!actor || !actor.outbox) return 0;
-    // Signed as the follower (30-7): the serving side recognises an accepted
-    // friend and hands the friends-only history along; an anonymous GET only
-    // ever sees the public set. A server that ignores the signature behaves
-    // exactly as before.
-    let page = await signedGetJson(slug, typeof actor.outbox === 'string' ? actor.outbox : actor.outbox.id);
-    let items = (page && (page.orderedItems || page.items)) || [];
-    if (!items.length && page && page.first) {
-      page = await signedGetJson(slug, typeof page.first === 'string' ? page.first : page.first.id);
-      items = (page && (page.orderedItems || page.items)) || [];
-    }
-    if (!Array.isArray(items) || !items.length) return 0;
-    const ai = actorInfo(actor, actorUri);
-    let added = 0;
-    for (const it of items.slice(0, limit)) {
-      // Each item is usually a Create wrapping a Note, or sometimes the Note itself.
-      const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-      if (!o || !o.id) continue;
-      if (o.type && o.type !== 'Note' && o.type !== 'Article' && o.type !== 'Question') continue; // skip boosts/other
-      if (o.inReplyTo) continue;                                          // top-level only
-      const auth = actorUriOf(o.attributedTo);
-      if (auth && auth !== actorUri) continue;                            // their OWN posts only
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      const poll = parsePoll(o); // a Question (poll) → carry its options/counts on backfill too
-      try {
-        const r = tlStmts().ins.run(o.id, slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, mediaFromNote(o), o.sensitive ? 1 : 0, contentWarning(o));
-        if (r && r.changes > 0) added++;
-        // FEP-9098: keep custom-emoji tags from backfilled posts too.
-        { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, slug); } catch { /* ignore */ } } }
-        storeAuthorEmoji(o.id, slug, ai);   // custom-emoji display name for the byline
-        // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
-        { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
-        // FEP-044f: resolve the embedded quote card for backfilled posts too.
-        if (quoteHrefOf(o)) { const qj = await resolveQuote(o); if (qj) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, slug); } catch { /* ignore */ } } }
-        // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
-        if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
-      } catch { /* ignore */ }
-    }
-    if (added) console.log('[AP] outbox backfill', actorUri, '→', slug, '+' + added);
-    return added;
-  } catch { return 0; }
-}
-
-// ── Remote thread crawl (fill the gaps in a local post's conversation) ────────────
-// Most replies reach us by delivery, but replies-to-replies that live on other servers and
-// aren't addressed to us are missed. This pulls the AS2 `replies` collections of the replies
-// we DO have, caching any newly-found ones in ap_interactions.
-//
-// Matches Mastodon's behaviour: ONE level per crawl (like its FetchRepliesService), not a deep
-// recursive walk. Deeper levels fill in incrementally across crawls — once a fetched reply is
-// cached it becomes a seed itself, so its own replies are pulled on a later view (Mastodon's
-// per-status cascade). Bounded + polite (serial), PULL only, and stale-while-revalidate: it
-// never runs in a page request — the view renders from cache; a stale post kicks off a
-// background refresh for the NEXT view.
-const THREAD_TTL_MS = 15 * 60 * 1000;   // don't re-crawl a post more than ~4×/hour
-const THREAD_MAX_DEPTH = 1;             // one hop per crawl (like Mastodon); deeper fills in over crawls
-const THREAD_MAX_FETCHES = 30;          // hard cap on remote GETs per crawl (be a good peer)
-const _crawlingThreads = new Set();     // per-post in-flight lock (no stampede across views)
-
-function threadCrawlTs(postId) {
-  try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('thread_crawl:' + postId); return r ? (Number(r.value) || 0) : 0; }
-  catch { return 0; }
-}
-function setThreadCrawlTs(postId, ts) {
-  try { db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run('thread_crawl:' + postId, String(ts)); }
-  catch { /* ignore */ }
-}
-
-// Read a note's `replies` (string ref / Collection with `first` / paged CollectionPages) →
-// child note URIs. Every remote GET goes through `budget` so the whole crawl stays capped.
-async function collectReplyItems(repliesRef, maxPages, budget) {
-  const uris = [];
-  let node = typeof repliesRef === 'string' ? await budget.get(repliesRef) : repliesRef;
-  if (node && node.first) node = typeof node.first === 'string' ? await budget.get(node.first) : node.first;
-  let pages = 0;
-  while (node && pages++ < maxPages) {
-    for (const it of (node.items || node.orderedItems || [])) {
-      const u = typeof it === 'string' ? it : (it && it.id);
-      if (u && /^https?:\/\//i.test(u)) uris.push(u);
-    }
-    if (!node.next) break;
-    node = typeof node.next === 'string' ? await budget.get(node.next) : node.next;
-  }
-  return uris;
-}
-
-async function crawlThread(postId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base) return;
-  // Seed frontier = the remote reply note URIs we already have; also the dedup set.
-  let known;
-  try { known = new Set(db.prepare("SELECT object_uri FROM ap_interactions WHERE post_id = ? AND kind = 'reply' AND object_uri != ''").all(postId).map((r) => r.object_uri)); }
-  catch { return; }
-  const seeds = [...known].filter((u) => /^https?:\/\//i.test(u));
-  if (!seeds.length) return; // nothing remote to expand
-  // Owner-removed replies (tombstones) join the dedup set AFTER seeding, so the
-  // crawler never re-adds them via thread-filling (they're gone from the seeds
-  // already because rejectInteraction deleted their ap_interactions row).
-  try { for (const r of db.prepare('SELECT object_uri FROM ap_rejected_objects WHERE post_id = ?').all(postId)) known.add(r.object_uri); }
-  catch { /* table always exists after boot migration */ }
-
-  let fetches = 0;
-  const budget = { get: async (u) => { if (fetches >= THREAD_MAX_FETCHES) return null; fetches++; return apGetJson(u); } };
-  const visited = new Set(); // notes whose replies collection we've already expanded
-  let frontier = seeds.slice();
-  let added = 0;
-
-  for (let depth = 0; depth < THREAD_MAX_DEPTH && frontier.length && fetches < THREAD_MAX_FETCHES; depth++) {
-    const nextFrontier = [];
-    for (const noteUri of frontier) {
-      if (visited.has(noteUri) || fetches >= THREAD_MAX_FETCHES) continue;
-      visited.add(noteUri);
-      const note = await budget.get(noteUri);
-      if (!note || !note.replies) continue;
-      const childUris = await collectReplyItems(note.replies, 2, budget);
-      for (const cu of childUris) {
-        if (known.has(cu) || fetches >= THREAD_MAX_FETCHES) continue;
-        known.add(cu);
-        const child = await budget.get(cu);
-        if (!child || !child.id || (child.type !== 'Note' && child.type !== 'Article')) continue;
-        if (isRejectedObject(child.id)) continue; // note id can differ from the collection URI (redirects)
-        const actorUri = actorUriOf(child.attributedTo);
-        if (!actorUri || isBlockedAny(actorUri)) continue; // skip blocked authors
-        const actor = await budget.get(actorUri); // may be null if budget spent → fallback handle
-        const ai = actorInfo(actor, actorUri);
-        const html = HtmlSanitizerService.sanitize(child.content || '');
-        // The child replies to `note` by construction (it's in note's replies collection).
-        try { iStmts().ins.run('reply', postId, child.id, actorUri, ai.name, ai.handle, ai.url, ai.icon, html, child.published || null, note.id || noteUri, noteVisibility(child), extractEmojiTags(child.tag), emojiJsonOf(ai.emojis)); added++; } catch { /* ignore */ }
-        nextFrontier.push(child.id); // expand this reply's own replies next depth
-      }
-    }
-    frontier = nextFrontier;
-  }
-  if (added) console.log('[AP] thread crawl', postId, '+' + added, 'remote replies (' + fetches + ' fetches)');
-}
-
-// Stale-while-revalidate entry point: call from the post view. Renders nothing, blocks nothing —
-// fires a background crawl only if this post hasn't been crawled within the TTL.
-export function maybeCrawlThread(postId) {
-  if (!postId || _crawlingThreads.has(postId)) return;
-  if (Date.now() - threadCrawlTs(postId) < THREAD_TTL_MS) return;
-  _crawlingThreads.add(postId);
-  setThreadCrawlTs(postId, Date.now()); // optimistic mark so concurrent/next views don't re-fire
-  crawlThread(postId).catch((e) => console.warn('[AP] thread crawl failed:', e && e.message)).finally(() => _crawlingThreads.delete(postId));
-}
-
-let _selfHealing = false;
-export async function selfHealTimeline() {
-  if (_selfHealing) return; _selfHealing = true;
-  try {
-    let cur = 0;
-    try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
-    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
-    // v21: direct notes used to land in the timeline as if they were posts, so a
-    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
-    // refuses them; drop the ones already cached. Scoped to the two kinds we can
-    // still recognise afterwards (help request, wave) — a plain public mention
-    // from someone you follow IS a timeline post and must stay.
-    try {
-      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
-        SELECT 1 FROM ap_mentions m
-         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
-           AND (m.help_request = 1 OR m.wave = 1))`).run();
-      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
-    } catch { /* table may predate the columns */ }
-    let rows = [];
-    try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
-    let healed = 0, failed = 0;
-    for (const r of rows) {
-      // Link previews first, and deliberately BEFORE the note re-fetch. A
-      // preview is resolved from the content we already hold, so hanging it
-      // behind a remote fetch meant one unreachable origin skipped the whole
-      // row (`continue` below) and the card never appeared. It needs nothing
-      // from the origin, so it must not depend on it.
-      if (!r.quote_json && !r.embed_json) {
-        try {
-          const ej = await resolveExternalEmbed(r.content);
-          if (ej) db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ?').run(ej, r.id);
-        } catch { /* best-effort, never blocks the heal */ }
-      }
-      try {
-        const note = await fetchNoteAP(r.id);
-        if (note === 404) { db.prepare('DELETE FROM ap_timeline WHERE id = ?').run(r.id); healed++; continue; }
-        if (!note || typeof note !== 'object') { failed++; continue; } // origin unreachable right now
-        // Door DEZELFDE bouwer als de innamekant (v22). Hij bouwde de inhoud
-        // hier zelf op, en daardoor miste een gerepareerde rij precies wat de
-        // inname wel doet -- de titel van een artikel bijvoorbeeld. Een
-        // zelfherstel dat een andere vorm oplevert dan de inname repareert naar
-        // een derde toestand.
-        const velden = timelineFields(note);
-        const html = velden.html;
-        const media = velden.atts.length ? JSON.stringify(velden.atts) : mediaFromNote(note);
-        const nsfw = note.sensitive ? 1 : 0;   // re-sync NSFW/sensitive + CW onto already-cached posts
-        const cw = contentWarning(note);
-        const url = note.url || null;          // re-sync the human url (catches a remote slug rename)
-        const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
-        const link = extractLinkJson(note);   // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
-        // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
-        // cached snapshot if the quoted post is momentarily unreachable now.
-        const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
-        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '') || (quote || '') !== (r.quote_json || '')) {
-          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
-          healed++;
-        }
-        // v13: a custom-emoji display name needs the author's emoji map. Fetch
-        // the actor once, only for rows whose name has a shortcode and no map yet.
-        if (/:[A-Za-z0-9_+-]+:/.test(r.author_name || '') && !r.author_emoji_json && r.author_uri) {
-          const ai = actorInfo(await fetchActor(r.author_uri), r.author_uri);
-          if (ai.emojis) { try { db.prepare('UPDATE ap_timeline SET author_emoji_json = ? WHERE id = ?').run(JSON.stringify(ai.emojis), r.id); } catch { /* ignore */ } }
-        }
-        // v14: same for the booster's display name ("X boosted"). The row stores
-        // no booster URI, so resolve it from the handle via webfinger. Scoped to
-        // this exact row (slug) since a note can be boosted by different people.
-        if (/:[A-Za-z0-9_+-]+:/.test(r.reblog_name || '') && !r.reblog_emoji_json && r.reblog_handle) {
-          const bUri = await webfingerResolve(r.reblog_handle);
-          const em = bUri ? actorNameEmojis(await fetchActor(bUri)) : undefined;
-          if (em) { try { db.prepare('UPDATE ap_timeline SET reblog_emoji_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(em), r.id, r.slug); } catch { /* ignore */ } }
-        }
-      } catch { failed++; /* per-note best-effort */ }
-    }
-    // Only mark this version DONE after a clean pass. Some origins are briefly
-    // offline exactly when we heal (phone-hosted instances!): skipping them and
-    // consuming the version would leave those rows stale forever. Instead retry
-    // on the next boots, giving up after a few attempts (permanently-dead
-    // origins answer 404/410 and are deleted above, so they don't loop).
-    const setSetting = (k, v) => { try { db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, String(v)); } catch { /* ignore */ } };
-    let attempts = 0;
-    try { const a = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_attempts'); attempts = a ? (parseInt(a.value, 10) || 0) : 0; } catch { /* ignore */ }
-    if (failed === 0 || attempts >= 4) {
-      setSetting('selfheal_version', SELFHEAL_VERSION);
-      setSetting('selfheal_attempts', 0);
-    } else {
-      setSetting('selfheal_attempts', attempts + 1);
-    }
-    if (rows.length) console.log(`[AP] self-heal v${SELFHEAL_VERSION}: ${healed}/${rows.length} timeline notes${failed ? ` (${failed} unreachable — will retry next boot)` : ''}`);
-  } catch { /* never block boot */ } finally { _selfHealing = false; }
-}
-
-// Follow a fediverse account by @handle (WebFinger → actor → signed Follow).
-
-/**
- * FEP-7628 (DRAFT status — the shape is Mastodon's since 2019, but the FEP can
- * still change): an account our sites follow says it moved to a new home.
- *
- * Validity has two independent legs, and both must hold:
- *  1. The SIGNER is a party to the move: the old actor announcing its own move
- *     (push mode) or the new actor doing it (pull mode). A third party
- *     narrating someone else's move is refused — without this, any signed
- *     stranger could re-point our follows.
- *  2. The NEW actor claims the old identity in its `alsoKnownAs`. That is the
- *     cross-side proof: the mover controls both ends. Without it, whoever
- *     holds ONE end could hijack the other end's followers.
- *
- * Effect: every local site following the old actor unfollows it and follows
- * the new one, keeping its auto-boost choice. Deliberately NOT retargeted:
- * guardianship relations (FEP-633c) — a guardian is a security anchor, not a
- * feed subscription, and moving one is shaer-tge's gated decision, not a
- * side effect of an inbox event. We only log when a move touches one.
- *
- * Deps are injectable for tests (no network in node:test).
- */
-export async function handleMoveInbox(act, { verifiedActor = null, fetchActorFn = null, followFn = null, unfollowFn = null } = {}) {
-  const oldUri = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-  const newUri = typeof act.target === 'string' ? act.target : (act.target && act.target.id);
-  if (!oldUri || !newUri || oldUri === newUri) return 400;
-  if (!verifiedActor || (verifiedActor !== oldUri && verifiedActor !== newUri)) {
-    console.warn('[AP] Move refused: signer is not a party to the move', verifiedActor || '(unsigned)', oldUri, '→', newUri);
-    return 401;
-  }
-  // Nobody here follows the old actor → nothing to move. This also makes
-  // redelivery idempotent: after the first swap the rows are gone.
-  let rows = [];
-  try { rows = db.prepare('SELECT * FROM ap_following WHERE actor_uri = ?').all(oldUri); } catch { /* fresh init */ }
-  if (!rows.length) return 202;
-  // A blocked destination is declined outright: the old follow stays (it goes
-  // stale on its own), and we will not open a door to a blocked house.
-  if (isBlockedAny(newUri)) { console.log('[AP] Move dropped: target is blocked', newUri); return 202; }
-  const target = await (fetchActorFn || fetchActor)(newUri);
-  const aka = [].concat((target && target.alsoKnownAs) || [])
-    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
-  if (!target || !target.id || !aka.includes(oldUri)) {
-    console.warn('[AP] Move refused: target does not claim the old actor in alsoKnownAs', oldUri, '→', newUri);
-    return 202; // decline to act; no 4xx, the sender may be a well-meaning retrying server
-  }
-  // EERST de guardianship, DAARNA pas de follows. Die volgorde is geen netheid
-  // maar de hele werking, en hij is met bloed geschreven: bij Robins verhuizing
-  // op 13-8 stond het andersom en het log liet precies zien wat er dan gebeurt.
-  //
-  //   [AP] outgoing Follow beta → .../robo (gated, awaiting guardians)
-  //
-  // Beta is zelf een ward. Zijn UITGAANDE follow naar de verhuisde guardian werd
-  // gepoort (§5.3), want op dat moment stond het nieuwe adres nog niet in zijn
-  // guardian-lijst: de code hieronder had de relatie nog niet bijgewerkt. En de
-  // INKOMENDE kant heeft hetzelfde probleem, want de ward gate't een Follow van
-  // een onbekende. Dus beide richtingen bleven hangen op goedkeuring die niemand
-  // hoefde te geven, omdat het om een guardian ging die er al was.
-  //
-  // Met de relatie eerst is de verhuisde actor al een erkende guardian als de
-  // follows langskomen, en gaat de auto-acceptatie gewoon door.
-  //
-  // Een Move is een Move: de guardian is dezelfde guardian, het kind is hetzelfde
-  // kind, alleen het adres is nieuw. Zelfde bescherming als de re-follow: alleen
-  // na een geverifieerde Move, en niet naar een geblokkeerde bestemming (daar
-  // zijn we hierboven al uitgestapt). De twee harde randen van shaer-tge staan
-  // hier LOS van: weigeren te verhuizen naar een instance die shaer:guardians
-  // niet kan dragen is een controle aan de UITGAANDE kant, en het
-  // terugkeren-zonder-set is een alsoKnownAs-kwestie.
-  try {
-    const g = db.prepare('SELECT slug, role FROM ap_guardianships WHERE other_uri = ? AND status = ?').all(oldUri, 'accepted');
-    if (g.length) {
-      const r = db.prepare('UPDATE ap_guardianships SET other_uri = ? WHERE other_uri = ? AND status = ?').run(newUri, oldUri, 'accepted');
-      console.log('[AP] guardianship moved:', oldUri, '→', newUri, `(${r.changes}x)`, g.map((x) => `${x.role}:${x.slug}`).join(', '));
-    }
-  } catch (e) { console.warn('[AP] guardianship move failed:', e && e.message); }
-
-  for (const row of rows) {
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.slug);
-    if (!site) continue;
-    try {
-      await (unfollowFn || unfollowActor)(site, oldUri);
-      const already = fwStmts().one.get(row.slug, newUri);
-      if (!already) await (followFn || followActor)(site, newUri, !!row.auto_boost);
-      console.log('[AP] follow moved', row.slug, ':', oldUri, '→', newUri);
-    } catch (e) {
-      console.warn('[AP] move re-follow failed for', row.slug, e && e.message);
-    }
-  }
-  return 202;
-}
-
-/**
- * Slice 2 van shaer-0j2 (FEP-7628, DRAFT): de UITGAANDE helft — deze Klonkt
- * is het oude huis en kondigt het vertrek aan. Twee eisen voordat er iets
- * de deur uit gaat:
- *  1. Geen guardians: een warded account verhuizen zonder de guardianship
- *     te hertargeten zou het vangnet van het kind stil breken; dat is
- *     shaer-tge's gated beslissing, dus tot die er is weigert een bewaakt
- *     account de verhuizing.
- *  2. De NIEUWE actor claimt ons in alsoKnownAs — dezelfde back-reference
- *     die elke ontvangende server (onze eigen slice 1 incluis) eist. Zonder
- *     die claim is de Move overal dood bij aankomst.
- * De Move gaat duurzaam naar elke volger-inbox; hun servers doen de
- * re-follow. `moved_to` wordt hier vastgelegd; het SERVEREN ervan op de
- * actor (en het beleid van de oude site) is slice 3.
- * Deps injecteerbaar voor tests (geen netwerk in node:test).
- */
-export async function moveAccount(site, targetRaw, { fetchActorFn = null, deliverFn = null } = {}) {
-  // Al verhuisd? Dan eerst het slot eraf (moved_to leegmaken). Anders stapel je
-  // wegwijzers op elkaar en weet niemand meer waar de keten eindigt.
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  if (movedLock(site).locked) return { error: 'already_moved', movedTo: movedLock(site).movedTo };
-  try {
-    // Was een harde weigering voor elk bewaakt account (shaer-tge); sinds 8-8
-    // een GATE met dezelfde standaard: de automatiek weigert voor een ward,
-    // maar de guardians kunnen shaer:accountMove expliciet openzetten -- en
-    // expliciet dichtzetten geldt dan ook voor een account dat net geen ward
-    // meer is, net als bij de embeds.
-    const isWard = Guardianship.listGuardians(site.slug).length > 0;
-    if (!Guardianship.wardGateAllowed(site.gate_account_move, isWard)) {
-      console.warn('[AP] move refused: gated (shaer-tge):', site.slug, '→', String(targetRaw || ''));
-      return { error: 'guarded_account' };
-    }
-  } catch { /* geen guardianship-tabellen = geen guardians */ }
-  const s = String(targetRaw || '').trim();
-  let targetUri = null;
-  if (/^https?:\/\//i.test(s)) targetUri = safeUrl(s);
-  else if (s.includes('@')) targetUri = await webfingerResolve(s);
-  if (!targetUri) return { error: 'not_found' };
-  const me = actorId(base, site.slug);
-  if (targetUri === me) return { error: 'self' };
-  const target = await (fetchActorFn ? fetchActorFn(targetUri) : signedGetJson(site.slug, targetUri));
-  if (!target || !target.id || !target.inbox) return { error: 'unreachable' };
-  const aka = [].concat(target.alsoKnownAs || [])
-    .map((a) => (typeof a === 'string' ? a : (a && a.id))).filter(Boolean);
-  if (!aka.includes(me)) return { error: 'no_backreference' };
-  db.prepare('UPDATE sites SET moved_to = ? WHERE slug = ?').run(target.id, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const move = {
-    '@context': AP_CONTEXT,
-    id: `${me}#move-${Date.now()}-${rid()}`,
-    type: 'Move',
-    actor: me,
-    object: me,
-    target: target.id,
-    to: [`${me}/followers`],
-  };
-  // FEP-7628: after setting movedTo, notify the followers with an Update of
-  // the actor, so their servers hold the signpost even if the Move itself is
-  // lost. Built from the FRESH row: `site` still carries the pre-move values.
-  const movedSite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(site.slug) || { ...site, moved_to: target.id };
-  const update = {
-    '@context': AP_CONTEXT,
-    id: `${me}#update-${Date.now()}-${rid()}`,
-    type: 'Update', actor: me, to: [PUBLIC], cc: [`${me}/followers`],
-    object: buildActor(base, movedSite),
-    published: new Date().toISOString(),
-  };
-  const inboxes = [...new Set(fStmts().list.all(site.slug).map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
-  const send = deliverFn || deliverWithRetry;
-  for (const inbox of inboxes) {
-    await send(site.slug, inbox, update, `${me}#main-key`, keys.private_pem);
-    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
-  }
-  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
-  return { ok: true, target: target.id, inboxes: inboxes.length };
-}
-
-// FEP-633c §5.3 note (authorized fetch): true when `actorUri` is a committed
-/**
- * Who is reading this outbox, and what may they see (30-7)?
- *  - 'blocked': a verified caller this instance blocks. They get an EMPTY
- *    collection, not even the public set (Robins eis): a block is a closed
- *    door, and a signed fetch is the caller knocking with their name on it.
- *  - 'friend': the owner (bearer) or a verified accepted follower or
- *    guardian: the fan-only history rides along.
- *  - 'public': everyone else: the public set.
- */
-export function outboxAudience(slug, { bearerSlug = null, verifiedActor = null } = {}) {
-  if (bearerSlug && bearerSlug === slug) return 'friend';
-  if (!verifiedActor) return 'public';
-  if (isBlockedAny(verifiedActor)) return 'blocked';
-  // FEP-1580, Source Instance: wie ondertekend vraagt namens de actor waar wij
-  // NAARTOE verhuisd zijn, moet behandeld worden alsof wij het zelf vragen.
-  // Anders kan de nieuwe instantie alleen het publieke deel ophalen en verhuist
-  // je fan-only geschiedenis niet mee.
-  if (isMoveTarget(slug, verifiedActor)) return 'friend';
-  try {
-    if (db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ?').get(slug, verifiedActor)) return 'friend';
-  } catch { /* table absent on fresh init */ }
-  if (isWardGuardian(slug, verifiedActor)) return 'friend';
-  return 'public';
-}
-
-/**
- * FEP-1580, de hele autorisatie van de bronkant in één predicaat.
- *
- * De spec zegt: behandel een verzoek dat namens de DOEL-actor getekend is alsof
- * de BRON-actor het deed, voor zichtbaarheid en toegang. Wij hangen dat aan
- * `moved_to`, en dat mag omdat moveAccount() `no_backreference` weigert: het
- * veld komt er alleen te staan als de doel-actor ons al in `alsoKnownAs` had.
- * Dus staat er iets, dan heeft iemand met beheer op BEIDE kanten dat gewild.
- * Een typefout kan hier niet binnenkomen, want die haalt de move zelf niet.
- *
- * Dat dit veilig is leunt op de keyId-binding in verifyRequest (shaer-xd8i):
- * zonder die controle kon een actor tekenen met de sleutel van een buurman op
- * dezelfde host, en dan is "wie tekende dit" te zacht om je hele geschiedenis
- * aan af te geven.
- */
-export function isMoveTarget(slug, actorUri) {
-  if (!slug || !actorUri) return false;
-  try {
-    const row = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
-    return !!(row && row.moved_to && row.moved_to === actorUri);
-  } catch { return false; }
-}
-
-// guardian of the local ward `wardSlug` — so a signed GET from it may read the
-// ward's non-public history without the guardian appearing as a follower.
-export function isWardGuardian(wardSlug, actorUri) {
-  try { return !!Guardianship.getRelation(wardSlug, 'ward', actorUri); } catch { return false; }
-}
-
-// FEP-633c §5.3: the guardians approved a gated follow of their ward. Send the
-// Accept to the follower and record them, so delivery (incl. followers-only)
-// begins. `pending` is a row from ap_pending_follows.
-/**
- * FEP-633c §5.3, the direction that was never gated (bead shaer-p729).
- *
- * A ward's OWN follow waited for nobody: it went straight out and the guardians
- * got a note afterwards (1a2f206). That is informing, not gating — the door is
- * already open when the message lands. Now it waits, with two exceptions that
- * are not favours but the same decision already taken:
- *
- *   - the target is one of the ward's own guardians. Following the adult who
- *     watches over you is not a question anyone needs to answer.
- *   - the target already follows the ward THROUGH THE GATE. A guardian
- *     approved that person by name; asking again about the same person only
- *     teaches everyone to stop reading the question.
- *
- * Returns the held request, or null when the follow may go out now.
- * Deliberately not a boolean: a held follow must be distinguishable from a sent
- * one all the way up to the app, which is the lesson the error path already
- * learned (Robins melding, 31-7).
- */
-export async function gateOutgoingFollow(site, targetUri) {
-  const slug = site && site.slug;
-  if (!slug || !targetUri) return null;
-  const guardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
-  if (!guardians.length) return null;                                   // not a ward: nothing to gate
-  // shaer:following (shaer-p729) — its own gate, apart from shaer:follows,
-  // which governs the OTHER direction. §5.3 fixes the inbound one on: a Follow
-  // aimed at a ward MUST pass the guardians. About this direction the FEP says
-  // nothing, so it is ours to set and ours to let go of, and the guardians can
-  // relax it for a child who has grown into it. Undecided means gated for a
-  // ward, the same automatiek as the rest of the family.
-  const gateRow = db.prepare('SELECT gate_following FROM sites WHERE slug = ?').get(slug);
-  if (Guardianship.wardGateAllowed(gateRow && gateRow.gate_following, true)) return null;
-  if (guardians.includes(targetUri)) return null;                       // your own guardian
-  if (Guardianship.outgoing.isMutual(slug, targetUri)) return null;     // already vetted by name
-
-  const seen = Guardianship.outgoing.findFor(slug, targetUri);
-  if (seen && seen.status === 'approved') return null;                  // the guardians said yes already
-  if (seen && (seen.status === 'pending' || seen.status === 'denied')) return seen;
-
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const wardActor = actorId(base, slug);
-  const target = await fetchActor(targetUri).catch(() => null);
-  const ti = actorInfo(target, targetUri);
-  const id = `${wardActor}#outfollow-${Date.now()}-${rid()}`;
-  const held = Guardianship.outgoing.recordPending(slug, {
-    id, target: targetUri,
-    inbox: target && ((target.endpoints && target.endpoints.sharedInbox) || target.inbox),
-    name: ti.name, handle: ti.handle, icon: ti.icon,
-  });
-
-  // Same routing as the inbound gate: a guardian on this instance gets a push
-  // and reads /guardian; one elsewhere gets an Offer delivered so its own
-  // server holds a copy to answer from.
-  const wardKeys = getOrCreateKeys(slug);
-  const followObj = { id, type: 'Follow', actor: wardActor, object: targetUri };
-  for (const g of guardians) {
-    try { Guardianship.availability.recordRequest(slug, g, id, Date.now()); } catch { /* never load-bearing */ }
-  }
-  for (const g of guardians) {
-    const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
-    const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
-    if (isLocal) {
-      const L = pushLang(gslug);
-      // De andere richting, en dus andere woorden: hier vraagt het kind of het
-      // iemand mag volgen. Met dezelfde tekst als hierboven kon een guardian
-      // op zijn telefoon niet zien wie er nu eigenlijk om wie vroeg.
-      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folout_t'), body: i18nT(L, 'push.n_guard_folout_b', { who: ti.name || ti.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
-    } else {
-      fetchActor(g).then((ga) => {
-        const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
-        if (!inbox) return;
-        // Zou DIT antwoord het besluit afmaken (shaer-8vt)? Bij twee guardians is de
-        // drempel 1, dus de EERSTE ja beslist -- en dat is precies wat de
-        // beantwoorder niet kon weten.
-        const beslissend = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
-        const offer = { '@context': AP_CONTEXT, id: `${wardActor}#outfollowoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:direction': 'outgoing', 'shaer:decisive': beslissend };
-        deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
-      }).catch(() => {});
-    }
-  }
-  console.log('[AP] outgoing Follow', slug, '→', targetUri, '(gated, awaiting guardians)');
-  return held || { id, ward_slug: slug, target_uri: targetUri, status: 'pending' };
-}
-
-/**
- * The guardians said yes: send the ward's Follow for real (§5.3, shaer-p729).
- *
- * The row stays behind as `approved` rather than being deleted. It is the
- * record that these guardians vetted this target, so an unfollow-and-refollow
- * later does not put the same question in front of them again.
- */
-export async function performApprovedFollow(pending) {
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(pending.ward_slug);
-  if (!site) return { error: 'no_such_ward' };
-  const r = await followActor(site, pending.target_uri, false, { approved: true });
-  if (r && r.error) return { error: r.error };
-  console.log('[AP] outgoing Follow approved', pending.ward_slug, '→', pending.target_uri);
-  return { ok: true };
-}
-
-export async function acceptGatedFollow(pending) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const slug = pending.ward_slug;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  fStmts().ins.run(slug, pending.follower_uri, pending.follower_inbox, pending.follower_shared_inbox, pending.follower_name, pending.follower_handle, pending.follower_icon);
-  // This follower came through the §5.3 gate: a guardian said yes to this
-  // person by name. That is precisely what lets the ward follow them back later
-  // without asking the same guardians the same question twice (shaer-p729).
-  db.prepare('UPDATE ap_followers SET gate_approved = 1 WHERE slug = ? AND actor_uri = ?').run(slug, pending.follower_uri);
-  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
-  const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: original };
-  await deliverWithRetry(slug, pending.follower_inbox, accept, `${me}#main-key`, keys.private_pem);
-  const filled = pending.follower_shared_inbox &&
-    db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1').get(slug, pending.follower_shared_inbox, pending.follower_uri);
-  if (!filled) backfillNewFollower(base, slug, pending.follower_shared_inbox || pending.follower_inbox).catch(() => {});
-  console.log('[AP] gated Follow accepted', pending.follower_uri, '→ ward', slug);
-  return { ok: true };
-}
-
-// The guardians denied the follow: send a Reject so the follower's server clears
-// its pending state, then the caller drops the record.
-export async function rejectGatedFollow(pending) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const slug = pending.ward_slug;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  const original = pending.activity_json ? JSON.parse(pending.activity_json) : { type: 'Follow', actor: pending.follower_uri, object: me };
-  const reject = { '@context': AP_CONTEXT, id: `${me}#reject-${Date.now()}-${rid()}`, type: 'Reject', actor: me, object: original };
-  if (pending.follower_inbox) await deliverWithRetry(slug, pending.follower_inbox, reject, `${me}#main-key`, keys.private_pem).catch(() => {});
-  console.log('[AP] gated Follow rejected', pending.follower_uri, '→ ward', slug);
-  return { ok: true };
-}
-
-// ── Cross-instance follow-approval (FEP-633c §5.3, modelled on the guardian
-//    offer). Inbound: an Offer(Follow) forwarded by a ward to a guardian (leg
-//    2), or a guardian's Accept/Reject coming back to the ward (leg 4). ──────
-async function handleFollowApprovalInbox(act, slugParam) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const type = Array.isArray(act.type) ? act.type[0] : act.type;
-  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-
-  // Leg 2: I am a guardian; the object is the Follow to approve. The Offer is
-  // signed by the ward, so act.actor is the ward.
-  if (type === 'Offer') {
-    const fo = (act.object && typeof act.object === 'object') ? act.object : null;
-    const foType = fo && (Array.isArray(fo.type) ? fo.type[0] : fo.type);
-    if (!fo || foType !== 'Follow') return false;
-    const followId = fo.id;
-    const follower = typeof fo.actor === 'string' ? fo.actor : (fo.actor && fo.actor.id);
-    const wardUri = actorUri;
-    if (!followId || !follower || !wardUri) return false;
-    const recips = (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : [])).filter((x) => typeof x === 'string');
-    if (slugParam) recips.push(actorId(base, slugParam));
-    let stored = false;
-    for (const r of new Set(recips)) {
-      const gslug = slugFromActorUrl(r);
-      if (!gslug) continue;
-      if (!Guardianship.getRelation(gslug, 'guardian', wardUri)) continue;   // must actually guard this ward
-      const wardDoc = await fetchActor(wardUri).catch(() => null);
-      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
-      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
-      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
-      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
-      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
-      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
-      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
-      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
-      Guardianship.follows.recordReview(gslug, {
-        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
-        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
-        direction: uitgaand ? 'outgoing' : 'incoming',
-        target: doel || null, targetHandle: dai ? dai.handle : null,
-      });
-      const L = pushLang(gslug);
-      // `uitgaand` staat hier al, drie regels hoger, en werd voor de melding
-      // weer weggegooid: elke richting kreeg dezelfde tekst, geleend van
-      // offer_for_ward. Op de telefoon las een volgverzoek dus als een
-      // adoptie-aanvraag, en beide richtingen als elkaar.
-      const wardNaam = (wardDoc && (wardDoc.preferredUsername || wardDoc.name)) || slugFromActorUrl(wardUri) || wardUri;
-      const anderNaam = uitgaand
-        ? ((dai && (dai.name || dai.handle)) || i18nT(L, 'notif.someone'))
-        : (fai.name || fai.handle || i18nT(L, 'notif.someone'));
-      pushEvent(gslug, {
-        type: 'guardian',
-        title: i18nT(L, uitgaand ? 'push.n_guard_folout_t' : 'push.n_guard_folin_t'),
-        body: i18nT(L, uitgaand ? 'push.n_guard_folout_b' : 'push.n_guard_folin_b', { who: anderNaam, ward: wardNaam }),
-        url: `${pushPrefix(gslug)}/guardian`,
-      });
-      stored = true;
-    }
-    return stored;
-  }
-
-  // Leg 4: I am the ward; a guardian decided. object is the Follow (id).
-  const fo = act.object;
-  const followId = typeof fo === 'string' ? fo : (fo && fo.id);
-  if (!followId) return false;
-  const pending = Guardianship.follows.getPending(followId);
-  if (!pending) return false;
-  const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!allGuardians.includes(actorUri)) return false;   // only a real guardian of this ward decides
-  const decision = type === 'Reject' ? 'reject' : 'approve';
-  // §3.5: the quorum runs over the AVAILABLE set. The voter itself was
-  // restored by the one-answer rule when its activity arrived, so answering
-  // is exactly what counts a guardian back in.
-  const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
-  const r = Guardianship.follows.decide(followId, actorUri, decision, guardians);
-  try {
-    if (r.outcome === 'approved') { await acceptGatedFollow(r.follow); Guardianship.follows.remove(followId); }
-    else if (r.outcome === 'rejected') { await rejectGatedFollow(r.follow); Guardianship.follows.remove(followId); }
-  } catch { /* delivery is retried */ }
-  return true;
-}
-
-// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
-// Accept/Reject back to the ward's inbox (signed by the guardian).
-export async function sendFollowDecision(guardianSite, review, decision) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, guardianSite.slug);
-  const keys = getOrCreateKeys(guardianSite.slug);
-  const fo = review.follow_json ? JSON.parse(review.follow_json) : { id: review.id, type: 'Follow', actor: review.follower_uri, object: review.ward_uri };
-  const activity = { '@context': AP_CONTEXT, id: `${me}#followdec-${Date.now()}-${rid()}`, type: decision === 'reject' ? 'Reject' : 'Accept', actor: me, to: [review.ward_uri], object: fo, 'shaer:followApproval': true };
-  if (review.ward_inbox) await deliverWithRetry(guardianSite.slug, review.ward_inbox, activity, `${me}#main-key`, keys.private_pem);
-  return { ok: true };
-}
-
-// Send a Like or Announce (boost) on a remote note FROM this site.
-export async function sendInteraction(site, kind, targetNoteId, authorUri) {
-  const _mv = movedRefusal(site, `interaction:${kind}`); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !targetNoteId) return { error: 'config' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  // 'unboost' = Undo(Announce): retracts a boost so followers' servers remove the
-  // reblog (matched on actor+object — no record of the original Announce needed).
-  const fanout = (kind === 'boost' || kind === 'unboost'); // also goes to our followers
-  const followersCol = `${me}/followers`;
-  // Address the original author in cc so their server (Mastodon, WordPress/ActivityPub, …)
-  // attributes the boost to their post and notifies them — without this, a shared-inbox
-  // receiver has nothing to route the Announce to. Non-fragment activity ids + a `published`
-  // stamp keep us aligned with what Mastodon emits.
-  const audience = authorUri ? [followersCol, authorUri] : [followersCol];
-  let act;
-  if (kind === 'unboost' || kind === 'unlike') {
-    // Undo(Announce) retracts a boost; Undo(Like) un-favourites (matched on actor+object,
-    // no record of the original activity needed — Mastodon honours both).
-    const inner = kind === 'unboost' ? 'Announce' : 'Like';
-    act = {
-      '@context': AP_CONTEXT,
-      id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me,
-      object: { id: `${me}/${inner.toLowerCase()}/${Date.now()}-${rid()}`, type: inner, actor: me, object: targetNoteId },
-    };
-    if (kind === 'unboost') { act.to = [PUBLIC]; act.cc = audience; }
-  } else {
-    const type = kind === 'boost' ? 'Announce' : 'Like';
-    act = {
-      '@context': AP_CONTEXT,
-      id: `${me}/${type.toLowerCase()}/${Date.now()}-${rid()}`,
-      type, actor: me, object: targetNoteId,
-    };
-    if (type === 'Announce') { act.published = new Date().toISOString(); act.to = [PUBLIC]; act.cc = audience; }
-  }
-  const inboxes = new Set();
-  // Author first, via their PERSONAL inbox (not the shared one) so a multi-user receiver
-  // routes the Announce/Like to the right post unambiguously.
-  if (authorUri) { const a = await fetchActor(authorUri).catch(() => null); if (a) inboxes.add(a.inbox || (a.endpoints && a.endpoints.sharedInbox)); }
-  if (fanout) { for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox); }
-  // Queue each delivery (immediate attempt + backoff retries on failure via ap_delivery)
-  // instead of a single fire-and-forget POST, so a transient hiccup at the receiver doesn't
-  // silently lose the boost — same durability a new post (deliverCreate) already gets.
-  let queued = 0;
-  for (const inbox of [...inboxes].filter(Boolean)) { deliverWithRetry(site.slug, inbox, act, `${me}#main-key`, keys.private_pem); queued++; }
-  console.log('[AP]', kind, site.slug, '→', targetNoteId, 'queued', queued, 'inbox(es)');
-  return { ok: true, delivered: queued };
-}
-
-// Notifications inbox: new followers + replies/likes/boosts on this site's posts.
-export function getNotifications(slug, limit) {
-  // Per-source cap scales with the requested limit so Messages can page deep
-  // (Load more). Bounded so a huge offset can't ask for unbounded rows.
-  const L = Math.min(1000, Math.max(80, limit || 60));
-  const out = [];
-  try {
-    for (const f of db.prepare('SELECT actor_uri, created_at FROM ap_followers WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      out.push({ type: 'follow', handle: deriveHandle(f.actor_uri), url: f.actor_uri, created_at: f.created_at });
-    }
-  } catch { /* ignore */ }
-  try {
-    const rows = db.prepare(`
-      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
-             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
-             p.slug AS post_slug, p.title AS post_title
-      FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
-      WHERE p.site_id = (SELECT id FROM sites WHERE slug = ?)
-      ORDER BY i.created_at DESC LIMIT ?
-    `).all(slug, L);
-    for (const r of rows) out.push({
-      type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
-      // Waar een antwoord uit de draad heen moet: het id is de parent voor
-      // deliverReply, de uri het adres voor een direct bericht.
-      interactionId: r.interaction_id, actorUri: r.actor_uri,
-      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
-      // When the post was written, for display. created_at (when it reached us)
-      // stays the sort key and the unread watermark: a note that federated late
-      // is still new to you.
-      published: r.published,
-      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
-      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
-      // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
-      visibility: r.visibility || 'public',
-    });
-  } catch { /* ignore */ }
-  try {
-    for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      // The reported objects: our own notes resolve to post links so the owner
-      // sees WHICH post the report is about; other URIs (e.g. the actor itself)
-      // are skipped — the report row already names the account.
-      const about = [];
-      try {
-        for (const u of JSON.parse(r.objects || '[]')) {
-          const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
-          if (!m) continue;
-          const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
-          if (p) about.push({ slug: p.slug, title: p.title || p.slug });
-        }
-      } catch { /* malformed objects json → no links */ }
-      out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, objects: about, created_at: r.created_at });
-    }
-  } catch { /* ignore */ }
-  try {
-    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at, published,
-                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
-                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
-      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at, published: r.published,
-        // Same trimmings a Krant row has, so Berichten renders the post identically.
-        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
-    }
-  } catch { /* ignore */ }
-  // Your own polls that have closed → a "results are in" item, derived read-time
-  // from poll_json (Scheduler marks closed=1) with the tally via ownPollView.
-  try {
-    const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
-    if (site) {
-      const polls = db.prepare(`
-        SELECT id, slug, title, poll_json FROM posts
-        WHERE site_id = ? AND poll_json IS NOT NULL
-          AND json_extract(poll_json, '$.closed') = 1
-          AND json_extract(poll_json, '$.endTime') IS NOT NULL
-        ORDER BY json_extract(poll_json, '$.endTime') DESC LIMIT 20`).all(site.id);
-      for (const p of polls) {
-        const view = ownPollView(p);
-        if (!view) continue;
-        let endTime = null; try { endTime = JSON.parse(p.poll_json).endTime; } catch { /* keep null */ }
-        out.push({ type: 'poll_done', post_slug: p.slug, post_title: p.title, poll: view, created_at: endTime || null });
-      }
-    }
-  } catch { /* ignore */ }
-  // NaN-safe sort: one row with a missing/garbled created_at would otherwise make the
-  // comparator return NaN and scramble the WHOLE ordering (seen live: follow rows landing
-  // between likes, which also broke Messages' like-grouping).
-  out.sort((a, b) => _msgTs(b) - _msgTs(a));
-  return out.slice(0, limit || 60);
-}
-function _msgTs(x) { const t = Date.parse((x && x.created_at) || ''); return Number.isFinite(t) ? t : 0; }
-
-// ── Blocking / defederation ───────────────────────────────────────
-// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
-// Orbit"). Thin delegations keep every existing caller working.
-export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
-
-// True if an actor (or its whole domain) is blocked anywhere on this instance.
-
-// Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard
-// AS2 `Flag`: object = [reported account, reported status?], content = the reason, delivered to the
-// reported account's inbox so their instance's moderators receive it. objectUri = a post URL (its
-// author is resolved + included) OR pass actorUri to report an account directly.
-export async function sendReport(site, { objectUri, actorUri, reason }) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  let targetActor = actorUri || null;
-  let noteUri = null;
-  if (objectUri && /^https?:\/\//i.test(objectUri)) {
-    const note = await apGetJson(objectUri).catch(() => null);
-    if (note && note.id) { noteUri = note.id; if (!targetActor) targetActor = actorUriOf(note.attributedTo); }
-    else if (!targetActor) return { error: 'not_found' };
-  }
-  if (!targetActor || !/^https?:\/\//i.test(targetActor)) return { error: 'not_found' };
-  const actor = await fetchActor(targetActor).catch(() => null);
-  const inbox = actor && (actor.inbox || (actor.endpoints && actor.endpoints.sharedInbox)); // personal inbox → their moderators
-  if (!inbox) return { error: 'unreachable' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const object = [targetActor];
-  if (noteUri && noteUri !== targetActor) object.push(noteUri);
-  const flag = {
-    '@context': AP_CONTEXT,
-    id: `${me}#report-${Date.now()}-${rid()}`,
-    type: 'Flag',
-    actor: me,
-    content: String(reason == null ? '' : reason).slice(0, 3000),
-    object, // [account, status?] — Mastodon's Flag shape
-    to: [targetActor],
-  };
-  deliverWithRetry(site.slug, inbox, flag, `${me}#main-key`, keys.private_pem);
-  return { ok: true };
-}
-
-export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
-
-// Block an actor (@handle or actor URL) or a whole domain; purges their content.
-// The handle resolver is ours; the storage/purge lives in BlocklistService.
-//
-// De BEZORGING hoort ook hier: BlocklistService kent de database, niet het
-// afleveren. Een Block gaat naar de inbox van wie je blokkeert, een
-// Undo(Block) bij het opheffen -- zonder retry-wachtrij, want een blokkade
-// wacht niet op een server die even plat ligt (en bij opheffen komt de ander
-// vanzelf weer langs).
-async function bezorgBlokkade(site, target, undo) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const blok = { id: `${me}#block-${Date.now()}-${rid()}`, type: 'Block', actor: me, object: target, to: [target] };
-  const activiteit = undo
-    ? { '@context': AP_CONTEXT, id: `${me}#unblock-${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: blok, to: [target] }
-    : { '@context': AP_CONTEXT, ...blok };
-  const r = await deliverToActor(site, target, activiteit);
-  console.log('[AP]', undo ? 'Undo(Block)' : 'Block', site.slug, '→', target, r && r.delivered ? 'bezorgd' : 'niet bezorgd');
-}
-
-export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve, bezorgBlokkade); }
-
-export function unblock(site, target) { return Blocklist.unblock(site, target, bezorgBlokkade); }
-
-// ── Guardianship module wiring (src/services/guardianship/) ────────
-// The module owns FEP-633c (context, relations, handshake, queues, the
-// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
-// imports us back.
-function selfActorId(slug) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  return actorId(base, slug);
-}
-// Deliver one activity to one actor's inbox, signed; queued + retried on any
-// hiccup so a slow or briefly-down ward server never loses the offer. Returns
-// { delivered, inbox }: delivered=false means the account could not be
-// resolved at all (a bad handle) — the offer stays recorded regardless.
-export async function deliverToActor(site, actorUri, activity) {
-  const me = selfActorId(site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const payload = { '@context': AP_CONTEXT, ...activity };
-  // Co-location is a TRANSPORT detail, never a decision path (Robins regel,
-  // 29-7). An inbox on this machine is not reachable over HTTP from this
-  // machine, and should not be, so a local recipient is handed the activity
-  // straight into the same inbox handler the wire would reach. Everything
-  // above this line therefore behaves as if every Klonkt were remote: one code
-  // path, exercised by every deployment, including the checks. Two bugs in one
-  // day came from having a second, local-only path that hid a broken remote
-  // one.
-  const localSlug = localSlugOf(actorUri);
-  if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) {
-    const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })();
-    const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} };
-    // The signer is us, and we say so: the actor-versus-signer check runs
-    // exactly as it does over the wire, so a mismatch fails here too.
-    const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500);
-    const ok = status >= 200 && status < 300;
-    console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri);
-    return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status };
-  }
-  const a = await fetchActor(actorUri).catch(() => null);
-  const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
-  if (!inbox) {
-    console.warn('[AP] guardianship: could not resolve an inbox for', actorUri, '(offer recorded, not sent)');
-    return { delivered: false, inbox: null };
-  }
-  try {
-    const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
-    if (st >= 200 && st < 300) { console.log('[AP] guardianship', activity.type, 'delivered →', inbox, st); return { delivered: true, inbox }; }
-    console.warn('[AP] guardianship', activity.type, 'got', st, 'from', inbox, '→ queued for retry');
-  } catch (e) { console.warn('[AP] guardianship', activity.type, 'to', inbox, 'failed:', e.message, '→ queued for retry'); }
-  enqueueDelivery(site.slug, inbox, payload);
-  return { delivered: true, inbox };   // queued: the retry worker gets it there
-}
-Guardianship.wireDelivery({
-  actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags,
-  getOutboxRow: (id) => iStmts().getO.get(id),
-  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
-  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
-  // een antwoord uit Berichten door precies één poort gaat.
-  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
-  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
-});
-/**
- * The actor document of a site WE host, read straight from the database.
- * Same shape fetchActor returns for anyone else, plus `local: true` so the
- * caller can take the loopback instead of a POST to our own hostname.
- * Null for an actor we do not host: that one really is fetched.
- */
-function localActor(actorUri) {
-  const slug = localSlugOf(actorUri);
-  if (!slug) return null;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) return null;
-  // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the
-  // actor route sets it the same way before building.
-  const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get();
-  try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; }
-}
-// Which local site (if any) hosts this actor URI — used by the handshake to
-// apply the local side of a commit and to derive a ward's existing guardians.
-export function localSlugOf(actorUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
-  const slug = slugFromActorUrl(actorUri);
-  if (!slug) return null;
-  try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
-  catch { return null; }
-}
-Guardianship.wireHandshake({
-  selfId: selfActorId,
-  localSlug: localSlugOf,
-  deliverTo: deliverToActor,
-  deriveHandle,
-  fetchActor,
-  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
-  // own Berichten; an existing guardian and a commit land in the PWA.
-  //
-  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
-  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
-  onEvent: (slug, ev) => onGuardianshipEvent(slug, ev),
-});
-
-/**
- * Wat er gebeurt als de guardianship-module iets uitzendt.
- *
- * TWEE VERSCHILLENDE VRAGEN, en ze horen niet dezelfde te zijn: wie maak je
- * WAKKER (push kiest bewust een handvol soorten), en wat moet een scherm dat
- * openstaat WETEN (alles). Het paneel werd daarom voorheen niet gewekt door de
- * tien soorten zonder pushtekst -- die zag je pas bij de volgende tik.
- *
- * Apart en met een naam, zodat een toets erbij kan. Verstopt in de deps-literal
- * was hij onbereikbaar, en een mutatie die het wekken weghaalde bleef groen.
- */
-/**
- * Hoeveel er van bewaard blijft. Een logboek dat oneindig groeit is een
- * logboek dat niemand meer opent, en dit is geschiedenis, geen archief: wat
- * ertoe doet staat vooraan.
- */
-export const GUARDIAN_EVENT_KEEP = 200;
-
-/**
- * Leg de gebeurtenis vast VOOR de melding.
- *
- * De meldingstabel beslist wie er wakker van wordt, en dat is terecht een korte
- * lijst -- maar hij besliste daarmee ook wat er onthouden werd, en dat was niet
- * de bedoeling. Elf van de achttien soorten verdwenen spoorloos, met hun inhoud:
- * een geweigerd aanbod droeg de REDEN mee tot hier en niet verder, terwijl §4.2
- * eist dat de ward en zijn guardians die te horen krijgen.
- *
- * Vastleggen en melden zijn nu twee dingen. Alles komt in het logboek; alleen
- * wat een mens moet wekken gaat ook als push de deur uit.
- */
-export function recordGuardianEvent(slug, ev) {
-  if (!slug || !ev || !ev.kind) return;
-  try {
-    db.prepare('INSERT INTO ap_guardian_events (slug, kind, payload, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)')
-      .run(slug, String(ev.kind), JSON.stringify(ev));
-    db.prepare(`DELETE FROM ap_guardian_events WHERE slug = ? AND id NOT IN
-                (SELECT id FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
-      .run(slug, slug, GUARDIAN_EVENT_KEEP);
-  } catch { /* een logboek mag nooit de gebeurtenis zelf breken */ }
-}
-
-/** De laatste gebeurtenissen voor dit account, nieuwste eerst. */
-export function listGuardianEvents(slug, limit = 50) {
-  try {
-    return db.prepare('SELECT id, kind, payload, created_at FROM ap_guardian_events WHERE slug = ? ORDER BY id DESC LIMIT ?')
-      .all(slug, Math.max(1, Math.min(Number(limit) || 50, GUARDIAN_EVENT_KEEP)))
-      .map((r) => ({ id: r.id, kind: r.kind, created: r.created_at, ...safeJson(r.payload) }));
-  } catch { return []; }
-}
-
-function safeJson(s) { try { return JSON.parse(s) || {}; } catch { return {}; } }
-
-export function onGuardianshipEvent(slug, ev) {
-  recordGuardianEvent(slug, ev);
-  wakeGuardian(slug);
-  const p = guardianEventPush(slug, ev);
-  if (p) pushEvent(slug, p);
-  return p;
-}
-
-/**
- * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
- *
- * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
- * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
- * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
- * erbij te halen.
- */
-export function guardianEventPush(slug, ev) {
-  const L = pushLang(slug);
-  const texts = {
-    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
-    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
-    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
-    // §3.2: a guardian ended the relation. The ward hears that someone who
-    // was looking after them has gone; a co-guardian hears they are one fewer.
-    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
-    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
-    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
-    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
-    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
-    // geen drempel.
-    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
-    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
-  }[ev.kind];
-  if (!texts) return null;
-  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
-  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
-  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
-  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
-  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
-  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
-  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
-  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
-}
-
-// Van een gated feature naar het woord dat het Guardian-paneel er al voor
-// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
-// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
-const GATE_LABEL = {
-  'shaer:externalEmbeds': 'guardian.prop_embeds',
-  'shaer:externalPlayback': 'guardian.prop_play',
-};
-const GATE_OUTCOME = {
-  accepted: 'guardian.prop_st_accepted',
-  rejected: 'guardian.prop_st_rejected',
-  expired: 'guardian.prop_st_expired',
-};
-
-// The notification duty of FEP-633c 3.6.2, wired once for every place a
-// dormancy promotion can happen (queue reads, fan-outs, tallies): marking a
-// guardian dormant MUST notify it, in protocol AND over the §6 handle. The
-// one-answer rule is worthless to someone who does not know an answer is
-// wanted. The handle of a committed guardian is its inbox (§6 minimum), which
-// is the same door this delivery knocks on; both attempts are logged.
-Guardianship.wireAvailability({
-  onDormant: (wardSlug, guardianUri) => {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug);
-    if (!base || !site) return;
-    const me = selfActorId(wardSlug);
-    const note = {
-      id: `${me}/dormant/${Date.now().toString(36)}${rid()}`,
-      type: 'Note', attributedTo: me, to: [guardianUri],
-      'shaer:dormant': true,
-      content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>',
-    };
-    deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note })
-      .catch(() => { /* retried by the queue */ });
-    console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)');
-  },
-});
-
-// De C2S-inname zijn werktuigen geven (stap 4, shaer-drc). Onderaan, zodat
-// elke const hierboven al bestaat; een verzoek kan pas na deze evaluatie
-// binnenkomen, dus de koppeling is altijd eerder dan de eerste aanroep.
-wireC2S({
-  proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-  postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-  followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-  deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-  deliverCreate,
-});
-// En de tijdlijn-leeskant zijn ene werktuig (stap 5): liked/boosted komen
-// sinds stap 6 uit ap-reactions, maar de koppeling blijft HIER lopen -- twee
-// zustermodules die elkaar importeren zou een kring zijn.
-wireTimeline({ getReactionsFor });
-// Het reactiecluster zijn ene werktuig (stap 6): de verhuisgrendel (FEP-7628).
-wireReactions({ movedLock });
-// De volgwinkel zijn zes werktuigen (stap 7): de verhuisweigering, de
-// §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers.
-wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor });
-// De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de
-// telling, de id-staart, de verhuisweigering en de attributedTo-lezer.
-wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf });
-// De schakelkast (stap 9): de lijst is bewust lang -- hij is de kaart van wat
-// de inbox aanraakt, en elke naam die eraf gaat is een cluster dat zelf
-// verhuisd is.
-wireInbox({
-  actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
-  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
-  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
-  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
-  storeAuthorEmoji, timelineFields, wakeGuardian,
-});
-
-export default {
-  movedLock,
-  // FEP-1580 bronkant. Vergeet je hem hier, dan werpt elke route die hem
-  // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
-  // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
-  isMoveTarget, signedGetJson, signedGetHeaders,
-  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
-  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
-  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
-  channelUrls, channelCategory, timelineFields, guessMediaType,
-  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate, trackHostPosts,
-  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
-  buildPostTrackCollection, uitgavePost,
-  buildLibrary, libraryId,
-  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverObjectDelete, deliverTrackDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  feedCursor, feedChangesSince, waitForFeedChange,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
-  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
-  acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
-  gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, previewCard,
-  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
-  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
-  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
-  sendMaybe304, etagFor, onGuardian, wakeGuardian, onGuardianshipEvent, proposeGate, getReplyUris, getThread, filterThreadToCircle, gateAttachments, stripEmojiTags, actorObject, previewObject, quoteObject, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
-  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
-  noteVisibility, belongsInTimeline, playerUrlFor, isRejectedObject, rejectInteraction, interactionReportTarget,
-  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, selfAuthor, getReplyMessages, onNews, wakeNews,
-};
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,648 +1,0 @@
-/**
- * Export van een draagbaar inhoudsarchief (shaer-1a6).
- *
- * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
- * alleen wat de code doet, niet waarom het formaat zo is.
- *
- * Twee dingen zijn geen implementatiedetail maar eis:
- *
- *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
- *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
- *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
- *                    in de postbestanden en een vaste mtime in de zip.
- *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
- *                    sleutel, sessie, hash of DM van een ander in.
- */
-
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import db, { isoSql } from '../config/database.js';
-import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
-
-// v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
-// BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
-// droeg alleen bestanden onder media/. De exporter rekende er met path.relative
-// een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
-// stond dus wel een track in de database van de nieuwe site, maar nooit een
-// bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
-// plaats van alleen wat in een bericht staat, en neemt de playlists mee.
-export const FORMAT_VERSION = 2;
-
-/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
-export function stableJson(value) {
-  const sorteer = (v) => {
-    if (Array.isArray(v)) return v.map(sorteer);
-    if (v && typeof v === 'object') {
-      const uit = {};
-      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
-      return uit;
-    }
-    return v;
-  };
-  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
-}
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-/**
- * Naar ISO 8601 in UTC.
- *
- * SQLite schrijft CURRENT_TIMESTAMP als "2026-07-01 12:56:10" -- in UTC, maar
- * ZONDER zone erbij. Date.parse leest die vorm als LOKALE tijd, en dan schuift
- * elk tijdstempel in het archief mee met de tijdzone van de machine die de export
- * draait. Op een server in Amsterdam is dat twee uur, en dat merk je pas als je
- * ergens anders importeert.
- *
- * Gevonden doordat Bart vroeg of dit wel naar UTC normaliseert. De testmachine
- * draait op UTC, dus geen enkele test kon het zien.
- */
-const toISO = (d) => {
-  if (!d) return null;
-  const s = String(d).trim();
-  const zonderZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(s);
-  const t = Date.parse(zonderZone ? `${s.replace(' ', 'T')}Z` : s);
-  return isNaN(t) ? null : new Date(t).toISOString();
-};
-
-const MIME_BY_EXT = {
-  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
-  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
-  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
-};
-const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
-const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
-const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
-
-/**
- * Van een media-URL naar een bestand op schijf.
- *
- * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
- * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
- * een pad -- de content slaat allebei op.
- *
- * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
- * anders een willekeurig bestand van de schijf het archief in trekken.
- */
-function localMediaPath(url, origin) {
-  let p = String(url || '');
-  if (!p) return null;
-  if (/^https?:/i.test(p)) {
-    try {
-      const u = new URL(p);
-      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
-      p = u.pathname;
-    } catch { return null; }
-  }
-  if (!p.startsWith('/media/')) return null;
-  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
-  const root = path.resolve(MEDIA_ROOT);
-  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
-  return abs;
-}
-
-/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
-function mediaRefsOf(post, origin) {
-  const uit = [];
-  const zie = new Set();
-  const voegToe = (url, name, rol, extra = {}) => {
-    const u = String(url || '').trim();
-    if (!u || zie.has(u)) return;
-    zie.add(u);
-    uit.push({ url: u, name: name || null, rol, ...extra });
-  };
-  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
-  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
-  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
-  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
-  // handjevol.
-  voegToe(post.cover_image_url, post.cover_alt, 'cover');
-  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
-  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
-  try {
-    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
-      voegToe(a && a.url, a && a.name, 'c2s');
-      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
-      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
-      // pas op bij de export van echte data.
-      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
-    }
-  } catch { /* kapotte kolom blokkeert de export niet */ }
-  // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
-  // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
-  // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
-  // aankomen: de importer weigert zo'n pad, terecht.
-  return uit;
-}
-
-/**
- * De audio-metadata die alleen in de database staat en nergens anders uit te
- * halen is: titel, artiest, credit, licentie, externe links.
- *
- * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
- * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
- * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
- * herstel op niets terug.
- */
-function audioOf(post, audioKaart) {
-  const uit = [];
-  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-    try {
-      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
-      if (!t) continue;
-      // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
-      // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
-      // bijlage te wijzen die er niet is.
-      const bestand = audioKaart.get(t.id) || undefined;
-      uit.push({
-        'shaer:ref': `[[track:${t.id}]]`,
-        'shaer:media': bestand,
-        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
-        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
-        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
-      });
-    } catch { /* idem */ }
-  }
-  return uit.length ? uit : undefined;
-}
-
-/**
- * De HELE audiobibliotheek, plus de playlists.
- *
- * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
- * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
- * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
- * verhuizing.
- *
- * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
- * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
- * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
- * door, en de export liet ze weg zonder dat iemand het merkte.
- *
- * @returns {Map<string,string>} trackId -> pad in het archief
- */
-/**
- * Een hoes in het archief leggen.
- *
- * cover_url reisde wel mee als STRING en het bestand niet, dus kwam een track
- * aan met een verwijzing naar een plaatje dat er niet was. Precies dezelfde
- * fout als bij de audio zelf, een laag hoger: een verwijzing zonder bytes.
- *
- * @returns {string|null} het pad in het archief, of null
- */
-function hoesToevoegen(url, origin, bestanden, tellingen) {
-  const schijf = localMediaPath(url, origin);
-  if (!schijf) return null;
-  let bytes = null;
-  try { bytes = fs.readFileSync(schijf); } catch { return null; }
-  const hash = sha256(bytes);
-  const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
-  if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
-  return naam;
-}
-
-function audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend) {
-  const kaart = new Map();
-  let tracks = [];
-  try {
-    tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
-                          LEFT JOIN media m ON m.id = t.media_id
-                         WHERE t.site_id = ?
-                         ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
-  } catch { return kaart; }              // installatie zonder audio-tabellen
-  if (!tracks.length) return kaart;
-
-  const items = [];
-  for (const t of tracks) {
-    const schijf = resolveAudioPath(t.storage_path, fs);
-    let naam = null;
-    let hash = null;
-    if (schijf) {
-      try {
-        const bytes = fs.readFileSync(schijf);
-        hash = sha256(bytes);
-        const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
-        naam = `audio/${hash}.${ext}`;
-        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
-        kaart.set(t.id, naam);
-      } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
-    }
-    // Een LINK-ONLY track is geen kapotte track. Klonkt kent dat type: geen
-    // gehost bestand, wel een Spotify- of YouTube-link, en buildNote maakt er
-    // een embed-kaart van (zie trackEmbedLinks). Die tellen dus niet als
-    // ontbrekend, anders meldt de export een probleem dat er niet is.
-    const alleenLinks = !t.media_id && [t.link_spotify, t.link_youtube, t.link_soundcloud].some(Boolean);
-    if (!naam && !alleenLinks) {
-      tellingen.audioMissing += 1;
-      ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
-    }
-    if (alleenLinks) tellingen.audioLinks = (tellingen.audioLinks || 0) + 1;
-    items.push({
-      id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
-      duration: t.duration || undefined, position: t.position ?? undefined,
-      credit: t.credit || undefined, license: t.license || undefined,
-      'shaer:coverUrl': t.cover_url || undefined,
-      // De BYTES van de hoes, niet alleen de verwijzing.
-      'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
-      'shaer:downloadable': t.downloadable ? 1 : 0,
-      'shaer:fediOpen': t.fedi_open ? 1 : 0,
-      'shaer:mediaType': t.mime_type || 'audio/mpeg',
-      'shaer:file': naam || undefined,
-      'shaer:sha256': hash || undefined,
-      // Derde staat, net als bij media: we weten DAT het bestond en waar het
-      // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
-      // kunnen weigeren in plaats van een track zonder bestand aan te maken.
-      // Drie staten in plaats van twee: erbij, weg, of bewust zonder bestand.
-      'shaer:availability': naam ? 'included' : (alleenLinks ? 'linkOnly' : 'missing'),
-      'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
-      url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
-    });
-  }
-  bestanden.set('tracks.json', Buffer.from(stableJson({
-    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-    type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
-  }), 'utf8'));
-  tellingen.tracks = items.length;
-
-  // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
-  try {
-    const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
-    if (pls.length) {
-      const lijst = pls.map((p) => ({
-        id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
-        'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
-        'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
-        'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
-          .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
-      }));
-      bestanden.set('playlists.json', Buffer.from(stableJson({
-        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-        type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
-      }), 'utf8'));
-      tellingen.playlists = lijst.length;
-    }
-  } catch { /* geen playlist-tabellen */ }
-
-  return kaart;
-}
-
-/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
-function postObject(post, site, origin, attachments, audioKaart) {
-  const heeftTitel = !!(post.title && String(post.title).trim());
-  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
-  const updated = toISO(post.updated_at);
-  const poll = (() => {
-    try {
-      const d = JSON.parse(post.poll_json || 'null');
-      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
-      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
-      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
-    } catch { return null; }
-  })();
-  const tags = [];
-  try {
-    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
-      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
-    }
-  } catch { /* tags zijn optioneel */ }
-
-  return {
-    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
-    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
-    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
-    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
-    name: heeftTitel ? post.title : undefined,
-    content: post.content || '',
-    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
-    summary: post.content_warning || undefined,
-    sensitive: post.nsfw ? true : undefined,
-    published,
-    updated: (updated && updated !== published) ? updated : undefined,
-    url: `${origin}/${encodeURIComponent(post.slug)}`,
-    attachment: attachments.length ? attachments : undefined,
-    tag: tags.length ? tags : undefined,
-    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
-    endTime: poll ? (poll.endTime || undefined) : undefined,
-    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
-    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
-    closed: (poll && poll.closed) ? true : undefined,
-    quoteUrl: post.quote_uri || undefined,
-    'shaer:quoteActor': post.quote_actor || undefined,
-    'shaer:slug': post.slug,
-    'shaer:status': post.status || 'draft',
-    'shaer:excerpt': post.excerpt || undefined,
-    'shaer:type': post.type || undefined,
-    'shaer:pinned': post.pinned ? true : undefined,
-    'shaer:noindex': post.noindex ? true : undefined,
-    'shaer:fanOnly': post.fan_only ? true : undefined,
-    'shaer:paid': post.paid ? true : undefined,
-    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
-    'shaer:apVisibility': post.ap_visibility || undefined,
-    'shaer:publishAt': toISO(post.publish_at) || undefined,
-    'shaer:coverAlt': post.cover_alt || undefined,
-    'shaer:viewCount': post.view_count || undefined,
-    'shaer:audio': audioOf(post, audioKaart),
-  };
-}
-
-/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
-function readableMarkdown(post, obj) {
-  const fm = [
-    '---',
-    `title: ${JSON.stringify(post.title || post.slug)}`,
-    `slug: ${JSON.stringify(post.slug)}`,
-    `date: ${obj.published || ''}`,
-    `status: ${post.status || 'draft'}`,
-    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
-    '---',
-  ].filter((l) => l !== null).join('\n');
-  return `${fm}\n${post.content || ''}\n`;
-}
-
-// ── Wie je volgt, als CSV ─────────────────────────────────────────
-//
-// Kolomvorm van Mastodon, zodat deze lijst ook DAAR te importeren is en die van
-// daar hier. Dat is het hele punt van een verhuisformaat: het moet ook werken
-// als je naar iets anders vertrekt dan waar je vandaan kwam.
-//
-//   Account address,Show boosts,Notify on new posts,Languages,Featured
-//
-// `Featured` is onze kolom en draagt `auto_boost`: het vinkje dat in de UI
-// "Uitgelicht" heet (tl.autoboost) en hun posts in jouw Cirkel laat meelopen.
-//
-// `Show boosts` blijft LEEG. Dat is bij Mastodon "toon de reblogs van deze
-// persoon in mijn tijdlijn", en dat kent Klonkt niet. De verleiding is groot om
-// er auto_boost in te schrijven omdat in beide het woord boost zit, maar het is
-// een ander ding: dat van ons gaat over hun eigen posts in JOUW Cirkel, niet
-// over andermans posts die zij doorgeven. `Notify` en `Languages` kent Klonkt
-// evenmin. Die drie staan er omdat Mastodon de POSITIES telt.
-const CSV_KOP = 'Account address,Show boosts,Notify on new posts,Languages,Featured';
-
-/** Een veld dat een komma, aanhalingsteken of nieuwe regel bevat moet geciteerd. */
-function csvVeld(v) {
-  const s = String(v == null ? '' : v);
-  return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
-}
-
-/**
- * De volglijst van een site als CSV, of null als er niets te melden valt.
- *
- * Alleen `accepted`: een openstaand verzoek is geen relatie, en het opnieuw
- * versturen ervan op de nieuwe plek zou een tweede verzoek zijn bij iemand die
- * de eerste misschien bewust liet liggen.
- *
- * Het adres is de handle zonder de leidende @, want zo schrijft Mastodon hem.
- * Ontbreekt de handle, dan valt hij terug op de actor-URI: die is altijd te
- * herleiden, ook als de webfinger-naam ooit verloren ging.
- */
-export function followingCsv(slug) {
-  let rijen = [];
-  try {
-    rijen = db.prepare(`SELECT actor_uri, handle, auto_boost FROM ap_following
-                         WHERE slug = ? AND status = 'accepted'
-                         ORDER BY handle IS NULL, handle, actor_uri`).all(slug);
-  } catch { return null; }        // oude database zonder de kolom
-  if (!rijen.length) return null;
-  const regels = rijen.map((r) => [
-    csvVeld((r.handle || r.actor_uri || '').replace(/^@/, '')),
-    '',                                    // Show boosts: niet van ons
-    '',                                    // Notify on new posts: idem
-    '',                                    // Languages: idem
-    r.auto_boost ? 'true' : 'false',       // Featured: het vinkje "Uitgelicht"
-  ].join(','));
-  return `${CSV_KOP}\n${regels.join('\n')}\n`;
-}
-
-/**
- * Lees zo'n CSV terug. Puur, zodat de vorm te toetsen is zonder database.
- *
- * Vergeeflijk met opzet: een bestand uit Mastodon heeft vier kolommen en geen
- * `Featured`, een handgemaakt bestand heeft misschien alleen adressen. Beide
- * moeten werken, want anders is het geen uitwisselformaat maar een eigen
- * bestandje dat toevallig op een CSV lijkt.
- */
-export function parseFollowingCsv(text) {
-  const uit = [];
-  const regels = String(text || '').split(/\r?\n/).filter((r) => r.trim());
-  if (!regels.length) return uit;
-  // Een kopregel herkennen we aan het eerste veld; anders is regel 1 al data.
-  const start = /^\s*"?account address"?\s*(,|$)/i.test(regels[0]) ? 1 : 0;
-  for (const regel of regels.slice(start)) {
-    const velden = splitsCsvRegel(regel);
-    const adres = (velden[0] || '').trim().replace(/^@/, '');
-    if (!adres) continue;
-    uit.push({
-      address: adres,
-      // Alleen kolom 5. Een bestand uit Mastodon heeft die niet en levert dus
-      // `false`, en dat is juist: hun "Show boosts" in kolom 2 gaat over iets
-      // anders en mag hier niet als uitgelicht binnenkomen.
-      featured: /^(true|1|yes)$/i.test((velden[4] || '').trim()),
-    });
-  }
-  return uit;
-}
-
-/** Eén CSV-regel, met respect voor geciteerde velden en verdubbelde aanhalingstekens. */
-function splitsCsvRegel(regel) {
-  const velden = [];
-  let veld = '';
-  let inCitaat = false;
-  for (let i = 0; i < regel.length; i++) {
-    const c = regel[i];
-    if (inCitaat) {
-      if (c === '"') {
-        if (regel[i + 1] === '"') { veld += '"'; i++; } else inCitaat = false;
-      } else veld += c;
-    } else if (c === '"') inCitaat = true;
-    else if (c === ',') { velden.push(veld); veld = ''; }
-    else veld += c;
-  }
-  velden.push(veld);
-  return velden;
-}
-
-/**
- * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
- *
- * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
- * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
- */
-export function buildArchive(slug, opts = {}) {
-  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-  if (!site) {
-    // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
-    // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
-    // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
-    let bestaand = [];
-    try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
-    const wat = slug ? `onbekende site: ${slug}` : 'geen site opgegeven';
-    throw new Error(bestaand.length
-      ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
-      : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
-  }
-
-  const bestanden = new Map();       // pad -> Buffer
-  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
-  const ontbrekend = [];             // voor de rapportage van de beller
-
-  // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
-  // kaart moet klaar zijn voor de eerste post gebouwd wordt.
-  const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
-
-  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
-  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
-  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
-                             ORDER BY ${isoSql('COALESCE(published_at, created_at)')} ASC, id ASC`).all(site.id);
-
-  for (const post of posts) {
-    const attachments = [];
-    for (const ref of mediaRefsOf(post, origin)) {
-      const schijf = localMediaPath(ref.url, origin);
-      const mime = mimeOf(ref.url);
-      let bytes = null;
-      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
-      if (bytes) {
-        const hash = sha256(bytes);
-        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
-        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
-          url: naam, 'shaer:availability': 'included',
-          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
-          'shaer:sha256': hash,
-          'shaer:role': ref.rol,
-          'shaer:posterFor': ref.posterFor || undefined,
-        });
-      } else {
-        // De derde staat uit het formaat: we weten DAT het bestond en waar het
-        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
-        tellingen.mediaMissing += 1;
-        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
-        ontbrekend.push({ post: post.slug, url: orig });
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
-          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
-          'shaer:role': ref.rol,
-          'shaer:posterFor': ref.posterFor || undefined,
-        });
-      }
-    }
-
-    const obj = postObject(post, site, origin, attachments, audioKaart);
-    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
-    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
-    tellingen.posts += 1;
-
-    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
-    let replies = [];
-    try {
-      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
-                             ORDER BY ${isoSql('COALESCE(published, created_at)')} ASC, id ASC`).all(post.id);
-    } catch { /* tabel kan ontbreken op een heel oude database */ }
-    if (replies.length) {
-      const coll = {
-        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
-        type: 'OrderedCollection',
-        'shaer:archive': true,
-        'shaer:inReplyTo': obj.id,
-        totalItems: replies.length,
-        orderedItems: replies.map((r) => ({
-          id: r.object_uri || undefined,
-          type: 'Note',
-          attributedTo: r.actor_uri || undefined,
-          inReplyTo: r.parent_uri || obj.id,
-          content: r.content || '',
-          published: toISO(r.published || r.created_at) || undefined,
-          'shaer:actorName': r.actor_name || undefined,
-          'shaer:actorHandle': r.actor_handle || undefined,
-        })),
-      };
-      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
-      tellingen.replies += replies.length;
-    }
-  }
-
-  // Wie je volgt. Dit ontbrak, en daarmee was een "verhuizing" halfslachtig: de
-  // Move vertelt je VOLGERS waar je heen ging, maar niets vertelde JOU wie jij
-  // volgde. Die lijst stond alleen in de oude database, en die laat je achter.
-  const volgCsv = followingCsv(slug);
-  if (volgCsv) { bestanden.set('following.csv', Buffer.from(volgCsv, 'utf8')); tellingen.following = volgCsv.trim().split('\n').length - 1; }
-
-  const files = {};
-  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
-  const manifest = {
-    formatVersion: FORMAT_VERSION,
-    generator: `klonkt/${opts.version || 'dev'}`,
-    exportedAt: opts.exportedAt || new Date().toISOString(),
-    origin,
-    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
-    site: { slug: site.slug, title: site.title || site.slug },
-    counts: tellingen,
-    files,
-  };
-  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
-
-  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
-}
-
-// ── Zip, store-only en deterministisch ────────────────────────────
-// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
-// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
-// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
-
-const _crcTabel = (() => {
-  const t = new Int32Array(256);
-  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
-  return t;
-})();
-function crc32(buf) {
-  let c = 0 ^ -1;
-  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
-  return (c ^ -1) >>> 0;
-}
-
-export function zipArchive(files) {
-  const paden = [...files.keys()].sort();
-  const lokaal = [];
-  const centraal = [];
-  let offset = 0;
-  for (const pad of paden) {
-    const naam = Buffer.from(pad, 'utf8');
-    const data = files.get(pad);
-    const crc = crc32(data);
-    const lh = Buffer.alloc(30);
-    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
-    lh.writeUInt16LE(0, 8);                       // store, geen compressie
-    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
-    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
-    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
-    lokaal.push(lh, naam, data);
-
-    const ch = Buffer.alloc(46);
-    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
-    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
-    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
-    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
-    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
-    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
-    ch.writeUInt32LE(offset, 42);
-    centraal.push(ch, naam);
-    offset += 30 + naam.length + data.length;
-  }
-  const cd = Buffer.concat(centraal);
-  const eocd = Buffer.alloc(22);
-  eocd.writeUInt32LE(0x06054b50, 0);
-  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
-  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
-  return Buffer.concat([...lokaal, cd, eocd]);
-}
-
-/** Schrijf het archief als losse bestanden naar een map. */
-export function writeArchiveDir(files, dir) {
-  for (const pad of [...files.keys()].sort()) {
-    const doel = path.join(dir, pad);
-    fs.mkdirSync(path.dirname(doel), { recursive: true });
-    fs.writeFileSync(doel, files.get(pad));
-  }
-}
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,541 +1,0 @@
-/**
- * Import van een draagbaar inhoudsarchief (shaer-pmr).
- *
- * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
- * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
- *
- *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
- *                  geweigerd. Een half begrepen herstel is erger dan geen
- *                  herstel, want het ziet eruit alsof het gelukt is.
- *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
- *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
- *                  antwoord publiceert objecten onder een id dat je niet beheert.
- *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
- *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
- *                  rechttrekken is een aparte, bewuste actie.
- *
- * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
- */
-
-import fs from 'fs';
-import path from 'path';
-import zlib from 'zlib';
-import crypto from 'crypto';
-import { randomUUID } from 'crypto';
-import db, { NU_ISO } from '../config/database.js';
-import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
-import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
-import * as Migration from './MigrationService.js';
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
-// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
-// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
-const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
-
-// ── Inlezen ───────────────────────────────────────────────────────
-
-/** Lees een archiefmap in als pad -> Buffer. */
-export function readArchiveDir(dir) {
-  const files = new Map();
-  const loop = (sub) => {
-    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
-      const rel = sub ? `${sub}/${naam.name}` : naam.name;
-      if (naam.isDirectory()) loop(rel);
-      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
-    }
-  };
-  loop('');
-  return files;
-}
-
-/**
- * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
- * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
- */
-export function readArchiveZip(buf) {
-  const files = new Map();
-  const eocd = (() => {
-    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
-    return -1;
-  })();
-  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
-  const aantal = buf.readUInt16LE(eocd + 10);
-  let p = buf.readUInt32LE(eocd + 16);
-  for (let n = 0; n < aantal; n++) {
-    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
-    const methode = buf.readUInt16LE(p + 10);
-    const gecomp = buf.readUInt32LE(p + 20);
-    const naamLen = buf.readUInt16LE(p + 28);
-    const extraLen = buf.readUInt16LE(p + 30);
-    const commentLen = buf.readUInt16LE(p + 32);
-    const lokaalOffset = buf.readUInt32LE(p + 42);
-    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
-    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
-    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
-    const start = lokaalOffset + 30 + lNaam + lExtra;
-    const rauw = buf.subarray(start, start + gecomp);
-    if (!naam.endsWith('/')) {
-      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
-    }
-    p += 46 + naamLen + extraLen + commentLen;
-  }
-  return files;
-}
-
-export function readArchive(bron) {
-  const st = fs.statSync(bron);
-  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
-}
-
-// ── Importeren ────────────────────────────────────────────────────
-
-/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
-function veiligMediaPad(urlPad) {
-  if (!urlPad || !urlPad.startsWith('/media/')) return null;
-  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
-  const root = path.resolve(MEDIA_ROOT);
-  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
-}
-
-/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
-function padVanOrigineel(u) {
-  const s = String(u || '');
-  if (s.startsWith('/media/')) return s;
-  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
-}
-
-/**
- * Waar deze bijlage komt te staan, als site-relatief pad.
- *
- * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
- * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
- * plek onder /media. Zonder een bestemming zou het bestand wel worden
- * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
- * en verwijzen de kolommen daarheen.
- */
-function bestemming(a) {
-  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
-}
-
-/**
- * De audiobibliotheek terugzetten (formaat v2).
- *
- * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
- * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
- * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
- * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
- * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
- * verhuizing gelukt is, dus je gooit de oude instantie weg.
- *
- * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
- * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
- *
- * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
- * (en bij een droogloop dus niet, maar het verslag klopt wel)
- */
-/**
- * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
- * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
- * verwijzing naar niets.
- */
-function hoesTerug(files, bestand, werk) {
-  if (!bestand) return null;
-  const bytes = files.get(bestand);
-  if (!bytes || !bytes.length) return null;
-  const naam = path.basename(String(bestand));
-  if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
-  const urlPad = `/media/archief/${naam}`;
-  const doel = veiligMediaPad(urlPad);
-  if (!doel) return null;
-  werk.push({ soort: 'media', doel, bytes });
-  return urlPad;
-}
-
-function tracksTerug(files, site, rapport) {
-  const buf = files.get('tracks.json');
-  if (!buf) return [];
-  let coll;
-  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
-  if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
-
-  const werk = [];
-  for (const t of (coll.orderedItems || [])) {
-    const id = String(t.id || '').trim();
-    if (!id) continue;
-    const bestand = t['shaer:file'];
-    const bytes = bestand ? files.get(bestand) : null;
-    // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
-    // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
-    // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
-    // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
-    // (Youngstown) dat op de oude site prima werkte.
-    const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
-    const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
-    if ((!bytes || !bytes.length) && !alleenLinks) {
-      rapport.tracksMissing += 1;
-      rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
-      continue;
-    }
-    if (alleenLinks) {
-      // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
-      werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
-      rapport.tracks += 1;
-      rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
-      continue;
-    }
-    // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
-    // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
-    const naam = path.basename(String(bestand));
-    if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
-      rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
-      continue;
-    }
-    const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
-    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
-    rapport.tracks += 1;
-  }
-  return werk;
-}
-
-/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
-function playlistsTerug(files, site, rapport, bekendeTracks) {
-  const buf = files.get('playlists.json');
-  if (!buf) return [];
-  let coll;
-  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
-  if (coll['shaer:archive'] !== true) return [];
-  const werk = [];
-  for (const p of (coll.orderedItems || [])) {
-    const id = String(p.id || '').trim();
-    if (!id) continue;
-    // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
-    // straks een playlist vol gaten die niemand kan afspelen.
-    const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
-    const kwijt = (p['shaer:tracks'] || []).length - items.length;
-    if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
-    const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
-    werk.push({ soort: 'playlist', p, id, items, hoes });
-    rapport.playlists += 1;
-  }
-  return werk;
-}
-
-/**
- * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
- *
- * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
- * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
- * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
- * en een rollback neemt verzonden activiteiten niet terug.
- *
- * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
- * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
- *
- * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
- * ActivityPubService niet hoeft te importeren.
- */
-export async function importFollowing(site, csvText, { followFn = null } = {}) {
-  const rijen = parseFollowingCsv(csvText);
-  const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
-  if (!followFn) return { ...rapport, error: 'no_follow_fn' };
-  for (const r of rijen) {
-    // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
-    // archief van een instance die je onder een nieuwe naam opnieuw opzet.
-    if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
-    try {
-      // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
-      // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
-      // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
-      // kan er precies een halve toestand ontstaan als die faalt.
-      const ok = await followFn(site, r.address, r.featured);
-      if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
-      rapport.gevolgd += 1;
-    } catch (e) {
-      rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
-    }
-  }
-  return rapport;
-}
-
-/**
- * Zet een archief terug in een site.
- *
- * @param {Map<string,Buffer>} files  het ingelezen archief
- * @param {object} opts  { slug, dryRun, overwrite, origin }
- */
-export function importArchive(files, opts = {}) {
-  const rapport = {
-    formatVersion: null, origin: null, idsBehouden: null,
-    posts: 0, overgeslagen: 0, overschreven: 0,
-    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
-    tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
-  };
-
-  const manifestBuf = files.get('manifest.json');
-  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
-  const manifest = JSON.parse(manifestBuf.toString('utf8'));
-  rapport.formatVersion = manifest.formatVersion;
-
-  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
-  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
-  if (manifest.formatVersion > FORMAT_VERSION) {
-    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
-  }
-
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
-  if (!site) {
-    // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
-    // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
-    // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
-    let bestaand = [];
-    try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
-    const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
-    throw new Error(bestaand.length
-      ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
-      : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
-  }
-  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  rapport.origin = manifest.origin || null;
-
-  // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
-  //
-  // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
-  // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
-  // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
-  // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
-  // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
-  // het goede ding.
-  //
-  // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
-  // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
-  // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
-  // staan, en je de shorthand als kale tekst in je bericht zag.
-  //
-  // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
-  // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
-  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
-  rapport.idsBehouden = idsBehouden;
-  if (!idsBehouden) {
-    rapport.waarschuwingen.push(
-      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
-    );
-  }
-
-  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
-  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
-  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
-  const idKaart = new Map();     // oud post-id -> nieuw post-id
-
-  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
-
-  for (const pad of postPaden) {
-    const o = JSON.parse(files.get(pad).toString('utf8'));
-    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
-    // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
-    // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
-    const nieuwId = oudId;
-    idKaart.set(oudId, nieuwId);
-
-    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
-    if (botsing && !opts.overwrite) {
-      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
-      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
-      // idempotent maakt.
-      rapport.overgeslagen += 1;
-      continue;
-    }
-    if (botsing) rapport.overschreven += 1;
-
-    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
-    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
-    // op een ander domein.
-    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
-      if (a['shaer:availability'] === 'missing') {
-        rapport.mediaMissing += 1;
-        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
-        continue;
-      }
-      const bytes = files.get(a.url);
-      if (!bytes) {
-        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
-        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
-        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
-        continue;
-      }
-      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
-        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
-        continue;
-      }
-      const doel = veiligMediaPad(bestemming(a));
-      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
-      schrijf.push({ soort: 'media', doel, bytes });
-      rapport.media += 1;
-    }
-
-    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
-    rapport.posts += 1;
-  }
-
-  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
-  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
-    const coll = JSON.parse(files.get(pad).toString('utf8'));
-    if (coll['shaer:archive'] !== true) {
-      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
-      continue;
-    }
-    const oudId = path.basename(pad, '.json');
-    const postId = idKaart.get(oudId);
-    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
-    for (const it of (coll.orderedItems || [])) {
-      schrijf.push({ soort: 'reply', postId, it });
-      rapport.replies += 1;
-    }
-  }
-
-  // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
-  // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
-  const trackWerk = tracksTerug(files, site, rapport);
-  const bekendeTracks = new Set(trackWerk.map((w) => w.id));
-  const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
-
-  if (opts.dryRun) return rapport;
-
-  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
-  // uitkomst, want dan lijkt het gelukt.
-  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
-    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
-     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
-     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
-    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
-     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
-     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
-  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
-    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
-    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, ${NU_ISO})`);
-
-  db.transaction(() => {
-    for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
-      if (s.soort === 'media') {
-        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
-        fs.writeFileSync(s.doel, s.bytes);
-        continue;
-      }
-      if (s.soort === 'reply') {
-        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
-          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
-        continue;
-      }
-      if (s.soort === 'track') {
-        // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
-        // rolt de hele transactie terug: liever geen import dan een track zonder
-        // geluid, want dat is precies de val waar dit uit voortkomt.
-        //
-        // Een link-only track heeft geen bestand en dus ook geen mediarij; die
-        // krijgt media_id NULL, precies zoals op de bron.
-        let mediaId = null;
-        if (s.doel && s.bytes) {
-          fs.mkdirSync(path.dirname(s.doel), { recursive: true });
-          fs.writeFileSync(s.doel, s.bytes);
-          mediaId = randomUUID();
-          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-            .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
-        }
-        const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
-        db.prepare(`INSERT OR REPLACE INTO audio_tracks
-            (id, site_id, title, artist, album, duration, media_id, position, credit, license,
-             cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
-          VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
-          .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
-            s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
-            s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
-            link('spotify'), link('youtube'), link('soundcloud'));
-        continue;
-      }
-      if (s.soort === 'playlist') {
-        db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
-                    VALUES (?,?,?,?,?,?,?)`)
-          .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
-            s.hoes || null, s.p['shaer:kind'] || null);
-        db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
-        const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
-        s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
-        continue;
-      }
-      const o = s.obj;
-      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
-      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
-      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
-      // en dat zie je pas als je alle kolommen vergelijkt.
-      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
-      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
-      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
-      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
-        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
-        return {
-          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
-          poster: poster ? padVan(poster) : undefined,
-        };
-      }).filter((a) => a.url);
-      insPost.run({
-        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
-        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
-        status: o['shaer:status'] || 'draft',
-        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
-        cover_video_url: padVan(metRol('coverVideo')),
-        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
-        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
-        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
-        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
-        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
-        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
-        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
-        content_warning: o.summary || null,
-        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
-        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
-        ap_visibility: o['shaer:apVisibility'] || null,
-        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
-        view_count: o['shaer:viewCount'] || 0,
-      });
-      // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
-      // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
-      // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
-      // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
-      // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
-      // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
-    }
-
-    // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
-    // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
-    // behandeld moeten worden als objecten die van de bron zijn opgehaald.
-    // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
-    // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
-    //
-    // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
-    // oude URI al naar het goede object en valt er niets te vertalen.
-    if (!idsBehouden && eigenOrigin) {
-      for (const s of schrijf) {
-        if (s.soort !== 'post' || !s.oudeUri) continue;
-        Migration.recordMigrated(site.slug, {
-          origin: s.oudeUri,
-          target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
-          sourceActor: manifest.actor || '',
-          isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
-        });
-      }
-    }
-
-    // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
-    // verhuizing is een verhuizing, en een bericht dat naar het oude domein
-    // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
-    // transactie, want half bijgetrokken is erger dan niet.
-    //
-    // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
-    // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
-    if (manifest.origin && manifest.origin !== eigenOrigin) {
-      Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
-    }
-  })();
-
-  return rapport;
-}
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,250 +1,0 @@
-/**
- * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
- *
- * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
- * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
- * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
- * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
- * elders wijzen naar die ids.
- *
- * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
- * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
- * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
- * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
- *
- * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
- * voordat iemand eraan begint:
- *
- *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
- *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
- *   - alles van VOOR het moment dat de bron ging volgen.
- *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
- *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
- *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
- *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
- */
-
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import Database from 'better-sqlite3';
-import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
-
-const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
-const MIME_BY_EXT = {
-  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
-  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
-};
-const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
-const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
-const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
-
-/**
- * De titel terugvissen uit de tekst.
- *
- * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
- * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
- * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
- * eerste regel in de body.
- *
- * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
- * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
- * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
- * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
- * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
- * een mens na te lopen, niet een script.
- */
-export function splitsTitel(html) {
-  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
-  if (!m) return { titel: null, rest: html || '' };
-  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
-  if (!titel) return { titel: null, rest: html || '' };
-  return { titel, rest: String(html).slice(m[0].length) };
-}
-
-/**
- * Van een URL naar een bestand op de geredde schijf.
- *
- * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
- * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
- * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
- * randgeval maar de helft van de bijlagen.
- */
-function schijfPad(url, origin, mediaRoot, audioRoot) {
-  let p = String(url || '');
-  if (/^https?:/i.test(p)) {
-    try {
-      const u = new URL(p);
-      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
-      p = u.pathname;
-    } catch { return null; }
-  }
-  const onder = (root, rest) => {
-    if (!root) return null;
-    const abs = path.resolve(root, decodeURIComponent(rest));
-    const r = path.resolve(root);
-    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
-  };
-  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
-  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
-  return null;
-}
-
-const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
-
-/**
- * Bouw een archief uit een of meer tijdlijn-caches.
- *
- * @param {object} opts
- *   sources   paden naar de database(s) van instances die de verloren site volgen
- *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
- *   mediaRoot de geredde mediamap van de verloren site (optioneel)
- *   houdTitelInTekst  laat de titel staan waar hij staat
- */
-export function recoverFromCache(opts = {}) {
-  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
-  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
-  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
-  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
-  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
-  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
-  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
-
-  const rapport = {
-    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
-    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
-  };
-
-  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
-  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
-  const beste = new Map();
-  for (const bron of sources) {
-    let n = 0;
-    let sdb;
-    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
-    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
-    let rijen = [];
-    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
-    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
-    for (const r of rijen) {
-      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
-      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
-      // wel van ons -- die houden we, maar zonder de booster.
-      const vorige = beste.get(r.id);
-      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
-      n += 1;
-    }
-    sdb.close();
-    rapport.bronnen.push({ pad: bron, rijen: n });
-  }
-
-  const files = new Map();
-  const ids = [...beste.keys()].sort();
-
-  for (const apId of ids) {
-    const r = beste.get(apId);
-    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
-    if (!postId) { rapport.overgeslagen += 1; continue; }
-    let slug = postId;
-    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
-
-    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
-    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
-
-    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
-    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
-    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
-    // het federeren verloren gegaan en komt niet terug.
-    const attachments = [];
-    const lijst = parse(r.media_json, []) || [];
-    lijst.forEach((m, i) => {
-      const url = m && (m.url || m.href);
-      if (!url) return;
-      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
-      const rol = i === 0 ? 'cover' : 'c2s';
-      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
-      let bytes = null;
-      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
-      if (bytes) {
-        const hash = sha256(bytes);
-        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
-        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
-          url: naam, 'shaer:availability': 'included',
-          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:sha256': hash, 'shaer:role': rol,
-        });
-      } else {
-        rapport.mediaMissing += 1;
-        rapport.gemist.push({ slug, url });
-        attachments.push({
-          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
-          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:availability': 'missing',
-          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
-          'shaer:role': rol,
-        });
-      }
-    });
-
-    const poll = parse(r.poll_json);
-    const quote = parse(r.quote_json);
-    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
-      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
-
-    const obj = {
-      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
-      id: apId,
-      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
-      attributedTo: actorUri,
-      name: gesplitst.titel || undefined,
-      content: gesplitst.rest,
-      summary: r.cw || undefined,
-      sensitive: r.nsfw ? true : undefined,
-      published: r.published || undefined,
-      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
-      attachment: attachments.length ? attachments : undefined,
-      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
-      endTime: opties ? (poll.endTime || undefined) : undefined,
-      closed: (opties && poll.closed) ? true : undefined,
-      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
-      'shaer:slug': slug,
-      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
-      'shaer:recoveredFrom': 'timeline-cache',
-    };
-    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
-    files.set(`readable/${slug}.md`, Buffer.from(
-      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
-    rapport.posts += 1;
-    if (r.published) {
-      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
-      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
-    }
-  }
-
-  const bestandsHashes = {};
-  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
-  const manifest = {
-    formatVersion: FORMAT_VERSION,
-    generator: `klonkt-recovery/${opts.version || 'dev'}`,
-    exportedAt: opts.exportedAt || new Date().toISOString(),
-    origin,
-    actor: actorUri,
-    site: { slug: opts.slug || '', title: opts.title || '' },
-    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
-    files: bestandsHashes,
-    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
-    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
-    'shaer:recovered': {
-      from: 'timeline-cache',
-      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
-      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
-      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
-    },
-  };
-  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
-
-  return { files, manifest, rapport };
-}
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioEmbedService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,48 +11,13 @@
  */
 
-// Dezelfde lijst soorten als de server en de editor gebruiken. Zie
-// assets/js/shared/post-music-type.js: die module is puur, dus hij mag hier.
-import { SOORTEN } from '../assets/js/shared/post-music-type.js';
-
-// "Open in" icons (brand-colored via CSS .pat-link--).
-const OPEN_IN_SVG = {
-  spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
-  youtube: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.1a3 3 0 00-2.1-2.12C19.04 4.5 12 4.5 12 4.5s-7.04 0-8.9.48A3 3 0 001 7.1 31.2 31.2 0 00.5 12 31.2 31.2 0 001 16.9a3 3 0 002.1 2.12c1.86.48 8.9.48 8.9.48s7.04 0 8.9-.48A3 3 0 0023 16.9 31.2 31.2 0 0023.5 12 31.2 31.2 0 0023 7.1zM9.75 15.5v-7l6 3.5-6 3.5z"/></svg>',
-  soundcloud: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 14v4M7.5 11v7M11 9v9"/><path d="M14.5 9.5V18h4a3 3 0 100-6 4 4 0 00-4-2.5z"/></svg>',
-};
-
 class AudioEmbedService {
-  // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
-  // are already validated server-side (https + correct host only). Returns ''
-  // when no links exist. Placed next to the play button (outside the button →
-  // no conflict with playback).
-  static openInLinks(t) {
-    if (!t) return '';
-    const out = [];
-    const add = (url, key, label) => {
-      if (!url) return;
-      out.push(`<a class="pat-link pat-link--${key}" href="${this.escape(url)}" target="_blank" rel="noopener noreferrer" title="Open in ${label}" aria-label="Open in ${label}">${OPEN_IN_SVG[key]}</a>`);
-    };
-    add(t.link_spotify, 'spotify', 'Spotify');
-    add(t.link_youtube, 'youtube', 'YouTube');
-    add(t.link_soundcloud, 'soundcloud', 'SoundCloud');
-    return out.length ? `<span class="pat-links">${out.join('')}</span>` : '';
-  }
-
   static detectProvider(url) {
     if (!url || typeof url !== 'string') return null;
     url = url.trim();
 
-    // Only embed http(s) URLs. The provider regexes below are NOT anchored,
-    // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
-    // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
-    // that text never passes through the HTML sanitizer because it lives in a
-    // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
-    if (!/^https?:\/\//i.test(url)) return null;
-
     // Spotify
     if (/open\.spotify\.com\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i.test(url)) {
       const match = url.match(/\/(track|album|playlist|episode|show)\/([A-Za-z0-9]+)/i);
-      return { provider: 'spotify', type: match[1], id: match[2], url };
+      return { provider: 'spotify', type: match[1], id: match[2] };
     }
 
@@ -72,35 +37,8 @@
     }
 
-    // YouTube — a video id is always exactly 11 characters (aligns with the
-    // client-side ytId() in embed-player.js, which also expects {11}).
-    //
-    // A link may carry a video, a playlist, or both, and until now we kept only
-    // the video and threw `list=` away -- so a link to an album played its first
-    // song and stopped. The ref now keeps whichever is there, in the same three
-    // shapes the Klonkt hub uses, so one ref travels between the two unchanged:
-    //
-    //   "<video>"           one video
-    //   "<video>?list=<L>"  that video, and on through the list
-    //   "list:<L>"          the whole playlist (YouTube's `videoseries`)
-    //
-    // `list` may sit before or after `v=` and is often entity-encoded (&amp;)
-    // in a baked href, hence the scan over the whole URL rather than a fixed
-    // order. A list id is 10-60 chars: longer and looser than a video id.
-    if (/(?:youtube(?:-nocookie)?\.com\/(?:watch\?|playlist\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i.test(url)) {
-      const vm = url.match(/(?:[?&](?:amp;)?v=|youtu\.be\/|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/i);
-      const lm = url.match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/i);
-      // `videoseries` is a marker, not a video: a bare playlist embed URL reads
-      // /embed/videoseries?list=..., and taking that for an id gives a dead
-      // frame. It is EXACTLY eleven characters, so no length rule catches it --
-      // it has to be named. (Measured, not assumed: it slipped through a
-      // boundary check that looked like it covered this.)
-      const id = vm && vm[1] !== 'videoseries' ? vm[1] : null;
-      const list = lm ? lm[1] : null;
-      if (id || list) {
-        const ref = id ? (list ? `${id}?list=${list}` : id) : `list:${list}`;
-        // `id` stays exactly what it was for every caller that only wants a
-        // video; `list` and `ref` are additions.
-        return { provider: 'youtube', id, list, ref, url };
-      }
+    // YouTube
+    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([A-Za-z0-9_-]{6,20})/i.test(url)) {
+      const match = url.match(/(?:v=|youtu\.be\/|embed\/)([A-Za-z0-9_-]{6,20})/i);
+      return { provider: 'youtube', id: match[1] };
     }
 
@@ -108,64 +46,22 @@
     if (/vimeo\.com\/(?:video\/)?(\d+)/i.test(url)) {
       const match = url.match(/\d+/);
-      return { provider: 'vimeo', id: match[0], url };
+      return { provider: 'vimeo', id: match[0] };
     }
 
     return null;
-  }
-
-  // Direct media files (video/audio) hosted anywhere → a native <video>/<audio>
-  // player. Kept OUT of detectProvider() on purpose: the timeline/cover callers
-  // switch on provider slugs (youtube/spotify/…) and a bare file has none, so
-  // overloading detectProvider would suppress e.g. a PeerTube fallback. Only
-  // autoembed() and [[embed:…]] use this.
-  static MEDIA_FILE_EXT = {
-    video: ['mp4', 'webm', 'm4v', 'mov', 'ogv'],
-    audio: ['mp3', 'ogg', 'oga', 'wav', 'm4a', 'flac', 'opus', 'aac'],
-  };
-
-  static detectMediaFile(url) {
-    if (!url || typeof url !== 'string') return null;
-    if (!/^https?:\/\//i.test(url)) return null;
-    let pathname;
-    try { pathname = new URL(url).pathname.toLowerCase(); } catch { return null; }
-    const ext = (pathname.match(/\.([a-z0-9]+)$/) || [])[1];
-    if (!ext) return null;
-    if (this.MEDIA_FILE_EXT.video.includes(ext)) return { kind: 'video', url };
-    if (this.MEDIA_FILE_EXT.audio.includes(ext)) return { kind: 'audio', url };
-    return null;
-  }
-
-  static mediaFileEmbed(url) {
-    const m = this.detectMediaFile(url);
-    if (!m) return null;
-    const src = this.escape(m.url);
-    if (m.kind === 'video') {
-      return `<figure class="folio-embed folio-embed--video"><video src="${src}" controls preload="metadata" playsinline></video></figure>`;
-    }
-    return `<figure class="folio-embed folio-embed--audio"><audio src="${src}" controls preload="metadata"></audio></figure>`;
   }
 
   static generateIframe(provider, config) {
     switch (provider) {
-      // Custom players (client-side via embed-player.js + the real platform APIs).
-      // We render a placeholder with data attributes instead of the bare platform
-      // iframe, so the embed appears in OUR brand style.
-      case 'youtube':
-        // The ref carries the list when there is one; `id` alone would drop it
-        // and play a single song out of an album.
-        return this.embedPlaceholder('youtube', config.ref || config.id, 'video',
-          config.url || (config.id ? `https://youtu.be/${config.id}`
-                                   : `https://www.youtube.com/playlist?list=${config.list}`));
-      case 'soundcloud':
-        return this.embedPlaceholder('soundcloud', config.url, 'track', config.url);
       case 'spotify':
-        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
-          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
-      // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
-      // mutual exclusion for these runs via the blur fallback.
+        return this.spotifyIframe(config);
       case 'bandcamp':
         return this.bandcampIframe(config);
+      case 'soundcloud':
+        return this.soundcloudIframe(config);
       case 'applemusic':
         return this.applemusicIframe(config);
+      case 'youtube':
+        return this.youtubeIframe(config);
       case 'vimeo':
         return this.vimeoIframe(config);
@@ -173,19 +69,4 @@
         return null;
     }
-  }
-
-  /**
-   * Placeholder for a custom player. embed-player.js picks up
-   * .folio-embed[data-embed-provider] and builds the card + player client-side.
-   * ALL values go through escape() — post.content_html is executed unescaped.
-   */
-  static embedPlaceholder(provider, ref, type, url) {
-    const attrs = [
-      `data-embed-provider="${this.escape(provider)}"`,
-      `data-embed-ref="${this.escape(ref)}"`,
-      type ? `data-embed-type="${this.escape(type)}"` : '',
-      `data-embed-url="${this.escape(url)}"`,
-    ].filter(Boolean).join(' ');
-    return `<div class="folio-embed folio-embed--${this.escape(provider)} pcms-embed pcms-embed-card pcms-embed-loading" ${attrs}></div>`;
   }
 
@@ -243,31 +124,11 @@
 
   static applemusicIframe({ url }) {
-    // Een album of nummer heeft een NUMMER als id, een afspeellijst niet: die
-    // heet `pl.u-LdbqzVvI3go5g`. Met alleen [0-9]+ viel elke playlist hier af
-    // en gaf deze functie null -- waarna de shortcode zelf op de pagina kwam.
-    // Barts melding (17-8): het concept "The Mixtape" toonde in preview
-    // letterlijk [[embed:https://music.apple.com/nl/playlist/...]].
-    //
-    // Bewust krap: geen slash, vraagteken of hekje in het id, want wat hier
-    // gevangen wordt gaat rechtstreeks achter https://embed.music.apple.com/ aan.
-    const match = url.match(
-      /music\.apple\.com\/([a-z]{2}\/(album|playlist|song)\/[^/?#]+\/(?:[0-9]+|pl\.[A-Za-z0-9_-]+))/i,
-    );
+    const match = url.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i);
     if (!match) return null;
     const src = `https://embed.music.apple.com/${match[1]}`;
-    // De hoogte hangt af van WAT je insluit, en dat stond hier op een vaste
-    // 175px -- de maat van een LOS NUMMER. Een album of afspeellijst is 450px,
-    // dus daarvan zag je ongeveer een derde, met `overflow:hidden` eroverheen
-    // zodat de rest ook niet te bereiken viel. Barts melding (20-8) over
-    // boiert.eu/the-mixtape.
-    //
-    // Nagemeten en niet overgenomen: de embed-pagina van die lijst
-    // (pl.u-LdbqzVvI3go5g) geeft zijn <main> EN zijn <body> allebei precies
-    // 450px. Dat is ook de hoogte in Apple's eigen insluitcode.
-    const hoogte = String(match[2]).toLowerCase() === 'song' ? 175 : 450;
     return `
       <figure class="folio-embed folio-embed--applemusic">
         <iframe src="${this.escape(src)}"
-                style="width:100%;height:${hoogte}px;border:0;overflow:hidden;border-radius:8px;"
+                style="width:100%;height:175px;border:0;overflow:hidden;border-radius:8px;"
                 loading="lazy"
                 allow="autoplay; clipboard-write; encrypted-media"
@@ -277,23 +138,6 @@
   }
 
-  /**
-   * The plain provider iframe. Takes the same ref shapes as the placeholder:
-   * "<video>", "<video>?list=<L>" and "list:<L>" -- a bare playlist embeds as
-   * `videoseries`. Kept in step with the placeholder path on purpose: this is
-   * the fallback, and a fallback that silently drops the playlist is the worst
-   * kind, because it looks like it worked.
-   */
-  static youtubeIframe({ id, ref }) {
-    const r = ref || id || '';
-    const base = 'https://www.youtube-nocookie.com/embed/';
-    let src;
-    if (r.startsWith('list:')) {
-      src = `${base}videoseries?list=${encodeURIComponent(r.slice(5))}`;
-    } else if (r.includes('?list=')) {
-      const [v, l] = r.split('?list=');
-      src = `${base}${encodeURIComponent(v)}?list=${encodeURIComponent(l)}`;
-    } else {
-      src = base + encodeURIComponent(r);
-    }
+  static youtubeIframe({ id }) {
+    const src = `https://www.youtube-nocookie.com/embed/${id}`;
     return `
       <figure class="folio-embed folio-embed--youtube">
@@ -340,37 +184,7 @@
           return iframe || match;
         }
-        // Bare media file (…/clip.webm, …/song.mp3) → native player.
-        const media = this.mediaFileEmbed(url);
-        if (media) return media;
         return match;
       }
     );
-  }
-
-  /**
-   * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
-   * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
-   * shortcode; bare URL lines also embed automatically via autoembed().
-   * Unsupported/invalid URLs get a clean inline notice.
-   */
-  static embedMediaShortcodes(html) {
-    if (!html) return html;
-    return html.replace(/\[\[embed:([^\]]+)\]\]/gi, (match, rawUrl) => {
-      const url = rawUrl.trim().replace(/&amp;/g, '&');
-      const detected = this.detectProvider(url);
-      if (!detected) {
-        // Bare media file (…/clip.webm, …/song.mp3) → native player.
-        const media = this.mediaFileEmbed(url);
-        if (media) return media;
-        return `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
-      }
-      // HERKEND maar niet te bouwen is geen reden om de shortcode zelf te
-      // tonen. Dat deed het wel, en dan leest een bezoeker "[[embed:https://...]]"
-      // op de pagina en denkt hij dat er iets stuk is. Onherkend gaf hierboven
-      // al een nette melding; herkend-maar-mislukt hoort dezelfde te geven,
-      // want voor de lezer is het hetzelfde geval.
-      return this.generateIframe(detected.provider, detected)
-        || `<div class="post-embed-missing"><em>Embed: niet-ondersteunde of ongeldige URL.</em></div>`;
-    });
   }
 
@@ -384,42 +198,17 @@
     return html.replace(/\[\[track:([A-Za-z0-9_-]+)\]\]/g, (match, id) => {
       const t = trackLookup(id);
-      if (!t) return match;
-      const titleH0 = this.escape(t.title || 'Untitled');
-      const artistH0 = this.escape(t.artist || '');
-      const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
-      // Link-only track (no audio file): no play button, but info + open-in links.
-      if (!t.url) {
-        const coverH0 = this.escape(t.cover || '');
-        const leader0 = coverH0
-          ? `<span class="pat-noplay pat-noplay--cover" style="background-image:url('${coverH0}')" aria-hidden="true"></span>`
-          : `<span class="pat-noplay" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>`;
-        return `<div class="post-audio-track post-audio-track--static" id="track-${id}">
-  ${leader0}
-  <div class="pat-info">
-    <div class="pat-title">${titleH0}</div>
-    ${artistH0 ? `<div class="pat-artist">${artistH0}</div>` : ''}
-    ${creditBits0 ? `<div class="pat-credit">${creditBits0}</div>` : ''}
-  </div>
-  ${this.openInLinks(t)}
-</div>`;
-      }
+      if (!t || !t.url) return match;
       const trackJson = JSON.stringify({
-        id,
         url: t.url,
         title: t.title || 'Untitled',
         artist: t.artist || '',
         cover: t.cover || '',
-        credit: t.credit || '',
-        license: t.license || '',
       });
       const titleH = this.escape(t.title || 'Untitled');
       const artistH = this.escape(t.artist || '');
       const urlH = this.escape(t.url);
-      // Visible owner/license line below the track.
-      const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
       const dataAttr = trackJson
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
-      // id="track-<id>" = anchor so the mini-player can scroll to this element.
-      return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
+      return `<div class="post-audio-track" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
   <button type="button" class="pat-play" aria-label="Play ${titleH}">
     <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
@@ -428,7 +217,5 @@
     <div class="pat-title">${titleH}</div>
     ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
-    ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
   </div>
-  ${this.openInLinks(t)}
 </div>`;
     });
@@ -451,7 +238,5 @@
       // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
       const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
-      // Only playable tracks (with url) in the queue; link-only tracks appear
-      // in the list but not in the playback JSON.
-      const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
+      const albumJson = JSON.stringify(album.tracks)
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
       const titleH = this.escape(album.title || name);
@@ -462,17 +247,6 @@
         const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
         const tArtist = this.escape(t.artist || '');
-        // Link-only track: no play button, but track number + info + open-in links.
-        if (!t.url) {
-          return `    <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
-      <span class="pat-track-num">${i + 1}.</span>
-      <div class="pat-info">
-        <div class="pat-title">${tTitle}</div>
-        ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
-      </div>
-      ${this.openInLinks(t)}
-    </li>`;
-        }
         const tUrl = this.escape(t.url);
-        return `    <li class="post-audio-track"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''} data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
+        return `    <li class="post-audio-track" data-pcms-track-url="${tUrl}" data-pcms-album-id="${albumDomId}">
       <button type="button" class="pat-play" aria-label="Play ${tTitle}">
         <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
@@ -483,5 +257,4 @@
         ${tArtist && tArtist !== artistH ? `<div class="pat-artist">${tArtist}</div>` : ''}
       </div>
-      ${this.openInLinks(t)}
     </li>`;
       }).join('\n');
@@ -542,10 +315,6 @@
 
       const albumDomId = 'album-' + id;
-      // De soort zoals hij is opgeslagen. Stond hier als ternair met twee
-      // uitkomsten, en dan draagt een mixtape het jasje en het woord van een
-      // album -- dezelfde vorm die op vier andere plekken al misging.
-      const kind = SOORTEN.includes(pl.kind) ? pl.kind : 'album';
-      const KIND_LABEL = { album: '💿 Album', playlist: '📃 Playlist', mixtape: '📼 Mixtape' };
-      const kindLabel = KIND_LABEL[kind] || KIND_LABEL.album;
+      const kind = (pl.kind === 'playlist') ? 'playlist' : 'album';
+      const kindLabel = kind === 'playlist' ? '📃 Playlist' : '💿 Album';
       const titleH  = this.escape(pl.title || 'Naamloos');
       const artistH = this.escape(pl.artist || '');
@@ -554,17 +323,9 @@
       // Audio-player.js reads data-pcms-album for queue. Same shape as
       // embedAlbumShortcodes — keep both in sync.
-      // Only playable tracks in the queue; link-only tracks appear in the list
-      // but not in the playback JSON.
-      const tracksData = pl.tracks.filter(t => t.url).map(t => ({
-        id:     t.id,
+      const tracksData = pl.tracks.map(t => ({
         url:    t.url,
         title:  t.title,
         artist: t.artist || pl.artist || '',
         cover:  t.cover  || pl.cover  || '',
-        // De duur gaat mee omdat een BANDJE een lengte heeft. Zonder dit kan de
-        // speler alleen de gebufferde keten optellen, en dan las de teller
-        // 2:05 met een nummer geladen en 4:07 met drie -- een totaal dat
-        // meegroeit terwijl je luistert.
-        duration: Number(t.duration) || 0,
       }));
       const albumJson = JSON.stringify(tracksData)
@@ -583,5 +344,5 @@
       }
       const metaLine = this.escape(metaParts.join(' · '));
-      const firstUrl = this.escape((pl.tracks.find(t => t.url) || {}).url || '');
+      const firstUrl = this.escape(pl.tracks[0].url);
 
       // Track items — playlist-kind shows per-track cover thumbs, album-kind shows numbers
@@ -602,20 +363,6 @@
           : `<span class="pat-num">${i + 1}</span>`;
 
-        // Link-only track: no clickable play row (static div), but open-in links.
-        if (!t.url) {
-          return `    <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
-      <div class="pat-row pat-static">
-        ${leader}
-        <span class="pat-meta">
-          <span class="pat-title">${tTitleH}</span>
-          ${showArtist ? `<span class="pat-artist">${tArtistH}</span>` : ''}
-        </span>
-        ${durHtml}
-      </div>
-      ${this.openInLinks(t)}
-    </li>`;
-        }
         const trackBase = String(t.url).split('?')[0];
-        return `    <li class="post-album-track-compact"${t.id ? ` id="track-${t.id}" data-pcms-track-id="${t.id}"` : ''}>
+        return `    <li class="post-album-track-compact">
       <button type="button" class="pat-row"
               data-pcms-track-url="${tUrl}"
@@ -630,19 +377,6 @@
         ${durHtml}
       </button>
-      ${this.openInLinks(t)}
     </li>`;
       }).join('\n');
-
-      // HET BANDJE HEEFT ZIJN EIGEN VORM. Een album toont een genummerde lijst
-      // waar je in kunt prikken; een cassette is juist het tegenovergestelde --
-      // je hoort wat er komt, in de volgorde waarin het is opgenomen. Alles
-      // hierboven (de wachtrij, de metaregel, de duur) is gedeeld; alleen de
-      // opmaak splitst hier.
-      if (kind === 'mixtape') {
-        return this.renderTape({
-          domId: albumDomId, id, titleH, artistH, coverH, metaLine, firstUrl,
-          albumJson, tracks: pl.tracks, isAdmin,
-        });
-      }
 
       return `<div class="post-album" id="${albumDomId}"
@@ -663,5 +397,5 @@
             data-pcms-track-url="${firstUrl}"
             data-pcms-album-id="${albumDomId}"
-            aria-label="Speel ${kind}">
+            aria-label="Speel ${kind === 'playlist' ? 'playlist' : 'album'}">
       ${coverH
         ? `<span class="post-album-cover" style="background-image:url('${coverH}')"></span>`
@@ -688,109 +422,4 @@
 
   /**
-   * Het bandje (Robins idee, 21-8).
-   *
-   * WAT HET ANDERS MAAKT DAN EEN ALBUM, en dat is de hele reden dat dit een
-   * eigen vorm heeft: bij een album prik je in een genummerde lijst en spring
-   * je naar nummer zeven. Op een cassette kan dat niet. Je spoelt vooruit of
-   * terug, en wat er komt hoor je in de volgorde waarin het is opgenomen. De
-   * lijst staat er dus wel -- je mag zien wat erop staat -- maar hij is geen
-   * knoppenrij.
-   *
-   * DE SPELER IS DE BESTAANDE SPELER. De knop hieronder draagt exact dezelfde
-   * data-attributen als de albumhoes (data-pcms-track-url + data-pcms-album-id),
-   * dus audio-player.js pakt hem op zonder dat hier iets nieuws bij komt. Vooruit
-   * en terug lopen via window.pcmsAudioPlayer.next()/prev(), en dat is meteen de
-   * reden dat spoelen per NUMMER gaat en niet per seconde: die speler denkt in
-   * een wachtrij, en een tweede speler ernaast bouwen om een band na te doen zou
-   * twee dingen tegelijk laten afspelen.
-   */
-  static renderTape({ domId, id, titleH, artistH, coverH, metaLine, firstUrl, albumJson, tracks, isAdmin }) {
-    // De nummers als tekst, niet als knoppen. Bewust geen data-pcms-track-url:
-    // een aanklikbaar nummer is precies wat een bandje niet heeft.
-    const lijst = tracks.map((t, i) => {
-      const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
-      const dur = t.duration > 0
-        ? `${Math.floor(t.duration / 60)}:${String(t.duration % 60).padStart(2, '0')}`
-        : '—:—';
-      return `      <li class="tape-track" data-tape-index="${i}"><span class="tape-track-title">${tTitle}</span><span class="tape-track-dur">${dur}</span></li>`;
-    }).join('\n');
-
-    const spoel = (richting, label, pad) => `    <button type="button" class="tape-btn tape-btn--${richting}" data-tape-go="${richting}" aria-label="${label}">
-      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${pad}</svg>
-    </button>`;
-
-    return `<div class="post-tape" id="${domId}"
-     data-pcms-album='${albumJson}'
-     data-pcms-album-title="${titleH}"
-     data-pcms-album-kind="mixtape"
-     data-pcms-playlist-id="${this.escape(id)}">
-${isAdmin ? `  <div class="post-album-actions" role="group" aria-label="Mixtape beheren">
-    <a class="post-album-action" href="/admin/playlists?edit=${this.escape(id)}" title="Bewerk mixtape" aria-label="Bewerk mixtape">
-      <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 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>
-    </a>
-  </div>
-` : ''}  <div class="tape-shell">
-    <svg class="tape-svg" viewBox="0 0 314 200" role="img" aria-label="Cassette" preserveAspectRatio="xMidYMid meet">
-      <defs>
-        <clipPath id="tapewin-${domId}"><rect x="72" y="96" width="170" height="62" rx="6"/></clipPath>
-      </defs>
-      <!-- de behuizing -->
-      <rect class="tape-body" x="3" y="3" width="308" height="194" rx="9"/>
-      <rect class="tape-body-inner" x="12" y="12" width="290" height="176" rx="6"/>
-      <!-- het labelvlak: hier komt de HTML-tekst overheen te staan -->
-      <rect class="tape-labelplate" x="24" y="22" width="266" height="62" rx="3"/>
-      <!-- het venster waardoor je de band ziet lopen -->
-      <rect class="tape-glass" x="72" y="96" width="170" height="62" rx="6"/>
-      <g clip-path="url(#tapewin-${domId})">
-        <!-- de bandpakketten om de spoelen; links loopt vol terwijl rechts leegloopt -->
-        <circle class="tape-pack tape-pack--left" cx="112" cy="127" r="30"/>
-        <circle class="tape-pack tape-pack--right" cx="202" cy="127" r="22"/>
-        <rect class="tape-ribbon" x="112" y="150" width="90" height="3"/>
-      </g>
-      <!-- de spoelen zelf: deze twee draaien -->
-      <g class="tape-reel tape-reel--left" style="transform-origin:112px 127px">
-        <circle class="tape-hub" cx="112" cy="127" r="15"/>
-        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="109.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 112 127)"/>`).join('')}
-      </g>
-      <g class="tape-reel tape-reel--right" style="transform-origin:202px 127px">
-        <circle class="tape-hub" cx="202" cy="127" r="15"/>
-        ${[0, 60, 120, 180, 240, 300].map((a) => `<rect class="tape-tooth" x="199.5" y="112" width="5" height="9" rx="1" transform="rotate(${a} 202 127)"/>`).join('')}
-      </g>
-      <!-- de schroefjes in de hoeken, en de openingen voor de kop onderin -->
-      <circle class="tape-screw" cx="22" cy="22" r="3.5"/>
-      <circle class="tape-screw" cx="292" cy="22" r="3.5"/>
-      <circle class="tape-screw" cx="22" cy="178" r="3.5"/>
-      <circle class="tape-screw" cx="292" cy="178" r="3.5"/>
-      <rect class="tape-slot" x="128" y="168" width="26" height="14" rx="2"/>
-      <rect class="tape-slot" x="160" y="168" width="26" height="14" rx="2"/>
-      <rect class="tape-slot tape-slot--capstan" x="98" y="170" width="10" height="12" rx="2"/>
-      <rect class="tape-slot tape-slot--capstan" x="206" y="170" width="10" height="12" rx="2"/>
-    </svg>
-    <div class="tape-label">
-      <p class="tape-kind">Mixtape</p>
-      <h3 class="tape-title">${titleH}</h3>
-      ${artistH ? `<p class="tape-artist">${artistH}</p>` : ''}
-      <p class="tape-meta">${metaLine}</p>
-    </div>
-  </div>
-  <div class="tape-controls" role="group" aria-label="Bandje bedienen">
-${spoel('back', 'Terugspoelen', '<path d="M11 12l9-7v14zM2 12l9-7v14z"/>')}
-    <button type="button" class="tape-btn tape-btn--play"
-            data-pcms-track-url="${firstUrl}"
-            data-pcms-album-id="${domId}"
-            data-tape-play
-            aria-label="Afspelen">
-      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
-    </button>
-${spoel('fwd', 'Vooruitspoelen', '<path d="M13 12L4 5v14zM22 12l-9-7v14z"/>')}
-  </div>
-  <p class="tape-now" data-tape-now aria-live="polite"></p>
-  <ol class="tape-tracks">
-${lijst}
-  </ol>
-</div>`;
-  }
-
-  /**
    * Human-readable label for a provider slug. Used by external-link buttons.
    */
@@ -834,5 +463,5 @@
    * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
    * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
-   * Per Robin's v9: "External link, click = open platform (target _blank)".
+   * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
    */
   static embedExternalLinkShortcodes(html) {
Index: src/services/AudioStreamService.js
===================================================================
--- src/services/AudioStreamService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioStreamService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,33 +1,112 @@
 /**
- * AudioStreamService — builds URLs for the audio streaming route.
+ * AudioStreamService — Signed audio streaming, v9-style.
  *
- * ┌─ ANTI-THEFT MODEL (Spotify-flavoured, step 1 — 2026-05-20) ─────────────┐
- * │ audioUrl() returns a plain /audio/stream/<filename> path. There is NO   │
- * │ signed/expiring token in the URL — that earlier design baked a single   │
- * │ 10-min deadline into a whole queue at render time, so later tracks'     │
- * │ tokens expired mid-session and the player looped "next" forever.        │
- * │                                                                          │
- * │ Protection now lives in two NON-expiring layers, so it can't cause that │
- * │ failure again:                                                           │
- * │   1. Client (audio-player.js) fetch()es the bytes and plays from a      │
- * │      blob: object URL — no shareable link, no "save audio as".          │
- * │   2. Server (routes/audio.js) gates /audio/stream to same-origin        │
- * │      browser fetches — blocks address-bar paste, hotlinks, curl/yt-dlp. │
- * │                                                                          │
- * │ FUTURE STEPS (deliberate, tested one at a time):                         │
- * │   - step 2: MSE chunked/progressive streaming (true Spotify feel)        │
- * │   - step 3: per-session short-lived token in a header, minted JIT        │
- * │   - step 4: light byte obfuscation (XOR/key) on the wire                 │
- * └──────────────────────────────────────────────────────────────────────┘
+ * The src of <audio> is /audio/stream/:filename?t=HMAC&exp=TIMESTAMP.
+ * HMAC = SHA256(filename|exp|AUDIO_SECRET).
+ *
+ * Defeats hotlinking, scrapers, casual URL sharing — not state actors.
+ * Token TTL: 10 minutes (long enough for a track, short enough that a
+ * shared link expires before anyone can use it).
+ *
+ * AUDIO_SECRET comes from env. If missing on first boot, generate one
+ * and persist to storage/.audio-secret so it survives restarts.
  */
 
-/**
- * Build the public stream URL for an audio filename.
- * Returns null for a falsy filename so callers can guard playability.
- */
-export function audioUrl(filename) {
-  if (!filename) return null;
-  return `/audio/stream/${encodeURIComponent(filename)}`;
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
+
+export const TOKEN_TTL_SECONDS = 600;
+
+let cachedSecret = null;
+
+function loadOrGenerateSecret() {
+  if (cachedSecret) return cachedSecret;
+
+  // 1. Env wins
+  if (process.env.AUDIO_SECRET && process.env.AUDIO_SECRET.length >= 32) {
+    cachedSecret = process.env.AUDIO_SECRET;
+    return cachedSecret;
+  }
+
+  // 2. Persisted file
+  try {
+    const fromDisk = fs.readFileSync(SECRET_FILE, 'utf-8').trim();
+    if (fromDisk.length >= 32) {
+      cachedSecret = fromDisk;
+      return cachedSecret;
+    }
+  } catch (e) { /* file missing — generate */ }
+
+  // 3. Generate + persist
+  const generated = crypto.randomBytes(32).toString('hex');
+  try {
+    fs.mkdirSync(path.dirname(SECRET_FILE), { recursive: true });
+    fs.writeFileSync(SECRET_FILE, generated, { mode: 0o600 });
+    console.log('AudioStreamService: generated new audio secret at', SECRET_FILE);
+  } catch (e) {
+    console.error('AudioStreamService: could not persist audio secret:', e.message);
+  }
+  cachedSecret = generated;
+  return cachedSecret;
 }
 
-export default { audioUrl };
+function makeHmac(filename, exp) {
+  const secret = loadOrGenerateSecret();
+  return crypto
+    .createHmac('sha256', secret)
+    .update(`${filename}|${exp}`)
+    .digest('hex');
+}
+
+/**
+ * Sign a filename → returns { url, exp, t } so callers can build the URL.
+ * The full URL is /audio/stream/<filename>?t=<t>&exp=<exp>.
+ */
+export function signUrl(filename, ttlSeconds = TOKEN_TTL_SECONDS) {
+  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
+  const t = makeHmac(filename, exp);
+  const safe = encodeURIComponent(filename);
+  return {
+    url: `/audio/stream/${safe}?t=${t}&exp=${exp}`,
+    exp,
+    t,
+  };
+}
+
+/**
+ * Verify a token for a filename. Returns true iff exp is in the future
+ * AND the HMAC matches.
+ */
+export function verifyToken(filename, t, exp) {
+  if (!filename || !t || !exp) return false;
+  const expNum = Number(exp);
+  if (!Number.isFinite(expNum)) return false;
+  if (expNum < Math.floor(Date.now() / 1000)) return false;
+
+  const expected = makeHmac(filename, expNum);
+  // timingSafeEqual requires equal-length buffers
+  try {
+    const a = Buffer.from(t, 'hex');
+    const b = Buffer.from(expected, 'hex');
+    if (a.length !== b.length) return false;
+    return crypto.timingSafeEqual(a, b);
+  } catch (e) {
+    return false;
+  }
+}
+
+/**
+ * Force-rotate the secret. Invalidates all outstanding tokens.
+ */
+export function rotateSecret() {
+  cachedSecret = null;
+  try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
+  return loadOrGenerateSecret();
+}
+
+export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
Index: src/services/AudioTranscoder.js
===================================================================
--- src/services/AudioTranscoder.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/AudioTranscoder.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -90,8 +90,6 @@
   const tmpPath = path.join(outputDir, `${outputBaseName}.transcoding-${process.pid}.mp3`);
 
-  let durationSec = null;
   try {
-    const r = await runFfmpeg({ inputPath, tmpPath, tags });
-    durationSec = r && r.durationSec != null ? r.durationSec : null;
+    await runFfmpeg({ inputPath, tmpPath, tags });
 
     // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
@@ -126,5 +124,4 @@
       size: outStat.size,
       mimeType: 'audio/mpeg',
-      durationSec,   // whole seconds from ffmpeg's codecData (null if unknown)
     };
 
@@ -138,43 +135,4 @@
 
 /**
- * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
- * Used when editing track metadata (title/artist/album/credit/license) so that
- * ownership info travels with the file on download.
- * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
- */
-export async function retagMp3({ filePath, tags = {} }) {
-  if (!filePath) throw new Error('retagMp3: filePath required');
-  await stat(filePath); // throws if file is missing
-  const dir = path.dirname(filePath);
-  const base = path.basename(filePath, path.extname(filePath));
-  const tmpPath = path.join(dir, `${base}.retag-${process.pid}.mp3`);
-  try {
-    await new Promise((resolve, reject) => {
-      const cmd = ffmpeg(filePath)
-        .audioCodec('copy')        // no re-encode → fast, no quality loss
-        .format('mp3')
-        .outputOptions('-id3v2_version', '3')
-        .outputOptions('-map_metadata', '-1')
-        .outputOptions('-vn');
-      if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
-      if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
-      if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
-      if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`);
-      if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);
-      cmd.on('error', (err, so, se) => reject(new Error(((err && err.message) || 'ffmpeg') + (se ? ' | ' + se : ''))))
-         .on('end', () => resolve())
-         .save(tmpPath);
-    });
-    const s = await stat(tmpPath);
-    if (s.size === 0) throw new Error('retag output is empty');
-    await rename(tmpPath, filePath);
-    return { filePath, size: s.size };
-  } catch (err) {
-    try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
-    throw err;
-  }
-}
-
-/**
  * Run a single ffmpeg pass: input -> tmp output.
  * Returns a promise that resolves when ffmpeg exits cleanly, rejects otherwise.
@@ -182,5 +140,4 @@
 function runFfmpeg({ inputPath, tmpPath, tags }) {
   return new Promise((resolve, reject) => {
-    let durationSec = null;
     const cmd = ffmpeg(inputPath)
       .audioCodec('libmp3lame')
@@ -206,14 +163,9 @@
     // breaks any value containing a space (e.g. "Test Artist" gets parsed
     // as a separate output filename).
-    if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
-    if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
-    if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
-    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
-    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — license
+    if (tags.title)  cmd.outputOptions('-metadata', `title=${tags.title}`);
+    if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
+    if (tags.album)  cmd.outputOptions('-metadata', `album=${tags.album}`);
 
     cmd
-      // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
-      // determine the track length automatically without a separate ffprobe binary.
-      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
       .on('error', (err, stdout, stderr) => {
         // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
@@ -224,44 +176,8 @@
         reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
       })
-      .on('end', () => resolve({ durationSec }))
+      .on('end', () => resolve())
       .save(tmpPath);
   });
 }
 
-/**
- * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
- * for "N/A" or an unexpected format.
- */
-function parseHmsToSeconds(hms) {
-  if (!hms || typeof hms !== 'string') return null;
-  const m = hms.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
-  if (!m) return null;
-  const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (m[4] ? Number('0.' + m[4]) : 0);
-  return Number.isFinite(sec) ? Math.round(sec) : null;
-}
-
-/**
- * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
- * Starts an ffmpeg pass and reads only the codecData event (duration), then
- * kills the process immediately — fast and without a separate ffprobe binary
- * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
- * @returns {Promise<number|null>}
- */
-export function probeDuration(filePath) {
-  return new Promise((resolve) => {
-    let durationSec = null, done = false;
-    const finish = () => { if (!done) { done = true; resolve(durationSec); } };
-    const cmd = ffmpeg(filePath)
-      .on('codecData', (data) => {
-        durationSec = parseHmsToSeconds(data && data.duration);
-        try { cmd.kill('SIGKILL'); } catch { /* already done */ }
-        finish();
-      })
-      .on('error', finish)
-      .on('end', finish)
-      .format('null')
-      .save(process.platform === 'win32' ? 'NUL' : '/dev/null');
-  });
-}
-
-export default { transcodeToMp3, probeDuration };
+export default { transcodeToMp3 };
Index: src/services/BlocklistService.js
===================================================================
--- src/services/BlocklistService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * The instance blocklist (ap_blocks): actors and whole domains a site has
- * blocked. Lives NEXT TO the guardianship module, not inside it, because it
- * is shared: Klonkt's own Block tab uses it, and Shaer's "in Orbit" reads it
- * as the source of truth (AP §5.6 blocked collection, owner-only).
- *
- * Extracted from ActivityPubService (guardianship refactor); behavior is
- * unchanged. ActivityPubService re-exports these under the old names so
- * existing callers keep working.
- */
-import db from '../config/database.js';
-
-let _insBl, _delBl, _listBl;
-function blStmts() {
-  if (!_insBl) {
-    _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
-    _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
-    _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
-  }
-  return { ins: _insBl, del: _delBl, list: _listBl };
-}
-
-export function listBlocks(slug) { return blStmts().list.all(slug); }
-
-// True if an actor (or its whole domain) is blocked anywhere on this instance.
-export function isBlockedAny(actorUri) {
-  if (!actorUri) return false;
-  let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
-  try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
-  catch { return false; }
-}
-
-function purgeBlocked(kind, target) {
-  try {
-    if (kind === 'domain') {
-      // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
-      // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
-      const purge = (table, col) => {
-        let rows = [];
-        try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
-        const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
-        for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
-      };
-      purge('ap_interactions', 'actor_uri');
-      purge('ap_timeline', 'author_uri');
-      purge('ap_followers', 'actor_uri');
-    } else {
-      db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
-      db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
-    }
-  } catch { /* best-effort */ }
-}
-
-// Block an actor (@handle or actor URL) or a whole domain; purges their content.
-// `resolveHandle` (async handle → actor URL) is injected by the caller so this
-// service needs nothing from ActivityPubService (no circular import).
-/**
- * Een blokkade is pas een blokkade als de ander het merkt (Robin, 21-8).
- *
- * Tot vandaag bleef hij binnenshuis: rij in ap_blocks, inhoud opruimen, volger
- * eruit -- en verder niets. De andere kant volgde je dan nog steeds in zijn
- * eigen boeken en bleef je publieke outbox lezen. Precies wat de hub deed:
- * kanaal en berichten stonden er gewoon nog. Vandaar dat we het nu ook
- * VERSTUREN, zoals Mastodon dat doet: een Block naar de inbox van wie je
- * blokkeert, en bij opheffen een Undo(Block) zodat de weg terug openligt.
- *
- * Alleen voor een actor-blokkade: een heel domein heeft geen inbox om aan te
- * schrijven. En bezorgen mag nooit de blokkade zelf tegenhouden -- die staat
- * al vast in de database voordat we ook maar iets proberen te versturen.
- */
-async function meldBlokkade(site, target, kind, bezorg, undo = false) {
-  if (kind !== 'actor' || typeof bezorg !== 'function') return;
-  try { await bezorg(site, target, undo); } catch { /* de blokkade staat, de melding is een gunst */ }
-}
-
-export async function blockTarget(site, input, resolveHandle, bezorg) {
-  const raw = String(input || '').trim();
-  if (!site || !site.slug || !raw) return { error: 'empty' };
-  let kind, target, label;
-  if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
-  else if (raw.includes('@')) {
-    const actorUrl = resolveHandle ? await resolveHandle(raw) : null;
-    if (!actorUrl) return { error: 'not_found' };
-    kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
-  } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
-  blStmts().ins.run(site.slug, target, kind, label);
-  purgeBlocked(kind, target);
-  console.log('[AP] block', site.slug, kind, target);
-  await meldBlokkade(site, target, kind, bezorg);
-  return { ok: true, label };
-}
-
-export async function unblock(site, target, bezorg) {
-  const rij = blStmts().list.all(site.slug).find((b) => b.target === target);
-  blStmts().del.run(site.slug, target);
-  // Undo(Block) zodat de ander weet dat de deur weer open is; zonder dit blijft
-  // hij bij zichzelf geblokkeerd staan en komt hij nooit terug.
-  await meldBlokkade(site, target, (rij && rij.kind) || 'actor', bezorg, true);
-  return { ok: true };
-}
-
-export default { listBlocks, isBlockedAny, blockTarget, unblock };
Index: src/services/CryptoBox.js
===================================================================
--- src/services/CryptoBox.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,102 +1,0 @@
-// Symmetric encryption for secrets at rest (paid posts: the site owner's
-// Patreon creator token, klonkt-demo-aki slice 1). AES-256-GCM with a key
-// derived from a secret, so a database dump alone leaks nothing usable.
-// Format: base64(iv) : base64(tag) : base64(ciphertext).
-//
-// The secret is resolved in this order so nobody has to edit the env:
-//   1. PAID_SECRET (env) — authoritative; a self-hoster who set it by hand
-//      (or Bart, who already did) keeps working unchanged.
-//   2. a persisted key file next to the database, auto-generated on first use
-//      (0600). This is what "first run" and "existing users after an update"
-//      get automatically.
-// The key lives OUTSIDE the sqlite DB on purpose: encrypting the Patreon
-// secrets is pointless if the key sits in the same file a DB dump would leak.
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// The key file sits in the same directory as the database.
-function keyFilePath() {
-  const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
-  const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
-  return path.join(dir, '.paid-secret');
-}
-
-// Read the persisted key, generating + writing it (0600) the first time.
-function fileSecret() {
-  const file = keyFilePath();
-  try {
-    const existing = fs.readFileSync(file, 'utf8').trim();
-    if (existing.length >= 16) return existing;
-  } catch { /* not created yet */ }
-  const generated = crypto.randomBytes(32).toString('base64');
-  fs.mkdirSync(path.dirname(file), { recursive: true });
-  fs.writeFileSync(file, generated, { mode: 0o600 });
-  try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
-  return generated;
-}
-
-// env wins; otherwise the auto-generated file. Never returns an ephemeral key:
-// if the file can't be persisted, fileSecret throws and the feature stays gated
-// (cryptoBoxReady false) rather than encrypting with a key lost on restart.
-function resolveSecret() {
-  const env = process.env.PAID_SECRET;
-  if (env && String(env).length >= 16) return String(env);
-  return fileSecret();
-}
-
-let _key = null;
-function key() {
-  if (_key) return _key;
-  _key = crypto.scryptSync(resolveSecret(), 'klonkt-paid', 32);
-  return _key;
-}
-
-// True when a key is configured, so callers can gate the feature instead of throwing.
-export function cryptoBoxReady() {
-  try { key(); return true; } catch { return false; }
-}
-
-export function encrypt(plaintext) {
-  if (plaintext == null) return null;
-  const iv = crypto.randomBytes(12);
-  const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
-  const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
-  const tag = cipher.getAuthTag();
-  return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
-}
-
-export function decrypt(blob) {
-  if (blob == null || blob === '') return null;
-  const parts = String(blob).split(':');
-  if (parts.length !== 3) throw new Error('malformed ciphertext');
-  const [iv, tag, ct] = parts.map((p) => Buffer.from(p, 'base64'));
-  const decipher = crypto.createDecipheriv('aes-256-gcm', key(), iv);
-  decipher.setAuthTag(tag);
-  return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
-}
-
-// The stateless signed blob reused for the OAuth state and the WebAuthn
-// challenge (design doc "cookie-less trick"): HMAC over a short-lived payload,
-// so no server session is needed to bind pending state to a browser.
-export function signBlob(payload, ttlSeconds = 600) {
-  const body = { ...payload, exp: Math.floor(Date.now() / 1000) + ttlSeconds, nonce: crypto.randomBytes(8).toString('hex') };
-  const b = Buffer.from(JSON.stringify(body)).toString('base64url');
-  const tag = crypto.createHmac('sha256', key()).update(b).digest('base64url');
-  return `${b}.${tag}`;
-}
-
-// Returns the payload if valid and unexpired, else null. Constant-time tag check.
-export function verifyBlob(token) {
-  const [b, tag] = String(token || '').split('.');
-  if (!b || !tag) return null;
-  const expected = crypto.createHmac('sha256', key()).update(b).digest('base64url');
-  const a = Buffer.from(tag); const e = Buffer.from(expected);
-  if (a.length !== e.length || !crypto.timingSafeEqual(a, e)) return null;
-  let payload; try { payload = JSON.parse(Buffer.from(b, 'base64url').toString('utf8')); } catch { return null; }
-  if (!payload || (payload.exp && payload.exp * 1000 < Date.now())) return null;
-  return payload;
-}
Index: src/services/EmbedResolver.js
===================================================================
--- src/services/EmbedResolver.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,346 +1,0 @@
-// One pipeline for everything you can drop a URL of into a post, and one visual
-// result. What differs is not what a reader sees but what FEDERATES.
-//
-// Resolution order (Robins besluit, shaer-277):
-//   1. ActivityPub object  → the FEP path. A quote of a fediverse object carries
-//      real semantics: FEP-044f `quote` + an FEP-e232 Link tag, the quoted
-//      author gets addressed, and the permission model applies. Never resolved
-//      over oEmbed, because oEmbed has none of that.
-//   2. oEmbed, else OpenGraph → one page fetch, and no list of providers to
-//      maintain. Whether an embed may be shown at all is a guardian decision
-//      (the gate), not a question of which host it came from.
-//   3. Otherwise           → a plain link.
-//
-// Everything returns the SAME normalised shape, so one renderer draws them all
-// (the quote card). Pure except for the two injected fetchers, so the ordering
-// logic is unit-testable without a network.
-
-const OEMBED_LINK = /<link\b[^>]*>/gi;
-
-/** Pull the oEmbed endpoint out of a page's <link rel="alternate"> tags. */
-export function findOEmbedEndpoint(html) {
-  if (!html || typeof html !== 'string') return null;
-  for (const tag of html.match(OEMBED_LINK) || []) {
-    const type = (tag.match(/\btype\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
-    if (!/application\/(json|xml)\+oembed/i.test(type)) continue;
-    const rel = (tag.match(/\brel\s*=\s*["']([^"']+)["']/i) || [])[1] || '';
-    if (rel && !/alternate/i.test(rel)) continue;
-    const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1];
-    // JSON only: we do not parse the XML flavour.
-    if (href && /json/i.test(type)) return decodeEntities(href);
-  }
-  return null;
-}
-
-function decodeEntities(s) {
-  return String(s).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
-}
-
-/**
- * OpenGraph, the one that actually carries link previews on the open web.
- * oEmbed is the richer protocol but most sites simply do not implement it;
- * og:image / og:title is what Mastodon and everyone else reads, so it is the
- * fallback that makes thumbnails appear at all. Same page fetch as the oEmbed
- * discovery, so it costs nothing extra.
- */
-export function findOpenGraph(html) {
-  if (!html || typeof html !== 'string') return null;
-  const meta = {};
-  for (const tag of html.match(/<meta\b[^>]*>/gi) || []) {
-    const key = (tag.match(/\b(?:property|name)\s*=\s*["']([^"']+)["']/i) || [])[1];
-    if (!key) continue;
-    const k = key.toLowerCase();
-    if (!/^(og:image|og:title|og:site_name|og:description|twitter:image|twitter:title)$/.test(k)) continue;
-    const val = (tag.match(/\bcontent\s*=\s*["']([^"']*)["']/i) || [])[1];
-    if (val && !meta[k]) meta[k] = decodeEntities(val);
-  }
-  const image = meta['og:image'] || meta['twitter:image'];
-  const title = meta['og:title'] || meta['twitter:title'];
-  if (!image && !title) return null;
-  return { image: image && /^https?:\/\//i.test(image) ? image : null, title: title || null, site: meta['og:site_name'] || null };
-}
-
-/**
- * Match a URL against the public oEmbed provider registry (oembed.com).
- *
- * Discovery through the page is the pure way, but it only works when the page
- * hands you its <link rel=oembed>, and big platforms do not always do that: from
- * a datacentre IP, YouTube serves a stripped page with no oEmbed link and no
- * OpenGraph at all, while its oEmbed API answers perfectly. The registry closes
- * that gap without us keeping a list of hosts: it is published and maintained by
- * oembed.com, we only read it.
- *
- * Pure, so the pattern matching is testable without a network.
- */
-export function matchProviderEndpoint(url, providers) {
-  if (!Array.isArray(providers)) return null;
-  let host = '';
-  try { host = new URL(url).host.replace(/^www\./, ''); } catch { return null; }
-  const toRe = (scheme) => new RegExp('^' + String(scheme)
-    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
-    .replace(/\*/g, '.*') + '$', 'i');
-  for (const p of providers) {
-    for (const ep of (p.endpoints || [])) {
-      const target = typeof ep.url === 'string' ? ep.url.replace('{format}', 'json') : null;
-      if (!target) continue;
-      for (const scheme of (ep.schemes || [])) {
-        if (toRe(scheme).test(url)) return target;
-      }
-      // No schemes listed: fall back to the provider's own host.
-      if (!ep.schemes || !ep.schemes.length) {
-        let phost = '';
-        try { phost = new URL(p.provider_url).host.replace(/^www\./, ''); } catch { /* skip */ }
-        if (phost && (host === phost || host.endsWith('.' + phost))) return target;
-      }
-    }
-  }
-  return null;
-}
-
-/** Add the url + json format to an oEmbed endpoint. */
-export function oembedRequestUrl(endpoint, url) {
-  const sep = endpoint.includes('?') ? '&' : '?';
-  return `${endpoint}${sep}format=json&url=${encodeURIComponent(url)}`;
-}
-
-/** Is this JSON an ActivityPub object we can quote? */
-export function looksLikeAPObject(doc) {
-  if (!doc || typeof doc !== 'object') return false;
-  const t = Array.isArray(doc.type) ? doc.type[0] : doc.type;
-  if (typeof t !== 'string') return false;
-  // Quotable content, not an actor and not an activity.
-  return ['Note', 'Article', 'Page', 'Video', 'Audio', 'Image', 'Question', 'Event'].includes(t)
-    && typeof doc.id === 'string';
-}
-
-/** An oEmbed payload → the shared card shape. */
-export function fromOEmbed(url, o) {
-  if (!o || typeof o !== 'object') return null;
-  const media = [];
-  if (o.thumbnail_url) media.push({ url: String(o.thumbnail_url), type: 'image/*' });
-  return {
-    kind: 'oembed',
-    url: typeof o.url === 'string' && /^https?:/i.test(o.url) ? o.url : url,
-    title: o.title ? String(o.title) : null,
-    author: (o.author_name || o.provider_name) ? {
-      name: o.author_name ? String(o.author_name) : String(o.provider_name),
-      handle: o.provider_name ? String(o.provider_name) : null,
-      icon: null,
-    } : null,
-    // `html` is the provider's own iframe. Kept separate from the card body so
-    // a caller can decide to frame it or to fall back to the thumbnail; it is
-    // never merged into sanitized note content.
-    html: typeof o.html === 'string' ? o.html : null,
-    provider: o.provider_name ? String(o.provider_name) : null,
-    media,
-  };
-}
-
-/** An AP object → the same shape a resolved quote already uses. */
-export function fromAPObject(url, doc, author) {
-  const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
-    : (doc.attributedTo && typeof doc.attributedTo.id === 'string' ? doc.attributedTo.id : null);
-  return {
-    kind: 'ap',
-    url: (typeof doc.url === 'string' && doc.url) || doc.id || url,
-    id: doc.id,
-    attributedTo: attributed,
-    title: doc.name ? String(doc.name) : null,
-    content: typeof doc.content === 'string' ? doc.content : '',
-    published: doc.published || null,
-    author: author || null,
-    media: [],
-  };
-}
-
-/**
- * Resolve one URL to the shared card shape.
- *
- * @param {string} url
- * @param {object} io
- *   - getAP(url)      → the AP JSON (Accept: application/activity+json) or null
- *   - getPage(url)    → the HTML body or null
- *   - getJSON(url)    → arbitrary JSON (the oEmbed endpoint) or null
- *   - actorOf(uri)    → { name, handle, icon } for the AP author, or null
- *   - provider(url)   → the known-provider hit (AudioEmbedService.detectProvider)
- */
-export async function resolveEmbed(url, io = {}) {
-  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
-
-  // 1. ActivityPub first: it is the only path that carries quote semantics.
-  if (io.getAP) {
-    const doc = await io.getAP(url).catch(() => null);
-    if (looksLikeAPObject(doc)) {
-      const attributed = typeof doc.attributedTo === 'string' ? doc.attributedTo
-        : (doc.attributedTo && doc.attributedTo.id);
-      const author = (attributed && io.actorOf) ? await io.actorOf(attributed).catch(() => null) : null;
-      return fromAPObject(url, doc, author);
-    }
-  }
-
-  // 2. The oEmbed registry: a cheap in-memory match, then one small API call.
-  //    Tried before the page because it is far cheaper AND because the big
-  //    platforms are exactly the ones that hide their tags from a server.
-  if (io.registry && io.getJSON) {
-    const providers = await io.registry().catch(() => null);
-    const endpoint = matchProviderEndpoint(url, providers);
-    if (endpoint) {
-      const o = await io.getJSON(oembedRequestUrl(endpoint, url)).catch(() => null);
-      const card = fromOEmbed(url, o);
-      if (card) return card;
-    }
-  }
-
-  // 3. oEmbed via the page, then OpenGraph. One page fetch serves both: oEmbed
-  //    is the richer protocol, OpenGraph is the one most of the web ships.
-  //
-  //    There is deliberately NO list of known providers here. A hardcoded list
-  //    is a whitelist you have to keep maintaining, and it was actively harmful:
-  //    a YouTube link matched the list, short-circuited before oEmbed, and came
-  //    out as a card with no title and no thumbnail, so nothing was stored at
-  //    all. YouTube serves both oEmbed and og:image like everyone else, so the
-  //    generic path handles it better than the special case did.
-  if (io.getPage) {
-    const page = await io.getPage(url).catch(() => null);
-    if (page) {
-      const endpoint = findOEmbedEndpoint(page);
-      if (endpoint && io.getJSON) {
-        const o = await io.getJSON(endpoint).catch(() => null);
-        const card = fromOEmbed(url, o);
-        if (card) return card;
-      }
-      const og = findOpenGraph(page);
-      if (og) {
-        return {
-          kind: 'opengraph',
-          url,
-          title: og.title,
-          author: og.site ? { name: og.site, handle: null, icon: null } : null,
-          provider: og.site,
-          html: null,
-          media: og.image ? [{ url: og.image, type: 'image/*' }] : [],
-        };
-      }
-    }
-  }
-
-  // 4. Nothing recognised it: a link stays a link.
-  return { kind: 'link', url, media: [] };
-}
-
-// ── The wired-up variant ──────────────────────────────────────────
-// The io above is injected so the ordering is testable without a network.
-// This binds it to the real, SSRF-safe fetchers. Every fetch is capped and
-// goes through safeFetch (which refuses private ranges and caps redirects),
-// so a hostile URL in a post cannot make the server probe an internal host.
-
-const MAX_JSON = 512_000;   // an oEmbed/AP payload must parse whole, so cap and refuse
-// Of a web page we only ever need the <head>. The cap has to clear the worst
-// real case rather than the tidy one: YouTube ships ~665kB of inline script
-// before its og:image and closes <head> at ~673kB, and at 512kB we cut the page
-// off just short of the tags and produced nothing. We stop as soon as the tags
-// are in hand, so a normal page still costs a few dozen kB.
-const MAX_HEAD = 1_048_576;
-const UA = 'Mozilla/5.0 (compatible; Klonkt/1.0; +https://klonkt.com)';
-
-/** A whole small document, refused when it is too big to be one. */
-async function safeJsonText(safeFetch, url, accept) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: accept } });
-    if (!r.ok) return null;
-    if (Number(r.headers.get('content-length') || 0) > MAX_JSON) return null;
-    const body = await r.text();
-    return body.length > MAX_JSON ? null : body;   // truncated JSON is useless
-  } catch { return null; }
-}
-
-/**
- * The START of a web page, streamed and cut off at MAX_HEAD.
- *
- * Refusing a page for being large was wrong: YouTube's watch page is megabytes,
- * so it was rejected outright and never produced a thumbnail, even though its
- * og:image sits in the first few kilobytes like everyone else's. We only ever
- * read the <head>, so read that much and stop pulling. The cap still protects
- * us from someone streaming us an endless body.
- */
-async function safeHead(safeFetch, url, extra = {}) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'text/html,application/xhtml+xml', ...extra } });
-    if (!r.ok) return null;
-    if (!r.body || typeof r.body.getReader !== 'function') {
-      const body = await r.text();                       // no stream (or a test double)
-      return body.length > MAX_HEAD ? body.slice(0, MAX_HEAD) : body;
-    }
-    const reader = r.body.getReader();
-    const dec = new TextDecoder('utf-8');
-    const parts = [];
-    let len = 0;
-    let tail = '';        // carry a little context so a tag split across chunks still matches
-    let done_ = false;
-    while (!done_) {
-      const { done, value } = await reader.read();
-      if (done) break;
-      const chunk = dec.decode(value, { stream: true });
-      parts.push(chunk);
-      len += chunk.length;
-      // Scan only the new chunk (plus overlap), not the whole buffer: testing
-      // the full string every read turns a 1MB page into quadratic work.
-      const window = tail + chunk;
-      // Stop on the real <meta property="og:image">, not on the bare string.
-      // Big sites carry "og:image" inside inline JSON long before the actual
-      // tag, and stopping there cut the page off just short of the meta block:
-      // the same near-miss as the old size cap, with a different cause.
-      if (len >= MAX_HEAD || /<\/head>/i.test(window) || /<meta[^>]{0,300}og:image/i.test(window)) done_ = true;
-      tail = chunk.slice(-512);
-    }
-    try { await reader.cancel(); } catch { /* already closed */ }
-    return parts.join('').slice(0, MAX_HEAD);
-  } catch { return null; }
-}
-
-/**
- * Bind the resolver to the live fetchers.
- * @param {object} deps - { safeFetch, detectProvider, actorInfo, fetchActor }
- */
-// The provider registry, fetched once and kept for a day. It is a public list
-// maintained by oembed.com, not by us; if it is unreachable we simply fall back
-// to page discovery, so nothing breaks, it just gets less clever.
-const REGISTRY_URL = 'https://oembed.com/providers.json';
-const REGISTRY_TTL = 24 * 60 * 60 * 1000;
-let _registry = null;
-let _registryAt = 0;
-
-export function liveIO({ safeFetch, detectProvider, fetchActor, actorInfo }) {
-  return {
-    registry: async () => {
-      if (_registry && Date.now() - _registryAt < REGISTRY_TTL) return _registry;
-      const body = await safeJsonText(safeFetch, REGISTRY_URL, 'application/json');
-      if (!body) return _registry;                       // keep a stale list over none
-      try { _registry = JSON.parse(body); _registryAt = Date.now(); } catch { /* keep the old one */ }
-      return _registry;
-    },
-    provider: detectProvider ? (u) => { try { return detectProvider(u); } catch { return null; } } : null,
-    getAP: async (u) => {
-      const body = await safeJsonText(safeFetch, u, 'application/activity+json, application/ld+json');
-      if (!body) return null;
-      try { return JSON.parse(body); } catch { return null; }   // an HTML page is simply not AP
-    },
-    // Plenty of sites only hand out their OpenGraph tags to something that
-    // looks like a browser, so the page fetch identifies itself.
-    getPage: (u) => safeHead(safeFetch, u, { 'User-Agent': UA }),
-    getJSON: async (u) => {
-      const body = await safeJsonText(safeFetch, u, 'application/json');
-      if (!body) return null;
-      try { return JSON.parse(body); } catch { return null; }
-    },
-    actorOf: async (uri) => {
-      if (!fetchActor || !actorInfo) return null;
-      const doc = await fetchActor(uri).catch(() => null);
-      if (!doc) return null;
-      const ai = actorInfo(doc, uri);
-      return { name: ai.name, handle: ai.handle, icon: ai.icon, emojis: ai.emojis };
-    },
-  };
-}
-
-export default { resolveEmbed, findOEmbedEndpoint, findOpenGraph, matchProviderEndpoint, oembedRequestUrl, looksLikeAPObject, fromOEmbed, fromAPObject, liveIO };
Index: src/services/HtmlSanitizerService.js
===================================================================
--- src/services/HtmlSanitizerService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/HtmlSanitizerService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -25,25 +25,17 @@
   'strong', 'em', 'b', 'i', 'u', 's', 'mark', 'small', 'sub', 'sup',
   'code', 'a', 'span', 'img',
-  // Native media (bare .webm/.mp4/.mp3 embeds + federated-in players)
-  'video', 'audio', 'source',
 ];
 
 // Per-tag attribute allowlist. '*' applies to every tag.
 const ALLOWED_ATTRS = {
-  '*':   ['class', 'id', 'dir', 'lang', 'data-sc'],
-  a:     ['href', 'title', 'target', 'rel'],
-  img:   ['src', 'alt', 'title', 'width', 'height', 'loading'],
-  video: ['src', 'controls', 'preload', 'poster', 'width', 'height', 'loop', 'muted', 'autoplay', 'playsinline'],
-  audio: ['src', 'controls', 'preload', 'loop', 'muted', 'autoplay'],
-  source: ['src', 'type'],
+  '*': ['class', 'id', 'dir', 'lang', 'data-sc'],
+  a:   ['href', 'title', 'target', 'rel'],
+  img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
 };
 
 const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel'];
 const ALLOWED_SCHEMES_BY_TAG = {
-  img:    ['http', 'https', 'data'],
-  a:      ['http', 'https', 'mailto', 'tel'],
-  video:  ['http', 'https'],
-  audio:  ['http', 'https'],
-  source: ['http', 'https'],
+  img: ['http', 'https', 'data'],
+  a:   ['http', 'https', 'mailto', 'tel'],
 };
 
Index: src/services/ImageWebpService.js
===================================================================
--- src/services/ImageWebpService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,40 +1,0 @@
-/**
- * Convert a freshly uploaded image to WebP (smaller, modern format).
- *
- * Uses the system `cwebp` (libwebp). Present → convert + delete the original,
- * return the new .webp filename. Not present or error → return the original
- * filename (graceful fallback, nothing breaks).
- *
- * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP
- * files are skipped.
- */
-import { execFileSync } from 'child_process';
-import fs from 'fs';
-import path from 'path';
-
-const QUALITY = '82';
-
-/**
- * @param {{path:string, filename:string, destination?:string}} file  multer file
- * @returns {string} the final filename (basename) — .webp or the original
- */
-export function toWebp(file) {
-  if (!file || !file.path || !file.filename) return file && file.filename;
-  const ext = path.extname(file.filename).toLowerCase();
-  if (ext === '.webp' || ext === '.gif') return file.filename;
-  const dir = file.destination || path.dirname(file.path);
-  const outName = path.basename(file.filename, ext) + '.webp';
-  const outPath = path.join(dir, outName);
-  try {
-    execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' });
-    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('empty output');
-    try { fs.unlinkSync(file.path); } catch { /* original gone, not critical */ }
-    return outName;
-  } catch (e) {
-    console.warn('[webp] conversion skipped (cwebp not available/error):', e.message);
-    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // clean up partial output
-    return file.filename; // keep original
-  }
-}
-
-export default { toWebp };
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,959 +1,0 @@
-/**
- * MigrationService.js — FEP-1580: je OBJECTEN verhuizen bij een Move.
- *
- * FEP-7628 verhuist je volgers en zegt zelf dat de rest een ander probleem is.
- * Dit is dat andere probleem: na een Move stonden je berichten nog op de oude
- * instantie, en elke reactie van een derde wees naar een URI die verdwijnt zodra
- * dat domein opgezegd wordt.
- *
- * DRIE DINGEN OM TE WETEN VOOR JE HIERIN LEEST:
- *
- * 1. DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant staat in
- *    ActivityPubService.isMoveTarget: een ondertekend verzoek namens de actor
- *    waar de bron naartoe verhuisde telt als de bron zelf. Dat mag omdat
- *    moveAccount() `no_backreference` weigert, dus `moved_to` staat er alleen
- *    als iemand met beheer op BEIDE kanten dat wilde. Hier in dit bestand zit
- *    de DOELkant, die van die toestemming gebruikmaakt.
- *
- * 2. NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een gemigreerd
- *    bericht krijgt hier een eigen URI, want het staat nu op een ander domein.
- *    De `migration`-collectie mapt oud naar nieuw en derden lezen die om hun
- *    eigen verwijzingen bij te werken. Zonder die collectie is de draad kapot,
- *    met die collectie is het een verhuisbericht.
- *
- * 3. ER GAAT GEEN Create DE DEUR UIT. De spec is daar expliciet over, en het is
- *    ook gewoon logisch: je volgers hebben deze berichten jaren geleden al
- *    gezien. Een ingest van driehonderd posts die als driehonderd nieuwe posts
- *    de tijdlijn in klettert is geen verhuizing maar spam.
- *
- * WAT HIER ONTBREEKT: FEP-8b32 integrity proofs (shaer-j1v0). De `moves`-
- * collectie hoort ondertekend te zijn en de Moves erin horen een proof van de
- * bron-actor te dragen. Klonkt kent 8b32 nog niet. We bewaren wel alle
- * grondstof (de rauwe activity en het actordocument), zodat het later alleen
- * ondertekenen is. Bewust geen leeg proof-veld: een derde die het controleert
- * wordt dan misleid, en dat is erger dan een veld dat ontbreekt.
- */
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { AP_CONTEXT, actorId, pagedCollection, PAGINA_GROOTTE } from './ap-core.js';
-
-// ── Vertaaltabel ──────────────────────────────────────────────────
-
-const stmts = {};
-function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
-
-/** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
-export function recordMigrated(slug, { origin, target, sourceActor = '', isPublic = true } = {}) {
-  if (!slug || !origin || !target) return false;
-  try {
-    q('ins', `INSERT INTO ap_migration (slug, origin, target, source_actor, is_public)
-              VALUES (?, ?, ?, ?, ?)
-              ON CONFLICT(slug, origin) DO UPDATE SET target = excluded.target`)
-      .run(slug, String(origin), String(target), String(sourceActor || ''), isPublic ? 1 : 0);
-    return true;
-  } catch (e) {
-    console.warn('[FEP-1580] mapping niet opgeslagen:', origin, e && e.message);
-    return false;
-  }
-}
-
-/**
- * De items, nieuwste kopie eerst.
- *
- * `alles` is alleen waar voor een geverifieerde lezer uit het publiek van de
- * niet-publieke objecten. De spec: Moves voor objecten die niet aan as:Public
- * gericht zijn MOGEN NIET publiek getoond worden. Een migration-collectie die
- * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
- */
-export function migrationItems(slug, { alles = false, limit = null, offset = 0 } = {}) {
-  try {
-    // IN SQL pagineren, niet in geheugen. Dit is een PUBLIEK endpoint dat
-    // derden volgens FEP-1580 juist herhaaldelijk ophalen tot migrationComplete
-    // waar is. Alles laden om er twintig te tonen is dan geen inefficientie
-    // maar een hefboom: bij honderdduizend berichten bouwt elke poll
-    // honderdduizend objecten die meteen de prullenbak in gaan.
-    const sql = `SELECT origin, target, source_actor FROM ap_migration
-                 WHERE slug = ?${alles ? '' : ' AND is_public = 1'}
-                 ORDER BY id DESC${limit ? ' LIMIT ? OFFSET ?' : ''}`;
-    return limit ? db.prepare(sql).all(slug, limit, offset) : db.prepare(sql).all(slug);
-  } catch { return []; }
-}
-
-export function migrationCount(slug, { alles = false } = {}) {
-  try {
-    const sql = `SELECT COUNT(*) n FROM ap_migration WHERE slug = ?${alles ? '' : ' AND is_public = 1'}`;
-    return db.prepare(sql).get(slug).n;
-  } catch { return 0; }
-}
-
-/** Is deze URI hier al binnen? Houdt een tweede ingest-ronde goedkoop. */
-export function alGemigreerd(slug, origin) {
-  try { return !!db.prepare('SELECT 1 FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin)); } catch { return false; }
-}
-
-/**
- * Waar kwam deze bron-URI hier terecht? Null als hij nog niet gemigreerd is.
- *
- * Bestaat omdat "al gehad" en "overslaan" niet hetzelfde horen te zijn. Een
- * tweede ronde na een uitgebreide ingest (hoezen, duur, playlists erbij) moet
- * de bestaande nummers KUNNEN AANVULLEN in plaats van ze te passeren. Deed hij
- * dat niet, dan zat je vast: opnieuw ophalen sloeg alles over, en opruimen hielp
- * niet omdat deze tabel de blokkade in stand hield.
- */
-export function migrationTarget(slug, origin) {
-  try {
-    const r = db.prepare('SELECT target FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin));
-    return r ? r.target : null;
-  } catch { return null; }
-}
-
-// ── De Move-activities ────────────────────────────────────────────
-
-export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
-  if (!slug || !moveId || !sourceActor || !targetActor) return false;
-  try {
-    q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
-                  VALUES (?, ?, ?, ?, ?, ?)
-                  ON CONFLICT(slug, move_id) DO NOTHING`)
-      .run(slug, String(moveId), String(sourceActor), String(targetActor),
-        JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
-    return true;
-  } catch (e) {
-    console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
-    return false;
-  }
-}
-
-export function moveRows(slug) {
-  try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
-}
-
-// ── Stand van zaken ───────────────────────────────────────────────
-
-export function migrationComplete(slug) {
-  try {
-    const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
-    // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
-    // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
-    return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
-  } catch { return true; }
-}
-
-export function setMigrationComplete(slug, klaar) {
-  try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
-}
-
-// ── De collecties ─────────────────────────────────────────────────
-
-/**
- * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
- * actor): origin is de oude URI, target de nieuwe.
- *
- * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
- * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
- * Klonkt, dus die gebruiken we ook hier.
- */
-export function buildMigration(base, site, { page = false, alles = false } = {}) {
-  const me = actorId(base, site.slug);
-  const id = `${me}/migration`;
-  const totaal = migrationCount(site.slug, { alles });
-  // Zonder pagina: alleen de omslag met eerste/laatste en de telling. Zo hoeft
-  // de kale collectie geen enkele rij aan te raken, en dat is precies wat een
-  // consument als eerste opvraagt.
-  const nr = page ? Math.max(1, Math.floor(Number(page)) || 1) : false;
-  const rows = nr ? migrationItems(site.slug, { alles, limit: PAGINA_GROOTTE, offset: (nr - 1) * PAGINA_GROOTTE }) : [];
-  const items = rows.map((r) => ({
-    type: 'Move',
-    actor: r.source_actor || undefined,
-    origin: r.origin,
-    target: r.target,
-  }));
-  return pagedCollection(id, items, {
-    page: nr,
-    totalItems: totaal,
-    alGesneden: true,
-    extra: {
-      attributedTo: me,
-      moves: `${me}/moves`,
-      migrationComplete: migrationComplete(site.slug),
-    },
-  });
-}
-
-/**
- * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
- * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
- * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
- * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
- *
- * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
- */
-export function buildMoves(base, site) {
-  const me = actorId(base, site.slug);
-  const rows = moveRows(site.slug);
-  const orderedItems = rows.map((r) => {
-    let act = {};
-    try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
-    let actorDoc = null;
-    try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
-    return {
-      id: r.move_id,
-      type: 'Move',
-      origin: r.source_actor,
-      target: r.target_actor,
-      actor: actorDoc || r.source_actor,
-      ...(act.published ? { published: act.published } : {}),
-    };
-  });
-  return {
-    '@context': AP_CONTEXT,
-    id: `${me}/moves`,
-    type: 'OrderedCollection',
-    attributedTo: me,
-    totalItems: orderedItems.length,
-    orderedItems,
-  };
-}
-
-// ── Wat de UI wil weten ───────────────────────────────────────────
-
-export function migrationStatus(slug) {
-  return {
-    total: migrationCount(slug, { alles: true }),
-    publiek: migrationCount(slug),
-    moves: moveRows(slug).length,
-    complete: migrationComplete(slug),
-  };
-}
-
-/** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
-export function nieuwId() { return crypto.randomUUID(); }
-
-// ── De ingest: van de bron hierheen ───────────────────────────────
-
-/** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
-function vrijeSlug(siteId, basis) {
-  const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
-  const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
-  if (!bestaat.get(siteId, schoon)) return schoon;
-  for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
-  return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
-}
-
-/** Het kale id uit een track-URI: .../tracks/t-een -> t-een. */
-function ruwId(uri) {
-  try { return decodeURIComponent(String(uri).split('/').filter(Boolean).pop() || ''); } catch { return ''; }
-}
-
-/** De laatste padcomponent van een URI, als beginpunt voor een slug. */
-function slugUitUri(uri) {
-  try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
-}
-
-const AFBEELDING = /^image\//i;
-
-/**
- * Links naar de BRONPOSTS ombuigen naar hier.
- *
- * De gebakken content zit vol met https://oud/<slug>#track-<id> en
- * https://oud/<slug>?fc=2: de "luister op"-links die buildNote maakt. Die
- * blijven naar de oude site wijzen, en dat is een tijdbom, want zodra dat
- * domein opgezegd wordt zijn het dode links in je eigen berichten.
- *
- * Kan pas als ALLE posts binnen zijn, en alleen voor een slug die hier echt
- * bestaat. Een link naar iets dat we niet hebben laten we met rust: dan is een
- * verwijzing naar de oude site nog altijd beter dan een 404 op de nieuwe.
- *
- * De #track-<id>-fragmenten kloppen vanzelf, want sinds "altijd behouden" is
- * dat id hier hetzelfde.
- */
-export function postLinksBijtrekken(site, bronOrigin, rapport = {}) {
-  if (!bronOrigin) return 0;
-  let n = 0;
-  const rijen = db.prepare('SELECT id, content FROM posts WHERE site_id = ? AND content LIKE ?')
-    .all(site.id, `%${bronOrigin}/%`);
-  if (!rijen.length) return 0;
-  const heeftSlug = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
-  const upd = db.prepare('UPDATE posts SET content = ? WHERE id = ?');
-  const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([A-Za-z0-9._~-]+)`, 'g');
-  for (const r of rijen) {
-    let inhoud = String(r.content || '');
-    let raak = false;
-    for (const m of [...new Set([...inhoud.matchAll(patroon)].map((x) => x[1]))]) {
-      // media en audio lopen via hun eigen weg; hier gaat het om postpagina's
-      if (m === 'media' || m === 'audio' || m === 'ap') continue;
-      if (!heeftSlug.get(site.id, m)) continue;
-      inhoud = inhoud.split(`${bronOrigin}/${m}`).join(`/${m}`);
-      raak = true;
-    }
-    if (raak) { upd.run(inhoud, r.id); n++; }
-  }
-  if (n) {
-    rapport.linksBijgetrokken = n;
-    console.log('[FEP-1580] postlinks bijgetrokken in', n, 'bericht(en)');
-  }
-  return n;
-}
-
-/**
- * Waar kan de omslag van een bericht zitten?
- *
- * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
- * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
- * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
- * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
- * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
- * viel er stil tussenuit.
- *
- * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
- */
-function coverKandidaten(o) {
-  const uit = [];
-  const pak = (v) => {
-    if (!v) return;
-    const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
-    if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
-  };
-  pak(o.image);
-  pak(o.icon);
-  for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
-  return [...new Set(uit)];
-}
-
-/**
- * Hoort deze URL bij de bron, en wijst hij onder /media/?
- *
- * Dan behouden we het PAD. Drie redenen tegelijk:
- *   - de gebakken content verwijst relatief of absoluut naar dat pad, en met
- *     hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
- *   - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
- *     databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
- *     onzichtbaar: Robins lege images-tab;
- *   - de zip-import bewaart originele paden al, dus zo convergeren beide
- *     routes op dezelfde bestanden.
- *
- * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
- */
-function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
-  try {
-    const u = new URL(String(url));
-    if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
-    if (!u.pathname.startsWith('/media/')) return null;
-    const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
-    const abs = path.resolve(mediaRoot, rel);
-    const root = path.resolve(mediaRoot);
-    if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
-    return { rel: `/media/${rel}`, abs };
-  } catch { return null; }
-}
-
-/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
-function duurSeconden(v) {
-  if (v == null) return null;
-  if (typeof v === 'number') return Math.round(v) || null;
-  const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
-  if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
-  const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
-  return sec > 0 ? Math.round(sec) : null;
-}
-
-/**
- * De titel terugwinnen uit de content.
- *
- * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
- * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
- * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
- * elk bericht titelloos aan en heet het naar zijn id.
- *
- * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
- * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
- * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
- * verliest die regel niet, hij verhuist naar het titelveld en staat straks
- * gewoon weer bovenaan.
- */
-function titelUitContent(html) {
-  const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
-  if (!m) return { titel: null, rest: html };
-  const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
-  if (!titel || titel.length > 300) return { titel: null, rest: html };
-  return { titel, rest: String(html).slice(m[0].length) };
-}
-
-/**
- * Haal een bijlage op en zet hem lokaal neer.
- *
- * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
- * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
- * moet stranden, ook als die bron "van onszelf" is.
- */
-async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
-  // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
-  // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
-  // niet zien dat wij de doel-actor van zijn Move zijn.
-  const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
-  if (!r || !r.ok) return null;
-  const buf = Buffer.from(await r.arrayBuffer());
-  if (!buf.length || buf.length > maxBytes) return null;
-  const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
-  const ext = (() => {
-    const uit = slugUitUri(url);
-    const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
-    if (m) return m[1].toLowerCase();
-    return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
-  })();
-  const naam = `${crypto.randomUUID()}.${ext}`;
-  // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
-  // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
-  // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
-  // in mappen eronder.
-  const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
-  const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
-  fs.mkdirSync(path.dirname(abs), { recursive: true });
-  fs.writeFileSync(abs, buf);
-  // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
-  return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
-}
-
-/**
- * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
- *
- * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
- * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
- * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
- * verwarrender dan geen herschrijving.
- *
- * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
- * ronde over dezelfde tekst vindt gewoon niets meer te doen.
- */
-async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
-  let inhoud = String(html || '');
-  if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
-  // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
-  // enkele \s eet de template literal de backslash op en sluit de klasse de
-  // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
-  // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
-  // meldde, afgekapt precies voor de s.
-  const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
-  const gezien = new Set();
-  let n = 0;
-  for (const m of [...inhoud.matchAll(patroon)]) {
-    const vol = m[0];
-    if (gezien.has(vol)) continue;
-    gezien.add(vol);
-    const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
-    if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
-    let ok = false;
-    try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
-    if (!ok) {
-      const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-      if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
-      rapport.media++;
-      try {
-        db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-          .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
-      } catch { /* administratie */ }
-    }
-    inhoud = inhoud.split(vol).join(doel.rel);
-    n++;
-  }
-  return { inhoud, n };
-}
-
-/**
- * FEP-1580 ingest-routine, de doelkant.
- *
- * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
- * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
- * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
- * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
- * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
- * geschiedenis van een vreemde opeisen.
- *
- * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
- */
-export async function ingestFromSource(site, {
-  sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
-} = {}) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  const me = actorId(base, site.slug);
-
-  const {
-    getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
-    sanitize = (h) => h,
-    // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
-    // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
-    // privacygrens en niet een weergavedetail.
-    noteVisibility = () => 'followers',
-    audioRoot = null, signHeaders = null,
-  } = deps;
-  const zichtbaarheid = noteVisibility;
-  if (!getJson || !noteId) return { error: 'config' };
-
-  // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
-  let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
-  if (!bron) {
-    try {
-      const aka = JSON.parse(site.ap_aliases || '[]');
-      bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
-    } catch { /* stukke ap_aliases telt als geen alias */ }
-  }
-  if (!bron) return { error: 'no_source' };
-
-  // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
-  const bronActor = await getJson(site.slug, bron);
-  if (!bronActor || !bronActor.id) return { error: 'unreachable' };
-  // De origin van de bron: alles op deze host onder /media/ is van hem en mag
-  // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
-  const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
-  if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
-
-  // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
-  const eigenAka = (() => {
-    try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
-  })();
-  if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
-
-  // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
-  recordMove(site.slug, {
-    moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
-    activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
-    actorDoc: bronActor,
-  });
-  setMigrationComplete(site.slug, false);
-
-  const rapport = {
-    bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
-    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, tracksLinks: 0, overgeslagenTracks: 0,
-    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
-  };
-
-  try {
-    // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
-    //    wie de rest te zien krijgt. Andersom importeer je even je hele
-    //    geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
-    if (bronActor.blocked) {
-      const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const b of Array.isArray(lijst) ? lijst : []) {
-        const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
-        if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
-        try {
-          db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
-          rapport.blocks++;
-        } catch { /* tabel ontbreekt op een verse db */ }
-      }
-    } else {
-      rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
-    }
-
-    // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
-    if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
-    let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
-    const verwacht = pagina && Number(pagina.totalItems) || null;
-    // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
-    // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
-    // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
-    // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
-    // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
-    // berichten, zonder één waarschuwing.
-    if (pagina && pagina.first) {
-      pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
-    }
-
-    const insPost = db.prepare(`INSERT INTO posts
-      (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
-       pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
-       content_warning, ap_visibility, c2s_attachments, origin_server)
-      VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
-       0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
-       @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
-
-    let gezien = 0;
-    while (pagina && gezien < max) {
-      const items = (pagina.orderedItems || pagina.items) || [];
-      for (const it of Array.isArray(items) ? items : []) {
-        if (gezien >= max) break;
-        const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-        if (!o || !o.id) continue;
-        if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
-        if (o.inReplyTo) continue;                                    // toplevel; antwoorden hangen aan hun ouder
-        const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
-        if (auteur && auteur !== bronActor.id) continue;              // alleen wat van HEM was
-        gezien++;
-        // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
-        // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
-        // aparte mapping moet afleiden. Verwijder je een bericht en haal je
-        // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
-        const id = ruwId(o.id) || crypto.randomUUID();
-        const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
-        if (bestaand) {
-          // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
-          // Robins 18 posts stonden er al, met hotlinks naar de bron in de
-          // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
-          // laat je met een site vol verwijzingen naar een domein dat
-          // opgezegd wordt.
-          if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
-            const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
-            if (r2.n) {
-              db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
-              rapport.postsBijgewerkt++;
-            }
-          }
-          if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
-            // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
-            // image/icon: bij een post met een speler staat hij daar.
-            const uit = [
-              ...(Array.isArray(o.attachment) ? o.attachment : [])
-                .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
-                .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
-                .filter(Boolean),
-              ...coverKandidaten(o),
-            ];
-            for (const u of uit) {
-              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-              const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-              if (!g) continue;
-              db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
-              rapport.media++;
-              rapport.postsBijgewerkt++;
-              break;
-            }
-          }
-          rapport.overgeslagen++;
-          continue;
-        }
-        if (migrationTarget(site.slug, o.id)) rapport.opnieuw++;   // was er, is weg, komt terug
-
-        // Media eerst, want een post die naar een plaatje wijst dat we niet
-        // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
-        // post wel door en staat het in het verslag.
-        const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
-        const binnen = [];
-        let inhoud = o.content || '';
-        if (safeFetch && fs && path && mediaRoot) {
-          for (const a of bijlagen.slice(0, 20)) {
-            const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
-            if (!u || !/^https?:\/\//i.test(String(u))) continue;
-            const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-            const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-            if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
-            binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
-            rapport.media++;
-            try {
-              db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-                .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
-            } catch { /* media-rij is administratie, het bestand staat er */ }
-          }
-          // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
-          // verwijzingen naar de bron (https://oud/media/...), en die bleven
-          // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
-          // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
-          // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
-          // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
-          // een hotlink.
-          const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
-          inhoud = r2.inhoud;
-          // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
-          if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
-            for (const u of coverKandidaten(o)) {
-              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
-              const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-              if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
-              binnen.unshift({ ...g, naam: null, type: g.mediaType });
-              rapport.media++;
-              try {
-                db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
-                  .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
-              } catch { /* administratie */ }
-              break;                       // een omslag is genoeg
-            }
-          }
-        }
-
-        const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
-        const rest = binnen.filter((b) => b !== cover);
-        // De titel zit in de content, niet in een veld (zie titelUitContent).
-        const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
-        // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
-        // hetzelfde webadres als op de oude instantie, en blijft een link die
-        // iemand ergens plakte kloppen op het nieuwe domein.
-        const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
-        // De publicatiedatum blijft die van het origineel. De spec eist dat, en
-        // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
-        insPost.run({
-          id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
-          author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
-          cover_image_url: cover ? cover.url : null,
-          tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
-          published_at: o.published || null, updated_at: o.updated || o.published || null,
-          fan_only: 0, nsfw: o.sensitive ? 1 : 0,
-          language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
-          content_warning: o.summary || null,
-          ap_visibility: null,
-          c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
-        });
-        recordMigrated(site.slug, {
-          origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
-          // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
-          // hoort deze regel niet in een publiek leesbare migration-pagina.
-          //
-          // Via noteVisibility en niet met een eigen test op '#Public': die kent
-          // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
-          // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
-          // precies hoe twee antwoorden uit elkaar gaan lopen.
-          isPublic: zichtbaarheid(o) === 'public',
-        });
-        rapport.posts++;
-      }
-      const volgende = pagina.next;
-      if (!volgende || gezien >= max) break;
-      pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
-    }
-    if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
-    // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
-    // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
-    if (verwacht && gezien < verwacht && gezien < max) {
-      rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
-    }
-
-    // ── De muziekbibliotheek ──────────────────────────────────────
-    //
-    // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
-    // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
-    // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
-    //
-    // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
-    // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
-    // geldt voor de bestanden zelf, die anders achter de gated audio-route
-    // blijven.
-    const trackKaart = new Map();   // bron-URI van een nummer -> ons nieuwe id
-    // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
-    // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
-    // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
-    // heen en zie je de code zelf in je bericht staan.
-    const ruwKaart = new Map();     // ruw bron-id -> ons id
-    const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
-    const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
-    if (tracksUrl && safeFetch && fs && path && audioRoot) {
-      const coll = await getJson(site.slug, tracksUrl);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
-        const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
-        if (!a || !a.id) continue;
-        if (a.type && a.type !== 'Audio') continue;
-        // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
-        // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
-        // pull die dan alles passeert laat je met een half resultaat zitten
-        // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
-        // in stand.
-        //
-        // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
-        // staan; een migratie hoort je correcties niet terug te draaien.
-        const trackId = ruwId(a.id) || crypto.randomUUID();
-        {
-          const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
-            .get(trackId, site.id);
-          if (rij) {
-            trackKaart.set(String(a.id), rij.id);   // MOET, anders vinden de playlists hem niet
-            ruwKaart.set(ruwId(a.id), rij.id);
-            const duur = rij.duration ? null : duurSeconden(a.duration);
-            const artiest = rij.artist ? null : (a.summary || a.artist || null);
-            let hoes = null;
-            const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
-            if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
-              const h = await haalBijlage(String(hUrl), {
-                safeFetch, mediaRoot, fs, path, maxBytes,
-                headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
-              }).catch(() => null);
-              if (h) { hoes = h.url; rapport.media++; }
-            }
-            if (duur || artiest || hoes) {
-              db.prepare(`UPDATE audio_tracks SET
-                            duration = COALESCE(?, duration),
-                            artist = COALESCE(?, artist),
-                            cover_url = COALESCE(?, cover_url)
-                          WHERE id = ?`).run(duur, artiest, hoes, rij.id);
-              rapport.tracksBijgewerkt++;
-            } else {
-              rapport.overgeslagenTracks++;
-            }
-            continue;
-          }
-        }
-        // Uit de url-lijst de LINK NAAR HET BESTAND vissen, niet zomaar de eerste:
-        // buildTrackAudio zet er ook een text/html-link naar de post voor. En een
-        // LINK-ONLY track (alleen Spotify of YouTube, nooit een gehost bestand)
-        // heeft er helemaal geen. Die hoort gewoon mee, met media_id NULL.
-        // Weggooien kostte Robin een nummer dat het op de oude site prima deed.
-        const urls = [].concat(a.url || []).map((u) => (typeof u === 'string' ? { href: u } : u)).filter((u) => u && u.href);
-        const bestandLink = urls.find((u) => /^audio\//i.test(String(u.mediaType || '')))
-          || urls.find((u) => /\/audio\/stream\//.test(String(u.href)));
-        const externe = urls.map((u) => String(u.href)).filter((h) => /spotify|youtube|youtu\.be|soundcloud|bandcamp/i.test(h));
-        const bron = bestandLink && bestandLink.href;
-        if (!bron || !/^https?:\/\//i.test(String(bron))) {
-          if (externe.length) {
-            const trackIdL = ruwId(a.id) || crypto.randomUUID();
-            try {
-              db.prepare(`INSERT OR REPLACE INTO audio_tracks
-                  (id, site_id, title, artist, media_id, link_spotify, link_youtube, link_soundcloud, fedi_open)
-                VALUES (?,?,?,?,NULL,?,?,?,0)`)
-                .run(trackIdL, site.id, a.name || 'zonder titel', a.summary || a.artist || null,
-                  externe.find((u) => /spotify/i.test(u)) || null,
-                  externe.find((u) => /youtube|youtu\.be/i.test(u)) || null,
-                  externe.find((u) => /soundcloud/i.test(u)) || null);
-              recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackIdL}`, sourceActor: bronActor.id, isPublic: false });
-              trackKaart.set(String(a.id), trackIdL);
-              ruwKaart.set(ruwId(a.id), trackIdL);
-              rapport.tracksLinks++;
-              rapport.tracksBinnen++;
-            } catch (e) {
-              rapport.tracksMislukt++;
-              rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
-            }
-            continue;
-          }
-          rapport.tracksMislukt++;
-          continue;
-        }
-        const g = await haalBijlage(String(bron), {
-          safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
-          headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
-        }).catch(() => null);
-        if (!g) {
-          rapport.tracksMislukt++;
-          rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
-          continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
-        }
-        // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
-        // nummer aan met een verwijzing naar een plaatje dat er niet is.
-        let hoes = null;
-        const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
-        if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
-          const h = await haalBijlage(String(hoesUrl), {
-            safeFetch, mediaRoot, fs, path, maxBytes,
-            headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
-          }).catch(() => null);
-          if (h) { hoes = h.url; rapport.media++; }
-          else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
-        }
-        const mediaId = crypto.randomUUID();
-        try {
-          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-            .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
-          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
-                      VALUES (?,?,?,?,?,?,?,?,0)`)
-            .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
-              duurSeconden(a.duration), mediaId, hoes);
-          recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
-          trackKaart.set(String(a.id), trackId);
-          ruwKaart.set(ruwId(a.id), trackId);
-          rapport.tracksBinnen++;
-        } catch (e) {
-          rapport.tracksMislukt++;
-          rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
-        }
-      }
-    } else if (tracksUrl) {
-      rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
-    }
-
-    // Postlinks eerst: pas nu zijn ALLE berichten binnen, dus pas nu weten we
-    // welke slugs hier bestaan.
-    postLinksBijtrekken(site, bronOrigin, rapport);
-
-    // ── De verwijzingen in de tekst bijtrekken ────────────────────
-    //
-    // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
-    // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
-    // shorthand nergens heen en zie je de code zelf in je bericht staan in
-    // plaats van een speler. Precies wat Robin op TikTik zag.
-    //
-    // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
-    // id echt veranderde: een gelijk id hoeft niet aangeraakt.
-    {
-      const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
-      if (paren.length) {
-        const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
-        let n = 0;
-        for (const [oud, nieuwId] of paren) {
-          const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
-          if (r && r.changes) n += r.changes;
-        }
-        if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
-      }
-    }
-
-    // ── De playlists ──────────────────────────────────────────────
-    //
-    // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
-    // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
-    // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
-    // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
-    // plaat met gaten.
-    const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
-    if (plUrl && trackKaart.size) {
-      const coll = await getJson(site.slug, plUrl);
-      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
-      for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
-        const uri = typeof p === 'string' ? p : (p && p.id);
-        if (!uri) continue;
-        const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
-        if (!plc) { rapport.playlistsMislukt++; continue; }
-        const nummers = (plc.orderedItems || plc.items || [])
-          .map((x) => (x && typeof x === 'object' ? x.id : x))
-          .map((id) => trackKaart.get(String(id)))
-          .filter(Boolean);
-        if (!nummers.length) {
-          rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
-          continue;
-        }
-        // De hoes van de plaat, net als bij een nummer.
-        let plHoes = null;
-        const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
-        if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
-          const h = await haalBijlage(String(plHoesUrl), {
-            safeFetch, mediaRoot, fs, path, maxBytes,
-            headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
-          }).catch(() => null);
-          if (h) { plHoes = h.url; rapport.media++; }
-          else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
-        }
-        // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
-        // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
-        const plId = ruwId(uri) || crypto.randomUUID();
-        try {
-          db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
-                      ON CONFLICT(id) DO UPDATE SET
-                        title = excluded.title,
-                        artist = COALESCE(playlists.artist, excluded.artist),
-                        cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
-            .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
-              plc.year || null, plc['shaer:kind'] || null, plHoes);
-          // De volgorde opnieuw zetten: die IS de plaat, en een halve
-          // bijgewerkte volgorde is erger dan een verse.
-          db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
-          const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
-          nummers.forEach((tid, i) => ins.run(plId, tid, i));
-          recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
-          rapport.playlistsBinnen++;
-          const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
-          if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
-        } catch (e) {
-          rapport.playlistsMislukt++;
-          rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
-        }
-      }
-    }
-  } catch (e) {
-    // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
-    // en dat is precies goed, want er is nog werk.
-    console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
-    return { ...rapport, error: 'partial', melding: e && e.message };
-  }
-
-  // 9. Klaar. Nu pas mag een derde stoppen met kijken.
-  setMigrationComplete(site.slug, true);
-  console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
-  return rapport;
-}
Index: src/services/MusicBrainzService.js
===================================================================
--- src/services/MusicBrainzService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,160 +1,0 @@
-/**
- * Een artiest zoekt zichzelf op in MusicBrainz (shaer-mbz).
- *
- * WAAROM DIT GEEN DIALECT IS. Funkwhale's Track/Artist/ArtistCredit zijn hun
- * eigen vocabulaire -- hun docs noemen ze letterlijk "Custom Funkwhale object"
- * -- en wij kunnen ze niet eerlijk vullen: artiest en album zijn bij ons
- * tekstkolommen, geen entiteiten. Een MBID is iets anders: geen vocabulaire
- * maar een REGISTER. Ernaar verwijzen is als een ISBN noemen. Je neemt niemands
- * model over en je wijst naar iets dat al bestaat.
- *
- * WAT HIER NIET GEBEURT: schrijven. Via hun API zijn alleen tags, ratings,
- * ISRC's en barcodes in te dienen -- artiesten, releases en recordings niet,
- * dat gaat via hun website. Wij lezen dus alleen, en dat is meteen de
- * geruststelling: we kunnen hun register niet vervuilen.
- *
- * TWEE HARDE REGELS VAN HUN KANT, allebei hieronder ingebakken omdat ze bij
- * overtreding tot blokkade leiden en niet tot een foutmelding:
- *   - hoogstens EEN verzoek per seconde, per applicatie (niet per bezoeker)
- *   - een echte User-Agent, met contactgegevens
- */
-import { safeFetch } from './ActivityPubService.js';
-// De twee pure vormcontroles wonen in ap-core: ActivityPubService heeft ze ook
-// nodig voor de actor, en zonder die verhuizing zou dat een KRINGLOOP zijn --
-// deze module leent immers safeFetch dáár.
-import { isMbid, artiestUrl } from './ap-core.js';
-
-const BASIS = 'https://musicbrainz.org/ws/2';
-
-/**
- * De User-Agent die MusicBrainz eist. Hun regel: naam, versie en een manier om
- * contact op te nemen. Een lege of generieke string is precies waarop ze
- * blokkeren, dus als er geen contact is ingesteld zeggen we dat met zoveel
- * woorden in plaats van iets aardigs te verzinnen.
- */
-function userAgent() {
-  const contact = (process.env.MUSICBRAINZ_CONTACT || '').trim()
-    || (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '')
-    || 'geen-contact-ingesteld';
-  return `Klonkt/1.0 ( ${contact} )`;
-}
-
-/**
- * Hun tempo aanhouden: ten hoogste een verzoek per seconde, over de HELE
- * applicatie. Geen bibliotheek en geen wachtrij -- een belofte die de volgende
- * aanroeper laat wachten tot het weer mag. Zonder dit is de eerste drukke dag
- * meteen een blokkade, en dan werkt het bij iedereen niet meer.
- */
-let laatste = 0;
-let beurt = Promise.resolve();
-function opDeBeurt() {
-  beurt = beurt.then(async () => {
-    const wachten = 1000 - (Date.now() - laatste);
-    if (wachten > 0) await new Promise((r) => setTimeout(r, wachten));
-    laatste = Date.now();
-  });
-  return beurt;
-}
-
-/**
- * Zoek artiesten op naam. Geeft de kandidaten met alles wat nodig is om er EEN
- * uit te kiezen -- de naam alleen is niet genoeg, want er zijn drie bands die
- * Nirvana heten. Vandaar disambiguation, land en de jaren erbij.
- *
- * Geeft een LEGE lijst bij een storing, geen exceptie: niet kunnen zoeken is
- * vervelend, maar het mag het beheerscherm niet omvergooien.
- */
-export async function zoekArtiesten(naam, { limit = 8 } = {}) {
-  const q = String(naam || '').trim();
-  if (!q) return [];
-  const url = `${BASIS}/artist?query=${encodeURIComponent(q)}&fmt=json&limit=${Math.min(25, Math.max(1, limit))}`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return [];
-    const doc = await r.json();
-    return (doc.artists || []).map(kandidaat).filter(Boolean);
-  } catch {
-    return [];
-  }
-}
-
-/**
- * Een MBID rechtstreeks opzoeken. Wie zijn id al kent hoeft niet te zoeken --
- * en een zoekopdracht op een UUID levert bij MusicBrainz niets op, dus zonder
- * deze tak zou plakken juist het slechtste resultaat geven.
- */
-export async function haalArtiest(mbid) {
-  if (!isMbid(mbid)) return null;
-  const url = `${BASIS}/artist/${encodeURIComponent(mbid)}?inc=url-rels&fmt=json`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return null;
-    return kandidaat(await r.json());
-  } catch {
-    return null;
-  }
-}
-
-/**
- * DE TERUG-WEG. Noemt de MusicBrainz-pagina van deze artiest ons domein?
- *
- * Een koppeling van onze kant is een bewering: iedereen kan een MBID in een
- * veld typen. Pas als de artiestenpagina TERUGWIJST is het een paar, en dan
- * weet een lezer dat dezelfde persoon aan allebei de kanten stond. Dat is
- * dezelfde gedachte als rel="me" bij Mastodon.
- *
- * Wij zetten die terugwijzing NIET zelf: via hun API kan het niet, en het hoort
- * ook niet -- de artiest doet dat op musicbrainz.org onder "social networking".
- * Wij kijken alleen of hij er staat.
- *
- * Geeft { verified, urls } -- bij een storing verified:false en een lege lijst,
- * want niet kunnen kijken is niet hetzelfde als niet gevonden.
- */
-export async function controleerTerugweg(mbid, domein) {
-  const leeg = { verified: false, urls: [] };
-  if (!isMbid(mbid) || !domein) return leeg;
-  let host;
-  try { host = new URL(domein).host.toLowerCase(); } catch { return leeg; }
-  const url = `${BASIS}/artist/${encodeURIComponent(mbid)}?inc=url-rels&fmt=json`;
-  try {
-    await opDeBeurt();
-    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
-    if (!r || !r.ok) return leeg;
-    const doc = await r.json();
-    const urls = (doc.relations || [])
-      .map((rel) => rel && rel.url && rel.url.resource)
-      .filter((u) => typeof u === 'string');
-    const wijst = urls.some((u) => { try { return new URL(u).host.toLowerCase() === host; } catch { return false; } });
-    return { verified: wijst, urls };
-  } catch {
-    return leeg;
-  }
-}
-
-/** Een kandidaat, teruggebracht tot wat een mens nodig heeft om te kiezen. */
-function kandidaat(a) {
-  if (!a || !a.id || !a.name) return null;
-  const jaren = [a['life-span']?.begin, a['life-span']?.ended ? a['life-span']?.end : null]
-    .filter(Boolean).join(' – ');
-  return {
-    mbid: a.id,
-    naam: a.name,
-    // "disambiguation" is het veld waarmee MusicBrainz zelf twee gelijknamige
-    // artiesten uit elkaar houdt. Precies wat de kiezer nodig heeft.
-    toelichting: a.disambiguation || '',
-    soort: a.type || '',            // Person, Group, ...
-    land: a.country || '',
-    jaren,
-    url: `https://musicbrainz.org/artist/${a.id}`,
-    // Hun eigen zoekscore. Niet om op te sorteren -- dat doen zij al -- maar om
-    // een zwakke treffer te kunnen tonen als zwak.
-    score: Number(a.score) || 0,
-  };
-}
-
-// Her-geexporteerd zodat een aanroeper er niet over hoeft na te denken waar
-// ze precies wonen.
-export { isMbid, artiestUrl };
-export default { zoekArtiesten, haalArtiest, controleerTerugweg, isMbid, artiestUrl };
Index: src/services/MusicMeta.js
===================================================================
--- src/services/MusicMeta.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,87 +1,0 @@
-// Phase 1 of music federation: emit STANDARD schema.org MusicRecording / MusicAlbum
-// structured data for an audio post (Google rich results + any generic JSON-LD consumer).
-// Deliberately a real, existing web standard — NOT a Klonkt-invented field. The track
-// resolution here is reused by the (future) Funkwhale Audio/Library federation (Phase 2).
-import db from '../config/database.js';
-
-const COLS = 'title, album, duration, credit, license, cover_url, media_id';
-
-function isoDuration(sec) {
-  const n = parseInt(sec, 10);
-  if (!n || n < 0) return null;
-  return `PT${Math.floor(n / 60)}M${n % 60}S`; // ISO-8601 duration, e.g. PT3M20S
-}
-function absUrl(base, u) {
-  if (!u) return null;
-  return /^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`;
-}
-
-// Resolve a post's [[track]]/[[album]]/[[playlist]] shortcodes to the HOSTED (playable)
-// tracks it references — only file-backed tracks (media_id), mirroring hasPlayableAudio.
-function resolveTracks(site, content) {
-  const tracks = [];
-  try {
-    for (const m of content.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-      const r = db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE id = ?`).get(m[1]);
-      if (r && r.media_id) tracks.push(r);
-    }
-    for (const m of content.matchAll(/\[\[album:([^\]]+)\]\]/g)) {
-      for (const r of db.prepare(`SELECT ${COLS} FROM audio_tracks WHERE site_id = ? AND album = ? AND media_id IS NOT NULL ORDER BY rowid`).all(site.id, m[1].trim())) tracks.push(r);
-    }
-    for (const m of content.matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
-      for (const r of db.prepare(`SELECT t.title, t.album, t.duration, t.credit, t.license, t.cover_url, t.media_id FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? AND t.media_id IS NOT NULL ORDER BY pt.position`).all(m[1])) tracks.push(r);
-    }
-  } catch { /* non-fatal */ }
-  return tracks;
-}
-
-// Build a schema.org MusicRecording (single track) or MusicAlbum (multiple) for a post,
-// or null when the post has no hosted audio. `url` points to the gated player page — the
-// anti-steal posture is preserved (no raw file URL is ever emitted).
-export function build(base, site, post) {
-  if (!post || !site || !post.content) return null;
-  if (!/\[\[(track|album|playlist):/i.test(post.content)) return null;
-  const b = (base || '').replace(/\/+$/, '');
-  const tracks = resolveTracks(site, post.content);
-  if (!tracks.length) return null;
-
-  const artist = {
-    '@type': 'MusicGroup',
-    name: site.title || site.slug,
-    url: `${b}/${site.is_primary ? '' : 'user/' + encodeURIComponent(site.slug)}`,
-  };
-  const postUrl = `${b}/${encodeURIComponent(post.slug)}`;
-  const recording = (t, withTop) => {
-    const o = { '@type': 'MusicRecording', name: t.title || post.title || 'Untitled' };
-    if (withTop) { o.byArtist = artist; o.url = postUrl; }
-    if (t.album) o.inAlbum = { '@type': 'MusicAlbum', name: t.album };
-    const d = isoDuration(t.duration); if (d) o.duration = d;
-    if (t.license) o.license = t.license;     // e.g. "CC BY 4.0" — Klonkt leads on this
-    if (t.credit) o.creditText = t.credit;
-    const cov = absUrl(b, t.cover_url) || absUrl(b, post.cover_image_url); if (cov) o.image = cov;
-    return o;
-  };
-
-  let ld;
-  if (tracks.length > 1) {
-    const albums = [...new Set(tracks.map((t) => t.album).filter(Boolean))];
-    ld = {
-      '@type': 'MusicAlbum',
-      name: albums.length === 1 ? albums[0] : (post.title || 'Album'),
-      byArtist: artist,
-      url: postUrl,
-      numTracks: tracks.length,
-      track: tracks.map((t) => recording(t, false)),
-    };
-    const cov = absUrl(b, post.cover_image_url) || absUrl(b, tracks[0].cover_url); if (cov) ld.image = cov;
-    const lic = tracks.find((t) => t.license); if (lic) ld.license = lic.license;
-  } else {
-    ld = recording(tracks[0], true);
-  }
-  ld['@context'] = 'https://schema.org';
-  const dp = post.published_at || post.created_at;
-  if (dp) ld.datePublished = new Date(dp).toISOString();
-  return ld;
-}
-
-export default { build };
Index: src/services/NoteRender.js
===================================================================
--- src/services/NoteRender.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,96 +1,0 @@
-// Server-side rendering of the bits the Shaer clients render natively, so the
-// Klonkt web timeline looks the same: FEP-9098 custom emojis (`:shortcode:` →
-// image) in note content and display names, and the FEP-044f embedded quote
-// card. Pure + deterministic (no DB, no I/O), so it is unit-testable and cheap.
-
-const SHORTCODE = /:[A-Za-z0-9_+-]+:/g;
-
-const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
-export function escapeHtml(s) {
-  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
-}
-function escapeAttr(s) {
-  return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => HTML_ESCAPES[c]);
-}
-
-// Normalise either representation into a { ":shortcode:": url } map:
-//  - emoji_json: an array of Emoji tag objects [{ name, icon:{url} }]
-//  - author_emoji_json / reblog_emoji_json / quote.emojis: already a map.
-export function emojiMap(json) {
-  try {
-    const v = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
-    if (!v) return {};
-    if (Array.isArray(v)) {
-      const m = {};
-      for (const t of v) {
-        const icon = t && t.icon;
-        const url = icon && (icon.url || (Array.isArray(icon) && icon[0] && icon[0].url));
-        if (t && typeof t.name === 'string' && url) m[t.name] = url;
-      }
-      return m;
-    }
-    if (typeof v === 'object') {
-      const m = {};
-      for (const k of Object.keys(v)) if (typeof v[k] === 'string') m[k] = v[k];
-      return m;
-    }
-    return {};
-  } catch { return {}; }
-}
-
-function emojiImg(url, alt) {
-  return `<img class="emoji" src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" title="${escapeAttr(alt)}" draggable="false" loading="lazy">`;
-}
-
-function substitute(text, map) {
-  return text.replace(SHORTCODE, (m) => (map[m] ? emojiImg(map[m], m) : m));
-}
-
-// Inject <img> for each known custom emoji into an already-sanitised HTML
-// fragment (note content). Substitutes only in text between tags (never inside
-// a tag or its attributes) and skips <code>/<pre>, mirroring the Shaer render.
-export function emojiHtml(html, json) {
-  const map = emojiMap(json);
-  if (!html || !Object.keys(map).length) return html || '';
-  let out = '';
-  let i = 0;
-  let code = 0;
-  while (i < html.length) {
-    if (html[i] === '<') {
-      const close = html.indexOf('>', i);
-      if (close < 0) { out += html.slice(i); break; }
-      const raw = html.slice(i + 1, close);
-      const name = raw.replace(/^\//, '').split(/[\s/>]/)[0].toLowerCase();
-      if (name === 'code' || name === 'pre') code = Math.max(0, code + (raw[0] === '/' ? -1 : 1));
-      out += html.slice(i, close + 1);   // copy the tag verbatim
-      i = close + 1;
-    } else {
-      const next = html.indexOf('<', i);
-      const end = next < 0 ? html.length : next;
-      const text = html.slice(i, end);
-      out += code > 0 ? text : substitute(text, map);
-      i = end;
-    }
-  }
-  return out;
-}
-
-// A plain-text display name with custom emojis → safe HTML. The name is HTML-
-// escaped first; shortcode characters ([A-Za-z0-9_+-]) survive escaping, so the
-// image substitution stays correct.
-export function emojiName(text, json) {
-  const esc = escapeHtml(text);
-  const map = emojiMap(json);
-  if (!Object.keys(map).length) return esc;
-  return substitute(esc, map);
-}
-
-// The resolved quoted-post snapshot Klonkt stored (quote_json), or null.
-export function parseQuote(json) {
-  try {
-    const q = json == null ? null : (typeof json === 'string' ? JSON.parse(json) : json);
-    return (q && typeof q === 'object' && q.url) ? q : null;
-  } catch { return null; }
-}
-
-export default { escapeHtml, emojiMap, emojiHtml, emojiName, parseQuote };
Index: src/services/OAuthService.js
===================================================================
--- src/services/OAuthService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,138 +1,0 @@
-/**
- * OAuthService — OAuth 2.0 for ActivityPub Client-to-Server (C2S).
- *
- * The AP spec recommends OAuth 2.0 bearer tokens for C2S; the actor document
- * advertises endpoints.oauthAuthorizationEndpoint / oauthTokenEndpoint, and
- * RFC 8414 (/.well-known/oauth-authorization-server) advertises the
- * registration endpoint. Design choices (v1):
- *   - PUBLIC clients only (native apps, RFC 8252): no client secrets,
- *     PKCE S256 is REQUIRED on the authorization-code flow.
- *   - A token is scoped to ONE user + ONE site (multi-site hub: the consent
- *     page picks the site). Scope string is informational ('c2s').
- *   - Tokens are stored hashed (sha256); codes are single-use, 10 min TTL.
- */
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-const CODE_TTL_MS = 10 * 60 * 1000;
-
-const b64url = (buf) => buf.toString('base64url');
-const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
-
-// Redirect URIs: https:// (web) or a custom scheme with a dot (reverse-DNS,
-// RFC 8252 §7.1, e.g. com.shaer.app:/callback). Plain http only for loopback.
-export function validRedirectUri(uri) {
-  try {
-    const u = new URL(uri);
-    if (u.protocol === 'https:') return true;
-    if (u.protocol === 'http:') return u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '[::1]';
-    return /^[a-z0-9-]+(\.[a-z0-9-]+)+:$/i.test(u.protocol); // custom reverse-DNS scheme
-  } catch { return false; }
-}
-
-// RFC 7591 (subset): register a public client. Returns the stored metadata.
-export function registerClient({ client_name, redirect_uris }) {
-  const name = String(client_name || '').trim().slice(0, 120);
-  const uris = (Array.isArray(redirect_uris) ? redirect_uris : [redirect_uris]).filter(Boolean).map(String);
-  if (!name) return { error: 'invalid_client_metadata', error_description: 'client_name is required' };
-  if (!uris.length || !uris.every(validRedirectUri)) {
-    return { error: 'invalid_redirect_uri', error_description: 'redirect_uris must be https, loopback http, or a reverse-DNS custom scheme' };
-  }
-  const clientId = b64url(crypto.randomBytes(18));
-  db.prepare('INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?,?,?)')
-    .run(clientId, name, JSON.stringify(uris));
-  return {
-    client_id: clientId,
-    client_name: name,
-    redirect_uris: uris,
-    token_endpoint_auth_method: 'none', // public client: PKCE, no secret
-    grant_types: ['authorization_code'],
-    response_types: ['code'],
-  };
-}
-
-export function getClient(clientId) {
-  const row = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
-  if (!row) return null;
-  let uris = []; try { uris = JSON.parse(row.redirect_uris); } catch { /* corrupt row */ }
-  return { client_id: row.client_id, client_name: row.client_name, redirect_uris: uris };
-}
-
-// Authorization step (after user consent): mint a single-use code.
-export function createCode({ clientId, userId, siteSlug, redirectUri, codeChallenge, scope }) {
-  if (!codeChallenge || !/^[A-Za-z0-9_-]{43}$/.test(String(codeChallenge))) {
-    return { error: 'invalid_request', error_description: 'PKCE S256 code_challenge is required' };
-  }
-  const code = b64url(crypto.randomBytes(24));
-  db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, site_slug, redirect_uri, code_challenge, scope, expires_at)
-              VALUES (?,?,?,?,?,?,?,?)`)
-    .run(code, clientId, userId, siteSlug, redirectUri, codeChallenge, scope || 'c2s',
-         new Date(Date.now() + CODE_TTL_MS).toISOString());
-  return { code };
-}
-
-// Token step: exchange code + PKCE verifier for a bearer token.
-export function exchangeCode({ code, client_id, redirect_uri, code_verifier }) {
-  const row = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(String(code || ''));
-  // Single use: delete immediately, whatever happens next (replay protection).
-  if (row) db.prepare('DELETE FROM oauth_codes WHERE code = ?').run(row.code);
-  if (!row) return { error: 'invalid_grant' };
-  if (Date.parse(row.expires_at) < Date.now()) return { error: 'invalid_grant', error_description: 'code expired' };
-  if (row.client_id !== String(client_id || '')) return { error: 'invalid_grant', error_description: 'client mismatch' };
-  if (row.redirect_uri !== String(redirect_uri || '')) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
-  const expected = b64url(sha256(String(code_verifier || '')));
-  if (expected !== row.code_challenge) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
-  const token = b64url(crypto.randomBytes(32));
-  db.prepare('INSERT INTO oauth_tokens (token_hash, client_id, user_id, site_slug, scope) VALUES (?,?,?,?,?)')
-    .run(b64url(sha256(token)), row.client_id, row.user_id, row.site_slug, row.scope);
-  return { access_token: token, token_type: 'Bearer', scope: row.scope };
-}
-
-// Resolve "Authorization: Bearer <token>" → { user, site } or null. The C2S
-// caller must additionally check the site matches the URL and permissions.
-export function verifyBearer(authHeader) {
-  const m = /^Bearer\s+([A-Za-z0-9_-]{20,})$/i.exec(String(authHeader || '').trim());
-  if (!m) return null;
-  const hash = b64url(sha256(m[1]));
-  const row = db.prepare('SELECT * FROM oauth_tokens WHERE token_hash = ?').get(hash);
-  if (!row) return null;
-  try { db.prepare('UPDATE oauth_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); } catch { /* non-fatal */ }
-  const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
-  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
-  if (!user || !site) return null;
-  return { user, site, scope: row.scope, client_id: row.client_id };
-}
-
-export function revokeToken(token) {
-  try { db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ?').run(b64url(sha256(String(token || '')))); } catch { /* ignore */ }
-}
-
-// The active authorizations (bearer tokens) a user has granted, with the app
-// name and the site each is scoped to. The bearer itself is never stored, so
-// revocation is keyed on token_hash: safe to render, you cannot derive the
-// token from its hash.
-export function listAuthorizations(userId) {
-  return db.prepare(`
-    SELECT t.token_hash, t.site_slug, t.scope, t.created_at, t.last_used_at, c.client_name
-    FROM oauth_tokens t
-    LEFT JOIN oauth_clients c ON c.client_id = t.client_id
-    WHERE t.user_id = ?
-    ORDER BY t.created_at DESC
-  `).all(String(userId || ''));
-}
-
-// Revoke one authorization, scoped to the owner so a user can only revoke their
-// own tokens. Returns true when a row was removed.
-export function revokeAuthorization(userId, tokenHash) {
-  try {
-    const r = db.prepare('DELETE FROM oauth_tokens WHERE token_hash = ? AND user_id = ?')
-      .run(String(tokenHash || ''), String(userId || ''));
-    return r.changes > 0;
-  } catch { return false; }
-}
-
-export default {
-  registerClient, getClient, createCode, exchangeCode, verifyBearer, revokeToken, validRedirectUri,
-  listAuthorizations, revokeAuthorization,
-};
Index: src/services/OgImageService.js
===================================================================
--- src/services/OgImageService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,119 +1,0 @@
-/**
- * OgImageService — generates a themed Open Graph card (1200x630 PNG) per site,
- * derived from the site's palette + accent, so every site has a branded social
- * preview even without uploading one. SVG is hand-built and rasterized with
- * @resvg/resvg-js. Result is cached on disk (keyed by the theming inputs).
- *
- * Graceful: if @resvg/resvg-js can't load (exotic platform), ogImageFor()
- * returns null and the caller falls back to no/other og:image — never throws.
- */
-import fs from 'fs';
-import path from 'path';
-import crypto from 'crypto';
-import { fileURLToPath } from 'url';
-import { createRequire } from 'module';
-import ThemeService from './ThemeService.js';
-
-const require = createRequire(import.meta.url);
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-const FONT = path.join(__dirname, '..', 'assets', 'fonts', 'fraunces-og.ttf');
-const DATA_DIR = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
-const CACHE_DIR = path.join(DATA_DIR, 'og');
-const TEMPLATE_VERSION = 1; // bump to invalidate all cached cards after a design change
-
-let _Resvg = null, _tried = false;
-function getResvg() {
-  if (_tried) return _Resvg;
-  _tried = true;
-  try { _Resvg = require('@resvg/resvg-js').Resvg; } catch { _Resvg = null; }
-  return _Resvg;
-}
-
-// ── tiny colour helpers ───────────────────────────────────────────
-function hx(h) {
-  h = String(h || '').replace('#', '');
-  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
-  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) || 0);
-}
-function rgb(a) {
-  return '#' + a.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
-}
-function mix(a, b, t) { const A = hx(a), B = hx(b); return rgb(A.map((v, i) => v + (B[i] - v) * t)); }
-function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c])); }
-
-function buildSvg(site, palette, accent, theme) {
-  const _p = ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt;
-  const pal = _p[theme] || _p.dark;
-  const paper = pal.paper, ink = pal.ink;
-  const paper2 = mix(paper, ink, 0.08);
-  const muted = mix(ink, paper, 0.42);
-
-  let title = (site.title || 'Klonkt').trim();
-  let tag = (site.tagline || site.description || '').trim();
-  if (title.length > 38) title = title.slice(0, 37) + '…';
-  if (tag.length > 74) tag = tag.slice(0, 73) + '…';
-  const tsize = title.length <= 12 ? 100 : title.length <= 20 ? 82 : title.length <= 30 ? 64 : 54;
-
-  return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
-  <defs>
-    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
-      <stop offset="0" stop-color="${paper}"/><stop offset="1" stop-color="${paper2}"/>
-    </linearGradient>
-    <radialGradient id="glow" cx="0.85" cy="0.12" r="0.7">
-      <stop offset="0" stop-color="${accent}" stop-opacity="0.20"/>
-      <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
-    </radialGradient>
-  </defs>
-  <rect width="1200" height="630" fill="url(#bg)"/>
-  <rect width="1200" height="630" fill="url(#glow)"/>
-  <rect x="0" y="0" width="14" height="630" fill="${accent}"/>
-  <g transform="translate(96,232)">
-    <rect x="0"  y="14" width="11" height="34" rx="3" fill="${accent}"/>
-    <rect x="18" y="0"  width="11" height="48" rx="3" fill="${accent}"/>
-    <rect x="36" y="22" width="11" height="26" rx="3" fill="${accent}"/>
-    <rect x="54" y="8"  width="11" height="40" rx="3" fill="${accent}"/>
-  </g>
-  <text x="96" y="400" font-size="${tsize}" fill="${ink}">${esc(title)}</text>
-  ${tag ? `<text x="98" y="462" font-size="34" fill="${muted}">${esc(tag)}</text>` : ''}
-  <text x="96" y="566" font-size="30" fill="${accent}">klonkt</text>
-</svg>`;
-}
-
-/**
- * Returns a PNG Buffer of the site's OG card (cached), or null if generation
- * isn't possible. `site` needs: slug, title, palette, accent, tagline/description.
- */
-export function ogImageFor(site) {
-  const Resvg = getResvg();
-  if (!Resvg || !site || !site.slug) return null;
-
-  const palette = ThemeService.PALETTES[site.palette] ? site.palette : 'klonkt';
-  // Card variant: an explicit SEO override (og_theme) wins; else follow the site's "default
-  // theme for new visitors" (theme_override) — light when the site is set to Light, otherwise
-  // dark (an OG image is static, so Auto/Dark → dark).
-  const theme = (site.og_theme === 'light' || site.og_theme === 'dark')
-    ? site.og_theme
-    : (site.theme_override === 'light' ? 'light' : 'dark');
-  const accent = site.accent || ((ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt)[theme] || ThemeService.PALETTES.klonkt.dark).accent;
-  const key = crypto.createHash('sha1')
-    .update([TEMPLATE_VERSION, site.slug, palette, accent, theme, site.title || '', site.tagline || site.description || ''].join('\x1f'))
-    .digest('hex').slice(0, 16);
-  const file = path.join(CACHE_DIR, key + '.png');
-
-  try { return fs.readFileSync(file); } catch { /* not cached yet */ }
-
-  try {
-    const svg = buildSvg(site, palette, accent, theme);
-    const png = new Resvg(svg, {
-      font: { fontFiles: [FONT], loadSystemFonts: false },
-      fitTo: { mode: 'width', value: 1200 },
-    }).render().asPng();
-    try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(file, png); } catch { /* cache best-effort */ }
-    return png;
-  } catch {
-    return null;
-  }
-}
-
-export default { ogImageFor };
Index: src/services/OpenWebAuthService.js
===================================================================
--- src/services/OpenWebAuthService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,372 +1,0 @@
-/**
- * OpenWebAuth (FEP-61cf) — de TARGET-kant.
- *
- * Waarom dit bestaat: `fan_only` betekent "mijn volgers op de fediverse", maar
- * de poort vroeg om een KLONKT-ACCOUNT. Dat is de verkeerde vraag: precies de
- * mensen voor wie de poort openstaat -- volgers elders -- konden er niet door,
- * en wie er wel door kon had meestal niets met volgen te maken. Hiermee kan een
- * bezoeker bewijzen dat hij @iemand@ergens is, zonder hier een account, een
- * wachtwoord of een cookie van een derde partij.
- *
- * WIJ ZIJN DE TARGET INSTANCE, nooit de home instance. Dat is de prettige helft:
- * de home instance heeft de prive-sleutel nodig (om te ondertekenen en om ons
- * token te ontsleutelen), wij hebben alleen publieke sleutels nodig. Er staat
- * hier dus geen geheim van iemand anders, en we kunnen ook niemands identiteit
- * uitgeven. Het spiegelbeeld (Klonkt-gebruikers laten inloggen OP andere sites,
- * de /magic-kant) is bewust NIET gebouwd: dat is een andere functie.
- *
- * De stroom, met de FEP-stappen erbij:
- *   1. bezoeker geeft zijn adres        -> wij webfingeren hem, vinden zijn
- *                                          redirect-endpoint, sturen hem daarheen
- *   2. zijn server controleert hem      -> en vraagt ONS om een token
- *   3. wij verifieren die ondertekende  -> token terug, versleuteld met ZIJN
- *      aanvraag                            publieke sleutel
- *   4. zijn server ontsleutelt          -> stuurt hem terug met ?owt=<token>
- *   5. wij wisselen het token in        -> nu weten we wie hij is
- *
- * DRIE DINGEN DIE DE FEP ALS AANVAL BESCHRIJFT, en die hieronder staan omdat ze
- * anders precies de fout worden die je niet ziet:
- *
- *  - IMPERSONATIE. `?zid=` mag NOOIT iemands identiteit bepalen; alleen het
- *    ingewisselde `?owt=` telt. Mallory kan een link maken met zid=bob@elders,
- *    en komt dan terug met een token dat MALLORY zegt. Wie zid gelooft, laat
- *    Mallory als Bob binnen.
- *  - OPEN REDIRECT. Het redirect-endpoint dat we uit webfinger halen moet
- *    dezelfde host hebben als het adres dat de bezoeker intypte, anders sturen
- *    wij bezoekers naar waar een vreemde maar wil.
- *  - DoS. Tokens vervallen in minuten en gaan na een keer gebruiken weg.
- */
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-/** Kort, want tussen stap 3 en 5 zit alleen een redirect. De FEP noemt "a couple of minutes". */
-export const TOKEN_TTL_MS = 3 * 60 * 1000;
-
-/** rel-waarden uit de FEP. Letterlijk, want hier hangt de vindbaarheid aan. */
-export const REL_TOKEN = 'http://purl.org/openwebauth/v1';
-export const REL_REDIRECT = 'http://purl.org/openwebauth/v1#redirect';
-
-// ── tokens ────────────────────────────────────────────────────────────────
-
-/** Alles wat over tijd is weg. Draait bij elke uitgifte en elke inwisseling. */
-export function sweepTokens(now = Date.now()) {
-  db.prepare('DELETE FROM owa_tokens WHERE created_at < ?').run(now - TOKEN_TTL_MS);
-}
-
-/**
- * Stap 3: een token voor deze actor, opgeslagen zodat we hem straks herkennen.
- * URL-veilig, want hij reist als query-parameter terug.
- */
-export function issueToken(actorUri, now = Date.now()) {
-  sweepTokens(now);
-  const token = crypto.randomBytes(32).toString('base64url');
-  db.prepare('INSERT INTO owa_tokens (token, actor_uri, created_at) VALUES (?,?,?)')
-    .run(token, String(actorUri), now);
-  return token;
-}
-
-/**
- * Stap 5: eenmalig inwisselen. Geeft de actor terug, of null.
- *
- * Het verwijderen gebeurt ALTIJD, ook als het token te oud bleek: een token dat
- * eenmaal is aangeboden mag nooit een tweede kans krijgen.
- */
-export function redeemToken(token, now = Date.now()) {
-  const t = String(token || '');
-  if (!t) return null;
-  const row = db.prepare('SELECT actor_uri, created_at FROM owa_tokens WHERE token = ?').get(t);
-  if (row) db.prepare('DELETE FROM owa_tokens WHERE token = ?').run(t);
-  sweepTokens(now);
-  if (!row) return null;
-  if (now - row.created_at > TOKEN_TTL_MS) return null;
-  return row.actor_uri;
-}
-
-/**
- * Het token versleuteld met de PUBLIEKE sleutel van de actor, zodat alleen zijn
- * server het kan lezen. PKCS#1 v1.5 en base64url zonder '=' staan zo in de FEP;
- * dat is geen smaak maar interop met Hubzilla en (streams).
- */
-export function encryptTokenFor(token, publicKeyPem) {
-  const buf = crypto.publicEncrypt(
-    { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING },
-    Buffer.from(String(token), 'utf8'),
-  );
-  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
-}
-
-// ── ontdekken waar de bezoeker vandaan komt ───────────────────────────────
-
-/** `@iemand@ergens.nl`, `iemand@ergens.nl`, `acct:iemand@ergens.nl` -> {user, host}. */
-export function parseHandle(input) {
-  const m = String(input || '').trim().replace(/^acct:/i, '').replace(/^@/, '')
-    .match(/^([^@\s/]+)@([^@\s/]+)$/);
-  if (!m) return null;
-  const host = m[2].toLowerCase();
-  if (!/^[a-z0-9.-]+(:\d+)?$/i.test(host)) return null;
-  return { user: m[1], host, acct: `${m[1]}@${host}` };
-}
-
-/**
- * Stap 1: waar stuurt deze bezoeker zich heen om zich te bewijzen?
- *
- * De FEP: nieuwe implementaties horen te webfingeren, oude hard-coden /magic.
- * We doen het eerste en vallen terug op het tweede -- die terugval is veilig
- * omdat hij per constructie op DEZELFDE host ligt.
- *
- * En hier staat de open-redirect-controle: wat webfinger ook teruggeeft, het
- * moet de host zijn van het adres dat de bezoeker zelf intypte. Zonder die
- * regel wordt dit formulier een doorgeefluik naar elke gewenste URL.
- */
-export async function discoverRedirectEndpoint(handle, { fetchImpl = fetch } = {}) {
-  const h = parseHandle(handle);
-  if (!h) return null;
-  const url = `https://${h.host}/.well-known/webfinger?resource=${encodeURIComponent('acct:' + h.acct)}`;
-  let href = null;
-  try {
-    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
-    if (r.ok) {
-      const jrd = await r.json();
-      const link = (jrd.links || []).find((l) => l && l.rel === REL_REDIRECT && l.href);
-      if (link) href = link.href;
-    }
-  } catch { /* geen webfinger: hieronder de terugval */ }
-  if (!href) href = `https://${h.host}/magic`;
-  try {
-    if (new URL(href).host.toLowerCase() !== h.host) return null;   // open redirect
-  } catch { return null; }
-  return { endpoint: href, handle: h };
-}
-
-/** `bdest`: de terugkeer-URL als hex, zo staat het in de FEP. */
-export function toBdest(url) {
-  return Buffer.from(String(url), 'utf8').toString('hex');
-}
-
-/**
- * De URL waar we de bezoeker heen sturen.
- *
- * De terugkeer-URL moet BINNEN onze eigen origin liggen -- en het liefst binnen
- * de PWA-scope (siteUrlBase), anders komt iemand die de site op zijn
- * beginscherm heeft na het inloggen terecht in een losse browsertab terwijl de
- * app uitgelogd blijft. Dat ziet eruit als "inloggen werkt niet" en is het niet.
- */
-export function buildRedirect(endpoint, returnUrl) {
-  const u = new URL(endpoint);
-  u.searchParams.set('owa', '1');
-  u.searchParams.set('bdest', toBdest(returnUrl));
-  return u.toString();
-}
-
-// ── de HOME-kant: onze gebruiker bewijst zich elders ──────────────────────
-//
-// Hier zijn de rollen omgedraaid. Wij hebben nu de prive-sleutel nodig -- om te
-// ondertekenen en om het token te ontsleutelen -- en dat is precies waarom
-// alleen een echte instance deze kant kan spelen.
-
-/** `bdest` terug naar een URL. Hex in, URL uit; ongeldig = null. */
-export function fromBdest(hex) {
-  const h = String(hex || '');
-  if (!/^[0-9a-f]+$/i.test(h) || h.length % 2) return null;
-  try {
-    const u = new URL(Buffer.from(h, 'hex').toString('utf8'));
-    if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-    return u;
-  } catch { return null; }
-}
-
-/**
- * Het token-endpoint van de doelsite, gevonden via webfinger op zijn WORTEL.
- *
- * En meteen de open-redirect-verdediging van deze kant: het gevonden endpoint
- * moet dezelfde origin hebben als `bdest`. De FEP zegt het met zoveel woorden --
- * lukt de ontdekking niet, of wijst hij ergens anders heen, dan sturen we de
- * browser NIET naar bdest maar geven we een fout. Anders is /magic het
- * doorgeefluik.
- */
-export async function discoverTokenEndpoint(bdestUrl, { fetchImpl = fetch } = {}) {
-  let origin;
-  try { origin = new URL(bdestUrl).origin; } catch { return null; }
-  const url = `${origin}/.well-known/webfinger?resource=${encodeURIComponent(origin + '/')}`;
-  try {
-    const r = await fetchImpl(url, { headers: { accept: 'application/jrd+json, application/json' } });
-    if (!r.ok) return null;
-    const jrd = await r.json();
-    const link = (jrd.links || []).find((l) => l && l.rel === REL_TOKEN && l.href);
-    if (!link) return null;
-    if (new URL(link.href).origin !== origin) return null;   // open redirect
-    return link.href;
-  } catch { return null; }
-}
-
-/**
- * Het token ophalen bij de doelsite, ondertekend namens onze actor.
- *
- * De handtekening gaat in `Authorization: Signature ...` -- zo schrijft de FEP
- * het voor, en niet in de `Signature`-header die de rest van de fediverse
- * gebruikt. Plus `X-Open-Web-Auth` met willekeur erin: de doelsite doet er
- * niets mee, het voegt alleen entropie toe aan wat we ondertekenen.
- */
-export async function requestToken(endpoint, { keyId, privatePem, fetchImpl = fetch } = {}) {
-  const u = new URL(endpoint);
-  const date = new Date().toUTCString();
-  const nonce = crypto.randomBytes(16).toString('hex');
-  const target = `${u.pathname}${u.search || ''}`;
-  const signingString = [
-    `(request-target): get ${target}`,
-    `host: ${u.host}`,
-    `date: ${date}`,
-    `x-open-web-auth: ${nonce}`,
-  ].join('\n');
-  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
-  const headers = {
-    Accept: 'application/json',
-    Date: date,
-    'X-Open-Web-Auth': nonce,
-    Authorization: `Signature keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date x-open-web-auth",signature="${signature}"`,
-  };
-  const r = await fetchImpl(endpoint, { headers });
-  if (!r.ok) return null;
-  const j = await r.json();
-  if (!j || j.success !== true || !j.encrypted_token) return null;
-  return String(j.encrypted_token);
-}
-
-/**
- * Een deterministische nep-uitkomst, afgeleid uit de ciphertext en onze eigen
- * sleutel. Dit is de kern van implicit rejection: bij ongeldige padding geven we
- * GEEN fout maar een waarde, zodat "klopte de padding" nergens af te lezen is.
- *
- * DETERMINISTISCH, en dat is geen detail. Zou dit verse willekeur zijn, dan
- * geeft dezelfde ciphertext twee keer aanbieden twee verschillende antwoorden --
- * en juist dat verschil is het onderscheid dat we wilden verbergen. Zo doen TLS
- * en OpenSSL 3.2 het ook: afgeleid uit sleutel + ciphertext, dus stabiel bij
- * herhaling en onvoorspelbaar voor wie de sleutel niet heeft.
- *
- * Geëxporteerd omdat die eigenschap toetsbaar moet zijn; buiten de tests heeft
- * niemand hem nodig.
- *
- * EERLIJK OVER WAT DIT WEL EN NIET DRAAGT (gemeten 19-8): haal je hem weg, dan
- * blijft de suite groen. De andere tak geeft dan een LEGE string terug, en die
- * sneuvelt net zo goed op de tekenset-controle hieronder -- "werpt niet" en
- * "levert geen token" zijn dus al gedekt zonder deze functie. Wat hij toevoegt
- * is dat ALLE faalwegen dezelfde vorm teruggeven: verkeerde sleutel, verkeerde
- * lengte, kapotte base64, ongeldige padding. Een lege string is een verklikker
- * voor wie ooit naar de rauwe waarde kijkt in plaats van naar het eindoordeel;
- * afgeleide bytes zijn dat niet. Zo doen TLS en OpenSSL 3.2 het ook.
- */
-export function _nepUitkomst(privatePem, ct) {
-  const geheim = crypto.createHash('sha256').update(String(privatePem)).digest();
-  return crypto.createHmac('sha256', geheim).update(ct).digest().toString('latin1');
-}
-
-/**
- * PKCS#1 v1.5 zelf uitpakken (EME-PKCS1-v1_5: 00 02 PS 00 M).
- *
- * WAAROM ZELF: Node weigert `privateDecrypt` met RSA_PKCS1_PADDING sinds de
- * mitigatie voor CVE-2023-46809 (Marvin). De revert-vlag bestaat alleen op de
- * lijnen 18/20/21 -- Node 22+ heeft hem nooit gehad, en 20 is sinds 30 april
- * 2026 EOL. Er is dus geen weg terug; zie shaer-r15.
- *
- * OpenWebAuth (FEP-61cf) schrijft v1.5 voor, dus overstappen op OAEP repareert
- * de fout en breekt de interop met Hubzilla. Blijft over: `RSA_NO_PADDING` en
- * het omhulsel er zelf afhalen -- precies het stuk dat de CVE veroorzaakte, dus
- * met de zorg die daarbij hoort.
- *
- * GEEN VROEGE UITGANG EN GEEN WORP. De scan loopt altijd het hele blok af en
- * beide takken doen hetzelfde werk. Dat is geen echte constant-time -- die
- * krijg je in JavaScript met JIT en GC niet -- maar het haalt wel het
- * waarneembare verschil weg. Wat de aanval hier echt begrenst is de teller op
- * /magic: een orakel heeft honderdduizenden pogingen nodig.
- */
-function pakUit(blok, privatePem, ct) {
-  const k = blok.length;
-  // Kop: 00 02. Als getal uitrekenen, niet als vertakking.
-  let goed = ((blok[0] === 0x00) & (blok[1] === 0x02));
-  // Eerste nulbyte vanaf 2 zoeken ZONDER de lus te verlaten.
-  let sep = -1;
-  for (let i = 2; i < k; i++) {
-    const isNul = blok[i] === 0x00 ? 1 : 0;
-    const nogNiet = sep === -1 ? 1 : 0;
-    sep = sep + (isNul & nogNiet) * (i - sep);
-  }
-  // PS moet minstens 8 bytes zijn (RFC 8017), dus de scheider ligt op >= 10.
-  goed = goed & (sep >= 10 ? 1 : 0) & (sep < k ? 1 : 0);
-  const echt = blok.subarray(goed ? sep + 1 : k).toString('utf8');
-  const nep = _nepUitkomst(privatePem, ct);
-  return goed ? echt : nep;
-}
-
-/** Het token uitpakken met onze eigen prive-sleutel. */
-export function decryptToken(encrypted, privatePem) {
-  const b64 = String(encrypted || '').replace(/-/g, '+').replace(/_/g, '/');
-  const ct = Buffer.from(b64, 'base64');
-  let k = 0;
-  try { k = crypto.createPublicKey(privatePem).asymmetricKeyDetails.modulusLength / 8; } catch { k = 0; }
-
-  // Een blok van de verkeerde lengte zegt niets over de sleutel, maar het zou
-  // wel werpen -- en een worp is precies het signaal dat we kwijt willen. Dus
-  // dezelfde weg als een ongeldige padding.
-  let blok = null;
-  if (k && ct.length === k) {
-    try {
-      blok = crypto.privateDecrypt({ key: privatePem, padding: crypto.constants.RSA_NO_PADDING }, ct);
-    } catch { blok = null; }
-  }
-  const t = (blok && blok.length === k) ? pakUit(blok, privatePem, ct) : _nepUitkomst(privatePem, ct);
-
-  // Een token is URL-veilige tekst. Wat hierboven uit een mislukking komt is
-  // afgeleide onzin, en die hoort hier te stranden in plaats van als token de
-  // wereld in te gaan.
-  //
-  // ALLEBEI de voorwaarden doen werk, en dat is gemeten met 300 vreemde sleutels:
-  //  - de TEKENSET vangt vrijwel alles. Van die 300 was er geen enkele die
-  //    volledig uit URL-veilige tekens bestond.
-  //  - de ONDERGRENS vangt de rest. De onzin heeft een willekeurige lengte, en
-  //    bij een kort stukje is "toevallig allemaal URL-veilig" niet meer
-  //    verwaarloosbaar: per byte is die kans ruwweg een kwart.
-  // Zestien is daarmee geen rond getal maar een grens die iets doet. Een echte
-  // implementatie zit er ruim boven (de onze: 43 tekens).
-  return /^[A-Za-z0-9._~-]{16,512}$/.test(t) ? t : null;
-}
-
-// ── wie is er binnen ──────────────────────────────────────────────────────
-
-/** De actor die deze sessie bewees te zijn, of null. */
-export function guestActor(req) {
-  const g = req && req.session && req.session.owa;
-  return (g && typeof g.actor === 'string' && g.actor) ? g.actor : null;
-}
-
-/** Volgt deze actor deze site? Dat is de vraag die `fan_only` altijd al stelde. */
-export function isFollowerOf(slug, actorUri) {
-  if (!slug || !actorUri) return false;
-  const row = db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? LIMIT 1')
-    .get(String(slug), String(actorUri));
-  return !!row;
-}
-
-/**
- * Alles wat een poort over deze bezoeker moet weten, op één plek.
- *
- * Bewust hier en niet in PostAccessService: die module beslist en raakt de
- * database niet aan. Deze haalt op, die beslist.
- */
-export function viewerFor(req, site, extra = {}) {
-  const actor = guestActor(req);
-  return {
-    user: (req && req.session && req.session.user) || null,
-    site: site || null,
-    fediActor: actor,
-    isFollower: actor && site ? isFollowerOf(site.slug, actor) : false,
-    ...extra,
-  };
-}
-
-export default {
-  TOKEN_TTL_MS, REL_TOKEN, REL_REDIRECT,
-  sweepTokens, issueToken, redeemToken, encryptTokenFor,
-  parseHandle, discoverRedirectEndpoint, toBdest, buildRedirect,
-  guestActor, isFollowerOf, viewerFor,
-  fromBdest, discoverTokenEndpoint, requestToken, decryptToken,
-};
Index: src/services/PaidPatreonService.js
===================================================================
--- src/services/PaidPatreonService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,232 +1,0 @@
-// Paid posts (klonkt-demo-aki) slice 1: the site owner's own Patreon campaign.
-// Stores client id/secret + the creator access/refresh token (encrypted) and a
-// default price. Separate from PatreonService, which is Klonkt Premium's
-// instance-level license flow and stays untouched.
-import db from '../config/database.js';
-import { encrypt, decrypt, cryptoBoxReady } from './CryptoBox.js';
-
-const TOKEN_URL = 'https://www.patreon.com/api/oauth2/token';
-
-// The owner's config, secrets decrypted. Returns null when unconfigured.
-export function getOwnerConfig(siteId) {
-  const row = db.prepare('SELECT * FROM paid_patreon WHERE site_id = ?').get(siteId);
-  if (!row) return null;
-  return {
-    siteId: row.site_id,
-    clientId: row.client_id || null,
-    clientSecret: row.client_secret_enc ? safeDecrypt(row.client_secret_enc) : null,
-    campaignId: row.campaign_id || null,
-    accessToken: row.access_token_enc ? safeDecrypt(row.access_token_enc) : null,
-    refreshToken: row.refresh_token_enc ? safeDecrypt(row.refresh_token_enc) : null,
-    tokenExp: row.token_exp || 0,
-    defaultMinCents: row.default_min_cents || 0,
-    patreonUrl: row.patreon_url || null,
-  };
-}
-
-// Non-secret status for the admin screen (never returns tokens/secret).
-export function ownerStatus(siteId) {
-  const c = getOwnerConfig(siteId);
-  if (!c) return { configured: false, connected: false, defaultMinCents: 0 };
-  return {
-    configured: !!(c.clientId && c.clientSecret),
-    connected: !!(c.accessToken && c.campaignId),
-    clientId: c.clientId || null,
-    campaignId: c.campaignId || null,
-    defaultMinCents: c.defaultMinCents || 0,
-    tokenExp: c.tokenExp || 0,
-    hasSecret: !!c.clientSecret,
-    patreonUrl: c.patreonUrl || null,
-  };
-}
-
-// The owner's public Patreon page, for the "Word supporter" link. Null when unset.
-export function patreonUrl(siteId) {
-  const c = getOwnerConfig(siteId);
-  return c && c.patreonUrl ? c.patreonUrl : null;
-}
-
-// Upsert. Only overwrites secret/token fields when a new value is provided, so
-// the admin form can be re-saved without re-pasting the secret.
-export function saveOwnerConfig(siteId, patch) {
-  if (!cryptoBoxReady()) throw new Error('encryption key unavailable: cannot store Patreon secrets');
-  const cur = getOwnerConfig(siteId) || {};
-  const merged = {
-    clientId: patch.clientId ?? cur.clientId ?? null,
-    clientSecret: patch.clientSecret ?? cur.clientSecret ?? null,
-    campaignId: patch.campaignId ?? cur.campaignId ?? null,
-    accessToken: patch.accessToken ?? cur.accessToken ?? null,
-    refreshToken: patch.refreshToken ?? cur.refreshToken ?? null,
-    tokenExp: patch.tokenExp ?? cur.tokenExp ?? 0,
-    defaultMinCents: patch.defaultMinCents ?? cur.defaultMinCents ?? 0,
-    // undefined = keep (e.g. token refresh doesn't touch it); null/'' = clear.
-    patreonUrl: patch.patreonUrl !== undefined ? (patch.patreonUrl || null) : (cur.patreonUrl ?? null),
-  };
-  db.prepare(`INSERT INTO paid_patreon
-      (site_id, client_id, client_secret_enc, campaign_id, access_token_enc, refresh_token_enc, token_exp, default_min_cents, patreon_url, updated_at)
-      VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(site_id) DO UPDATE SET
-      client_id=excluded.client_id, client_secret_enc=excluded.client_secret_enc,
-      campaign_id=excluded.campaign_id, access_token_enc=excluded.access_token_enc,
-      refresh_token_enc=excluded.refresh_token_enc, token_exp=excluded.token_exp,
-      default_min_cents=excluded.default_min_cents, patreon_url=excluded.patreon_url, updated_at=CURRENT_TIMESTAMP`)
-    .run(
-      siteId,
-      merged.clientId,
-      merged.clientSecret != null ? encrypt(merged.clientSecret) : null,
-      merged.campaignId,
-      merged.accessToken != null ? encrypt(merged.accessToken) : null,
-      merged.refreshToken != null ? encrypt(merged.refreshToken) : null,
-      merged.tokenExp || 0,
-      Math.max(0, parseInt(merged.defaultMinCents, 10) || 0),
-      merged.patreonUrl || null,
-    );
-}
-
-export function disconnect(siteId) {
-  db.prepare('DELETE FROM paid_patreon WHERE site_id = ?').run(siteId);
-}
-
-export function defaultMinCents(siteId) {
-  const row = db.prepare('SELECT default_min_cents FROM paid_patreon WHERE site_id = ?').get(siteId);
-  return row ? (row.default_min_cents || 0) : 0;
-}
-
-// True when the stored creator token is missing or within `skewSeconds` of exp.
-export function needsRefresh(siteId, skewSeconds = 3600) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.refreshToken) return false;
-  return !c.accessToken || (c.tokenExp || 0) <= (Math.floor(Date.now() / 1000) + skewSeconds);
-}
-
-// Refresh the creator token via Patreon. Returns true on success. `fetchImpl`
-// is injectable for tests; defaults to global fetch.
-export async function refreshCreatorToken(siteId, fetchImpl = fetch) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.clientId || !c.clientSecret || !c.refreshToken) return false;
-  const body = new URLSearchParams({
-    grant_type: 'refresh_token',
-    refresh_token: c.refreshToken,
-    client_id: c.clientId,
-    client_secret: c.clientSecret,
-  });
-  const res = await fetchImpl(TOKEN_URL, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    body: body.toString(),
-  });
-  if (!res.ok) return false;
-  const j = await res.json();
-  if (!j || !j.access_token) return false;
-  saveOwnerConfig(siteId, {
-    accessToken: j.access_token,
-    refreshToken: j.refresh_token || c.refreshToken,
-    tokenExp: Math.floor(Date.now() / 1000) + (parseInt(j.expires_in, 10) || 0),
-  });
-  return true;
-}
-
-// A valid creator access token, refreshing first if it is stale. Null when the
-// owner has not connected. Used by the patron verify path (slice 3).
-export async function creatorAccessToken(siteId, fetchImpl = fetch) {
-  if (needsRefresh(siteId)) { try { await refreshCreatorToken(siteId, fetchImpl); } catch { /* fall through */ } }
-  const c = getOwnerConfig(siteId);
-  return c && c.accessToken ? c.accessToken : null;
-}
-
-// Pure: pick the owner's-campaign membership out of a Patreon
-// identity?include=memberships.campaign response (JSON:API). Returns
-// { status, cents } or null.
-//
-// STRICT match on campaignId only. Patreon's /identity returns ALL of the
-// visitor's memberships across every creator they back (verified: a tester had
-// 12), NOT just this creator's, so any fallback would grant access to someone
-// who backs a DIFFERENT creator. The campaignId must therefore be the owner's
-// real campaign; verifyPatron auto-derives it from the creator token so a
-// mistyped admin value can't lock real patrons out.
-export function pickCampaignMembership(identity, campaignId) {
-  if (!campaignId) return null;
-  const inc = (identity && identity.included) || [];
-  for (const it of inc) {
-    if (it.type !== 'member') continue;
-    const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
-    if (!camp || String(camp.id) !== String(campaignId)) continue;
-    const a = it.attributes || {};
-    return { status: a.patron_status || null, cents: a.currently_entitled_amount_cents || 0 };
-  }
-  return null;
-}
-
-// The campaign id owned by the creator token (i.e. the site owner's OWN
-// campaign). This is authoritative: it removes the "typed the wrong campaign_id"
-// failure mode. Null if there's no valid creator token or the call fails.
-export async function fetchOwnerCampaignId(siteId, fetchImpl = fetch) {
-  const token = await creatorAccessToken(siteId, fetchImpl).catch(() => null);
-  if (!token) return null;
-  const res = await fetchImpl('https://www.patreon.com/api/oauth2/v2/campaigns', {
-    headers: { Authorization: `Bearer ${token}` },
-  }).catch(() => null);
-  if (!res || !res.ok) return null;
-  const j = await res.json().catch(() => null);
-  const id = j && j.data && j.data[0] && j.data[0].id;
-  return id ? String(id) : null;
-}
-
-// Exchange a patron's auth code and read their membership of the owner's
-// campaign. Returns { status, cents, diag } (status null = not a patron); the
-// `diag` string is a NON-identifying breadcrumb (campaign ids + status + cents)
-// so a stuck owner can see why. Returns null only on hard misconfig. The patron
-// token is used once and discarded here: nothing identifying is stored.
-export async function verifyPatron(siteId, code, redirectUri, fetchImpl = fetch) {
-  const c = getOwnerConfig(siteId);
-  if (!c || !c.clientId || !c.clientSecret) return null;
-  const none = (diag) => ({ status: null, cents: 0, diag });
-  let tokenRes;
-  try {
-    tokenRes = await fetchImpl(TOKEN_URL, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-      body: new URLSearchParams({
-        grant_type: 'authorization_code', code,
-        client_id: c.clientId, client_secret: c.clientSecret, redirect_uri: redirectUri,
-      }).toString(),
-    });
-  } catch { return none('token_fetch_error'); }
-  if (!tokenRes.ok) return none(`token_http_${tokenRes.status}`);
-  const tok = await tokenRes.json();
-  if (!tok || !tok.access_token) return none('no_access_token');
-  const url = 'https://www.patreon.com/api/oauth2/v2/identity'
-    + '?include=memberships.campaign'
-    + '&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents';
-  const idRes = await fetchImpl(url, { headers: { Authorization: `Bearer ${tok.access_token}` } });
-  if (!idRes.ok) return none(`identity_http_${idRes.status}`);
-  const identity = await idRes.json();
-  // Authoritative campaign id: the one owned by the creator token. Beats a
-  // mistyped admin value. Self-heal the stored config when they differ.
-  const ownerCampaign = await fetchOwnerCampaignId(siteId, fetchImpl).catch(() => null);
-  const campaignId = ownerCampaign || c.campaignId;
-  if (ownerCampaign && String(ownerCampaign) !== String(c.campaignId)) {
-    try { saveOwnerConfig(siteId, { campaignId: ownerCampaign }); } catch { /* non-fatal */ }
-  }
-  const membership = pickCampaignMembership(identity, campaignId);   // token goes out of scope, discarded
-  const seen = ((identity && identity.included) || [])
-    .filter((it) => it.type === 'member')
-    .map((it) => {
-      const camp = it.relationships && it.relationships.campaign && it.relationships.campaign.data;
-      const a = it.attributes || {};
-      return `${camp ? camp.id : '?'}:${a.patron_status || 'null'}:${a.currently_entitled_amount_cents || 0}c`;
-    });
-  const diag = `owner=${ownerCampaign || 'unknown'} config=${c.campaignId || 'none'} seen=[${seen.join(', ') || 'none'}] picked=${membership ? membership.status + '/' + membership.cents + 'c' : 'null'}`;
-  if (!membership || membership.status !== 'active_patron') console.warn(`[paid] verifyPatron: ${diag}`);
-  return { status: membership ? membership.status : null, cents: membership ? membership.cents : 0, diag };
-}
-
-function safeDecrypt(blob) {
-  try { return decrypt(blob); } catch { return null; }
-}
-
-export default {
-  getOwnerConfig, ownerStatus, saveOwnerConfig, disconnect,
-  defaultMinCents, patreonUrl, needsRefresh, refreshCreatorToken, creatorAccessToken,
-  pickCampaignMembership, fetchOwnerCampaignId, verifyPatron,
-};
Index: src/services/PasskeyService.js
===================================================================
--- src/services/PasskeyService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,137 +1,0 @@
-// Paid posts (klonkt-demo-aki) slice 3: passkey registration + verification for
-// pseudonymous entitlements. Uses @simplewebauthn/server. Cookie-less: the
-// challenge is not kept in a session but travels inside a signed blob
-// (CryptoBox.signBlob) that the client returns, so there is nothing to store
-// between the two requests. An entitlement is {passkey, site, cents, expiry}
-// with NO patron identity.
-import crypto from 'crypto';
-import db from '../config/database.js';
-
-// Lazy so a not-yet-installed dependency can never crash app boot; only the
-// paid passkey flow fails until `npm ci` has run.
-let _lib = null;
-async function lib() { if (!_lib) _lib = await import('@simplewebauthn/server'); return _lib; }
-
-const DEFAULT_TTL_DAYS = 32;   // aligns with Patreon's monthly cycle; re-link after
-
-// rpID is the site host; origin is the full base URL.
-export function rpFor(base) {
-  let host = ''; try { host = new URL(base).host.split(':')[0]; } catch { /* keep empty */ }
-  return { rpID: host, origin: String(base).replace(/\/+$/, '') };
-}
-
-// Registration options for a fresh, discoverable (usernameless) passkey. The
-// user handle is random: the credential is pseudonymous by design.
-export async function registrationOptions(base, siteSlug) {
-  const { rpID } = rpFor(base);
-  const { generateRegistrationOptions } = await lib();
-  return generateRegistrationOptions({
-    rpName: `Supporter of ${siteSlug}`,
-    rpID,
-    userName: 'supporter',
-    userDisplayName: 'Supporter',
-    userID: crypto.randomBytes(16),
-    attestationType: 'none',
-    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
-    timeout: 120000,
-  });
-}
-
-// Verify a registration response against the challenge (read from the signed
-// blob by the caller). Returns the credential to store, or null.
-export async function verifyRegistration(base, response, expectedChallenge) {
-  const { rpID, origin } = rpFor(base);
-  let v;
-  try {
-    const { verifyRegistrationResponse } = await lib();
-    v = await verifyRegistrationResponse({
-      response,
-      expectedChallenge,
-      expectedOrigin: origin,
-      expectedRPID: rpID,
-      requireUserVerification: false,
-    });
-  } catch { return null; }
-  if (!v || !v.verified || !v.registrationInfo) return null;
-  const cred = v.registrationInfo.credential;
-  return {
-    credentialId: cred.id,                                        // base64url string
-    publicKey: Buffer.from(cred.publicKey).toString('base64url'), // COSE key bytes
-    counter: cred.counter || 0,
-    transports: response.response && response.response.transports ? JSON.stringify(response.response.transports) : null,
-  };
-}
-
-// Authentication (assertion) options for the unlock. Discoverable credentials,
-// so allowCredentials is empty and the browser offers the site's passkeys.
-export async function authenticationOptions(base) {
-  const { rpID } = rpFor(base);
-  const { generateAuthenticationOptions } = await lib();
-  return generateAuthenticationOptions({ rpID, userVerification: 'preferred', allowCredentials: [] });
-}
-
-// Verify an assertion against a stored entitlement row. Returns { newCounter }
-// or null. Challenge is read from the signed blob by the caller.
-export async function verifyAssertion(base, response, expectedChallenge, ent) {
-  const { rpID, origin } = rpFor(base);
-  let v;
-  try {
-    const { verifyAuthenticationResponse } = await lib();
-    v = await verifyAuthenticationResponse({
-      response,
-      expectedChallenge,
-      expectedOrigin: origin,
-      expectedRPID: rpID,
-      requireUserVerification: false,
-      credential: {
-        id: ent.credential_id,
-        publicKey: Buffer.from(ent.public_key, 'base64url'),
-        counter: ent.counter || 0,
-        transports: ent.transports ? JSON.parse(ent.transports) : undefined,
-      },
-    });
-  } catch { return null; }
-  if (!v || !v.verified) return null;
-  return { newCounter: v.authenticationInfo.newCounter };
-}
-
-// Bump the signature counter after a successful assertion (clone detection).
-export function bumpCounter(credentialId, newCounter) {
-  db.prepare('UPDATE paid_entitlements SET counter = ? WHERE credential_id = ?').run(newCounter || 0, credentialId);
-}
-
-// Store (or refresh) a pseudonymous entitlement for this passkey.
-export function storeEntitlement({ credentialId, siteId, publicKey, counter, transports, minCents, ttlDays = DEFAULT_TTL_DAYS }) {
-  const expiresAt = Math.floor(Date.now() / 1000) + ttlDays * 86400;
-  db.prepare(`INSERT INTO paid_entitlements
-      (credential_id, site_id, public_key, counter, transports, min_cents, expires_at, created_at)
-      VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(credential_id) DO UPDATE SET
-      public_key=excluded.public_key, counter=excluded.counter, transports=excluded.transports,
-      min_cents=excluded.min_cents, expires_at=excluded.expires_at`)
-    .run(credentialId, siteId, publicKey, counter || 0, transports || null, Math.max(0, minCents || 0), expiresAt);
-  return expiresAt;
-}
-
-// A valid, unexpired entitlement for this passkey on this site, else null.
-export function getEntitlement(credentialId, siteId) {
-  const row = db.prepare('SELECT * FROM paid_entitlements WHERE credential_id = ? AND site_id = ?').get(credentialId, siteId);
-  if (!row) return null;
-  if ((row.expires_at || 0) < Math.floor(Date.now() / 1000)) return null;
-  return row;
-}
-
-export function deleteEntitlement(credentialId) {
-  return db.prepare('DELETE FROM paid_entitlements WHERE credential_id = ?').run(credentialId).changes > 0;
-}
-
-// Prune expired entitlements (Scheduler, slice 5).
-export function pruneExpired() {
-  return db.prepare('DELETE FROM paid_entitlements WHERE expires_at < ?').run(Math.floor(Date.now() / 1000)).changes;
-}
-
-export default {
-  rpFor, registrationOptions, verifyRegistration, storeEntitlement,
-  getEntitlement, deleteEntitlement, pruneExpired,
-  authenticationOptions, verifyAssertion, bumpCounter,
-};
Index: src/services/PatreonService.js
===================================================================
--- src/services/PatreonService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-// Patreon entitlement (premium layer).
-//
-// Model (Klonkt, 2026-06): the app + all updates are free. A set of premium
-// extras (newsletter, download-for-email, release planning + fan-only posts,
-// EPK/press kit, pro statistics, link-in-bio, embeddable player, show agenda)
-// is gated behind a $16 lifetime Patreon supporter status. The central license
-// server (license.klonkt.com)
-// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
-// verifies that token OFFLINE using the server's public key — a cracked/forked
-// self-host cannot forge a valid token (only the license server can sign).
-// That is the real lock; feature flags themselves can be patched on self-host
-// (deliberately accepted: $16 < effort to crack).
-//
-// Premium gating is ON by default: the extras (newsletter, statistics, …)
-// require a linked Patreon supporter. KLONKT_PREMIUM_ENABLED=off disables the
-// premium layer (intended for internal/demo instances).
-
-import crypto from 'node:crypto';
-import { getSetting, setSetting } from './SettingsService.js';
-
-const LICENSE_URL = (process.env.KLONKT_LICENSE_URL || 'https://license.klonkt.com').replace(/\/$/, '');
-const ISSUER = 'klonkt-license';
-
-export function premiumEnabled() {
-  // Default ON; only an explicit 'off' disables the premium layer.
-  return String(process.env.KLONKT_PREMIUM_ENABLED || 'on').toLowerCase() !== 'off';
-}
-export function licenseBase() { return LICENSE_URL; }
-
-// --- Cache the license-server public key (for offline verification) ---
-let _pubKey = null;
-async function licensePublicKey() {
-  if (_pubKey) return _pubKey;
-  const res = await fetch(`${LICENSE_URL}/pubkey`);
-  if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
-  const pem = await res.text();
-  _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
-  return _pubKey;
-}
-
-function b64urlToBuf(s) {
-  return Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
-}
-
-// Verify an entitlement token (EdDSA JWT from the license server). Throws on
-// invalid signature, issuer, or expiry. Returns the claims on success.
-export async function verifyEntitlementToken(token) {
-  const parts = String(token || '').split('.');
-  if (parts.length !== 3) throw new Error('malformed token');
-  const [h, p, s] = parts;
-  const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
-  if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
-  const key = await licensePublicKey();
-  const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
-  if (!ok) throw new Error('invalid signature');
-  const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
-  if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
-  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
-  return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
-}
-
-export function storeEntitlement(payload, token) {
-  setSetting('patreon_entitled', payload.entitled ? '1' : '0');
-  setSetting('patreon_sub', String(payload.sub || ''));
-  setSetting('patreon_support_cents', String(payload.lifetime_support_cents || 0));
-  setSetting('patreon_token_exp', String(payload.exp || 0));
-  setSetting('patreon_token', token || '');
-}
-
-export function clearEntitlement() {
-  for (const k of ['patreon_entitled', 'patreon_sub', 'patreon_support_cents', 'patreon_token_exp', 'patreon_token']) {
-    setSetting(k, '');
-  }
-}
-
-// Is this instance premium? Premium layer enabled + a valid, non-expired,
-// entitled stored token. Patreon lifetime never decreases, so re-linking
-// after expiry always succeeds.
-export function isPremium() {
-  if (!premiumEnabled()) return false;
-  if (getSetting('patreon_entitled') !== '1') return false;
-  const exp = Number(getSetting('patreon_token_exp', '0')) || 0;
-  if (exp && exp * 1000 < Date.now()) return false;
-  return true;
-}
-
-// Is a premium feature available? True if the premium layer is OFF (nothing is
-// gated — current behavior), or ON and this instance is entitled. False only
-// if premium is on but there is no valid Patreon connection (= paywall).
-export function premiumUnlocked() {
-  return !premiumEnabled() || isPremium();
-}
-
-export function entitlementStatus() {
-  return {
-    enabled: premiumEnabled(),
-    premium: isPremium(),
-    connected: getSetting('patreon_entitled') === '1',
-    sub: getSetting('patreon_sub', '') || null,
-    supportCents: Number(getSetting('patreon_support_cents', '0')) || 0,
-    exp: Number(getSetting('patreon_token_exp', '0')) || 0,
-  };
-}
Index: src/services/PermissionsService.js
===================================================================
--- src/services/PermissionsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PermissionsService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,6 +3,4 @@
  * Used in templates to show/hide edit buttons, delete buttons, etc.
  */
-
-import db from '../config/database.js';
 
 class PermissionsService {
@@ -46,5 +44,4 @@
     if (!user) return false;
     if (user.role === 'god') return true;
-    if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to
     if (user.id === site.owner_id) return true; // Site owner
     if (this.canAdminSite(user, site)) return true;
@@ -56,13 +53,9 @@
    */
   static canAdminSite(user, site) {
-    if (!user || !site) return false;
+    if (!user) return false;
     if (user.role === 'god') return true;
     if (user.id === site.owner_id) return true;
-    // Assigned co-admin (collaborator) via site_members. Previously read from
-    // a never-populated user.siteRoles → dead code; now queried directly on
-    // the table (a few checks per page, indexed = cheap).
-    return !!db.prepare(
-      "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1"
-    ).get(site.id, user.id);
+    // Check site_members table
+    return user.siteRoles && user.siteRoles[site.id] === 'admin';
   }
 
Index: src/services/PlatformIcons.js
===================================================================
--- src/services/PlatformIcons.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PlatformIcons.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -35,7 +35,5 @@
     brand: '#FC3C44',
     host: 'music.apple.com',
-    // The classic Apple mark (bitten apple + leaf). The previous path was a garbled
-    // app-tile outline that didn't read as the Apple logo at icon size.
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.03 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.56-1.702"/></svg>',
+    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23.997 6.124c0-.738-.065-1.47-.24-2.19-.317-1.31-1.062-2.31-2.18-3.043C21.003.517 20.373.285 19.7.164c-.517-.093-1.038-.135-1.564-.15-.04-.003-.083-.01-.124-.013H5.988c-.152.01-.303.017-.455.026C4.786.07 4.043.15 3.34.428 2.004.958 1.04 1.88.475 3.208c-.192.448-.292.925-.363 1.408-.056.392-.088.785-.1 1.18 0 .032-.007.062-.01.093v12.223c.01.14.017.283.027.424.05.815.154 1.624.497 2.373.65 1.42 1.738 2.353 3.234 2.802.42.127.856.187 1.293.228.555.053 1.11.06 1.667.06h11.03c.525 0 1.048-.034 1.57-.1.823-.106 1.597-.35 2.296-.81 1.36-.89 2.193-2.13 2.515-3.728.057-.32.084-.65.104-.978.046-.78.06-1.54.06-2.323-.013-.05-.013-.1-.013-.146 0-.13 0-.26.013-.39 0-.026 0-.04-.013-.067V7.197c0-.35-.023-.7-.05-1.05M17.875 14.7c-.166.48-.37.94-.624 1.38-.504.866-1.197 1.57-2.078 2.057-.36.198-.738.358-1.13.477-.96.292-1.962.353-2.96.158-.978-.193-1.876-.602-2.69-1.184-.83-.595-1.526-1.327-2.09-2.176-.512-.77-.91-1.604-1.196-2.49-.286-.886-.46-1.81-.504-2.74-.044-.94.043-1.87.27-2.78.226-.91.59-1.78 1.085-2.6.494-.82 1.114-1.55 1.85-2.18.736-.628 1.586-1.13 2.522-1.5.936-.37 1.94-.555 2.967-.55.7.003 1.4.094 2.08.27.68.176 1.34.44 1.952.78.04.022.08.043.122.064-.005.046-.012.09-.018.135-.063.514-.158 1.02-.29 1.516-.06.226-.13.45-.21.668-.11.28-.236.555-.376.82-.1.19-.21.376-.327.557-.16.247-.336.482-.524.706-.187.224-.39.435-.598.638-.115.111-.234.218-.355.323-.04.034-.08.066-.118.1-.205.176-.42.337-.642.488-.27.183-.554.346-.852.488l-.044.022c-.003-.005-.005-.01-.008-.013-.002-.005-.005-.01-.008-.014v-.013z"/></svg>',
   },
   youtube: {
@@ -87,39 +85,10 @@
     svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg>',
   },
-  telegram: {
-    label: 'Telegram',
-    brand: '#229ED9',
-    host: 't.me',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
-  },
-  whatsapp: {
-    label: 'WhatsApp',
-    brand: '#25D366',
-    host: 'wa.me',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/></svg>',
-  },
-  phone: {
-    label: 'Telefoon',
-    brand: '#34A853',
-    host: '',
-    svg: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>',
-  },
 };
 
 export const PLATFORM_ORDER = [
   'spotify','bandcamp','soundcloud','applemusic','youtube','vimeo',
-  'instagram','twitter','mastodon','github','website','email','telegram','whatsapp','phone',
+  'instagram','twitter','mastodon','github','website','email',
 ];
-
-// Contact-type platforms (shown grouped in the profile summary).
-export const CONTACT_PLATFORMS = ['email','phone','telegram','whatsapp'];
-
-// Build the real href for a profile link (tel:/mailto: prefixes where needed).
-export function linkHref(platform, url) {
-  const u = String(url || '').trim();
-  if (platform === 'email' && u && u.indexOf('mailto:') !== 0) return 'mailto:' + u;
-  if (platform === 'phone' && u && !/^tel:/i.test(u)) return 'tel:' + u.replace(/[^\d+]/g, '');
-  return u;
-}
 
 export function listPlatforms() {
@@ -127,3 +96,3 @@
 }
 
-export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref };
+export default { PLATFORMS, PLATFORM_ORDER, listPlatforms };
Index: src/services/PlaylistService.js
===================================================================
--- src/services/PlaylistService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/PlaylistService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -14,57 +14,4 @@
 import db from '../config/database.js';
 import { v4 as uuid } from 'uuid';
-import { SOORTEN } from '../assets/js/shared/post-music-type.js';
-
-/**
- * Een volledige datum of niets (shaer-756s).
- *
- * STRIKT, en dat is de hele functie. `year` bestaat al en blijft; dit veld
- * bestaat juist omdat een jaartal geen uitgavedatum is. Zou hij "2024"
- * doorlaten en er 2024-01-01 van maken, dan stond er straks een dag op de
- * federatie die niemand ooit heeft ingevoerd -- en dan hadden we het veld net
- * zo goed niet kunnen toevoegen.
- *
- * Ook 2024-02-31 valt af: dat is geen strengheid om de strengheid, Date rolt
- * hem stilletjes door naar 2 maart en dan slaan we iets anders op dan er
- * ingetypt is.
- */
-function normDatum(v) {
-  const s = String(v == null ? '' : v).trim();
-  if (!s) return null;
-  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
-  const d = new Date(`${s}T00:00:00Z`);
-  return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;
-}
-
-/** Een MusicBrainz-id of niets. Zelfde vorm als sites.mb_artist_id. */
-function normMbid(v) {
-  const s = String(v == null ? '' : v).trim().toLowerCase();
-  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s) ? s : null;
-}
-
-/**
- * De drie soorten die een playlist kan zijn, op EEN plek.
- *
- * Stond eerder vijf keer als `data.kind === 'playlist' ? 'playlist' : 'album'`
- * verspreid over dit bestand. Met twee soorten kon dat nog; bij een derde is
- * het een fout die staat te wachten, want een van de vijf vergeten betekent dat
- * een mixtape stilletjes als album wordt opgeslagen en als Album de deur uit
- * gaat.
- *
- * Album blijft de terugval: een onbekende waarde hoort niet stilzwijgend iets
- * nieuws te worden. De lijst zelf staat in de gedeelde pure module, want de
- * editor in de browser moet dezelfde drie kennen.
- */
-export function normKind(v) {
-  const s = String(v == null ? '' : v).trim().toLowerCase();
-  return SOORTEN.includes(s) ? s : 'album';
-}
-
-/**
- * Draagt deze soort uitgavegegevens? Alleen een album. Een afspeellijst en een
- * mixtape hebben geen uitgavedatum en geen release-id: het zijn samenstellingen
- * van andermans of eigen werk, geen uitgave op zichzelf.
- */
-export const isUitgave = (kind) => normKind(kind) === 'album';
 
 class PlaylistService {
@@ -105,5 +52,4 @@
     const rows = db.prepare(`
       SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind,
-             p.release_date, p.mb_release_id,
              p.created_at, p.updated_at,
              (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count
@@ -118,7 +64,5 @@
       year: r.year || 0,
       cover: r.cover_url || '',
-      kind: normKind(r.kind),
-      release_date: r.release_date || '',
-      mb_release_id: r.mb_release_id || '',
+      kind: r.kind || 'album',
       track_count: r.track_count,
       created_at: r.created_at,
@@ -133,14 +77,14 @@
    * Returns null if the playlist doesn't exist.
    *
-   * `urlFor` is an optional callback that takes a media filename and returns
-   * its stream URL. If not provided, tracks come back with no `url` and the
-   * caller has to resolve them. The render pipeline in posts.js always passes
-   * urlFor.
-   */
-  static get(siteId, id, urlFor) {
+   * `signUrl` is an optional callback that takes a media filename and returns
+   * a (possibly signed) URL. If not provided, tracks come back with no `url`
+   * and the caller has to resolve them. The render pipeline in posts.js
+   * always passes signUrl.
+   */
+  static get(siteId, id, signUrl) {
     id = this.normalizeId(id);
     if (!id) return null;
     const p = db.prepare(`
-      SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at
+      SELECT id, title, artist, year, cover_url, kind, created_at, updated_at
       FROM playlists WHERE site_id = ? AND id = ?
     `).get(siteId, id);
@@ -150,6 +94,5 @@
     // filenames (only tracks with a media file are playable).
     const tracks = db.prepare(`
-      SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
-             t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
+      SELECT t.id, t.title, t.artist, t.duration, t.cover_url, m.filename
       FROM playlist_tracks pt
       JOIN audio_tracks t   ON t.id = pt.track_id
@@ -159,19 +102,4 @@
     `).all(id);
 
-    const mappedTracks = tracks
-      // Link-only tracks (no media file) remain in the list with url ''.
-      .map(t => ({
-        id: t.id,
-        title: t.title || 'Untitled',
-        artist: t.artist || p.artist || '',
-        cover: t.cover_url || p.cover_url || '',
-        duration: t.duration || 0,
-        link_spotify: t.link_spotify || '',
-        link_youtube: t.link_youtube || '',
-        link_soundcloud: t.link_soundcloud || '',
-        url: (t.filename && urlFor) ? urlFor(t.filename) : '',
-      }));
-    // No playlist cover? Fall back to the first track cover so the card isn't empty.
-    const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
     return {
       id: p.id,
@@ -179,16 +107,16 @@
       artist: p.artist || '',
       year: p.year || 0,
-      cover: p.cover_url || fallbackCover,
-      kind: normKind(p.kind),
-      // created_at hoort erbij omdat de AP-kant er `published` van maakt. Zonder
-      // dit veld viel buildAlbumObject terug op 1970, en dat stond op 16-8
-      // gewoon op de federatie.
-      created_at: p.created_at,
-      // Leeg als het een afspeellijst is -- de opslag houdt ze daar al leeg,
-      // maar dit is de plek waar de editor leest en die mag niet afhangen van
-      // wat er toevallig in de kolom stond.
-      release_date: isUitgave(p.kind) ? (p.release_date || '') : '',
-      mb_release_id: isUitgave(p.kind) ? (p.mb_release_id || '') : '',
-      tracks: mappedTracks,
+      cover: p.cover_url || '',
+      kind: (p.kind === 'playlist') ? 'playlist' : 'album',
+      tracks: tracks
+        .filter(t => t.filename)  // skip orphaned references
+        .map(t => ({
+          id: t.id,
+          title: t.title || 'Untitled',
+          artist: t.artist || p.artist || '',
+          cover: t.cover_url || p.cover_url || '',
+          duration: t.duration || 0,
+          url: signUrl ? signUrl(t.filename).url : null,
+        })),
     };
   }
@@ -204,20 +132,10 @@
     const id = this.generateId(siteId, title);
     const now = new Date().toISOString();
-    const kind = normKind(data.kind);
-
-    // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
-    // uitgavedatum en geen release-id, en dat onderscheid is precies wat de
-    // keuze album/playlist betekent (shaer-cyg). Het afdwingen gebeurt HIER en
-    // niet alleen in het scherm: een scherm kun je omzeilen -- de API ligt open
-    // voor de post-editor -- en dan staat er stille rommel op een mixtape die
-    // later als Album de deur uit gaat.
-    const uitgave = isUitgave(kind);
-    const releaseDate = uitgave ? normDatum(data.release_date) : null;
-    const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
+    const kind = data.kind === 'playlist' ? 'playlist' : 'album';
 
     const tx = db.transaction(() => {
       db.prepare(`
-        INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, created_at, updated_at)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
       `).run(
         id, siteId, title,
@@ -225,5 +143,5 @@
         Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
         String(data.cover || '').trim() || null,
-        kind, releaseDate, mbRelease, now, now,
+        kind, now, now,
       );
       this._writeTracks(id, siteId, data.tracks);
@@ -246,5 +164,5 @@
     if (!id) return false;
     const existing = db.prepare(
-      'SELECT id, kind FROM playlists WHERE site_id = ? AND id = ?'
+      'SELECT id FROM playlists WHERE site_id = ? AND id = ?'
     ).get(siteId, id);
     if (!existing) return false;
@@ -252,9 +170,4 @@
     const fields = [];
     const values = [];
-    // Wat wordt het NA deze wijziging? `kind` hoeft niet in data te staan, dus
-    // val terug op wat er ligt.
-    const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
-      ? normKind(data.kind)
-      : normKind(existing.kind);
     if (Object.prototype.hasOwnProperty.call(data, 'title')) {
       const v = String(data.title || '').trim();
@@ -273,21 +186,5 @@
     }
     if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
-      fields.push('kind = ?'); values.push(nieuwKind);
-    }
-    // De uitgavevelden. Wordt dit een afspeellijst, dan gaan ze ALTIJD leeg --
-    // ook als de aanroeper er niets over zei. Anders houdt een album dat je tot
-    // mixtape ombouwt zijn uitgavedatum en zijn release-id, en die duiken dan
-    // weer op zodra iemand hem terugzet. Een veld dat niet meer mag bestaan
-    // hoort weg te zijn, niet te wachten.
-    if (nieuwKind !== 'album') {
-      fields.push('release_date = ?'); values.push(null);
-      fields.push('mb_release_id = ?'); values.push(null);
-    } else {
-      if (Object.prototype.hasOwnProperty.call(data, 'release_date')) {
-        fields.push('release_date = ?'); values.push(normDatum(data.release_date));
-      }
-      if (Object.prototype.hasOwnProperty.call(data, 'mb_release_id')) {
-        fields.push('mb_release_id = ?'); values.push(normMbid(data.mb_release_id));
-      }
+      fields.push('kind = ?'); values.push(data.kind === 'playlist' ? 'playlist' : 'album');
     }
     fields.push('updated_at = ?'); values.push(new Date().toISOString());
@@ -316,5 +213,5 @@
    * Delete a playlist. Track references in playlist_tracks are removed
    * automatically via ON DELETE CASCADE. Posts that embed this playlist
-   * will render a "playlist not found" placeholder.
+   * will render a "playlist niet gevonden" placeholder.
    */
   static delete(siteId, id) {
Index: src/services/PostAccessService.js
===================================================================
--- src/services/PostAccessService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,108 +1,0 @@
-/**
- * Wat mag DEZE bezoeker van DIT bericht zien?
- *
- * De vraag stond tot nu toe alleen in de postroute, en daar werd hij beantwoord
- * door een ANDERE PAGINA te renderen: paid-gate of fan-gate in plaats van het
- * bericht. Dat werkt zolang een bericht een pagina is. Zodra de tijdlijn hele
- * berichten toont, moet hetzelfde besluit een STUK opleveren dat tussen de
- * andere berichten past -- en twee plekken die allebei zelf beslissen wie wat
- * mag zien, zijn een lek dat wacht op een gelegenheid.
- *
- * Dus één functie, en de route en de tijdlijn lezen er allebei uit. Deze module
- * bepaalt alleen WAT er mag; hoe het eruitziet is aan de sjablonen.
- */
-import PermissionsService from './PermissionsService.js';
-
-/**
- * De teaser van een betaald bericht: nooit meer dan de eerste alinea.
- *
- * Stond als paidTeaser() in routes/posts.js. Hier neergezet omdat de tijdlijn
- * hem ook nodig heeft, en een tweede kopie vroeg of laat meer prijsgeeft dan
- * deze -- precies de fout die je bij een betaalmuur niet wilt maken.
- */
-export function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  const html = String((post && post.content) || '');
-  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
-  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
-}
-
-/**
- * Het besluit, als één waarde.
- *
- *   'full'      → de hele inhoud
- *   'paid'      → betaalmuur: teaser + ontgrendelknop
- *   'fan'       → alleen voor fans: teaser + inloggen
- *   'forbidden' → niet gepubliceerd en jij mag het niet bewerken
- *
- * De VOLGORDE is niet vrijblijvend. Betaald wordt vóór fan-only getoetst, want
- * een bericht dat allebei is gaat open met een passkey en niet met een
- * Klonkt-login; andersom belandt een anonieme supporter op het inlogscherm en
- * ziet hij de ontgrendelknop nooit. Die regel stond als commentaar in de route
- * en verhuist hier mee, want hij hoort bij het besluit en niet bij de pagina.
- *
- * @param post     de rij uit `posts`
- * @param viewer   { user, site, unlockedSlug, isFollower } -- unlockedSlug is de
- *                 slug uit een vers ?u=-bewijs van /paid/unlock, al geverifieerd
- *                 door de aanroeper; isFollower komt uit OpenWebAuthService en
- *                 zegt dat een bewezen fedi-actor deze site volgt. Deze module
- *                 doet geen crypto en raakt de database niet aan: ophalen doet
- *                 de aanroeper, beslissen doet deze.
- */
-export function postAccess(post, { user = null, site = null, unlockedSlug = null, isFollower = false } = {}) {
-  if (!post) return 'forbidden';
-
-  const canEdit = !!(user && PermissionsService.canEditPost(user, post, site));
-
-  // Een concept is van de maker. Dit stond vóór beide poorten in de route en
-  // hoort dat te blijven: over een ongepubliceerd bericht valt niets te kopen
-  // en niets te ontgrendelen.
-  if (post.status !== 'published' && !canEdit) return 'forbidden';
-
-  // De eigenaar/redacteur ziet altijd zijn eigen werk, betaald of niet.
-  if (canEdit) return 'full';
-
-  if (post.paid && String(unlockedSlug || '') !== String(post.slug)) return 'paid';
-  // `fan_only` betekende altijd al "mijn volgers op de fediverse", maar de poort
-  // vroeg om een lokaal account -- de verkeerde vraag, die juist de mensen
-  // buitensloot voor wie hij openstond. `isFollower` is het antwoord op de
-  // ECHTE vraag: een bezoeker die via OpenWebAuth bewees @iemand@ergens te zijn
-  // en die deze site volgt. Wie dat bewijs levert, komt binnen.
-  //
-  // Het lokale account blijft er ook door, zoals het altijd deed: dat is een
-  // andere manier om te weten wie iemand is, niet een mindere.
-  if (post.fan_only && !user && !isFollower) return 'fan';
-  return 'full';
-}
-
-/** Handig voor sjablonen: mag de bezoeker de echte inhoud zien? */
-export function canReadBody(post, viewer) { return postAccess(post, viewer) === 'full'; }
-
-/**
- * Eén bericht, klaar voor de tijdlijn.
- *
- * DE HELE REDEN DAT DIT EEN FUNCTIE IS: het lijf wordt alleen GERENDERD als het
- * ook getoond mag worden. Een sjabloon dat zelf `<% if (access === 'full') %>`
- * doet krijgt de volledige tekst tóch mee in het model, en dan is het één
- * vergeten conditie -- of één `?partial=1` die net iets anders samenstelt --
- * tussen een betaalmuur en de tekst erachter. Hier komt hij niet eens mee.
- *
- * `renderBody` wordt ingespoten (posts.js levert renderPostBodyHtml), zodat
- * deze module niets van routes of sjablonen hoeft te weten.
- */
-export function postEntry(post, viewer, { renderBody } = {}) {
-  const access = postAccess(post, viewer);
-  const full = access === 'full';
-  return {
-    slug: post && post.slug,
-    title: (post && post.title) || '',
-    access,
-    // Alleen bij een gesloten poort een teaser, en nooit allebei: de teaser is
-    // de vervanging van het lijf, niet een voorproefje ernaast.
-    teaser: full || access === 'forbidden' ? null : paidTeaser(post),
-    content_html: full && typeof renderBody === 'function' ? renderBody(post) : null,
-  };
-}
-
-export default { postAccess, canReadBody, paidTeaser, postEntry };
Index: src/services/PrutterService.js
===================================================================
--- src/services/PrutterService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/services/PrutterService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,196 @@
+/**
+ * PrutterService — Real-time Direct Messaging
+ * 
+ * Features:
+ * - Per-conversation (user A ↔ user B)
+ * - Optional site-specific (conversations tied to a community)
+ * - WebSocket real-time notifications
+ * - Message history in SQLite
+ * - Unread message tracking
+ */
+
+import { v4 as uuid } from 'uuid';
+
+class PrutterService {
+  constructor(db) {
+    this.db = db;
+    this.wsConnections = new Map(); // userId → Set<WebSocket>
+  }
+
+  /**
+   * Get or create conversation
+   */
+  getOrCreateConversation(userA, userB, siteId = null) {
+    if (!userA || !userB) throw new Error('Both users required');
+    
+    // Normalize order: always smaller ID first
+    const [u1, u2] = userA < userB ? [userA, userB] : [userB, userA];
+
+    const existing = this.db.prepare(`
+      SELECT * FROM conversations
+      WHERE (user_a_id = ? AND user_b_id = ? AND site_id IS ?)
+      LIMIT 1
+    `).get(u1, u2, siteId);
+
+    if (existing) {
+      return existing;
+    }
+
+    const convId = uuid();
+    this.db.prepare(`
+      INSERT INTO conversations (id, user_a_id, user_b_id, site_id)
+      VALUES (?, ?, ?, ?)
+    `).run(convId, u1, u2, siteId);
+
+    return { id: convId, user_a_id: u1, user_b_id: u2, site_id: siteId };
+  }
+
+  /**
+   * Send message
+   */
+  sendMessage(conversationId, authorId, content) {
+    if (!conversationId || !authorId || !content) {
+      throw new Error('Missing required fields');
+    }
+
+    const msgId = uuid();
+    const now = new Date().toISOString();
+
+    this.db.prepare(`
+      INSERT INTO messages (id, conversation_id, author_id, content, created_at)
+      VALUES (?, ?, ?, ?, ?)
+    `).run(msgId, conversationId, authorId, content, now);
+
+    // Update last_message_at on conversation
+    this.db.prepare(`
+      UPDATE conversations SET last_message_at = ? WHERE id = ?
+    `).run(now, conversationId);
+
+    // Fetch full message for response
+    const message = this.db.prepare(`
+      SELECT m.*, u.username, u.avatar_url
+      FROM messages m
+      JOIN users u ON m.author_id = u.id
+      WHERE m.id = ?
+    `).get(msgId);
+
+    // Notify recipient via WebSocket (if online)
+    const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
+    const recipientId = conv.user_a_id === authorId ? conv.user_b_id : conv.user_a_id;
+    
+    this.notifyUser(recipientId, {
+      type: 'new_message',
+      conversationId,
+      message
+    });
+
+    return message;
+  }
+
+  /**
+   * Get conversation messages
+   */
+  getMessages(conversationId, limit = 50, offset = 0) {
+    return this.db.prepare(`
+      SELECT m.*, u.username, u.avatar_url
+      FROM messages m
+      JOIN users u ON m.author_id = u.id
+      WHERE m.conversation_id = ?
+      ORDER BY m.created_at DESC
+      LIMIT ? OFFSET ?
+    `).all(conversationId, limit, offset);
+  }
+
+  /**
+   * Get user's conversations (list)
+   */
+  getUserConversations(userId) {
+    return this.db.prepare(`
+      SELECT c.*,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.id
+               ELSE u1.id
+             END as other_user_id,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.username
+               ELSE u1.username
+             END as other_username,
+             CASE 
+               WHEN c.user_a_id = ? THEN u2.avatar_url
+               ELSE u1.avatar_url
+             END as other_avatar,
+             (SELECT COUNT(*) FROM messages m 
+              WHERE m.conversation_id = c.id 
+              AND m.author_id != ? 
+              AND m.read_at IS NULL) as unread_count,
+             (SELECT content FROM messages m 
+              WHERE m.conversation_id = c.id 
+              ORDER BY m.created_at DESC LIMIT 1) as last_message_preview
+      FROM conversations c
+      JOIN users u1 ON c.user_a_id = u1.id
+      JOIN users u2 ON c.user_b_id = u2.id
+      WHERE c.user_a_id = ? OR c.user_b_id = ?
+      ORDER BY c.last_message_at DESC
+    `).all(userId, userId, userId, userId, userId, userId);
+  }
+
+  /**
+   * Mark conversation messages as read
+   */
+  markAsRead(conversationId, userId) {
+    const now = new Date().toISOString();
+    this.db.prepare(`
+      UPDATE messages
+      SET read_at = ?
+      WHERE conversation_id = ? AND author_id != ? AND read_at IS NULL
+    `).run(now, conversationId, userId);
+  }
+
+  /**
+   * WebSocket connection management
+   */
+  addConnection(userId, ws) {
+    if (!this.wsConnections.has(userId)) {
+      this.wsConnections.set(userId, new Set());
+    }
+    this.wsConnections.get(userId).add(ws);
+  }
+
+  removeConnection(userId, ws) {
+    const conns = this.wsConnections.get(userId);
+    if (conns) {
+      conns.delete(ws);
+      if (conns.size === 0) {
+        this.wsConnections.delete(userId);
+      }
+    }
+  }
+
+  /**
+   * Notify user via WebSocket (if online)
+   */
+  notifyUser(userId, message) {
+    const conns = this.wsConnections.get(userId);
+    if (!conns) return;
+
+    const data = JSON.stringify(message);
+    for (const ws of conns) {
+      if (ws.readyState === 1) { // OPEN
+        ws.send(data);
+      }
+    }
+  }
+
+  /**
+   * Broadcast to all users in conversation (except sender)
+   */
+  broadcastToConversation(conversationId, senderUserId, message) {
+    const conv = this.db.prepare('SELECT * FROM conversations WHERE id = ?').get(conversationId);
+    if (!conv) return;
+
+    const otherUserId = conv.user_a_id === senderUserId ? conv.user_b_id : conv.user_a_id;
+    this.notifyUser(otherUserId, message);
+  }
+}
+
+export default PrutterService;
Index: src/services/PushService.js
===================================================================
--- src/services/PushService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,167 +1,0 @@
-// Web Push (VAPID) — background notifications to the owner's browser/PWA
-// (docs/webpush-design.md). RFC 8030 delivery + RFC 8291 payload encryption via
-// the `web-push` dependency (approved); the push service only ever sees
-// ciphertext. No cookies anywhere: only enabling/disabling is a logged-in action.
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// Lazy so a not-yet-installed dependency can never crash app boot; only push
-// fails until `npm ci` has run (same pattern as @simplewebauthn/server).
-let _lib = null;
-async function lib() {
-  if (!_lib) { const m = await import('web-push'); _lib = m.default || m; }  // CJS: API on default
-  return _lib;
-}
-
-// ── VAPID keys ──────────────────────────────────────────────────────
-// env wins; otherwise a persisted key file next to the database, generated on
-// first use. NEVER regenerated while the file exists: new keys invalidate every
-// existing subscription. Back up storage/ as a whole (README).
-
-function keyFilePath() {
-  const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
-  const dir = dbPath === ':memory:' ? path.join(__dirname, '../../storage') : path.dirname(dbPath);
-  return path.join(dir, '.vapid');
-}
-
-// The VAPID subject: an https URL (PUBLIC_BASE_URL) or a mailto.
-function subject() {
-  if (process.env.VAPID_SUBJECT) return process.env.VAPID_SUBJECT;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (/^https:\/\//.test(base)) return base;
-  const from = (process.env.SMTP_FROM || '').replace(/^.*</, '').replace(/>.*$/, '').trim();
-  return from.includes('@') ? `mailto:${from}` : 'mailto:webpush@invalid.local';
-}
-
-let _keys = null;
-async function vapidKeys() {
-  if (_keys) return _keys;
-  const envPub = process.env.VAPID_PUBLIC_KEY, envPriv = process.env.VAPID_PRIVATE_KEY;
-  if (envPub && envPriv) { _keys = { publicKey: envPub, privateKey: envPriv }; return _keys; }
-  const file = keyFilePath();
-  try {
-    const j = JSON.parse(fs.readFileSync(file, 'utf8'));
-    if (j && j.publicKey && j.privateKey) { _keys = j; return _keys; }
-  } catch { /* not created yet */ }
-  const { generateVAPIDKeys } = await lib();
-  const fresh = generateVAPIDKeys();
-  fs.mkdirSync(path.dirname(file), { recursive: true });
-  fs.writeFileSync(file, JSON.stringify(fresh), { mode: 0o600 });
-  try { fs.chmodSync(file, 0o600); } catch { /* non-POSIX fs */ }
-  _keys = fresh;
-  return _keys;
-}
-
-// The public key for the client (pushManager.subscribe). Null when the
-// dependency is missing or the key can't be persisted → feature stays gated.
-export async function publicKey() {
-  try { return (await vapidKeys()).publicKey; } catch { return null; }
-}
-
-export async function pushReady() { return (await publicKey()) !== null; }
-
-// ── Subscriptions ───────────────────────────────────────────────────
-
-// help (a ward's call for help) and guardian (adoption handshake) serve the
-// Guardian PWA and default ON: a guardian must never miss a call for help.
-export const DEFAULT_ALERTS = { follow: 1, reply: 1, like: 0, boost: 0, dm: 1, help: 1, guardian: 1 };
-
-export function saveSubscription({ endpoint, userId, p256dh, auth, alertTypes, uaLabel }) {
-  if (!endpoint || !userId || !p256dh || !auth) return false;
-  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
-  db.prepare(`INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, alert_types, ua_label, created_at)
-      VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)
-    ON CONFLICT(endpoint) DO UPDATE SET
-      user_id=excluded.user_id, p256dh=excluded.p256dh, auth=excluded.auth,
-      alert_types=excluded.alert_types, ua_label=excluded.ua_label`)
-    .run(endpoint, userId, p256dh, auth, alerts, uaLabel || null);
-  return true;
-}
-
-export function deleteSubscription(endpoint) {
-  return db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint).changes > 0;
-}
-
-export function listSubscriptions(userId) {
-  return db.prepare('SELECT endpoint, alert_types, ua_label, created_at, last_ok_at FROM push_subscriptions WHERE user_id = ? ORDER BY created_at').all(userId);
-}
-
-export function updateAlerts(endpoint, userId, alertTypes) {
-  const alerts = JSON.stringify({ ...DEFAULT_ALERTS, ...(alertTypes || {}) });
-  return db.prepare('UPDATE push_subscriptions SET alert_types = ? WHERE endpoint = ? AND user_id = ?').run(alerts, endpoint, userId).changes > 0;
-}
-
-// ── Sending ─────────────────────────────────────────────────────────
-
-// Send one payload to one stored subscription row. 404/410 → the device is
-// gone or permission was revoked → delete the row (self-pruning).
-async function sendTo(row, payload) {
-  const wp = await lib();
-  const keys = await vapidKeys();
-  wp.setVapidDetails(subject(), keys.publicKey, keys.privateKey);
-  try {
-    await wp.sendNotification(
-      { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
-      JSON.stringify(payload),
-      { TTL: 3600 },
-    );
-    db.prepare('UPDATE push_subscriptions SET last_ok_at = CURRENT_TIMESTAMP WHERE endpoint = ?').run(row.endpoint);
-    return true;
-  } catch (e) {
-    if (e && (e.statusCode === 404 || e.statusCode === 410)) deleteSubscription(row.endpoint);
-    else console.warn('[push] send failed:', e && (e.statusCode || e.message));
-    return false;
-  }
-}
-
-// Burst throttle: a wave of likes or a mass-follow must not become a wave of
-// pushes. Per (user, type) at most one push per window; extras drop silently
-// (the events themselves are still in Berichten — only the ping is deduped).
-// In-memory is fine: one process, and a restart just means one extra ping.
-const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0, help: 0, guardian: 30 };
-const _lastPush = new Map();
-export function throttled(userId, type, nowSeconds = Math.floor(Date.now() / 1000)) {
-  const windowS = THROTTLE_SECONDS[type] ?? 60;
-  if (!windowS) return false;
-  const key = `${userId}:${type}`;
-  const prev = _lastPush.get(key) || 0;
-  if (nowSeconds - prev < windowS) return true;
-  _lastPush.set(key, nowSeconds);
-  return false;
-}
-
-// Notify one user on all their devices, honouring per-type preferences.
-// type ∈ {follow, reply, like, boost, dm, help, guardian, test}. Fire-and-forget at call sites.
-export async function notifyUser(userId, { type, title, body, url }) {
-  if (!(await pushReady())) return 0;
-  if (throttled(userId, type)) return 0;
-  const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
-  let sent = 0;
-  for (const row of rows) {
-    if (type !== 'test') {
-      let alerts = DEFAULT_ALERTS;
-      try { alerts = { ...DEFAULT_ALERTS, ...JSON.parse(row.alert_types || '{}') }; } catch { /* keep defaults */ }
-      if (!alerts[type]) continue;
-    }
-    if (await sendTo(row, { type, title: String(title || '').slice(0, 120), body: String(body || '').slice(0, 240), url: url || '/' })) sent++;
-  }
-  return sent;
-}
-
-// Notify the owner of a site (the usual entry point from the S2S inbox).
-export async function notifySite(slug, event) {
-  const row = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(slug);
-  if (!row || !row.owner_id) return 0;
-  return notifyUser(row.owner_id, event);
-}
-
-export default {
-  publicKey, pushReady, DEFAULT_ALERTS, throttled,
-  saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
-  notifyUser, notifySite,
-};
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,82 +1,0 @@
-/**
- * Scheduler — release planning (premium #3).
- *
- * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
- * timer flips them to 'published' once publish_at is reached. This means public
- * queries (status='published') need NO changes — a scheduled post simply isn't
- * 'published' yet and therefore invisible until that moment.
- */
-
-import db, { NU_ISO } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import ActivityPubService from './ActivityPubService.js';
-
-export function flipScheduledPosts() {
-  try {
-    const due = db.prepare(`
-      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.language, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
-             p.published_at, p.publish_at, p.created_at, u.username
-      FROM posts p JOIN users u ON u.id = p.author_id
-      WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now')
-    `).all();
-    if (!due.length) return 0;
-    const upd = db.prepare(
-      `UPDATE posts SET status = 'published', published_at = COALESCE(published_at, publish_at, ${NU_ISO}) WHERE id = ?`
-    );
-    const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?');
-    const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
-    const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
-    for (const p of due) {
-      upd.run(p.id);
-      // Delete-before-insert so a re-scheduled (previously published) post doesn't
-      // get a duplicate FTS row → duplicate search hits.
-      try { ftsDel.run(p.id); fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
-      // ActivityPub: federate the now-published post to followers (fan_only → followers-only).
-      try {
-        const site = siteStmt.get(p.site_id);
-        if (site) {
-          ActivityPubService.deliverCreate(site, {
-            id: p.id, slug: p.slug, title: p.title || p.slug,
-            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt, language: p.language,
-            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
-          }).catch(() => { /* best-effort */ });
-        }
-      } catch { /* non-fatal */ }
-    }
-    return due.length;
-  } catch { return 0; }
-}
-
-// Close hosted polls whose endTime has passed: mark them closed (once) and push the final
-// tally + closed state to followers as Update(Question). The `closed` flag in poll_json
-// guards against re-sending — a poll is only processed on the tick that crosses its endTime.
-export function closeExpiredPolls() {
-  try {
-    const due = db.prepare(`
-      SELECT id, poll_json FROM posts
-      WHERE poll_json IS NOT NULL
-        AND status = 'published'
-        AND json_extract(poll_json, '$.endTime') IS NOT NULL
-        AND IFNULL(json_extract(poll_json, '$.closed'), 0) = 0
-        AND datetime(json_extract(poll_json, '$.endTime')) <= datetime('now')
-    `).all();
-    if (!due.length) return 0;
-    const upd = db.prepare('UPDATE posts SET poll_json = ? WHERE id = ?');
-    for (const p of due) {
-      let d; try { d = JSON.parse(p.poll_json); } catch { continue; }
-      d.closed = true;
-      upd.run(JSON.stringify(d), p.id);
-      ActivityPubService.deliverPollUpdate(p.id).catch(() => { /* best-effort */ });
-    }
-    return due.length;
-  } catch { return 0; }
-}
-
-let _timer = null;
-function tick() { flipScheduledPosts(); closeExpiredPolls(); }
-export function startScheduler() {
-  tick();                               // run immediately on boot
-  if (_timer) return;
-  _timer = setInterval(tick, 60 * 1000); // every minute
-  if (_timer.unref) _timer.unref();
-}
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,42 +1,0 @@
-// Global app settings (key/value, cached).
-//
-// One instance is one owner (Robins besluit, 31-7-2026). The old tenancy modes
-// (hub = many artists on one domain, circle) are gone, code and all: the
-// branches were already unreachable and have now been deleted.
-//
-// The cache is updated immediately on setSetting, so a toggle in admin
-// takes effect live without a restart.
-
-import db from '../config/database.js';
-
-let _cache = null;
-
-function load() {
-  if (!_cache) {
-    _cache = {};
-    for (const r of db.prepare('SELECT key, value FROM app_settings').all()) {
-      _cache[r.key] = r.value;
-    }
-  }
-  return _cache;
-}
-
-export function getSetting(key, fallback = null) {
-  const v = load()[key];
-  return v === undefined ? fallback : v;
-}
-
-export function setSetting(key, value) {
-  db.prepare(`
-    INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
-    ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
-  `).run(key, String(value));
-  if (_cache) _cache[key] = String(value);
-}
-
-// ActivityPub / fediverse federation. ON by default. '0' = off: the site does
-// not federate, /ap/* is gone, and the "from the fediverse" reactions disappear
-// — which (since native comments were removed) means no comments at all.
-export function apEnabled() {
-  return getSetting('ap_enabled', '1') !== '0';
-}
Index: src/services/SqliteSessionStore.js
===================================================================
--- src/services/SqliteSessionStore.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/SqliteSessionStore.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -35,8 +35,8 @@
     this._stmtDestroy = db.prepare('DELETE FROM sessions WHERE sid = ?');
     this._stmtTouch = db.prepare('UPDATE sessions SET expiresAt = ? WHERE sid = ?');
-    this._stmtCount = db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE datetime(expiresAt) > datetime('now')");
+    this._stmtCount = db.prepare("SELECT COUNT(*) AS c FROM sessions WHERE expiresAt > datetime('now')");
     this._stmtClear = db.prepare('DELETE FROM sessions');
-    this._stmtAll = db.prepare("SELECT sid, data FROM sessions WHERE datetime(expiresAt) > datetime('now')");
-    this._stmtGc = db.prepare("DELETE FROM sessions WHERE datetime(expiresAt) <= datetime('now')");
+    this._stmtAll = db.prepare("SELECT sid, data FROM sessions WHERE expiresAt > datetime('now')");
+    this._stmtGc = db.prepare("DELETE FROM sessions WHERE expiresAt <= datetime('now')");
   }
 
Index: src/services/StatsService.js
===================================================================
--- src/services/StatsService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,186 +1,0 @@
-// StatsService — cookie-free statistics (premium module).
-//
-// Counters: posts.view_count, audio_tracks.play_count, and per day/site the number
-// of pageviews (stat_daily) + unique visitors (stat_visitor_day).
-//
-// Unique visitors WITHOUT cookies: a sha256 of IP+UA+daily-salt. The salt rotates
-// every day and is never stored longer → you cannot track someone across days,
-// and the raw IP is never persisted. No persistent identifier, no consent
-// banner required (Plausible/Fathom approach).
-
-import crypto from 'node:crypto';
-import db from '../config/database.js';
-import { getSetting, setSetting } from './SettingsService.js';
-
-function today() {
-  return new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
-}
-
-// Daily rotating salt (cached in process, persisted in app_settings so that
-// a restart within the same day reuses the same salt).
-let _salt = null, _saltDay = null;
-function dailySalt() {
-  const d = today();
-  if (_salt && _saltDay === d) return _salt;
-  let stored = getSetting('stat_salt', null);
-  if (!stored || getSetting('stat_salt_day', null) !== d) {
-    stored = crypto.randomBytes(16).toString('hex');
-    setSetting('stat_salt', stored);
-    setSetting('stat_salt_day', d);
-  }
-  _salt = stored; _saltDay = d;
-  return stored;
-}
-
-function visitorHash(req) {
-  const ip = (req && (req.ip || (req.socket && req.socket.remoteAddress))) || '';
-  const ua = (req && req.headers && req.headers['user-agent']) || '';
-  return crypto.createHash('sha256').update(dailySalt() + '|' + ip + '|' + ua).digest('hex').slice(0, 32);
-}
-
-// Don't count the owner/admin — otherwise you inflate your own numbers.
-function isOperator(req) {
-  const u = req && req.session && req.session.user;
-  return !!(u && (u.role === 'god' || u.role === 'admin'));
-}
-
-// Skip known bots/crawlers + link-preview fetchers + scripts so they don't inflate
-// view/visitor-day counts. Empty UA = almost always automated.
-const BOT_RE = /bot|crawl|spider|slurp|mediapartners|bingpreview|facebookexternalhit|whatsapp|telegram|discord|twitter|linkedin|embedly|pinterest|redditbot|applebot|petalbot|yandex|baidu|duckduckbot|semrush|ahrefs|mj12|dotbot|uptimerobot|pingdom|statuscake|headless|lighthouse|gptbot|claude|ccbot|perplexity|bytespider|amazonbot|googleother|google-read-aloud|python-requests|scrapy|curl|wget|axios|node-fetch|go-http|java\/|okhttp|libwww|httpclient|mastodon|pleroma|akkoma|misskey|calckey|firefish|friendica|hubzilla|lemmy|pixelfed|peertube|gotosocial|honk|http\.rb|activitypub|klonkt/i;
-function isBot(req) {
-  const ua = (req && req.headers && req.headers['user-agent']) || '';
-  if (!ua) return true;          // empty UA = script/bot
-  // A signed request or an ActivityPub content-negotiation is BY DEFINITION a
-  // server fetching, not a reader (Robins vraag, 31-7): one boosted post made
-  // every fediverse instance's link-preview fetch count as a unique visitor.
-  const h = (req && req.headers) || {};
-  if (h['signature']) return true;
-  if (/application\/(activity|ld)\+json/i.test(String(h['accept'] || ''))) return true;
-  return BOT_RE.test(ua);
-}
-
-// The client IP (trust-proxy gives the real one), normalised: drop an IPv4-mapped-IPv6 prefix
-// and a trailing :port so it matches what the admin sees + stores.
-function clientIp(req) {
-  let ip = (req && (req.ip || (req.socket && req.socket.remoteAddress))) || '';
-  if (ip.startsWith('::ffff:')) ip = ip.slice(7);
-  if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
-  return ip;
-}
-export function currentIp(req) { return clientIp(req); }
-
-// Admin-configured IPs to skip — so an owner browsing logged-OUT (incognito, another browser)
-// doesn't inflate their own stats. Stored as a comma-separated app_setting.
-export function getExcludedIps() {
-  return (getSetting('stats_exclude_ips', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
-}
-export function setExcludedIps(list) {
-  const clean = [...new Set((list || []).map((s) => String(s).trim()).filter(Boolean))].slice(0, 20);
-  setSetting('stats_exclude_ips', clean.join(','));
-}
-function isExcludedIp(req) {
-  try { const ip = clientIp(req); return !!ip && getExcludedIps().includes(ip); } catch { return false; }
-}
-
-// Lazy prepares — tables only exist after initializeDatabase(); this module is
-// imported before that call.
-let _s = null;
-function stmts() {
-  if (_s) return _s;
-  _s = {
-    bumpDaily: db.prepare(`
-      INSERT INTO stat_daily (site_id, day, pageviews) VALUES (?, ?, 1)
-      ON CONFLICT(site_id, day) DO UPDATE SET pageviews = pageviews + 1
-    `),
-    addVisitor: db.prepare('INSERT OR IGNORE INTO stat_visitor_day (site_id, day, visitor_hash) VALUES (?, ?, ?)'),
-    bumpPost: db.prepare('UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?'),
-    bumpTrack: db.prepare('UPDATE audio_tracks SET play_count = COALESCE(play_count, 0) + 1 WHERE id = ?'),
-    bumpReferrer: db.prepare(`
-      INSERT INTO stat_referrer (site_id, host, count) VALUES (?, ?, 1)
-      ON CONFLICT(site_id, host) DO UPDATE SET count = count + 1
-    `),
-  };
-  return _s;
-}
-
-// External referrer host from the Referer header (pro stats #5). Empty/own-site/
-// invalid referrers are skipped → only genuine external sources are counted.
-function recordReferrer(siteId, req) {
-  try {
-    const ref = req && req.headers && (req.headers.referer || req.headers.referrer);
-    if (!ref) return;
-    const host = new URL(ref).host.replace(/^www\./, '').toLowerCase();
-    if (!host) return;
-    const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase();
-    if (host === own) return; // internal navigation does not count as a source
-    stmts().bumpReferrer.run(siteId, host.slice(0, 120));
-  } catch { /* not a valid referrer URL → skip */ }
-}
-
-export function recordPageview(siteId, req) {
-  if (!siteId || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
-  try {
-    const d = today();
-    stmts().bumpDaily.run(siteId, d);
-    stmts().addVisitor.run(siteId, d, visitorHash(req));
-    recordReferrer(siteId, req);
-  } catch { /* stats must never break a request */ }
-}
-
-export function recordPostView(post, req) {
-  if (!post || !post.id || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
-  try {
-    stmts().bumpPost.run(post.id);
-    recordPageview(post.site_id, req);
-  } catch {}
-}
-
-export function recordPlay(trackId) {
-  if (!trackId) return;
-  try { stmts().bumpTrack.run(trackId); } catch {}
-}
-
-// Instance-wide statistics (solo = the site, hub = all sites combined).
-export function getStats(days = 14) {
-  days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14;
-  const pvMap = Object.fromEntries(
-    db.prepare('SELECT day, SUM(pageviews) AS pv FROM stat_daily GROUP BY day').all().map((r) => [r.day, r.pv]),
-  );
-  const visMap = Object.fromEntries(
-    db.prepare('SELECT day, COUNT(*) AS v FROM stat_visitor_day GROUP BY day').all().map((r) => [r.day, r.v]),
-  );
-  const series = [];
-  for (let i = days - 1; i >= 0; i--) {
-    const dt = new Date();
-    dt.setUTCDate(dt.getUTCDate() - i);
-    const d = dt.toISOString().slice(0, 10);
-    series.push({ day: d, pageviews: pvMap[d] || 0, visitors: visMap[d] || 0 });
-  }
-  const totals = {
-    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // last N days
-    visitors: series.reduce((s, r) => s + r.visitors, 0),   // sum of daily uniques (cookieless has no alternative)
-    plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n,
-    postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n,
-  };
-  const topPosts = db.prepare(`
-    SELECT title, slug, COALESCE(view_count, 0) AS views FROM posts
-    WHERE status = 'published' ORDER BY view_count DESC, published_at DESC LIMIT 5
-  `).all();
-  const topTracks = db.prepare(`
-    SELECT title, COALESCE(play_count, 0) AS plays FROM audio_tracks
-    ORDER BY play_count DESC LIMIT 5
-  `).all();
-  // Top external sources (pro #5) — aggregated instance-wide per host.
-  let referrers = [];
-  try {
-    referrers = db.prepare(
-      'SELECT host, SUM(count) AS n FROM stat_referrer GROUP BY host ORDER BY n DESC LIMIT 10'
-    ).all();
-  } catch { referrers = []; }
-  // All-time totals (cookieless unique visitors = sum of daily uniques).
-  const allTime = {
-    pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n,
-    visitorDays: db.prepare('SELECT COUNT(*) AS n FROM stat_visitor_day').get().n,
-  };
-  return { totals, series, topPosts, topTracks, referrers, allTime, days };
-}
Index: src/services/SubscriberService.js
===================================================================
--- src/services/SubscriberService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,83 +1,0 @@
-/**
- * SubscriberService — newsletter subscribers per site (premium feature #1).
- *
- * Double opt-in when SMTP is configured (status 'pending' → 'confirmed' via
- * confirm link), otherwise single opt-in ('confirmed' immediately). Each
- * subscriber has a token used for both the confirm and unsubscribe links.
- * Reused by #2 (download-for-email) and #8 (notify-me) as shared subscriber storage.
- */
-
-import crypto from 'crypto';
-import db from '../config/database.js';
-import { v4 as uuid } from 'uuid';
-
-const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
-export function isValidEmail(email) {
-  return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email.trim());
-}
-
-function newToken() {
-  return crypto.randomBytes(24).toString('hex');
-}
-
-/**
- * Add a subscriber (or reactivate an unsubscribed/existing one).
- * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
- *   status 'pending'  → confirmation still required (send confirm email)
- *   status 'confirmed'→ immediately active (single opt-in)
- */
-export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
-  email = (email || '').trim().toLowerCase();
-  if (!siteId) return { ok: false, error: 'no_site' };
-  if (!isValidEmail(email)) return { ok: false, error: 'invalid_email' };
-
-  const existing = db.prepare('SELECT * FROM subscribers WHERE site_id = ? AND email = ?').get(siteId, email);
-  const status = doubleOptin ? 'pending' : 'confirmed';
-
-  if (existing) {
-    // Already confirmed → nothing to do (idempotent, no duplicate email).
-    if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
-    // Pending or unsubscribed → re-invite/reactivate with a fresh token.
-    const token = newToken();
-    db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
-      .run(status, token, source, status, existing.id);
-    return { ok: true, status, token, created: false };
-  }
-
-  const token = newToken();
-  db.prepare(
-    "INSERT INTO subscribers (id, site_id, email, status, source, token, confirmed_at) VALUES (?,?,?,?,?,?, CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END)"
-  ).run(uuid(), siteId, email, status, source, token, status);
-  return { ok: true, status, token, created: true };
-}
-
-export function confirm(token) {
-  if (!token) return false;
-  const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
-  if (!row) return false;
-  db.prepare("UPDATE subscribers SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.id);
-  return true;
-}
-
-export function unsubscribe(token) {
-  if (!token) return false;
-  const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
-  if (!row) return false;
-  db.prepare("UPDATE subscribers SET status = 'unsub' WHERE id = ?").run(row.id);
-  return true;
-}
-
-/** Confirmed subscribers (email + token) for a site — for sending newsletters.
- * Optionally filter by source (e.g. 'notify' for show announcements). */
-export function confirmedFor(siteId, source) {
-  if (source) {
-    return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed' AND source = ?").all(siteId, source);
-  }
-  return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed'").all(siteId);
-}
-
-export function counts(siteId) {
-  const c = (st) => db.prepare('SELECT COUNT(*) AS n FROM subscribers WHERE site_id = ? AND status = ?').get(siteId, st).n;
-  return { confirmed: c('confirmed'), pending: c('pending'), unsub: c('unsub') };
-}
Index: src/services/ThemeService.js
===================================================================
--- src/services/ThemeService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/services/ThemeService.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -2,59 +2,58 @@
  * ThemeService — Palette and theme management
  * 
- * 8 Built-in Palettes — 1 neutral default + 7 real colours:
- * - Klonkt (DEFAULT, clean white/black neutral + gold accent; was 'Paper')
- * - Forest (green) · Ocean (blue) · Teal · Lilac (purple)
- * - Sunset (pink) · Candy (red) · Amber (warm)
- * Colour palettes are tinted (like forest/lilac), not navy-with-accent.
- *
+ * 8 Built-in Palettes (from v9):
+ * - Sage (default, warm crème)
+ * - Paper (minimalist white)
+ * - Ocean (cool blues)
+ * - Forest (greens)
+ * - Stone (grays)
+ * - Midnight (dark blue)
+ * - Sunset (warm oranges)
+ * - Cream (light beige)
+ * 
  * Dark/Light mode toggle stored per user
  */
 
 class ThemeService {
-  // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
-  // is ACTUALLY applied); the accent dot is a representative color per palette.
   static PALETTES = {
-    // DEFAULT — clean neutral (formerly 'Paper'), renamed to the brand 'Klonkt'.
-    // White → near-black, with the brand gold as accent. The old navy 'Klonkt' is gone.
-    klonkt: {
-      name: 'Klonkt',
-      light: { paper: '#ffffff', ink: '#09090b', accent: '#c98a2a' },
-      dark: { paper: '#0a0a0a', ink: '#fafafa', accent: '#e8b04b' }
-    },
-    // 7 real-colour palettes (tinted paper, like forest/lilac — NOT navy-with-accent).
+    sage: {
+      name: 'Sage',
+      light: { paper: '#faf8f3', ink: '#1a1a1a', accent: '#c2410c' },
+      dark: { paper: '#1c1a17', ink: '#f4ede0', accent: '#c2410c' }
+    },
+    paper: {
+      name: 'Paper',
+      light: { paper: '#ffffff', ink: '#09090b', accent: '#000000' },
+      dark: { paper: '#09090b', ink: '#fafafa', accent: '#ffffff' }
+    },
+    ocean: {
+      name: 'Ocean',
+      light: { paper: '#f0f9ff', ink: '#0c2d48', accent: '#0369a1' },
+      dark: { paper: '#001f3f', ink: '#e0f2fe', accent: '#06b6d4' }
+    },
     forest: {
       name: 'Forest',
-      light: { paper: '#f2f6ed', ink: '#1a2e15', accent: '#4d7c2a' },
-      dark: { paper: '#0d1f12', ink: '#dcf2d0', accent: '#6fae3f' }
-    },
-    ocean: {
-      name: 'Ocean',
-      light: { paper: '#eef4fb', ink: '#0f2942', accent: '#1d6fe0' },
-      dark: { paper: '#081726', ink: '#d6e8fb', accent: '#5ba0f5' }
-    },
-    teal: {
-      name: 'Teal',
-      light: { paper: '#ecf7f5', ink: '#0c2e2a', accent: '#0d9488' },
-      dark: { paper: '#06201d', ink: '#d4f2ec', accent: '#2dd4bf' }
-    },
-    lilac: {
-      name: 'Lilac',
-      light: { paper: '#faf4fb', ink: '#2a1830', accent: '#a855f7' },
-      dark: { paper: '#170a1c', ink: '#f3e2f7', accent: '#c084fc' }
+      light: { paper: '#f0fdf4', ink: '#15803d', accent: '#16a34a' },
+      dark: { paper: '#052e16', ink: '#dcfce7', accent: '#22c55e' }
+    },
+    stone: {
+      name: 'Stone',
+      light: { paper: '#f5f5f5', ink: '#262626', accent: '#737373' },
+      dark: { paper: '#1f1f1f', ink: '#e5e5e5', accent: '#a3a3a3' }
+    },
+    midnight: {
+      name: 'Midnight',
+      light: { paper: '#f8fafc', ink: '#1e293b', accent: '#3b82f6' },
+      dark: { paper: '#0f172a', ink: '#f1f5f9', accent: '#60a5fa' }
     },
     sunset: {
       name: 'Sunset',
-      light: { paper: '#fdf4f3', ink: '#2e1618', accent: '#d6477f' },
-      dark: { paper: '#1f0a14', ink: '#fce7f3', accent: '#f06fa3' }
-    },
-    candy: {
-      name: 'Candy',
-      light: { paper: '#fdf1f3', ink: '#3a1018', accent: '#e11d48' },
-      dark: { paper: '#220810', ink: '#fde0e6', accent: '#fb6f8b' }
-    },
-    amber: {
-      name: 'Amber',
-      light: { paper: '#fdf6e9', ink: '#3a2a0c', accent: '#d97706' },
-      dark: { paper: '#221a08', ink: '#fdeecb', accent: '#f0a93a' }
+      light: { paper: '#fef3c7', ink: '#92400e', accent: '#f97316' },
+      dark: { paper: '#5a1f08', ink: '#fef3c7', accent: '#fb923c' }
+    },
+    cream: {
+      name: 'Cream',
+      light: { paper: '#fffbf0', ink: '#78350f', accent: '#d97706' },
+      dark: { paper: '#3f2305', ink: '#fffbf0', accent: '#f59e0b' }
     }
   };
@@ -65,17 +64,13 @@
    * Each color works against both light and dark themes.
    */
-  // Balanced across the color wheel — fewer greens/blues (4 of 12),
-  // more warm + purple/pink variation. All readable on both light and dark.
   static ACCENTS = [
-    { key: 'klonkt',  name: 'Klonkt-geel', color: '#e8b04b' },
-    { key: 'red',     name: 'Candy-rood', color: '#e11d48' },
-    { key: 'amber',   name: 'Amber',     color: '#d97706' },
-    { key: 'forest',  name: 'Groen',     color: '#16a34a' },
-    { key: 'teal',    name: 'Turquoise', color: '#0d9488' },
-    { key: 'ocean',   name: 'Blauw',     color: '#2563eb' },
-    { key: 'indigo',  name: 'Indigo',    color: '#4f46e5' },
-    { key: 'violet',  name: 'Violet',    color: '#7c3aed' },
-    { key: 'plum',    name: 'Magenta',   color: '#c026d3' },
-    { key: 'pink',    name: 'Roze',      color: '#db2777' },
+    { key: 'orange',  name: 'Oranje',  color: '#c2410c' },
+    { key: 'sage',    name: 'Salie',   color: '#5a8a5a' },
+    { key: 'ocean',   name: 'Oceaan',  color: '#0369a1' },
+    { key: 'forest',  name: 'Bos',     color: '#16a34a' },
+    { key: 'plum',    name: 'Pruim',   color: '#9d3a78' },
+    { key: 'gold',    name: 'Goud',    color: '#d97706' },
+    { key: 'crimson', name: 'Karmijn', color: '#ef2840' },
+    { key: 'indigo',  name: 'Indigo',  color: '#6366f1' },
   ];
 
@@ -100,5 +95,5 @@
    */
   static getPalette(paletteKey) {
-    return this.PALETTES[paletteKey] || this.PALETTES.klonkt;
+    return this.PALETTES[paletteKey] || this.PALETTES.sage;
   }
 
@@ -172,6 +167,6 @@
   static generateThemeMeta(userTheme, userPalette, siteTheme, siteAccent) {
     const theme = userTheme || siteTheme || 'dark';
-    const palette = userPalette || 'klonkt';
-    const accent = siteAccent || '#e8b04b';
+    const palette = userPalette || 'sage';
+    const accent = siteAccent || '#c2410c';
     const paletteData = this.getPalette(palette);
     const colors = theme === 'dark' ? paletteData.dark : paletteData.light;
Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,251 +1,0 @@
-/**
- * On-demand cover thumbnails.
- *
- * High-res covers (especially line-art) look jagged because the BROWSER downscales
- * them to the small grid/list size. We instead downscale the stored original
- * server-side with ffmpeg's lanczos filter to a small WebP and cache it on disk, so
- * the browser receives a near-1:1 image → crisp lines.
- *
- * No re-upload / backfill: the original file is only READ, never modified, and
- * thumbnails are generated lazily on first request. Cover filenames are content-
- * hashed/UUID, so a cached thumbnail can never go stale (a new cover = a new name).
- *
- * Uses the bundled `ffmpeg-static` (always present); cwebp is not required here.
- */
-import { execFile } from 'child_process';
-import ffmpegPath from 'ffmpeg-static';
-import path from 'path';
-import fs from 'fs';
-import crypto from 'crypto';
-import { promisify } from 'util';
-import { safeFetch } from './ActivityPubService.js';
-
-const execFileP = promisify(execFile);
-
-// Allowed widths (whitelist → no arbitrary-size abuse). 96 = small feed/comment avatars
-// (~44px); 128 = nav/profile avatars; 256 ≈ 2× a list cover; 480 ≈ 2× a grid tile; 1280 =
-// full-width timeline media (crisp on mobile retina, ~430px × 3 DPR). Keep these ~2× the
-// display size so the browser barely scales (avoids both jaggies and upscaling blur).
-export const THUMB_SIZES = new Set([96, 128, 256, 320, 480, 640, 1280]);
-
-let _seq = 0;
-
-// Limit concurrent ffmpeg spawns. A cold-cache, image-heavy page fires many thumbnail
-// requests at once; without a cap each spawns its own ffmpeg → CPU saturation makes the
-// WHOLE instance slow (the thundering herd). With the cap, excess requests wait briefly
-// for a slot → bounded CPU, the page still loads (images just appear progressively).
-const MAX_CONCURRENT = 3;
-let _active = 0;
-const _waiters = [];
-function acquireSlot() {
-  if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
-  return new Promise((resolve) => _waiters.push(resolve));
-}
-function releaseSlot() {
-  const next = _waiters.shift();
-  if (next) next();      // transfer the slot directly to the next waiter (_active unchanged)
-  else _active--;
-}
-async function runFfmpeg(args) {
-  await acquireSlot();
-  try { await execFileP(ffmpegPath, args, { timeout: 20000 }); }
-  finally { releaseSlot(); }
-}
-
-function mediaRoot() {
-  return path.resolve(process.env.MEDIA_PATH || './storage/media');
-}
-
-// Resolve a safe absolute path for a relative media path; null on traversal attempts.
-function safeOriginal(rel) {
-  const root = mediaRoot();
-  const orig = path.resolve(root, rel);
-  if (orig !== root && !orig.startsWith(root + path.sep)) return null;
-  return orig;
-}
-
-// Is the source an animated image (animated WebP or GIF)? If so the thumbnail must keep ALL
-// frames (a downscaled animated WebP) instead of grabbing a single frame — otherwise an
-// animated cover shows up frozen on the site.
-function isAnimatedSrc(filePath) {
-  try {
-    const ext = path.extname(filePath).toLowerCase();
-    if (ext === '.gif') return true; // a flattened GIF would lose its animation too
-    if (ext !== '.webp') return false;
-    const fd = fs.openSync(filePath, 'r');
-    try {
-      const buf = Buffer.alloc(40);
-      const n = fs.readSync(fd, buf, 0, 40, 0);
-      // RIFF…WEBP, then a VP8X chunk (bytes 12-15) whose flags byte (20) has the animation bit.
-      return n >= 21 && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0;
-    } finally { fs.closeSync(fd); }
-  } catch { return false; }
-}
-
-/**
- * Return the on-disk path of the cached thumbnail, generating it if needed.
- * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
- */
-export async function getThumbnail(rel, width) {
-  if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
-  const orig = safeOriginal(rel);
-  if (!orig || !fs.existsSync(orig)) return null;
-
-  const root = mediaRoot();
-  // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
-  const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
-  if (fs.existsSync(cached)) return cached;
-
-  // ffmpeg-static can't decode an animated WebP ("image data not found"), so we can't make a
-  // scaled animated thumbnail. Return null → the route serves the ORIGINAL instead, which keeps
-  // animating. (Animated covers are usually already small, so skipping the downscale is fine.)
-  if (isAnimatedSrc(orig)) return null;
-
-  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
-  const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
-  try {
-    await runFfmpeg([
-      '-hide_banner', '-loglevel', 'error', '-y',
-      '-i', orig,
-      // Downscale to `width` (never upscale past the original) with lanczos; even height.
-      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
-      '-frames:v', '1',
-      '-c:v', 'libwebp', '-q:v', '82',
-      // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
-      // can't infer the output format from it.
-      '-f', 'webp',
-      tmp,
-    ]);
-    await fs.promises.rename(tmp, cached);
-    return cached;
-  } catch (e) {
-    try { await fs.promises.unlink(tmp); } catch {}
-    console.warn('[thumb] generation failed for', rel, '-', e.message);
-    return null;
-  }
-}
-
-// ── Signed remote-image proxy ─────────────────────────────────────
-// Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once
-// (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is
-// HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt
-// itself rendered are accepted.
-
-let _key;
-function imgKey() {
-  if (_key) return _key;
-  _key = process.env.SESSION_SECRET || '';
-  if (!_key) {
-    try {
-      const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite'));
-      _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim();
-    } catch { _key = 'klonkt-img-proxy'; }
-  }
-  return _key;
-}
-
-function sign(url, w) {
-  return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24);
-}
-
-// Signed proxy URL for a remote image (used by the avatar() view helper).
-export function imgProxyUrl(url, width) {
-  return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
-}
-
-export function verifyImg(url, width, sig) {
-  if (!sig || !url) return false;
-  let want;
-  try { want = sign(url, width); } catch { return false; }
-  try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; }
-}
-
-/**
- * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it.
- * @returns {Promise<string|null>} cached path, or null.
- */
-// Same animation check as isAnimatedSrc but on an in-memory buffer (remote fetch): ffmpeg-static
-// can't decode an animated WebP, and flattening a GIF/animated WebP to one frame loses its motion.
-function isAnimatedBuf(buf) {
-  try {
-    if (!buf || buf.length < 21) return false;
-    if (buf.toString('ascii', 0, 3) === 'GIF') return true; // any GIF (a flattened one loses its animation)
-    // Animated WebP: RIFF…WEBP with a VP8X chunk whose flags byte (20) has the animation bit (0x02).
-    if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP'
-        && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0) return true;
-    return false;
-  } catch { return false; }
-}
-export async function getRemoteThumbnail(url, width) {
-  if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
-  const root = mediaRoot();
-  const hash = crypto.createHash('sha256').update(url).digest('hex');
-  // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
-  const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
-  if (fs.existsSync(cached)) return cached;
-
-  let buf, isVideo = false;
-  try {
-    const r = await safeFetch(url);
-    if (!r.ok) return null;
-    const ct = r.headers.get('content-type') || '';
-    isVideo = ct.startsWith('video/');
-    if (!ct.startsWith('image/') && !isVideo) return null;
-    if (isVideo) {
-      // Remote video → poster frame (feed/tile posters). A bounded head (first 4MB) only decodes
-      // a faststart mp4 (moov atom up front). Many platforms (Loops.video, phone exports) put the
-      // moov atom at the END, so a head-only fetch fails → no thumbnail. So: if the file is small
-      // enough (content-length within VIDEO_CAP) grab it WHOLE — that works regardless of moov
-      // position. Only when the size is unknown or very large do we fall back to a bounded head
-      // (best-effort; a large moov-at-end file still yields null → the route's 302 fallback).
-      const VIDEO_CAP = 64 * 1024 * 1024; // 64MB — covers short-form clips incl. moov-at-end
-      const clen = parseInt(r.headers.get('content-length') || '0', 10);
-      try { if (r.body && r.body.cancel) r.body.cancel(); } catch { /* ignore */ }
-      let rv;
-      if (clen && clen <= VIDEO_CAP) {
-        rv = await safeFetch(url);
-        if (!rv.ok) return null;
-      } else {
-        rv = await safeFetch(url, { headers: { Range: 'bytes=0-8388607' } }); // 8MB head fallback
-        if (!rv.ok && rv.status !== 206) return null;
-      }
-      buf = Buffer.from(await rv.arrayBuffer());
-      if (!buf.length || buf.length > VIDEO_CAP) return null;
-    } else {
-      if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
-      buf = Buffer.from(await r.arrayBuffer());
-    }
-  } catch (e) {
-    console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
-    return null;
-  }
-  if (!isVideo && buf.length > 12 * 1024 * 1024) return null; // video already capped at VIDEO_CAP
-  // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
-  // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
-  // (keeps the animation; mirrors the local path's isAnimatedSrc guard). Video heads skip this
-  // (they're not webp/gif) and go straight to the single-frame extract.
-  if (!isVideo && isAnimatedBuf(buf)) return null;
-
-  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
-  const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
-  const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
-  try {
-    await fs.promises.writeFile(tmpIn, buf);
-    await runFfmpeg([
-      '-hide_banner', '-loglevel', 'error', '-y',
-      '-i', tmpIn,
-      '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
-      '-frames:v', '1',
-      '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
-      tmpOut,
-    ]);
-    await fs.promises.rename(tmpOut, cached);
-    return cached;
-  } catch (e) {
-    console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
-    return null;
-  } finally {
-    fs.promises.unlink(tmpIn).catch(() => {});
-    fs.promises.unlink(tmpOut).catch(() => {});
-  }
-}
Index: src/services/VideoCoverService.js
===================================================================
--- src/services/VideoCoverService.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,141 +1,0 @@
-/**
- * VideoCoverService — turn an animated cover into a small, Safari-friendly muted loop video.
- *
- * Safari renders animated WebP poorly; a muted <video> loop plays smoothly everywhere (iOS too).
- * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM,
- * NO native deps → installs on every platform, never breaks `npm ci`) into RGBA frames, then
- * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio). A real
- * uploaded video goes straight through ffmpeg. Both are best-effort: on any failure (or an oversized
- * input) we return null and the caller keeps the still image.
- */
-import { execFile } from 'child_process';
-import { promisify } from 'util';
-import fs from 'fs';
-import path from 'path';
-import ffmpegPath from 'ffmpeg-static';
-import WebP from 'node-webpmux';
-
-const execFileP = promisify(execFile);
-const MAX_SECONDS = 60;            // cap a cover loop at one minute
-const MAX_FRAMES = 600;            // skip a pathological animated WebP (keep the still image)
-const MAX_WORK = 250_000_000;      // W*H*frames cap — bounds compositor memory churn + CPU/time
-
-let _lib = null;
-function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
-
-// node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
-// NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
-// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and STREAM consistent
-// full frames straight to `rawPath` — one canvas in memory, not all N frames. Feeding ffmpeg the raw
-// varying-size sub-regions desyncs the stream → a torn/tiled video.
-async function compositeFramesToFile(img, rawPath) {
-  const W = img.width, H = img.height, frames = img.anim.frames;
-  const canvas = Buffer.alloc(W * H * 4); // transparent black
-  const fd = fs.openSync(rawPath, 'w');
-  try {
-    let prev = null; // previous frame's rect + dispose
-    for (let i = 0; i < frames.length; i++) {
-      const fr = frames[i];
-      if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
-        for (let row = 0; row < prev.h; row++) {
-          const y = prev.y + row; if (y < 0 || y >= H) continue;
-          canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
-        }
-      }
-      const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
-      // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
-      // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
-      // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
-      const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
-      if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
-        data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
-      } else {
-        for (let row = 0; row < fh; row++) {
-          const cy = fy + row; if (cy < 0 || cy >= H) continue;
-          for (let col = 0; col < fw; col++) {
-            const cx = fx + col; if (cx < 0 || cx >= W) continue;
-            const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
-            if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
-            else if (sa !== 0) { // alpha-over the existing canvas pixel
-              const a = sa / 255, ia = 1 - a;
-              canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
-              canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
-              canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
-              canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
-            }
-          }
-        }
-      }
-      fs.writeSync(fd, canvas); // stream the full composited canvas (bounds memory to one frame)
-      prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
-    }
-  } finally { fs.closeSync(fd); }
-}
-
-// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
-export function isAnimatedWebp(filePath) {
-  try {
-    if (path.extname(filePath).toLowerCase() !== '.webp') return false;
-    const fd = fs.openSync(filePath, 'r');
-    try {
-      const b = Buffer.alloc(40);
-      const n = fs.readSync(fd, b, 0, 40, 0);
-      return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
-    } finally { fs.closeSync(fd); }
-  } catch { return false; }
-}
-
-// Animated WebP → muted loop MP4. Returns { videoPath } or null (caller keeps the still image).
-export async function animatedWebpToVideo(srcPath, outDir, baseName) {
-  let rawPath = null;
-  try {
-    if (!ffmpegPath) return null;
-    await ensureLib();
-    const img = new WebP.Image();
-    await img.load(srcPath);
-    if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
-    const W = img.width, H = img.height, n = img.anim.frames.length;
-    // Guard a pathologically large cover (memory/CPU): keep the still image instead of converting.
-    if (!W || !H || n > MAX_FRAMES || W * H * n > MAX_WORK) {
-      console.warn(`[videocover] cover too large to convert (${W}x${H}, ${n} frames) — keeping the still image`);
-      return null;
-    }
-    const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
-    await fs.promises.mkdir(outDir, { recursive: true });
-    rawPath = path.join(outDir, baseName + '.rgba.tmp');
-    await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk
-    const videoPath = path.join(outDir, baseName + '.mp4');
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
-      '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
-      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
-      { timeout: 60000 });
-    return { videoPath };
-  } catch (e) {
-    console.warn('[videocover] animated webp → mp4 failed:', e.message);
-    return null;
-  } finally {
-    if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
-  }
-}
-
-// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s). ffmpeg decodes every video format,
-// so no node-webpmux here. Returns { videoPath } or null.
-export async function videoToLoop(srcPath, outDir, baseName) {
-  try {
-    if (!ffmpegPath) return null;
-    await fs.promises.mkdir(outDir, { recursive: true });
-    const videoPath = path.join(outDir, baseName + '.mp4');
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-i', srcPath, '-t', String(MAX_SECONDS),
-      '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
-      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
-      { timeout: 120000 });
-    return { videoPath };
-  } catch (e) {
-    console.warn('[videocover] video → loop failed:', e.message);
-    return null;
-  }
-}
-
-export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
Index: src/services/ap-c2s.js
===================================================================
--- src/services/ap-c2s.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,455 +1,0 @@
-/**
- * ap-c2s.js — de Client-to-Server-inname (stap 4 van shaer-drc).
- *
- * De C2S-tegenhanger van handleInbox: een eigen client (Shaer, R9999) POST een
- * activity op de outbox en dit blok vertaalt hem naar DEZELFDE machinerie die
- * het web gebruikt (deliverReply, sendInteraction, followActor, deliverCreate).
- *
- * Anders dan ap-transport is dit een COORDINATOR: hij roept de dienstlaag aan,
- * en de regel van shaer-drc verbiedt een import uit ActivityPubService.js.
- * Daarom het patroon dat guardianship al bewees: de dienstlaag geeft zijn
- * werktuigen bij het laden door via wireC2S, en de verhuisde functies staan
- * hier byte-voor-byte ongewijzigd -- ze merken niet dat hun buren injectie
- * werden. Wat WEL rechtstreeks geimporteerd wordt, wijst omlaag: db, ap-core,
- * de sanitizer en guardianship.
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import path from 'path';
-import db from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, actorId } from './ap-core.js';
-
-// Dezelfde twee als de re-exports in ActivityPubService: het directe-note-been
-// en de zichtbaarheidsregel wonen in guardianship, hier alleen kortgesloten
-// zodat de verhuisde regels ongewijzigd blijven.
-const c2sVisibility = Guardianship.c2sVisibility;
-const deliverDirectNote = Guardianship.deliverDirectNote;
-
-// De werktuigen uit de dienstlaag. ActivityPubService vult ze onderaan zijn
-// eigen evaluatie met wireC2S -- ruim voordat er een verzoek kan binnenkomen.
-// Een aanroep VOOR de koppeling is een programmeerfout en mag hard vallen.
-let proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-  postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-  followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-  deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-  deliverCreate;
-export function wireC2S(deps) {
-  ({ proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
-    postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
-    followActor, unfollowActor, blockTarget, unblock, deliverDelete,
-    deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
-    deliverCreate } = deps);
-}
-
-// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
-// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
-// activity here and we translate it onto the SAME delivery machinery the web UI
-// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
-// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
-const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
-
-export async function ingestOutboxActivity(site, user, activity) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
-
-  // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
-  let type = activity.type;
-  let object = activity.object;
-  if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
-  if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
-
-  // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
-  // Relationship) belongs to the guardianship module; anything else falls
-  // through to the switch below.
-  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
-    const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
-    if (g) return g;
-  }
-  // Een gate-voorstel uit de app (5.6, shaer-8ru): een Offer van een
-  // shaer:GatedSetting, de vorm die 5.6 al beschrijft.
-  //
-  // HIER, EN GEEN `case` IN DE SWITCH. Dat was hij eerst, en die claimde ELKE
-  // Offer: wat geen gate-voorstel was kreeg 400 unsupported_offer -- ook de
-  // adoptie-handshake, en straks elke Offer-vorm die we nog toevoegen. Barts
-  // honderd aanbiedingen liepen er meteen op stuk. Alleen claimen wat je
-  // herkent, en de rest laten doorlopen.
-  if (type === 'Offer') {
-    const gs = Guardianship.gated.parseGatedSetting(activity.object);
-    if (gs) {
-      const uit = proposeGate(site, gs.ward, gs.feature, gs.value);
-      return uit.status === 200 ? { ...uit, status: 201, id: uit.offerId } : uit;
-    }
-  }
-
-  try {
-    switch (type) {
-      case 'Create': {
-        if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
-        // Innamepoorten (shaer-ahy.1, 8-8): wat de ward niet mag versturen
-        // wordt HIER geweigerd, niet in de app verstopt -- een knop die de
-        // client alleen verbergt is geen poort. De reddingsboei gaat ALTIJD
-        // voor: een hulpvraag aan de guardians mag door elke dichte deur heen,
-        // anders sluit een messages-poort precies het kanaal af dat het kind
-        // veilig houdt.
-        {
-          const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
-          const isHelp = object['shaer:helpRequest'] === true || object.helpRequest === true;
-          // Een poortverzoek van het kind zelf (shaer-8ru) gaat langs de
-          // messages-poort. Dat lijkt een gat en is het niet: het verzoek draagt
-          // ALLEEN de naam van de feature, geen vrije tekst, dus er ontstaat geen
-          // kanaal om omheen die poort te praten. Zonder deze uitzondering kan
-          // een kind met berichten dicht nergens meer om vragen -- en dan is de
-          // hele weg dood op precies het moment dat hij nodig is.
-          const isGateReq = !!Guardianship.gatereq.parseRequest(object);
-          const direct = c2sVisibility(object) === 'direct';
-          if (!isHelp && !isGateReq) {
-            if (direct && !Guardianship.wardGateAllowed(site.gate_messages, isWard)) {
-              return { status: 403, error: 'gated_messages' };
-            }
-            if (!direct && !object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_compose, isWard)) {
-              return { status: 403, error: 'gated_compose' };
-            }
-            // Meedoen aan een gesprek is ook iets (Bart, 8-8). Hier stond de
-            // aanname dat een antwoord geen eigen podium is en dus onder compose
-            // door mocht. Dat is teruggedraaid: antwoorden heeft een EIGEN poort,
-            // los van compose in beide richtingen -- je kunt willen dat een kind
-            // meepraat zonder podium, en ook andersom.
-            //
-            // Geldt ook voor een DIRECT antwoord, bovenop de messages-poort: een
-            // privé-antwoord is allebei, en dan mag allebei hem tegenhouden.
-            if (object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_replies, isWard)) {
-              return { status: 403, error: 'gated_replies' };
-            }
-          }
-        }
-        // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
-        // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
-        const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
-        // A picture (or a recording) can be the whole message: media-only
-        // notes pass here; c2sCreatePost validates the attachments themselves.
-        if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
-          return { status: 400, error: 'empty_note' };
-        }
-        // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
-        // outbox machinery to the addressed inboxes only; shows under Messages.
-        if (c2sVisibility(object) === 'direct') {
-          const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-          const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
-            .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
-          if (!recipients.length) return { status: 400, error: 'no_recipients' };
-          // AS2 attachments (e.g. the help-buoy capture, uploaded via
-          // uploadMedia): normalize our own absolute /media/ URLs to relative
-          // so the deliverReply-style validation applies unchanged.
-          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
-            .map((a) => a && typeof a === 'object' ? {
-              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
-              mediaType: String(a.mediaType || ''),
-              name: String(a.name || '').slice(0, 120),
-            } : null)
-            .filter(Boolean);
-          const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
-          // FEP-633c 3.6.1: a guardian here declaring itself away to its
-          // wards. An away without a (future) end fails loudly, exactly as
-          // the daemon refuses it: stored quietly it would be a nominal
-          // guardian holding a seat.
-          let awayUntil = null;
-          if (Guardianship.availability.isAway(object)) {
-            awayUntil = Guardianship.availability.parseEndTime(object.endTime);
-            if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
-            // No local shortcut here: the note below reaches a ward on this
-            // instance through the loopback, and its inbox handler applies the
-            // absence like it does for a ward anywhere else. One path.
-          }
-          const gateReq = Guardianship.gatereq.parseRequest(object);
-          // Een hulpvraag oppikken of afsluiten vanuit de app (5.2.1, shaer-lgo).
-          // De markering IS al een gewone directe note met een shaer:-eigenschap,
-          // dus hier hoeft niets nieuws bij: de app stuurt precies wat de PWA
-          // stuurt, en het gaat over dezelfde bezorging naar de mede-guardians.
-          //
-          // We boeken hem ook LOKAAL. Zonder dat zou de guardian die de knop
-          // indrukt zijn eigen markering pas zien als hij bij zichzelf
-          // terugkomt -- en die weg bestaat niet.
-          const mark = Guardianship.help.parseMarker(object);
-          if (mark) {
-            const base2 = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-            // Met de VOLLEDIGE handle. Hier stond `@${site.slug}` -- zonder host,
-            // dus een derde vorm naast de kale URI van de PWA-route en de echte
-            // handle die een binnengekomen markering draagt. Drie spellingen van
-            // dezelfde naam, en "door wie" was de hele vraag van shaer-lgo.
-            const mij = actorId(base2, site.slug);
-            Guardianship.help.record(mark.noteUri, mij, mark.kind, deriveHandle(mij));
-          }
-          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil, gateRequest: gateReq && gateReq.feature, helpMark: mark });
-          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
-          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
-        }
-        if (object.inReplyTo) {
-          const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
-          if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
-          // The attachments ride along (Robins melding, 30-7: "502
-          // reply_failed" op een reply met een foto): deliverReply validates
-          // them itself (own /media only, image|audio|video, max 4) and a
-          // media-only reply is a valid reply there. Dropping them here made
-          // a photo reply arrive naked, and a photo-ONLY reply fail outright.
-          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
-            .map((a) => a && typeof a === 'object' ? {
-              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
-              mediaType: String(a.mediaType || ''),
-              name: String(a.name || '').slice(0, 120),
-            } : null)
-            .filter(Boolean);
-          // DE MENTIONS VAN DE CLIENT (Robins melding, 26-8). Zonder deze
-          // regel kreeg deliverReply `mentions: undefined`, en dat betekent
-          // daar "oud gedrag: noem alleen de auteur van de ouder". Een client
-          // die er drie stuurde zag er dus een gepubliceerd worden -- niet
-          // door een filter, maar doordat de andere twee hier nooit aankwamen.
-          //
-          // De tags zijn de bron, niet `to`/`cc`: die dragen ook de
-          // volgerscollectie en Public, en dat zijn geen mensen. `href` is de
-          // actor, `name` de handle zoals de client hem spelt.
-          //
-          // Ontdubbeld op actor, want de ouder-auteur zit meestal ook in de
-          // tags en zou anders twee keer vooraan komen te staan.
-          //
-          // GEEN tags meegestuurd blijft undefined en dus het oude gedrag. Een
-          // LEGE lijst kan niet: dat betekent in deliverReply "niemand noemen",
-          // en dat is een keuze die een client die geen tags kent nooit maakte.
-          const gezien = new Set();
-          const mentions = (Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []))
-            .filter((t) => t && t.type === 'Mention' && typeof t.href === 'string' && /^https?:\/\//i.test(t.href))
-            .filter((t) => !gezien.has(t.href) && gezien.add(t.href))
-            .map((t) => ({ uri: t.href, url: t.href, handle: typeof t.name === 'string' ? t.name : undefined }));
-          // Honour the client's visibility for the reply: 'friends' (followers-
-          // only, the Shaer detail-view Reply) drops Public; anything else stays
-          // quiet-public. 'direct' was already handled above.
-          const r = await deliverReply(site, {
-            postId: parent.localPostId || '', postSlug: null, parent, text: plain,
-            html: object.content || null, attachments: atts,
-            language: object.language || null, visibility: c2sVisibility(object),
-            mentions: mentions.length ? mentions : undefined,
-          });
-          if (!r || !r.id) return { status: 502, error: 'reply_failed' };
-          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
-        }
-        return await c2sCreatePost(base, site, user, object);
-      }
-      // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
-      //
-      // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
-      // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
-      // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
-      // leesbevestiging heeft in de fediverse niets te zoeken.
-      case 'Read': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const uit = markRead(site.slug, targetUri);
-        // Kennen we die note niet, dan is er niets gelezen om te onthouden.
-        // Geen fout: een client mag best een oud bericht aanwijzen.
-        return { status: uit ? 200 : 202 };
-      }
-      case 'Like':
-      case 'Announce': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        // A non-public local note cannot be boosted or liked into the open
-        // (shaer-tqc hardening; the Mastodon 422 equivalent).
-        const localPid = postIdFromNoteUrl(targetUri, base);
-        if (localPid) {
-          const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
-          if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
-            return { status: 403, error: 'not_public' };
-          }
-        }
-        const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
-        const objUri = (note && note.object_uri) || targetUri;
-        const authorUri = note && note.actor_uri;
-        const kind = type === 'Announce' ? 'boost' : 'like';
-        await sendInteraction(site, kind, objUri, authorUri);
-        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
-        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
-        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
-        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
-        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
-        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
-        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
-        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
-        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
-        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
-        // Anders dan bij een boost geen upsert: een like hoort een post niet
-        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
-        // terecht een no-op.
-        return { status: 202, url: objUri };
-      }
-      case 'Follow': {
-        const actorUri = c2sIdOf(object);
-        if (!actorUri) return { status: 400, error: 'missing_object' };
-        // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
-        // A held request is a THIRD outcome — not sent, not failed — and it
-        // travels to the app as one, so Shaer can show "waiting" instead of a
-        // tile that already looks followed.
-        const held = await gateOutgoingFollow(site, actorUri);
-        if (held) {
-          return {
-            status: 202, url: actorUri, id: held.id,
-            state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
-          };
-        }
-        // The error REACHES the app (Robins melding, 31-7): swallowing it
-        // made a failed follow look exactly like a successful one.
-        const r = await followActor(site, actorUri);
-        if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
-        return { status: 202, url: actorUri };
-      }
-      // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
-      // ap_blocks, shows in the Block tab, and purges the actor's cached
-      // content. Client-side filtering becomes a cache of this state.
-      case 'Block': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const r = await blockTarget(site, targetUri);
-        if (r && r.error) return { status: 400, error: r.error };
-        return { status: 202, url: targetUri };
-      }
-      case 'Undo': {
-        const inner = object && typeof object === 'object' ? object : null;
-        let innerType = inner && inner.type;
-        if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
-        const innerTarget = c2sIdOf(inner && inner.object);
-        if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
-        if (innerType === 'Block') {
-          if (!innerTarget) return { status: 400, error: 'missing_object' };
-          unblock(site, innerTarget).catch(() => {});   // release from Orbit
-          return { status: 202, url: innerTarget };
-        }
-        if (innerType === 'Like' || innerType === 'Announce') {
-          const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
-          const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
-          const objUri = (note && note.object_uri) || innerTarget;
-          await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
-          catch { /* non-fatal */ }
-          return { status: 202, url: objUri };
-        }
-        return { status: 400, error: 'unsupported_undo' };
-      }
-      // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
-      // app). Scope stays narrow: this account's posts and outbound replies,
-      // nothing else. The web delete route is the model: Tombstone to the
-      // followers first, then the cascade, so nobody keeps a live copy of a
-      // post the child took back.
-      case 'Delete': {
-        const targetUri = c2sIdOf(object);
-        if (!targetUri) return { status: 400, error: 'missing_object' };
-        const pid = postIdFromNoteUrl(targetUri, base);
-        if (pid) {
-          const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
-          if (post) {
-            if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
-            if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
-            db.transaction(() => {
-              db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
-              try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
-              db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
-            })();
-            return { status: 202, url: targetUri };
-          }
-          // Same /ap/notes/ namespace: one of our outbound replies/messages.
-          // deliverOutboxDelete checks the site itself and tombstones too.
-          if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
-        }
-        return { status: 404, error: 'not_your_note' };
-      }
-      // Update of arbitrary objects needs the post-edit pipeline; tracked
-      // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
-      default:
-        return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
-    }
-  } catch (e) {
-    console.warn('[AP] C2S ingest failed:', e && e.message);
-    return { status: 500, error: 'ingest_error' };
-  }
-}
-
-// Create a top-level microblog post from a C2S Note and federate it. Minimal
-// sibling of the /posts/create route: sanitized HTML content, no title/cover.
-async function c2sCreatePost(base, site, user, object) {
-  const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
-  // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
-  // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
-  // to be silently dropped here, so a photo post from the app arrived naked.
-  const media = (Array.isArray(object.attachment) ? object.attachment : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => {
-      const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
-      // The poster the upload leg made, when it did: a video's still frame
-      // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
-      // .poster.png). Rides along so the tag, the federated attachment and
-      // the apps all have something to show instead of a bare box.
-      const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
-        : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
-      if (posterExt) {
-        try {
-          const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
-          const rel = entry.url.replace(/^\/media\//, '');
-          if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
-        } catch { /* no poster is fine */ }
-      }
-      return entry;
-    });
-  if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
-  // The web reads the post's content, so the media goes IN it (we build these
-  // tags ourselves from validated paths, after the sanitizer). buildNote
-  // strips <img> back out into AS2 attachments; audio/video tags stay for the
-  // web player and federate via c2s_attachments below.
-  const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
-  const mediaHtml = media.map((a) => {
-    if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
-    // data-poster: <audio> has no poster attribute, but the tile derivation
-    // reads this one to show the waveform (post-tile/post-card).
-    if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
-    const poster = a.poster ? ` poster="${a.poster}"` : '';
-    return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
-  }).join('');
-  // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
-  // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
-  // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
-  // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
-  // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
-  // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
-  // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
-  // op een manier die iemand merkt.
-  // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
-  // en buildNote vouwt hem als vetgedrukte eerste regel in de content
-  // (Mastodon negeert `name` op een Note).
-  const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
-  const postId = crypto.randomUUID();
-  const slug = 'n-' + postId.slice(0, 8);
-  const now = new Date().toISOString();
-  // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
-  // public, Public in `cc` = quiet public (unlisted), followers-only = friends
-  // (rides the existing fan_only pipeline: followers-only AP delivery + web
-  // gating), neither = participants-only (kept local until mention addressing
-  // lands; still followers-gated on the web).
-  const vis = c2sVisibility(object);
-  const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
-  // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
-  // content, and a cover next to it showed the same video twice on the post
-  // page. The tiles derive their picture from the content instead.
-  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
-    .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
-  if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
-  try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
-  bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
-  // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
-  // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
-  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), title, user.username || '', postId); } catch { /* FTS non-fatal */ }
-  if (vis !== 'direct') {
-    deliverCreate(site, { id: postId, slug, title, content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
-  }
-  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
-}
Index: src/services/ap-cirkel.js
===================================================================
--- src/services/ap-cirkel.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,38 +1,0 @@
-/**
- * ap-cirkel.js — de Cirkel (stap 10 van shaer-drc).
- *
- * De feed van uitgelichte accounts (auto_boost) plus zelf gebooste posts,
- * en de twee lijstjes eromheen. Leest ap_timeline, ap_following en
- * ap_my_reactions; schrijft niets. De enige snede tot nu toe zonder ook maar
- * een werktuig uit de dienstlaag: alleen db.
- */
-import db, { isoSql } from '../config/database.js';
-
-// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
-let _abCount, _cirkelPosts, _cirkelMembers;
-export function autoBoostCount(slug) {
-  try { if (!_abCount) _abCount = db.prepare('SELECT COUNT(*) AS n FROM ap_following WHERE slug = ? AND auto_boost = 1'); return _abCount.get(slug).n; } catch { return 0; }
-}
-export function getCirkelPosts(slug, limit, offset) {
-  try {
-    // Cirkel = posts from featured (auto_boost) accounts + posts you boosted
-    // (t.boosted), mixed by date. One row per note in ap_timeline → no duplicates.
-    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
-      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
-             (rb.target_uri IS NOT NULL) AS boosted
-      FROM ap_timeline t
-      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
-      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
-      -- deze join kan geen rijen verdubbelen.
-      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
-      ORDER BY ${isoSql('COALESCE(t.published, t.created_at)')} DESC, t.rowid DESC
-      LIMIT ? OFFSET ?`);
-    return _cirkelPosts.all(slug, limit || 60, offset || 0);
-  } catch { return []; }
-}
-export function getCirkelMembers(slug) {
-  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
-}
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,360 +1,0 @@
-/**
- * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
- *
- * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
- * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
- * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
- * putten -- anders importeert de submap uit ActivityPubService en importeert
- * die weer terug, en dat is een kring.
- *
- * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
- * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
- *
- * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
- * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
- *
- * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
- * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
- * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
- * en zijn eigen buren en importeert nooit terug.
- */
-
-import * as Guardianship from './guardianship/index.js';
-
-export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
-// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
-// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
-// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
-// This is the same context shape Mastodon publishes, so Mastodon sees no change.
-export const AP_CONTEXT = [
-  'https://www.w3.org/ns/activitystreams',
-  'https://w3id.org/security/v1',
-  {
-    toot: 'http://joinmastodon.org/ns#',
-    schema: 'http://schema.org#',
-    sensitive: 'as:sensitive',
-    Hashtag: 'as:Hashtag',
-    manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
-    discoverable: 'toot:discoverable',
-    // FEP-7628 (account moves): same term declaration Mastodon ships.
-    alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
-    movedTo: { '@id': 'as:movedTo', '@type': '@id' },
-    featured: { '@id': 'toot:featured', '@type': '@id' },
-    PropertyValue: 'schema:PropertyValue',
-    value: 'schema:value',
-    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
-    // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
-    // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
-    // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
-    // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
-    // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
-    license: { '@id': 'schema:license', '@type': '@id' },
-    // "Dit ding is ook bekend onder die URI" -- voor de MusicBrainz-koppeling
-    // van een artiest (shaer-mbz). Bewust NIET alsoKnownAs: dat is in AS2
-    // gereserveerd voor vroegere IDENTITEITEN van dezelfde actor, en een
-    // verhuizing leunt erop (FEP-7628). Een verwijzing naar een register is
-    // iets anders dan een oud account van jezelf, en die twee door elkaar halen
-    // zou een verhuizing kunnen laten mislukken.
-    sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
-    // ── Wat we uit Funkwhale's vocabulaire overnemen, en waarom ──
-    //
-    // LIBRARY. Gemeten op 13-8: open.audio had onze vier tracks binnengehaald
-    // via hun AP-id, met een artist_credit dat het zelf uit onze attributedTo
-    // afleidde -- maar uploads LEEG en is_playable false. Bij Funkwhale hangt
-    // een upload aan een library; zonder die bak blijft een track een naam
-    // zonder geluid.
-    //
-    // TRACK. Hier stond dat we deze NIET namen, met als reden: hij vraagt een
-    // entiteit waar wij tekst hebben, en er een id voor verzinnen belooft wat
-    // we niet waarmaken. Die redenering klopte half, en de Emissary-meting van
-    // 16-8 (shaer-3f8a) laat zien welke helft. Een TRACK heeft bij ons wel
-    // degelijk een eigen identiteit -- het is een rij met een titel en een
-    // plaats in een uitgave. Wat wij niet hebben is een ALBUM als entiteit, en
-    // dat is een andere vraag (shaer-k37k). Emissary stuurt precies die kleine
-    // vorm: type, id, name, position. Wij ook, en album blijft eruit tot het
-    // een echt object is -- een verzonnen album-URI is nu juist wel de belofte
-    // die we niet kunnen waarmaken.
-    //
-    // Twee onafhankelijke implementaties zenden dit nu, en het is de kant waar
-    // FEP-be68 heen beweegt. ArtistCredit blijft eruit: dat is nog steeds een
-    // entiteit die wij niet hebben.
-    //
-    // De vorm van de termen is letterlijk die van hun contexts.py (regel
-    // 293-306), zodat een lezer die hun context laadt en een lezer die de onze
-    // leest op dezelfde IRI's uitkomen.
-    fw: 'https://funkwhale.audio/ns#',
-    Library: 'fw:Library',
-    library: { '@id': 'fw:library', '@type': '@id' },
-    Track: 'fw:Track',
-    track: { '@id': 'fw:track', '@type': '@id' },
-    // ARTIEST-CREDIT. Hun TrackSerializer eist minstens een artist_credit, en
-    // dat leek lang onmogelijk: het vraagt een Artist met een eigen id, en bij
-    // ons was een artiest tekst. Sinds de MusicBrainz-koppeling (shaer-mbz) is
-    // dat niet meer waar -- de site-ACTOR is de artiest. Een echte, opvraagbare
-    // URI, met de sitetitel als naam en een musicbrainzId als hij gekoppeld is.
-    // Er valt hier niets te verzinnen; open.audio leidde dit zelfs al zelf af
-    // uit onze attributedTo (gemeten 13-8).
-    //
-    // `@container: @list` is GEEN opsmuk. Ze lezen dit veld met
-    // first_attr(FW.artist_credit, "@list"), en zonder die declaratie expandeert
-    // onze array niet naar een @list -- dan staat er iets dat er goed uitziet en
-    // door hun lezer niet gevonden wordt. Letterlijk hun contexts.py regel 311.
-    Artist: 'fw:Artist',
-    ArtistCredit: 'fw:ArtistCredit',
-    artist: { '@id': 'fw:artist', '@type': '@id' },
-    artist_credit: { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' },
-    credit: 'fw:credit',
-    musicbrainzId: 'fw:musicbrainzId',
-    position: 'schema:position',
-    bitrate: 'schema:bitrate',
-    size: 'schema:contentSize',
-    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
-    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
-    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
-    votersCount: 'toot:votersCount',
-    // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
-    // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
-    // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
-    // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
-    // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
-    // `migration`, want daar leest een consument op.
-    migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
-    moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
-    migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
-    migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
-    migratedAt: 'https://w3id.org/fep/1580/migratedAt',
-    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
-    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
-    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
-    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
-    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
-    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
-    // antwoordt.
-    category: { '@id': 'https://funkwhale.audio/ns#category' },
-    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
-    // module (src/services/guardianship/).
-    ...Guardianship.SHAER_CONTEXT,
-  },
-];
-
-/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
- *  bruikbare URL vinden. */
-export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
-
-export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
-export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
-
-/**
- * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
- * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
- * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
- * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
- * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
- */
-export function guessMediaType(u) {
-  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-  return ({
-    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
-    webp: 'image/webp', avif: 'image/avif',
-    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
-  })[(e || '').toLowerCase()] || 'image/jpeg';
-}
-
-/**
- * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
- * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
- * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
- * terugval voor wat er handmatig is ingevuld.
- */
-export function normalizeTags(t) {
-  if (Array.isArray(t)) return t;
-  if (typeof t === 'string') {
-    const s = t.trim(); if (!s) return [];
-    if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
-    return s.split(',').map((x) => x.trim()).filter(Boolean);
-  }
-  return [];
-}
-
-/**
- * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
- * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
- * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
- * blijven kleingeschreven ("livemusic").
- */
-export function tagParts(raw) {
-  const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
-  if (!words.length) return null;
-  const slug = words.join('').toLowerCase();
-  if (!slug) return null;
-  const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
-  return { label, slug };
-}
-
-/**
- * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
- * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
- * naam niet: #DoenweNiet blijft #DoenweNiet.
- */
-export function hashtagTags(base, content) {
-  const tags = [], seen = new Set();
-  const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const k = m[1].toLowerCase();
-    if (seen.has(k)) continue; seen.add(k);
-    tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
-  }
-  return tags;
-}
-
-/**
- * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
- *
- * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
- * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
- * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
- * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
- * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
- * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
- *
- * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
- * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
- * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
- * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
- * tagveld, precies de vorm die hier juist niet moest winnen.
- */
-export function hashtagTagsRuw(base, content) {
-  const tags = [], seen = new Set();
-  // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
-  // tag, en die zou hier anders als hashtag de deur uit gaan.
-  const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
-  let m;
-  while ((m = re.exec(content || ''))) {
-    const k = m[2].toLowerCase();
-    if (seen.has(k)) continue; seen.add(k);
-    tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
-  }
-  return tags;
-}
-
-export function buildHashtagList(base, tagsField, content, opts = {}) {
-  const out = [], seen = new Set();
-  const uitLijf = opts.ruw
-    ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
-    : hashtagTags(base, content);
-  for (const h of uitLijf) {
-    const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
-    out.push(h);
-  }
-  for (const t of normalizeTags(tagsField)) {
-    const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
-    out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
-  }
-  return out;
-}
-
-/**
- * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
- *
- * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
- * "first: This field is required" en "last: This field is required" -- de eerste
- * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
- * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
- * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
- * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
- * opnieuw vergeet.
- *
- * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
- * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
- * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
- * naar dezelfde.
- *
- * @param {string} id        de collectie-uri, zonder query
- * @param {Array}  items     wat erin zit (mag leeg)
- * @param {object} opts
- *   totalItems  als de telling niet items.length is (followers geeft publiek
- *               alleen een AANTAL en houdt de lijst dicht)
- *   page        true -> een OrderedCollectionPage met partOf in plaats van de wortel
- *   extra       velden die op de wortel horen (attributedTo, shaer:*)
- */
-/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
-export const PAGINA_GROOTTE = 20;
-
-/**
- * Een collectie, met ECHTE paginering (shaer-sk4).
- *
- * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
- * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
- * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
- * pagina's identiek bleven; dit is waarom.
- *
- * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
- * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
- * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
- * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
- *
- * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
- * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
- * antwoord zijn dat over zichzelf liegt.
- *
- * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
- * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
- * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
- * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
- * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
- * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
- * blijft dus zoals hij was.
- *
- * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
- * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
- * kan zo terug naar het begin zonder eerst de wortel op te halen.
- */
-export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
-  const lijst = items || [];
-  const telling = totalItems === undefined ? lijst.length : totalItems;
-  const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
-  // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
-  // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
-  // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
-  // en nooit een `next` aanbieden.
-  const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
-  const url = (n) => `${id}?page=${n}`;
-
-  if (page) {
-    const n = Math.max(1, Math.floor(Number(page)) || 1);
-    const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
-    return {
-      '@context': AP_CONTEXT,
-      id: url(n),
-      type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
-      partOf: id,
-      totalItems: telling,
-      ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
-      ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
-      ...(n > 1 ? { prev: url(n - 1) } : {}),
-      ...(n < paginas ? { next: url(n + 1) } : {}),
-      ...(ongeordend ? { items: deel } : { orderedItems: deel }),
-    };
-  }
-  return {
-    '@context': AP_CONTEXT,
-    id,
-    type: ongeordend ? 'Collection' : 'OrderedCollection',
-    ...extra,
-    totalItems: telling,
-    first: url(1),
-    last: url(paginas),
-    ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
-  };
-}
-
-/** Is dit een MBID? Een UUID, en niets anders. */
-export function isMbid(s) {
-  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || '').trim());
-}
-
-/** De publieke pagina van een artiest, of null als het geen MBID is. */
-export function artiestUrl(mbid) {
-  return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
-}
Index: src/services/ap-following.js
===================================================================
--- src/services/ap-following.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,201 +1,0 @@
-/**
- * ap-following.js — de volgwinkel (stap 7 van shaer-drc).
- *
- * Alles rond ap_following: WebFinger, de statements, de lijst en de
- * auto-boost-knop, en de drie federatiehandelingen (followActor,
- * resolveRemoteActor, unfollowActor).
- *
- * fwStmts exporteert mee: de Accept-tak van de inbox en de verhuizing
- * (FEP-7628) schrijven de winkel bij en blijven in de dienst wonen -- zelfde
- * verhouding als tlStmts bij ap-timeline. De poortwachter (gateOutgoingFollow)
- * en zijn goedkeuring (performApprovedFollow) blijven daar ook: de eerste komt
- * hier via injectie binnen, de tweede roept followActor gewoon via de dienst
- * aan, en zo is er geen kring.
- */
-import db from '../config/database.js';
-import * as Guardianship from './guardianship/index.js';
-import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
-import { safeFetch, signedGetJson, fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
-  deliverToActor;
-export function wireFollowing(deps) {
-  ({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox,
-    deliverToActor } = deps);
-}
-
-// ── Fediverse CLIENT: follow accounts + home timeline ─────────────
-// Resolve an @user@domain handle to its actor URL via WebFinger.
-export async function webfingerResolve(handle) {
-  const h = String(handle || '').trim().replace(/^@/, '');
-  const parts = h.split('@');
-  if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
-  const acct = `${parts[0]}@${parts[1]}`;
-  try {
-    const r = await safeFetch(`https://${parts[1]}/.well-known/webfinger?resource=acct:${encodeURIComponent(acct)}`,
-      { headers: { Accept: 'application/jrd+json, application/json' } });
-    if (!r.ok) return null;
-    const jrd = await r.json();
-    const link = (jrd.links || []).find((l) => l.rel === 'self' && /activity\+json|ld\+json/.test(l.type || ''));
-    return safeUrl(link ? link.href : '') || null;
-  } catch { return null; }
-}
-
-let _insFw, _delFw, _listFw, _accFw, _accFwByActor, _oneFw, _setAB;
-export function fwStmts() {
-  if (!_insFw) {
-    _insFw = db.prepare('INSERT OR REPLACE INTO ap_following (slug, actor_uri, handle, name, icon, url, inbox, follow_id, status, auto_boost, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
-    _delFw = db.prepare('DELETE FROM ap_following WHERE slug = ? AND actor_uri = ?');
-    _listFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? ORDER BY created_at DESC');
-    _accFw = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE follow_id = ?");
-    // Terugval als de Accept ons follow-id niet teruggeeft (zie de Accept-tak
-    // in handleInbox): dan is het paar dat we WEL zeker weten (deze site, deze
-    // actor) genoeg, mits de rij nog op pending staat.
-    _accFwByActor = db.prepare("UPDATE ap_following SET status = 'accepted' WHERE slug = ? AND actor_uri = ? AND status = 'pending'");
-    _oneFw = db.prepare('SELECT * FROM ap_following WHERE slug = ? AND actor_uri = ?');
-    _setAB = db.prepare('UPDATE ap_following SET auto_boost = ? WHERE slug = ? AND actor_uri = ?');
-  }
-  return { ins: _insFw, del: _delFw, list: _listFw, acc: _accFw, accByActor: _accFwByActor, one: _oneFw, setAB: _setAB };
-}
-export function listFollowing(slug) { return fwStmts().list.all(slug); }
-
-// Toggle auto-boost ("feature") on an account we already follow.
-export function setAutoBoost(slug, actorUri, on) {
-  try { fwStmts().setAB.run(on ? 1 : 0, slug, actorUri); } catch { /* ignore */ }
-  // Featuring an account → AP-native catch-up so the Cirkel isn't empty until they next
-  // post (push doesn't backfill history-before-follow). Fire-and-forget pull, sends nothing.
-  if (on) backfillFromOutbox(slug, actorUri).catch(() => {});
-  return { ok: true };
-}
-
-// Resolve a Klonkt/AP actor URL from a site root: a Klonkt site's root 302s to
-// /ap/users/<slug> (content negotiation; Location may be relative). Used by
-// followActor for bare-domain follows.
-// NB: the old auto-migration of legacy Cirkels (circle_links -> AP follows) was
-// REMOVED on 2026-06-26 — it auto-sent Follows on boot, which violates "the code
-// never throws anything into the fediverse automatically" (would surprise-Follow
-// for some operators at scale). The dead circle_links table stays as harmless dead
-// data; an operator restores an old cirkel by re-following in /following (their click).
-async function resolveApActor(siteUrl) {
-  try {
-    const r = await fetch(siteUrl, { headers: { Accept: 'application/activity+json' }, redirect: 'manual' });
-    if (r.status >= 300 && r.status < 400) { const loc = r.headers.get('location'); if (loc) return new URL(loc, siteUrl).href; }
-    if (r.ok) return siteUrl;
-  } catch { /* unreachable */ }
-  return null;
-}
-
-export async function followActor(site, handle, autoBoost = false, { approved = false } = {}) {
-  const _mv = movedRefusal(site, 'follow'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug) return { error: 'config' };
-  // DE POORT STAAT HIER, en niet alleen in de C2S-outbox (shaer-p729, Barts
-  // melding 8-8: de volgverzoeken van Esmee kwamen nooit bij haar guardians
-  // aan). Hij stond in `case 'Follow'` van de outbox -- dus alleen als je via
-  // Shaer volgt. Volgde het kind vanuit Klonkts eigen webinterface, dan werd er
-  // geen verzoek aangemaakt, ging er niets naar de guardians, en was er dus ook
-  // niets om te beantwoorden. Precies dezelfde deur-naast-de-poort als bij de
-  // antwoordpoort vanmiddag (shaer-r4c).
-  //
-  // Merk op wat het NIET was: niet dat een guardian elders het niet kon
-  // beantwoorden. Die weg werkt en levert een Offer af bij de externe guardian.
-  // Er kwam alleen nooit iets aan om af te leveren.
-  //
-  // `approved` is de enige doorlaat, voor performApprovedFollow: zonder dat zou
-  // een goedgekeurd verzoek opnieuw op de poort stuiten en voor eeuwig wachten.
-
-  // Accept any of: a profile/actor URL, an @user@host handle (WebFinger), or a
-  // bare site domain (site.com) — for a single-actor site (Klonkt etc.) the root
-  // resolves to its AP actor, so you can follow a site by just its domain.
-  const s = String(handle || '').trim();
-  let actorUrl;
-  if (/^https?:\/\//i.test(s)) actorUrl = safeUrl(s) || null;
-  else if (s.includes('@')) actorUrl = await webfingerResolve(s);
-  else if (/^[a-z0-9.-]+\.[a-z]{2,}/i.test(s)) actorUrl = await resolveApActor('https://' + s.replace(/^\/+|\/+$/g, ''));
-  else actorUrl = null;
-  if (!actorUrl) return { error: 'not_found' };
-  // NA het oplossen, want een kind volgt net zo goed met @naam@server of een
-  // kaal domein. Zou de poort alleen naar de ruwe invoer kijken, dan is elke
-  // handle een sluiproute -- en dat is precies de fout die we hier repareren,
-  // een maat kleiner.
-  if (!approved) {
-    const held = await gateOutgoingFollow(site, actorUrl);
-    if (held) return { held: true, id: held.id, status: held.status || 'pending' };
-  }
-  // SIGNED, as this actor: an authorized-fetch instance refuses an anonymous
-  // GET of the actor doc, which made following from a boost silently fail
-  // (Robins melding, 31-7). Signed, the other side sees who asks.
-  const actor = await signedGetJson(site.slug, actorUrl);
-  if (!actor || !actor.id || !actor.inbox) return { error: 'unreachable' };
-  const ai = actorInfo(actor, actor.id);
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const followId = `${me}#follow-${Date.now()}-${rid()}`;
-  fwStmts().ins.run(site.slug, actor.id, ai.handle, ai.name, ai.icon, ai.url, actor.inbox, followId, 'pending', autoBoost ? 1 : 0);
-  const follow = { '@context': AP_CONTEXT, id: followId, type: 'Follow', actor: me, object: actor.id };
-  // Deliver via the retry queue: a Follow that fails the first attempt (peer down,
-  // timeout, transient 5xx) is retried with backoff instead of staying stuck on
-  // 'pending' forever — the Accept can only come back once the Follow lands.
-  await deliverWithRetry(site.slug, actor.inbox, follow, `${me}#main-key`, keys.private_pem);
-  console.log('[AP] follow', site.slug, '→', actor.id);
-  // Follow + feature in one step → backfill their recent posts into the Cirkel right away.
-  if (autoBoost) backfillFromOutbox(site.slug, actor.id).catch(() => {});
-  // A ward's guardians are TOLD about a new follow (Robins verzoek, 31-7):
-  // a follow brings new content into the child's feed, and the village
-  // should know the door opened. A direct note per guardian, best-effort;
-  // FEP-633c 5.3 gates inbound follows, the outbound notice is Shaer policy
-  // for now (bead: spec-vraag).
-  try {
-    const guardians = Guardianship.listGuardians(site.slug);
-    if (guardians.length) {
-      const meRef = actorId(base, site.slug);
-      const esc = (t) => String(t).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
-      const label = esc(ai.name || ai.handle || actor.id);
-      for (const g of guardians) {
-        const note = {
-          id: `${meRef}/follow-notice/${Date.now().toString(36)}${rid()}`,
-          type: 'Note', attributedTo: meRef, to: [g.other_uri],
-          tag: [{ type: 'Mention', href: g.other_uri }],
-          content: `<p>👀 ${esc(site.title || site.slug)} is now following ${label}.</p>`,
-        };
-        deliverToActor(site, g.other_uri, { id: `${note.id}#create`, type: 'Create', actor: meRef, to: [g.other_uri], object: note })
-          .catch(() => { /* retried by the queue */ });
-      }
-      console.log('[AP] follow notice →', guardians.length, 'guardian(s) of', site.slug);
-    }
-  } catch { /* geen guardians is geen fout */ }
-  return { ok: true, name: ai.name, handle: ai.handle, actor: actor.id };
-}
-
-// Resolve a profile URL or @handle to a followable remote actor (for the
-// authorize_interaction "Follow" flow). Returns display fields + inbox, or null
-// when it isn't a reachable actor (e.g. the input was a post, not a profile).
-export async function resolveRemoteActor(input) {
-  const s = String(input || '').trim();
-  const actorUrl = /^https?:\/\//i.test(s) ? (safeUrl(s) || null) : await webfingerResolve(s);
-  if (!actorUrl) return null;
-  const actor = await fetchActor(actorUrl).catch(() => null);
-  if (!actor || !actor.id || !actor.inbox) return null;
-  const ai = actorInfo(actor, actor.id);
-  return { actor_uri: actor.id, actor_name: ai.name, actor_handle: ai.handle, actor_url: ai.url, actor_icon: ai.icon, inbox: actor.inbox };
-}
-
-export async function unfollowActor(site, actorUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const row = fwStmts().one.get(site.slug, actorUri);
-  // Undo(Follow) MUST reference the original Follow's real id so the remote can correlate it
-  // and drop the follow. The old `${me}#follow` fallback never matched anything → the unfollow
-  // silently failed on the remote. With no stored follow id (legacy row), skip the network Undo
-  // rather than send an unmatchable one. Deliver durably via the retry queue.
-  if (row && row.inbox && row.follow_id) {
-    const undo = { '@context': AP_CONTEXT, id: `${me}/undo/${Date.now()}-${rid()}`, type: 'Undo', actor: me, object: { id: row.follow_id, type: 'Follow', actor: me, object: actorUri } };
-    deliverWithRetry(site.slug, row.inbox, undo, `${me}#main-key`, keys.private_pem);
-  } else if (row && row.inbox) {
-    console.warn('[AP] unfollow', site.slug, '→', actorUri, '— no stored follow id; removed locally only (legacy follow, remote may keep it)');
-  }
-  fwStmts().del.run(site.slug, actorUri);
-  return { ok: true };
-}
Index: src/services/ap-inbox.js
===================================================================
--- src/services/ap-inbox.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1056 +1,0 @@
-/**
- * ap-inbox.js — de inbox (stap 9 van shaer-drc).
- *
- * Het hart van de federatie-ontvangst: handleInbox (de grote switch over
- * Follow, Accept, Undo, Create, Like, Announce, Delete, Update, Move, Flag en
- * Block), de her-verificatie van doorgestuurde activiteiten
- * (dereferenceForwarded, shaer-s8k) en de kleine kas eromheen (bekende notes,
- * geziene notes, recente ophaal-missers).
- *
- * De inbox is de SCHAKELKAST van de dienst: hij raakt vrijwel elk cluster.
- * Wat al een eigen module heeft komt statisch binnen (transport, tijdlijn,
- * peilingen, volgwinkel, guardianship, ap-core); de tweeendertig werktuigen
- * die nog in de dienstlaag wonen komen via wireInbox. Die lijst is bewust
- * lang en expliciet -- hij IS de kaart van wat de inbox aanraakt, en elke
- * naam die er ooit afgaat is een cluster dat zelf verhuisd is.
- * De §5.3-goedkeuring (handleFollowApprovalInbox) blijft bij zijn
- * guardian-broers in de dienst, zoals gateOutgoingFollow bij stap 7.
- */
-import db, { NU_ISO } from '../config/database.js';
-import HtmlSanitizerService from './HtmlSanitizerService.js';
-import * as Guardianship from './guardianship/index.js';
-import { t as i18nT } from './i18n.js';
-import { safeUrl, actorId, AP_CONTEXT } from './ap-core.js';
-import {
-  verifyRequest, fetchActor, deliver, deliverWithRetry, signedGetJson,
-  apGetJson, anySigningSlug, getOrCreateKeys,
-} from './ap-transport.js';
-import { tlStmts, extractEmojiTags, extractLinkJson, quoteHrefOf } from './ap-timeline.js';
-import { parsePoll, recordPollBallot } from './ap-polls.js';
-import { fwStmts } from './ap-following.js';
-
-/**
- * Welke objectsoorten deze inbox in de tijdlijn opneemt.
- *
- * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
- * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
- * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
- * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
- */
-const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-  belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-  findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-  isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug, localMentionSlugs,
-  localPostExists, localSlugOf, mediaFromNote, noteVisibility,
-  postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx, pushPrefix,
-  resolveCard, resolveExternalEmbed, resolveQuote, rid, slugFromActorUrl,
-  storeAuthorEmoji, timelineFields, wakeGuardian;
-export function wireInbox(deps) {
-  ({ actorInfo, actorUriOf, backfillFromOutbox, backfillNewFollower,
-    belongsInTimeline, contentWarning, emojiJsonOf, fetchNoteAP,
-    findThreadTarget, fStmts, handleFollowApprovalInbox, handleMoveInbox,
-    isBlockedAny, isRejectedObject, iStmts, libraryOwnerSlug,
-    localMentionSlugs, localPostExists, localSlugOf, mediaFromNote,
-    noteVisibility, postIdFromNoteUrl, pushEvent, pushLang, pushPostCtx,
-    pushPrefix, resolveCard, resolveExternalEmbed, resolveQuote, rid,
-    slugFromActorUrl, storeAuthorEmoji, timelineFields, wakeGuardian } = deps);
-}
-
-/**
- * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
- *
- * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
- * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
- * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
- * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
- * binnen, zonder dat iemand een fout zag.
- *
- * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
- * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
- * bezorgde inhoud NIET en halen het object op bij de bron.
- *
- * Vier voorwaarden, en geen ervan is optioneel:
- *
- *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
- *     dereferencen -- het object is weg -- dus die blijft geweigerd.
- *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
- *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
- *     attributedTo alles kan beweren.
- *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
- *     levert een doorsturer een echt id met verdraaide inhoud.
- *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
- *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
- */
-/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
- *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
- *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
- *  doorstuurt; iets anders is dat niet. */
-function knownNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return false;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  try {
-    if (base && uri.startsWith(`${base}/ap/notes/`)) {
-      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
-      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
-      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
-    }
-    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
-    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
-    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
-    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
-  } catch { /* bij twijfel niet ophalen */ }
-  return false;
-}
-
-/**
- * Onthoud dat we dit bericht al eens bezorgd kregen.
- *
- * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
- * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
- * stelt voordat er iets bij de bron wordt opgehaald.
- *
- * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
- * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
- * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
- * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
- * schrijvers die je zelf volgt.
- */
-const SEEN_NOTES_DAYS = 30;
-let _seenSinceSnoei = 0;
-function rememberNoteUri(uri) {
-  if (!uri || typeof uri !== 'string') return;
-  try {
-    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
-    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
-    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
-    // ouder is dan een maand beantwoordt geen enkele vraag meer.
-    if (++_seenSinceSnoei >= 500) {
-      _seenSinceSnoei = 0;
-      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE datetime(created_at) < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
-      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
-    }
-  } catch { /* niet fataal */ }
-}
-const isFollowedActor = (uri) => {
-  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
-};
-
-/**
- * De hulpvraag zoals WIJ hem opsloegen (shaer-gt70).
- *
- * Een markering wijst naar een note-URI, en die komt van de afzender. Welke
- * ward erbij hoort mag daarom niet uit die markering komen maar uit onze eigen
- * rij: de hulpvraag kwam hier binnen als directe vermelding met help_request=1,
- * en `actor_uri` daarvan IS de ward.
- *
- * Geen rij, geen markering. Dat sluit meteen de aardigste variant af: iemand
- * die markeringen stuurt voor hulpvragen die hij ergens anders zag.
- *
- * GEVOLG DAT JE MOET KENNEN: een markering gaat naar [ward, ...guardians] (zie
- * routes/guardian.js), en op de instantie van de WARD staat zijn eigen
- * hulpvraag niet hier maar in ap_outbox. Daar wordt een markering dus
- * geweigerd. Dat kost vandaag niets, en dat is aan BEIDE kanten nagelopen: de
- * enige lezer op de server is het guardian-paneel (queues.helpItemsFor), dat
- * juist deze tabel leest, en de app leest diezelfde queue -- op het account van
- * een kind levert die alleen zijn INKOMENDE hulpvragen op. Een scherm waarop
- * het kind zijn eigen vraag met status ziet bestaat niet: HomeBase filtert de
- * eigen hulpvraag uit de feed en kan hem verder alleen versturen.
- *
- * Komt dat scherm er ooit -- "er is iemand mee bezig", en dat is een goed idee
- * voor een kind dat wacht -- dan is dit de plek: die moet dan ook de eigen
- * uitgaande hulpvraag (ap_outbox) als bron erkennen.
- */
-function helpRequestRow(noteUri) {
-  if (!noteUri || typeof noteUri !== 'string') return null;
-  try {
-    return db.prepare('SELECT slug, actor_uri FROM ap_mentions WHERE object_uri = ? AND help_request = 1 LIMIT 1').get(noteUri) || null;
-  } catch { return null; }
-}
-
-/**
- * Is deze actor guardian van deze ward?
- *
- * De WARD is de bron van waarheid over zijn eigen guardians -- onze tabel kent
- * alleen onze eigen relatie. existingGuardiansOf stelt de vraag op de goede
- * plek: hosten wij de ward, dan is het een databaselezing; woont hij elders,
- * dan komt het uit shaer:guardians op zijn actor.
- *
- * MET EEN CACHE, want dat tweede geval is een netwerkaanroep in het inbox-pad.
- * Zonder zou een vreemde onze inbox kunnen laten wachten door markeringen te
- * blijven sturen. De lokale tak raakt de cache ook, en dat kost daar niets.
- *
- * Vijf minuten is kort genoeg dat een verse guardian niet lang buiten staat, en
- * lang genoeg om herhaald bevragen te dempen. Een geweigerde markering is niet
- * verloren: de andere kant levert opnieuw af, en dan is de cache ververst.
- */
-const _guardiansOfWard = new Map();   // ward-uri -> { at, set }
-const GUARDIAN_CACHE_MS = 5 * 60 * 1000;
-async function isGuardianOfWard(actorUri, wardUri) {
-  if (!actorUri || !wardUri) return false;
-  const nu = Date.now();
-  const gecached = _guardiansOfWard.get(wardUri);
-  if (gecached && nu - gecached.at < GUARDIAN_CACHE_MS) return gecached.set.has(actorUri);
-  let lijst = [];
-  try { lijst = await Guardianship.existingGuardiansOf(wardUri); } catch { lijst = []; }
-  // Een MISLUKTE ophaal niet als lege lijst wegschrijven: dan zou een tijdelijk
-  // onbereikbare server vijf minuten lang elke markering weigeren. Bij twijfel
-  // niets onthouden en de volgende keer opnieuw kijken.
-  if (Array.isArray(lijst) && lijst.length) {
-    if (_guardiansOfWard.size > 500) _guardiansOfWard.clear();   // simpele begrenzing
-    _guardiansOfWard.set(wardUri, { at: nu, set: new Set(lijst) });
-  }
-  return Array.isArray(lijst) && lijst.includes(actorUri);
-}
-
-// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
-// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
-// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
-//
-// DE SLEUTEL IS DE HELE BESCHERMING (shaer-qawr). Er zijn twee soorten
-// mislukking en ze zeggen iets heel verschillends:
-//
-//   TRANSPORTFOUT -- de note is niet op te halen. Dat is een eigenschap van de
-//   note zelf en geldt voor iedereen die hem doorstuurt, dus de objId alleen is
-//   de goede sleutel.
-//
-//   attributedTo-MISMATCH -- de bron zegt dat iemand ANDERS de auteur is. Dat
-//   zegt alles over de doorstuurder en niets over de note, dus die onthouden we
-//   per (note, beweerde actor).
-//
-// Met een enkele sleutel voor allebei was dit een censuurknop: neem de echte
-// note-URI van je slachtoffer, zet er je eigen actor op dezelfde host bij en
-// wijs naar een van onze publieke notes. De fetch slaagt, de mismatch volgt, en
-// die note-URI stond dertig minuten op de zwarte lijst -- waarna het ECHTE
-// doorgestuurde antwoord erop stukliep. Elke dertig minuten herhalen gaf
-// onbeperkte, gerichte onderdrukking van een specifiek antwoord, voor een
-// verzoek per keer. Nu raakt de leugenaar alleen zijn eigen ingang.
-//
-// Query en fragment tellen niet mee. Ze horen zelden bij de identiteit van een
-// note, en met een kale URL als sleutel waren ?x=1, ?x=2 enzovoort losse
-// ingangen: dan is de rem geen rem, want varieren kost niets. Zelfde reden dat
-// de host in kleine letters gaat.
-//
-// GEEN rem per HOST, hoe verleidelijk ook: wie een handvol niet-bestaande
-// URL's op een host laat mislukken zou daarmee die HELE host het zwijgen
-// opleggen. Dat is een grotere versie van precies de fout die hier gerepareerd
-// wordt.
-const _derefMiss = new Map();
-const DEREF_MISS_MS = 30 * 60 * 1000;
-const derefKey = (uri, claimedActor) => {
-  let basis = String(uri || '');
-  try { const u = new URL(basis); basis = `${u.protocol}//${u.host.toLowerCase()}${u.pathname}`; }
-  catch { /* onparseerbaar: de kale string is dan de sleutel */ }
-  // Een NUL-teken als scheiding, als escape geschreven en niet als byte: het
-  // kan in geen enkele URL staan, dus een actor-sleutel is nooit per ongeluk
-  // als note-sleutel te lezen.
-  return claimedActor ? `${basis}\u0000${claimedActor}` : basis;
-};
-function derefRecentlyFailed(uri, claimedActor) {
-  for (const k of [derefKey(uri), derefKey(uri, claimedActor)]) {
-    const t = _derefMiss.get(k);
-    if (t === undefined) continue;
-    if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(k); continue; }
-    return true;
-  }
-  return false;
-}
-function noteDerefFailure(uri, claimedActor) {
-  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
-    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
-    for (const [k] of oud) _derefMiss.delete(k);
-  }
-  _derefMiss.set(derefKey(uri, claimedActor), Date.now());
-}
-// Alleen voor de toets: de aanval speelt zich af in deze twee functies, en de
-// weg erheen (dereferenceForwarded) eist https en een echte fetch. De
-// dienstlaag exporteert ze niet, dus het uitvoeroppervlak blijft gelijk.
-export const _derefCacheForTests = { derefRecentlyFailed, noteDerefFailure };
-
-async function dereferenceForwarded(act, claimedActor, type, slugParam) {
-  // Every exit states its reason. Five of the six used to return silently, so a
-  // rejection count could not be told apart from a narrowing that closed too far
-  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
-  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
-  const skipped = (reason, detail) => {
-    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
-    return null;
-  };
-  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
-  const o = act && act.object;
-  const objId = typeof o === 'string' ? o : (o && o.id);
-  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
-  try {
-    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
-  } catch { return skipped('unparsable id', objId); }
-  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
-  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
-  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
-  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
-  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
-  // niets aan legitiem verkeer waarvan we de ouder kennen.
-  const parent = typeof o === 'object' && o
-    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
-    : null;
-  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
-  if (derefRecentlyFailed(objId, claimedActor)) return skipped('recent failure', objId);
-  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
-  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
-  // reden als bij fetchActor sinds efe5633.
-  let fetched = await apGetJson(objId).catch(() => null);
-  if (!fetched || fetched.id !== objId) {
-    // The signer used to be slugParam, which is null on the shared inbox — and
-    // that is where forwarded traffic lands, because we advertise a sharedInbox.
-    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
-    // secure mode could never be dereferenced at all. Same fix verifyRequest got
-    // in shaer-afq: any local actor is a valid signer.
-    const asSlug = slugParam || anySigningSlug();
-    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
-  }
-  const attributed = fetched && (typeof fetched.attributedTo === 'string'
-    ? fetched.attributedTo
-    : (fetched.attributedTo && fetched.attributedTo.id));
-  if (!fetched || fetched.id !== objId) {
-    noteDerefFailure(objId);
-    return skipped('fetch failed', objId);
-  }
-  if (attributed !== claimedActor) {
-    // Not a transport hiccup: the source itself says someone else wrote this.
-    // Per (note, beweerde actor), nooit op de note alleen: dit zegt iets over
-    // DEZE doorstuurder, en op de note alleen was het een censuurknop op de
-    // note van een ander (shaer-qawr).
-    noteDerefFailure(objId, claimedActor);
-    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
-  }
-  return fetched;
-}
-
-// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
-export async function handleInbox(req, slugParam, preVerified = null) {
-  const act = req.body || {};
-  const type = act.type;
-  // Real client IP (behind the proxy via `trust proxy`) — logged on dropped/rejected/
-  // ignored inbox hits so an operator can see who is probing their fediverse inbox.
-  const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?';
-  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
-  // preVerified is the loopback (see deliverToActor): a delivery between two
-  // actors on THIS instance never crosses a socket, so there is no signature to
-  // check — but we do know who signed, because we signed it. Handing that in
-  // keeps everything below identical, including the actor-versus-signer check,
-  // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
-
-  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
-  // actor it claims to be. No valid signature, or signer ≠ actor → reject (no
-  // forged replies/likes/follows/timeline posts). GET/discovery stays open.
-  const claimedActor = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  // Blocked actor/domain → silently drop (202, don't reveal the block).
-  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
-  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
-  if (GATED.includes(type)) {
-    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
-    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
-    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
-    let forwarded = null;
-    if (verified && claimedActor && verified.id !== claimedActor) {
-      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
-      if (forwarded) {
-        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
-        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
-      }
-    }
-    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
-      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
-      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
-      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
-      // of geldig ondertekend door iemand anders.
-      const reden = verified ? '(signer mismatch)'
-        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
-        : '(no signature)';
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
-      return 401;
-    }
-    // One answer restores everything (FEP-633c 3.6): any VERIFIED activity
-    // from an actor that guards someone here restores it to active for those
-    // wards and cancels any lapse running against it, before the activity is
-    // even looked at. Signature-gated on purpose: an unverified claim of
-    // being gran must not wake gran up.
-    try {
-      const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now());
-      if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', '));
-      for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id);
-    } catch { /* availability is never load-bearing for delivery */ }
-  }
-
-  // FEP-633c §5.3 (modelled on the adoption offer): a gated follow forwarded to
-  // the guardians as an Offer(Follow), their Accept/Reject back to the ward.
-  if ((type === 'Offer' || type === 'Accept' || type === 'Reject') && act['shaer:followApproval'] === true) {
-    if (await handleFollowApprovalInbox(act, slugParam)) { console.log('[AP] follow-approval', type, 'from', claimedActor); return 202; }
-  }
-
-  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
-  // Accept/Reject answers an offer a local guardian sent. Anything the
-  // guardianship module does not recognize falls through to the old paths.
-  // An Undo of the guardianship Relationship (§3.2) is handled here too, and it
-  // must be seen BEFORE the generic Undo branch below, which only knows about
-  // Follow/Like/Announce and would swallow it with a 202.
-  if (type === 'Offer' || type === 'Accept' || type === 'Reject' || (type === 'Undo' && Guardianship.parseUndoRelationship(act))) {
-    // Every LOCAL party this activity is addressed to gets its own copy of the
-    // handshake (a ward and a co-guardian may both live here). Gather candidate
-    // local slugs from the inbox owner, the `to` list, and the ward.
-    // MET localSlugOf en niet met slugFromActorUrl. Dat laatste knipt alleen de
-    // staart van een pad af, zonder naar de HOST te kijken -- en deze uri's
-    // komen uit `to` en uit de relatie, dus van de afzender. Een Offer gericht
-    // aan https://elders.example/ap/users/dev leverde zo de slug "dev" op, en
-    // die bestaat hier. Dan draait onze dev de afhandeling van een activiteit
-    // die nooit aan hem geadresseerd was. localSlugOf eist dat de uri met onze
-    // eigen basis begint en dat de site echt bestaat.
-    const cand = new Set();
-    if (slugParam) cand.add(slugParam);
-    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
-      if (typeof t === 'string') { const s = localSlugOf(t); if (s) cand.add(s); }
-    }
-    if (type === 'Offer' || type === 'Undo') {
-      const rel = type === 'Undo' ? Guardianship.parseUndoRelationship(act) : Guardianship.parseRelationship(act.object);
-      if (rel) { const s = localSlugOf(rel.ward); if (s) cand.add(s); }
-    }
-    let consumed = false;
-    for (const slug of cand) {
-      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
-      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
-    }
-    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
-  }
-
-  // A moderation report (Flag) about our content — store it for the targeted site's owner
-  // (each Klonkt site is moderated by its own owner). Signature is enforced (GATED).
-  if (type === 'Flag') {
-    const objs = Array.isArray(act.object) ? act.object : (act.object ? [act.object] : []);
-    const objectUris = objs.map((o) => (typeof o === 'string' ? o : (o && o.id))).filter(Boolean);
-    let targetSlug = null;
-    const noteIds = [];
-    for (const u of objectUris) {
-      const s = localSlugOf(u);             // one of OURS -- host meegewogen
-      if (s) { targetSlug = targetSlug || s; continue; }
-      const pid = postIdFromNoteUrl(u, base); // one of our notes?
-      if (pid) noteIds.push(pid);
-    }
-    if (!targetSlug && noteIds.length) {
-      try { const r = db.prepare('SELECT s.slug FROM posts p JOIN sites s ON s.id = p.site_id WHERE p.id = ? LIMIT 1').get(noteIds[0]); if (r) targetSlug = r.slug; } catch { /* ignore */ }
-    }
-    if (!targetSlug) return 202; // not about us / can't tell → drop
-    // Flag is GATED, so `verified` is the signer's (reporter's) actor doc already.
-    const ai = actorInfo(verified || null, claimedActor);
-    try {
-      db.prepare('INSERT INTO ap_reports (slug, actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at) VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-        .run(targetSlug, claimedActor || null, ai.name, ai.handle, ai.icon, HtmlSanitizerService.toPlainText(act.content || '').slice(0, 3000), JSON.stringify(objectUris.slice(0, 20)));
-      console.log('[AP] report received for', targetSlug, 'from', claimedActor);
-    } catch { /* ignore */ }
-    return 202;
-  }
-
-  // FEP-7628 (DRAFT): an account moved house. Handled before Follow on purpose:
-  // a Move often arrives seconds before the new actor's re-Follow wave, and the
-  // swap below must not race our own outgoing Follow of the target.
-  if (type === 'Move') {
-    return handleMoveInbox(act, { verifiedActor: claimedActor });
-  }
-
-  if (type === 'Follow') {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
-    //
-    // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
-    // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
-    // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
-    // ongeluk meenemen.
-    //
-    // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
-    // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
-    const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (who && libSlug) {
-      const remote = await fetchActor(who);
-      if (!remote || !remote.inbox) return 202;
-      const fi = actorInfo(remote, who);
-      luisteraars.voegToe(libSlug, {
-        actorUri: who, inbox: remote.inbox,
-        sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
-        name: fi.name, handle: fi.handle, icon: fi.icon,
-      });
-      const keys = getOrCreateKeys(libSlug);
-      const accept = {
-        '@context': AP_CONTEXT,
-        id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
-        type: 'Accept', actor: actorId(base, libSlug), object: act,
-      };
-      deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
-        .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
-      console.log('[AP] library follow from', who, '->', libSlug);
-      return 202;
-    }
-    // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
-    // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
-    // kon een Follow op andermans actor met dezelfde padstaart hier een volger
-    // opleveren.
-    const slug = slugParam || localSlugOf(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
-    if (!who || !slug) return 400;
-    const remote = await fetchActor(who);
-    if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
-    const sharedInbox = (remote.endpoints && remote.endpoints.sharedInbox) || null;
-    const fi = actorInfo(remote, who);   // cache display for the friends list (shaer-aa3)
-    // FEP-633c §5.3: if the followed actor is a WARD (has guardians), the
-    // follow is gated. A committed guardian's own Follow is auto-accepted
-    // (it needs no gate); anyone else is held pending for guardian approval.
-    // Free actors / normal sites have no guardians → fall through, unchanged.
-    const wardGuardians = Guardianship.listGuardians(slug).map((g) => g.other_uri);
-    if (wardGuardians.length && !wardGuardians.includes(who)) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act,
-      });
-      // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
-      // gated follow to its guardians for approval. A LOCAL guardian gets a
-      // push and reads /guardian directly; a REMOTE guardian gets an
-      // Offer(Follow) delivered so its instance stores a copy (same distributed
-      // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
-      const wardActor = actorId(base, slug);
-      const wardKeys = getOrCreateKeys(slug);
-      const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor };
-      // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses
-      // every guardian. The ONLY admissible evidence is a request like this
-      // one going unanswered; recordRequest itself skips a declared absence.
-      for (const g of wardGuardians) {
-        try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ }
-      }
-      for (const g of wardGuardians) {
-        // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl
-        // ignores the host (an /ap/users/x path on a remote host is someone
-        // else's actor), so also require our base + an existing local site.
-        const gslug = g.startsWith(`${base}/`) ? slugFromActorUrl(g) : null;
-        const isLocal = gslug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(gslug);
-        if (isLocal) {
-          const L = pushLang(gslug);
-          // Een volgverzoek is geen mede-voogdij. Deze push leende de tekst van
-          // offer_for_ward en meldde dus een adoptie die niet gebeurde -- met de
-          // volger als onderwerp. Eigen woorden, en allebei de namen erin: wie
-          // er vraagt, en om wie het gaat (shaer-p729).
-          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_folin_t'), body: i18nT(L, 'push.n_guard_folin_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone'), ward: slug }), url: `${pushPrefix(gslug)}/guardian` });
-        } else {
-          fetchActor(g).then((ga) => {
-            const inbox = ga && ((ga.endpoints && ga.endpoints.sharedInbox) || ga.inbox);
-            if (!inbox) return;
-            const beslissend2 = Guardianship.gated.isDecisive(0, Guardianship.follows.followThreshold(guardians.length));
-            const offer = { '@context': AP_CONTEXT, id: `${wardActor}#followoffer-${Date.now()}-${rid()}`, type: 'Offer', actor: wardActor, to: [g], object: followObj, 'shaer:followApproval': true, 'shaer:decisive': beslissend2 };
-            deliverWithRetry(slug, inbox, offer, `${wardActor}#main-key`, wardKeys.private_pem).catch(() => {});
-          }).catch(() => {});
-        }
-      }
-      console.log('[AP] Follow', who, '→ ward', slug, '(gated, awaiting guardians)');
-      return 202;
-    }
-    // De eigenaarspoort (Robins wens, 18-8): met approve_followers aan wordt
-    // een Follow niet automatisch geaccepteerd — hij wacht in dezelfde
-    // wachtrij als een ward-follow, maar hier beslist de EIGENAAR, op
-    // /connect. Zo kan niemand een klonkt zomaar aan een hub of ander
-    // verzamelplatform hangen zonder dat de eigenaar ja heeft gezegd.
-    // Wards vallen hier nooit: de guardianpoort hierboven gaat vóór.
-    const ownerGate = db.prepare('SELECT approve_followers FROM sites WHERE slug = ?').get(slug);
-    if (ownerGate && ownerGate.approve_followers) {
-      const followId = (typeof act.id === 'string' && act.id) || `${who}#follow-${Date.now()}-${rid()}`;
-      Guardianship.follows.recordPending(slug, {
-        id: followId, follower: who, inbox: remote.inbox, sharedInbox,
-        name: fi.name, handle: fi.handle, icon: fi.icon, activity: act, quorum: 'owner',
-      });
-      const L = pushLang(slug);
-      pushEvent(slug, {
-        type: 'follow',
-        title: i18nT(L, 'push.n_folreq_t'),
-        body: i18nT(L, 'push.n_folreq_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }),
-        url: `${pushPrefix(slug)}/connect`,
-      });
-      console.log('[AP] Follow', who, '→', slug, '(awaiting owner approval)');
-      return 202;
-    }
-    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
-    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
-    { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
-    const me = actorId(base, slug);
-    const keys = getOrCreateKeys(slug);
-    const accept = { '@context': AP_CONTEXT, id: `${me}#accept-${Date.now()}-${rid()}`, type: 'Accept', actor: me, object: act };
-    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
-    // Auto-backfill: send our recent posts as Create so the instance has our history
-    // (Mastodon doesn't fetch history on follow). ONCE PER REMOTE INSTANCE only —
-    // Mastodon dedupes notes per-instance, so re-filling an instance that already has
-    // a follower of ours is wasted work (and won't re-populate the new follower's
-    // timeline anyway). Deliver to the shared inbox (instance-level) when present.
-    // Sync insert+check (no await between) → no interleave race with concurrent Follows.
-    const instanceFilled = sharedInbox &&
-      db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND shared_inbox = ? AND actor_uri != ? LIMIT 1')
-        .get(slug, sharedInbox, who);
-    if (!instanceFilled) {
-      backfillNewFollower(base, slug, sharedInbox || remote.inbox).catch(() => { /* best-effort */ });
-    }
-    console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
-    return 202;
-  }
-  // Een luisteraar die weggaat, hoort meteen weg te zijn.
-  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
-    const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
-    const libSlug = libraryOwnerSlug(doel);
-    const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
-      console.log('[AP] library unfollow from', wie, '->', libSlug);
-      return 202;
-    }
-  }
-
-  if (type === 'Undo' && act.object) {
-    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-    const ot = act.object.type;
-    if (ot === 'Follow') {
-      const obj = act.object.object;
-      const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
-      if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
-      return 202;
-    }
-    if (ot === 'Like' || ot === 'Announce') {
-      const tgt = act.object.object;
-      const pid = postIdFromNoteUrl(typeof tgt === 'string' ? tgt : (tgt && tgt.id), base);
-      if (who && pid) { iStmts().delLA.run(ot.toLowerCase(), pid, who); console.log('[AP] Undo', ot, who, '→', pid); }
-      return 202;
-    }
-    return 202;
-  }
-
-  const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
-  const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null));
-  // Our OWN activity is already stored via ap_outbox: don't store it twice.
-  // "Our own" means THIS inbox's owner, not "anyone who happens to live on this
-  // machine". The old reading dropped every activity between two sites on one
-  // instance, so a note from a co-located guardian to its ward was accepted
-  // with a 202 and then quietly thrown away: no mention, no away, no help
-  // request. Neighbours are not us (Robins regel, 29-7: on this machine
-  // everything behaves as if every Klonkt were somewhere else).
-  const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam));
-
-  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
-  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
-    const o = act.object;
-    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
-    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
-    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
-    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
-      const seg = postIdFromNoteUrl(o.inReplyTo, base);
-      if (seg && localPostExists(seg)) {
-        const rec = recordPollBallot(seg, actorUri, o.name);
-        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
-      }
-    }
-    const tgt = findThreadTarget(o.inReplyTo, base);
-    if (tgt && actorUri && !isLocalActor) {
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      if (isRejectedObject(o.id)) { console.log('[AP] reply skipped (tombstoned)', o.id); return 202; }
-      iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
-      console.log('[AP] reply', actorUri, '→', tgt.post_id);
-      // A reply is a post too: Berichten renders it the way de Krant renders a
-      // timeline row, so it needs the same media and the same quote/preview card.
-      {
-        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
-        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
-        const mj = mediaFromNote(o);
-        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
-        resolveCard(o).then((c) => {
-          if (!c) return;
-          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
-        }).catch(() => { /* best-effort */ });
-      }
-      {
-        // Private (followers/direct) replies push as a DM ping WITHOUT content
-        // (the push service should never carry private text, design decision);
-        // public replies carry a short snippet.
-        const ctx = pushPostCtx(tgt.post_id);
-        const vis = noteVisibility(o);
-        const priv = vis === 'direct' || vis === 'followers';
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
-          else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
-        }
-      }
-      return 202;
-    }
-    // Home timeline (client): a top-level post from an account we follow.
-    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
-      let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
-      if (subs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const { html, atts: _atts, url: _url } = timelineFields(o);
-        const media = JSON.stringify(_atts);
-        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
-        // "Feature" = show in the Cirkel (local only). We do NOT auto-Announce
-        // incoming posts to the fediverse — that flooded followers. Boosting to the
-        // fediverse is only ever a deliberate, manual per-post action (the 🔁 on
-        // the timeline).
-        for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, contentWarning(o));
-          // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
-          if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
-          // FEP-9098: keep the note's custom-emoji tags so the C2S inbox read can serve them.
-          { const ej = extractEmojiTags(o.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, s.slug); } catch { /* ignore */ } } }
-          storeAuthorEmoji(o.id, s.slug, ai);   // custom-emoji display name for the byline
-
-          // FEP-e232 + FEP-044f: keep the note's object-link/quote tags for the same read.
-          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
-          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
-        }
-        // FEP-044f embedded quote card: resolve the quoted post out of band so
-        // the inbox response is not blocked on a remote fetch. Best-effort.
-        if (quoteHrefOf(o)) {
-          const slugs = subs.map((s) => s.slug);
-          resolveQuote(o).then((qj) => {
-            if (!qj) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        } else {
-          // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider),
-          // thumbnail-only. Also out of band, and stored for everyone; the gate
-          // that decides who may SEE it is applied at serve time (§5.3-style
-          // gated feature, see the inbox read).
-          const slugs = subs.map((s) => s.slug);
-          resolveExternalEmbed(o.content).then((ej) => {
-            if (!ej) return;
-            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } }
-          }).catch(() => { /* best-effort */ });
-        }
-        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
-      }
-    }
-    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
-    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
-    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
-    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
-    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
-    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
-    // moet iemand zijn die je zelf bent gaan volgen.
-    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
-      rememberNoteUri(o.id);
-    }
-    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
-    // above): store a mention notification for each of our actors named in the Mention tags.
-    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
-    // someone else's actor, not ours.
-    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
-    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
-    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
-    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
-    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
-    if (actorUri && !isLocalActor) {
-      const mark = Guardianship.help.parseMarker(o);
-      if (mark) {
-        // WIE MAG DIT (shaer-gt70). Hier stond alleen "de actor is niet lokaal",
-        // en dat is geen poort: elke ondertekende actor die de URI van een
-        // hulpvraag kende kon hem op 'handled' zetten. Afgehandeld kent geen
-        // terugdraai en de vraag verdwijnt daarna uit de teller van ELKE
-        // guardian -- een vreemde kon dus de noodknop van een kind uitzetten.
-        //
-        // Ondertekening zegt WIE, niet OF HET MAG. Die tweede laag stond er niet.
-        //
-        // DE WARD IS DE BRON VAN WAARHEID over wie zijn guardians zijn; onze
-        // eigen tabel kent alleen ONZE relatie. existingGuardiansOf stelt die
-        // vraag op de goede plek: lokaal opzoeken als wij de ward hosten,
-        // anders shaer:guardians van zijn actor.
-        //
-        // En WELKE ward dat is komt uit onze EIGEN administratie -- de
-        // hulpvraag zoals wij hem opsloegen -- nooit uit wat de afzender
-        // beweert. Kennen we die hulpvraag niet, dan is er niets te markeren.
-        const vraag = helpRequestRow(mark.noteUri);
-        if (!vraag) {
-          console.warn('[AP] help-markering voor een onbekende hulpvraag, genegeerd:', actorUri, '→', mark.noteUri);
-        } else if (!(await isGuardianOfWard(actorUri, vraag.actor_uri))) {
-          console.warn('[AP] help-markering van iemand die geen guardian van deze ward is, geweigerd:', actorUri, '→', mark.noteUri);
-        } else {
-          const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
-          Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
-          // Het paneel dat de hulpvraag HOUDT wordt gewekt, en dat is
-          // `vraag.slug`. Hier stond `slug`, en die bestaat in deze scope niet:
-          // de markering werd vastgelegd en daarna gooide de handler een
-          // ReferenceError, dus het paneel hoorde het nooit en de rest van de
-          // verwerking van deze activiteit viel weg. Gemeten, niet geredeneerd.
-          // slugParam zou hier ook fout zijn: op de gedeelde inbox is die null.
-          wakeGuardian(vraag.slug);   // een mede-guardian pakte iets op: het paneel hoort het meteen
-          console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
-        }
-      }
-    }
-    if (actorUri && !isLocalActor && o.id) {
-      const slugs = localMentionSlugs(o.tag, base);
-      if (slugs.length) {
-        const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const html = HtmlSanitizerService.sanitize(o.content || '');
-        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
-        // flag is stored so the Guardian PWA's message centre can list it.
-        const help = Guardianship.isHelpRequest(o);
-        const wave = Guardianship.isWave(o);
-        const hasG = Guardianship.objectHasGuardians(o);   // §2.2 hint, register-only
-        // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the
-        // same direct note the mention below stores (so the kid also reads it
-        // as an ordinary message). Recorded only from an actual guardian of
-        // the addressed ward, and only with an end: an absence without an end
-        // is logged and dropped, never guessed.
-        if (Guardianship.availability.isAway(o)) {
-          const until = Guardianship.availability.parseEndTime(o.endTime);
-          for (const slug of slugs) {
-            const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })();
-            if (!isG) continue;
-            if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; }
-            Guardianship.availability.declareAway(slug, actorUri, until);
-            console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString());
-          }
-        }
-        // Een kind dat zelf om een poort vraagt (shaer-8ru). Zelfde weg als de
-        // afwezigheidsmelding: een gewone directe note met een shaer:-markering,
-        // per genoemde ontvanger afgehandeld.
-        //
-        // ALLEEN VAN EEN EIGEN WARD. Een verzoek van een vreemde is geen vraag
-        // maar een onbekende die iets over jouw instellingen wil zeggen -- dat
-        // hoort in geen enkele lijst te belanden waar een guardian op afgaat.
-        {
-          const req = Guardianship.gatereq.parseRequest(o);
-          if (req) {
-            for (const slug of slugs) {
-              const mijn = (() => { try { return Guardianship.listWards(slug).some((w) => w.other_uri === actorUri); } catch { return false; } })();
-              if (!mijn) { console.warn('[AP] gate request from someone who is not our ward, ignored:', actorUri, '→', slug); continue; }
-              Guardianship.gatereq.record(slug, actorUri, req.feature, o.id);
-              wakeGuardian(slug);   // het kind vroeg om een poort
-              console.log('[AP] gate request', req.feature, actorUri, '→', slug);
-            }
-          }
-        }
-        for (const slug of slugs) {
-          try {
-            // De OUDER gaat mee (Robins melding, 26-8). Hij stond nergens in
-            // deze rij, dus een antwoord binnen een gesprek kwam bij de client
-            // aan alsof het een gesprek begon: de app kan een keten alleen
-            // teruglopen langs inReplyTo, en die was leeg.
-            //
-            // Alleen een http(s)-adres, langs dezelfde poort als `url`: een
-            // inReplyTo komt van een vreemde en mag geen ander schema
-            // binnensmokkelen. AS2 staat een string of een object toe, dus
-            // allebei uitpakken -- alleen de string erkennen zou hetzelfde gat
-            // laten voor iedereen die de objectvorm stuurt.
-            const ouder = safeUrl(typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id)) || null;
-            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, in_reply_to, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
-                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, ouder, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
-                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
-            if (r.changes) {
-              // The quote / link-preview card resolves out of band (a remote
-              // fetch), exactly as it does for a timeline post, so the inbox
-              // answer is never blocked on it.
-              resolveCard(o).then((c) => {
-                if (!c) return;
-                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
-                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
-              }).catch(() => { /* best-effort */ });
-              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
-              const vis = noteVisibility(o);
-              const priv = vis === 'direct' || vis === 'followers';
-              const L = pushLang(slug);
-              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-              // Same privacy rule as replies: private mentions push without content.
-              // A help request pushes as its own alert type, aimed at the
-              // Guardian PWA's message centre.
-              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
-              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
-              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
-            }
-          } catch { /* ignore */ }
-        }
-      }
-    }
-    return 202;
-  }
-  // A remote post we cached was edited upstream → refresh our cached copy. This is the
-  // push-based edit-sync that keeps the Cirkel/timeline fresh without polling (selfHeal
-  // does it on a version bump; this does it live). Scope to the SIGNING actor so B can't
-  // edit A's note (the signature gate guarantees claimedActor == the verified signer).
-  if (type === 'Update' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
-    const o = act.object;
-    if (o.id && claimedActor) {
-      const html = HtmlSanitizerService.sanitize(o.content || '');
-      const media = mediaFromNote(o);
-      try {
-        // Refresh url too (COALESCE keeps the old one if the Update omits it): a remote slug
-        // rename keeps the same AP id but changes the human url, so without this the cached
-        // post would keep linking to the old, now-dead URL.
-        const r = db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE id = ? AND author_uri = ?')
-          .run(html, media, o.sensitive ? 1 : 0, contentWarning(o), o.url || null, o.id, claimedActor);
-        if (r.changes) console.log('[AP] timeline update', claimedActor, '→', o.id);
-        // A poll's Update carries the fresh vote counts / closed state. Refresh per-row so each
-        // site keeps its own `voted` state while the counts/closed update to the new totals.
-        const poll = parsePoll(o);
-        if (poll) {
-          const rows = db.prepare('SELECT rowid AS rid, poll_json FROM ap_timeline WHERE id = ? AND author_uri = ?').all(o.id, claimedActor);
-          const upd = db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE rowid = ?');
-          for (const rw of rows) {
-            let voted = null; try { voted = rw.poll_json ? (JSON.parse(rw.poll_json).voted || null) : null; } catch { /* ignore */ }
-            upd.run(JSON.stringify({ ...poll, voted }), rw.rid);
-          }
-        }
-      } catch { /* ignore */ }
-      // If this note is a cached fediverse reply on one of our posts, refresh its text too.
-      try { db.prepare('UPDATE ap_interactions SET content = ? WHERE object_uri = ? AND actor_uri = ?').run(html, o.id, claimedActor); } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  if (type === 'Like' || type === 'Announce') {
-    const tgt = act.object;
-    const objUrl = typeof tgt === 'string' ? tgt : (tgt && tgt.id);
-    const pid = postIdFromNoteUrl(objUrl, base);
-    if (pid && actorUri && !isLocalActor && localPostExists(pid)) {
-      // A boost/like of a non-public post is dropped, not stored: nobody
-      // outside the audience should even hold it (shaer-tqc hardening).
-      const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid);
-      if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) {
-        console.log('[AP] dropped', type, 'on non-public post', pid);
-        return;
-      }
-      const ai = actorInfo(await resolveActor(actorUri), actorUri);
-      iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act), null, emojiJsonOf(ai.emojis));
-      console.log('[AP]', type === 'Like' ? 'like' : 'boost', actorUri, '→', pid);
-      {
-        const ctx = pushPostCtx(pid);
-        if (ctx) {
-          const L = pushLang(ctx.site);
-          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
-          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
-          else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
-        }
-      }
-    } else if (type === 'Announce' && objUrl && actorUri && !isLocalActor) {
-      // A boost FROM an account we follow, of a REMOTE post → show it in the News feed.
-      // We only STORE it for display; we NEVER auto-Announce it onward (anti-feedback-loop:
-      // re-announcing an incoming Announce would cascade boosts across the network).
-      let subs = []; try { subs = db.prepare('SELECT slug FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist */ }
-      if (subs.length) {
-        const bn = await fetchNoteAP(objUrl);
-        if (bn && bn !== 404 && (bn.type === 'Note' || bn.type === 'Article') && bn.id) {
-          const origUri = actorUriOf(bn.attributedTo);
-          // Block completeness: even if you follow the booster, drop a boost whose ORIGINAL
-          // author is blocked — otherwise a block is bypassed via someone else's boost.
-          if (origUri && isBlockedAny(origUri)) { console.log('[AP] timeline boost dropped (blocked origin)', origUri, 'via', actorUri); return 202; }
-          const oai = actorInfo(await resolveActor(origUri), origUri);
-          const html = HtmlSanitizerService.sanitize(bn.content || '');
-          const media = mediaFromNote(bn);
-          const booster = actorInfo(await resolveActor(actorUri), actorUri);
-          for (const s of subs) {
-            // published = now → the boost shows as fresh activity at the top (Mastodon shows
-            // reblogs at reblog-time, not the original's date). INSERT OR IGNORE: if we already
-            // have the note (e.g. we also follow the author), keep it and DON'T relabel it.
-            let inserted = false;
-            try { const r = tlStmts().ins.run(bn.id, s.slug, origUri || '', oai.name, oai.handle, oai.icon, oai.url, html, bn.url || null, new Date().toISOString(), media, bn.sensitive ? 1 : 0, contentWarning(bn)); inserted = r.changes > 0; } catch { /* ignore */ }
-            if (inserted) { try { db.prepare('UPDATE ap_timeline SET reblog_name = ?, reblog_handle = ?, reblog_icon = ?, reblog_emoji_json = ? WHERE slug = ? AND id = ?').run(booster.name, booster.handle, booster.icon, (booster.emojis && Object.keys(booster.emojis).length) ? JSON.stringify(booster.emojis) : null, s.slug, bn.id); } catch { /* ignore */ } }
-            storeAuthorEmoji(bn.id, s.slug, oai);   // custom-emoji display name for the byline
-            // A boost carries the same renderable tags as a Create: capture the
-            // note's content emojis (FEP-9098) and object links / quote (FEP-e232/
-            // 044f) so boosted posts render like any other, not as raw shortcodes.
-            { const ej = extractEmojiTags(bn.tag); if (ej) { try { db.prepare('UPDATE ap_timeline SET emoji_json = ? WHERE id = ? AND slug = ?').run(ej, bn.id, s.slug); } catch { /* ignore */ } } }
-            { const lj = extractLinkJson(bn); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, bn.id, s.slug); } catch { /* ignore */ } } }
-          }
-          // FEP-044f: resolve the embedded quote card for a boosted post too
-          // (out of band, best-effort, so it does not block the inbox response).
-          if (quoteHrefOf(bn)) {
-            const slugs = subs.map((s) => s.slug);
-            resolveQuote(bn).then((qj) => {
-              if (!qj) return;
-              for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, bn.id, sl); } catch { /* ignore */ } }
-            }).catch(() => { /* best-effort */ });
-          }
-          console.log('[AP] timeline boost +', actorUri, 'x' + subs.length);
-        }
-      }
-    }
-    return 202;
-  }
-  if (type === 'Delete') {
-    // A remote note was deleted upstream → drop it from replies AND the timeline.
-    // Scope to the SIGNING actor so actor B can't delete actor A's content (the
-    // signature gate guarantees claimedActor == the verified signer here).
-    const oid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    if (oid && claimedActor) {
-      try { db.prepare('DELETE FROM ap_interactions WHERE object_uri = ? AND actor_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      try { db.prepare('DELETE FROM ap_timeline WHERE id = ? AND author_uri = ?').run(oid, claimedActor); } catch { /* ignore */ }
-      // Also clear a boost/like YOU made of this now-deleted remote post (the interact-page
-      // ap_my_reactions state), so it can't stay stuck as "boosted" on a post that's gone.
-      // Guard: only when the deleter owns the note's domain (B mustn't clear your reactions
-      // to A's posts).
-      try {
-        let sameHost = false;
-        try { sameHost = new URL(oid).host === new URL(claimedActor).host; } catch { sameHost = false; }
-        if (sameHost) db.prepare('DELETE FROM ap_my_reactions WHERE target_uri = ?').run(oid);
-      } catch { /* ignore */ }
-    }
-    return 202;
-  }
-  // Accept/Reject of a Follow WE sent (client side).
-  if (type === 'Accept' && act.object) {
-    const fid = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
-    let raak = 0;
-    if (fid) { try { raak = fwStmts().acc.run(fid).changes; } catch { /* ignore */ } }
-    // TERUGVAL, en die is nodig gebleken tegen Funkwhale. Een Accept hoort de
-    // Follow terug te geven die hij beantwoordt, maar Funkwhale verzint er een
-    // EIGEN id voor, in ONZE namespace:
-    //
-    //   wij stuurden   .../ap/users/dev#follow-1786161977286-bb2de32f
-    //   Funkwhale zegt .../ap/users/dev#follows/19fd8b00-8f66-...
-    //
-    // Matchen op follow_id raakt dan niets, en de volgrelatie bleef eeuwig op
-    // 'pending' staan terwijl de logregel 'accepted' riep -- een stille no-op
-    // die pas opviel toen er nooit iets binnenkwam.
-    //
-    // Het paar dat we WEL zeker weten is (deze site, deze actor): de Accept is
-    // handtekening-geverifieerd, en actorUri is de ondertekenaar. Alleen een
-    // rij die nog op pending staat wordt geraakt, dus dit kan niets anders
-    // openzetten dan een follow die wij zelf hebben verstuurd.
-    //
-    // En de slug mag NIET van slugParam afhangen: Funkwhale bezorgt op de
-    // GEDEELDE inbox, en dan is die leeg. Wie wij zijn staat in de ingesloten
-    // Follow -- die hebben wij immers zelf verstuurd, dus `object.actor` is
-    // onze eigen actor-URI.
-    let mij = slugParam;
-    if (!mij && act.object && typeof act.object === 'object') mij = slugFromActorUrl(act.object.actor);
-    if (!raak && mij && actorUri) {
-      try { raak = fwStmts().accByActor.run(mij, actorUri).changes; } catch { /* ignore */ }
-    }
-    // Eerlijk loggen: zonder treffer is er niets geaccepteerd, en dat hoort te
-    // zien te zijn in plaats van als succes voorbij te komen.
-    console.log('[AP] follow', raak ? 'accepted' : 'accept UNMATCHED', actorUri, fid ? '(' + fid + ')' : '');
-    // The moment a friendship exists is the moment the history comes along
-    // (Robins besluit, 30-7): delivery cannot reach into the past, so the
-    // fresh follower pulls the outbox, signed, and the other side now serves
-    // the friends-only posts too.
-    if (slugParam && actorUri) backfillFromOutbox(slugParam, actorUri).catch(() => { /* best-effort */ });
-    return 202;
-  }
-  if (type === 'Reject' && act.object) {
-    const who = actorUri;
-    if (who && slugParam) { try { fwStmts().del.run(slugParam, who); } catch { /* ignore */ } }
-    return 202;
-  }
-
-  // Zeg ook WAT er viel. Een kale "Create (ignored)" verbergt het verschil
-  // tussen een soort die we bewust overslaan en een die we niet kennen -- en
-  // dat verschil was precies de vraag bij Funkwhale, dat Create(Audio) stuurt
-  // waar deze inbox alleen Note, Article en Question aanneemt.
-  const objType = act.object && typeof act.object === 'object' ? act.object.type : (typeof act.object === 'string' ? '<uri>' : null);
-  console.log('[AP] inbox', type || 'unknown', objType ? '(' + objType + ')' : '', '→', slugParam || 'shared',
-    'from', ip, 'by', claimedActor || '?', '(ignored)');
-  return 202;
-}
-
Index: src/services/ap-polls.js
===================================================================
--- src/services/ap-polls.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,217 +1,0 @@
-/**
- * ap-polls.js — de peilingen (stap 8 van shaer-drc).
- *
- * Beide kanten van een fediverse-poll:
- *   - VREEMDE polls: parsePoll (het AS2 Question-formaat naar onze compacte
- *     vorm) en de twee stemhandelingen (voteOnPoll uit de tijdlijncache,
- *     voteOnRemotePoll op URL).
- *   - EIGEN polls: de definitie op posts.poll_json, de telling uit de
- *     stembiljetten (poll_votes), de Question-vorm op een note, het innemen
- *     van een biljet en de gebundelde Update(Question) naar de volgers.
- *
- * Vier werktuigen uit de dienstlaag komen via wirePolls binnen; de rest wijst
- * omlaag (db, ap-core, ap-transport).
- */
-import db from '../config/database.js';
-import { actorId, AP_CONTEXT } from './ap-core.js';
-import { fetchActor, getOrCreateKeys, deliverWithRetry } from './ap-transport.js';
-
-// De werktuigen uit de dienstlaag; ActivityPubService vult ze onderaan.
-let deliverUpdate, rid, movedRefusal, actorUriOf;
-export function wirePolls(deps) {
-  ({ deliverUpdate, rid, movedRefusal, actorUriOf } = deps);
-}
-
-// Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)
-// into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note
-// with a `name` and a `replies` collection whose `totalItems` is that option's vote count.
-export function parsePoll(o) {
-  if (!o || o.type !== 'Question') return null;
-  const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);
-  if (!raw || !raw.length) return null;
-  const options = raw.slice(0, 12).map((opt) => ({
-    name: String((opt && opt.name) || '').slice(0, 300),
-    count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),
-  })).filter((x) => x.name);
-  if (!options.length) return null;
-  const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);
-  const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };
-}
-
-// ── Polls WE host (a local post with a poll) ──────────────────────
-// Parse the poll definition stored on our own post (posts.poll_json). Counts are
-// NOT stored here — they're derived from the poll_votes ballots so a re-render always
-// reflects the authoritative tally.
-export function parseOwnPoll(pollJson) {
-  if (!pollJson) return null;
-  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
-  if (!d || !Array.isArray(d.options)) return null;
-  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
-  if (options.length < 2) return null;
-  const endTime = d.endTime || null;
-  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
-  return { multiple: !!d.multiple, options, endTime, closed };
-}
-
-// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
-export function pollTally(postId) {
-  const counts = {}; let voters = 0;
-  try {
-    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
-    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
-  } catch { /* table may not exist yet */ }
-  return { counts, voters };
-}
-
-// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
-// Voting is fediverse-only, so this is display-only on the site.
-export function ownPollView(post) {
-  const poll = parseOwnPoll(post && post.poll_json);
-  if (!poll) return null;
-  const { counts, voters } = pollTally(post.id);
-  const total = Object.values(counts).reduce((a, b) => a + b, 0);
-  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
-  const options = poll.options.map((o) => {
-    const count = counts[o.name] || 0;
-    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
-  });
-  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
-}
-
-// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
-// status with either media OR a poll (never both), so a poll federates as content +
-// options with no media attachment. oneOf = single choice, anyOf = multiple.
-export function applyPollToNote(note, postId, poll) {
-  const { counts, voters } = pollTally(postId);
-  const opts = poll.options.map((o) => ({
-    type: 'Note',
-    name: o.name,
-    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
-  }));
-  note.type = 'Question';
-  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
-  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
-  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
-  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
-  note.votersCount = voters;
-  delete note.attachment;   // media ATTACHMENTS + a poll are mutually exclusive on Mastodon
-  // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway
-  // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.
-  // Deleting it stripped the cover off every boosted poll.
-  return note;
-}
-
-// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
-// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
-// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
-// caller must NOT also store it as a reply), false means "not a poll, fall through".
-export function recordPollBallot(postId, actorUri, rawChoice) {
-  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
-  if (!choice) return { handled: false };
-  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
-  const poll = post && parseOwnPoll(post.poll_json);
-  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
-  if (poll.closed) return { handled: true };          // voting closed → drop
-  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
-  try {
-    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
-    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
-    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
-    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
-  } catch { return { handled: true }; }
-  schedulePollUpdate(postId);
-  return { handled: true };
-}
-
-// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
-// refresh ~15s out; further votes in that window ride the same pending update (which carries
-// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
-const _pollUpdTimers = new Map();
-function schedulePollUpdate(postId) {
-  if (_pollUpdTimers.has(postId)) return;
-  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
-  if (t.unref) t.unref();
-  _pollUpdTimers.set(postId, t);
-}
-
-// Push the fresh poll tally (or closed state) to followers as Update(Question).
-export async function deliverPollUpdate(postId) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !postId) return;
-  let post, site;
-  try {
-    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
-    if (!post || !post.poll_json) return;
-    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
-  } catch { return; }
-  if (site) await deliverUpdate(site, post);
-}
-
-// Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a
-// `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the
-// Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the
-// author's Update(Question) refreshes the authoritative totals when it arrives.
-export async function voteOnPoll(site, questionId, choices) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !questionId) return { error: 'config' };
-  let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }
-  if (!row || !row.poll_json) return { error: 'not_found' };
-  let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }
-  if (poll.closed) return { error: 'closed' };
-  if (poll.voted) return { error: 'already' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  const authorUri = row.author_uri || null;
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  // Local optimistic update (authoritative counts arrive via the author's Update(Question)).
-  poll.voted = poll.multiple ? chosen : chosen[0];
-  for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;
-  if (poll.voters != null) poll.voters += 1;
-  try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }
-  return { ok: true };
-}
-
-// Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches
-// the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a
-// Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find
-// by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).
-export async function voteOnRemotePoll(site, questionUrl, choices) {
-  const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };
-  const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)
-  if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };
-  const poll = parsePoll(q);
-  if (!poll) return { error: 'not_found' };
-  if (poll.closed) return { error: 'closed' };
-  const valid = new Set(poll.options.map((o) => o.name));
-  const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));
-  if (!picks.length) return { error: 'invalid' };
-  const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];
-  const authorUri = actorUriOf(q.attributedTo);
-  const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;
-  const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));
-  if (!inbox) return { error: 'unreachable' };
-  const me = actorId(base, site.slug);
-  const keys = getOrCreateKeys(site.slug);
-  for (const name of chosen) {
-    const nid = `${me}/votes/${Date.now()}-${rid()}`;
-    const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };
-    const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };
-    deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);
-  }
-  return { ok: true };
-}
Index: src/services/ap-reactions.js
===================================================================
--- src/services/ap-reactions.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,313 +1,0 @@
-/**
- * ap-reactions.js — het reactiecluster (stap 6 van shaer-drc).
- *
- * De waarheid over "heb ik hierop gereageerd" (ap_my_reactions), de afgeleide
- * vlaggen op ap_timeline die setReaction daaruit bijhoudt, de eenmalige
- * migratie, en de boost-upsert die een vreemde post de tijdlijn in trekt.
- *
- * Twee koppelingen, allebei bewust zo:
- *   - tlStmts komt STATISCH uit ap-timeline: reacties schrijven de
- *     tijdlijnvlaggen, dus die pijl wijst een kant op en mag gewoon een import
- *     zijn.
- *   - de omgekeerde pijl (de tijdlijn-leeskant heeft getReactionsFor nodig)
- *     blijft de injectie via wireTimeline in ActivityPubService -- twee
- *     zustermodules die elkaar importeren zou precies de kring zijn die
- *     shaer-drc vermijdt.
- * movedLock komt uit de dienstlaag (FEP-7628) en dus via wireReactions binnen.
- */
-import db from '../config/database.js';
-import { tlStmts } from './ap-timeline.js';
-
-// Het ene werktuig uit de dienstlaag; ActivityPubService vult het onderaan.
-let movedLock;
-export function wireReactions(deps) {
-  ({ movedLock } = deps);
-}
-
-// Your like/boost state on a REMOTE post (interact page toggles).
-export function setMyReaction(slug, uri, kind, on) {
-  if (on) db.prepare('INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind) VALUES (?,?,?)').run(slug, uri, kind);
-  else db.prepare('DELETE FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ? AND kind = ?').run(slug, uri, kind);
-}
-export function getMyReactions(slug, uri) {
-  const rows = (slug && uri) ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, uri) : [];
-  return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
-}
-
-// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
-// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
-// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
-// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
-//
-// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
-// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
-// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
-// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
-// terugdraaien een code-revert blijft in plaats van dataherstel.
-let _markBoost, _unmarkBoost, _boostedCount;
-export function markBoosted(slug, noteId) {
-  try { if (!_markBoost) _markBoost = db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?'); _markBoost.run(slug, noteId); } catch { /* ignore */ }
-}
-export function unmarkBoosted(slug, noteId) {
-  try { if (!_unmarkBoost) _unmarkBoost = db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?'); _unmarkBoost.run(slug, noteId); } catch { /* ignore */ }
-}
-let _markLike, _unmarkLike;
-export function markLiked(slug, noteId) {
-  try { if (!_markLike) _markLike = db.prepare('UPDATE ap_timeline SET liked = 1 WHERE slug = ? AND id = ?'); _markLike.run(slug, noteId); } catch { /* ignore */ }
-}
-export function unmarkLiked(slug, noteId) {
-  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
-}
-/**
- * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
- * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
- * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
- * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
- * dat leverde de shaer:liked-bug op (04aca12).
- *
- * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
- * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
- * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
- * boost nergens -- ook niet in de Cirkel.
- *
- * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
- * worden: de tussentabel op de URI die de client stuurde, de vlag op de
- * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
- * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
- * werk voor fase 2, mét datamigratie.
- */
-// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
-// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
-// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
-// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
-// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
-// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
-const REACTIONS_MIGRATION_VERSION = 2;
-
-/**
- * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
- *
- * Twee stappen, en ze zijn allebei nodig:
- *
- *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
- *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
- *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
- *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
- *     wanneer je reageerde, bij aanvullen niet.
- *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
- *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
- *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
- *     fediverse in als gevolg.
- *
- * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
- */
-export function migrateReactions(opts = {}) {
-  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
-  try {
-    if (!opts.force) {
-      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
-      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
-      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
-    }
-  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
-
-  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
-  const wees = `
-    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
-     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
-  const scheef = (kind, kolom) => `
-    FROM ap_timeline t
-     WHERE t.${kolom} = 1
-       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
-                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
-  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
-  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
-  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
-  //    dus die uitsluiting verliest per constructie niets.
-  const acted = (kind, kolom) => `
-    FROM ap_interactions i
-     JOIN posts p ON p.id = i.post_id
-     JOIN sites s ON s.id = p.site_id
-     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
-       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
-                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
-
-  if (opts.dryRun) {
-    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
-    uit.hersleuteld = tel(wees);
-    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
-    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
-    return uit;
-  }
-
-  try {
-    db.transaction(() => {
-      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
-      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
-      //    oplevert en nooit een verdwenen reactie.
-      uit.hersleuteld = db.prepare(`
-        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
-        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
-      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
-
-      // 2. Aanvullen vanuit de kolommen.
-      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
-        uit.aangevuld += db.prepare(`
-          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
-          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
-      }
-
-      // 3. En vanuit acted_* op de reacties onder je eigen posts.
-      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
-        uit.reacties += db.prepare(`
-          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
-          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
-      }
-    })();
-    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
-      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
-    }
-    if (!opts.force) {
-      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
-        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
-    }
-  } catch (e) {
-    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
-    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
-    console.warn('[AP] reaction migration failed:', e.message);
-  }
-  return uit;
-}
-
-/**
- * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
- *
- * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
- * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
- * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
- * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
- * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
- *
- * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
- * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
- * highlight. Vandaar hier, en niet later.
- *
- * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
- * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
- * een reactie op iets buiten je tijdlijn moet gewoon werken.
- */
-export function canonicalReactionUri(slug, uri) {
-  if (!slug || !uri) return uri;
-  try {
-    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
-    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
-    return (row && row.id) || uri;
-  } catch { return uri; }
-}
-
-/**
- * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
- * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
- * dezelfde vraag beantwoordden uit twee verschillende bronnen.
- */
-export function getReaction(slug, uri) {
-  try {
-    const key = canonicalReactionUri(slug, uri);
-    const rows = (slug && key)
-      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
-      : [];
-    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
-  } catch { return { liked: false, boosted: false }; }
-}
-
-/**
- * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
- * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
- * een consistentiebug geruild voor een traagheidsbug.
- */
-export function getReactionsFor(slug, uris) {
-  const out = new Map();
-  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
-  if (!slug || !list.length) return out;
-  try {
-    const rows = db.prepare(
-      `SELECT target_uri, kind FROM ap_my_reactions
-        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
-    ).all(slug, ...list);
-    for (const r of rows) {
-      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
-      if (r.kind === 'like') cur.liked = true;
-      if (r.kind === 'boost') cur.boosted = true;
-      out.set(r.target_uri, cur);
-    }
-  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
-  return out;
-}
-
-export function setReaction(slug, uri, kind, on, opts = {}) {
-  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
-  // Ook hier, en niet alleen bij sendInteraction. Deze functie schrijft ALLEEN de
-  // lokale vlag; het versturen gebeurt elders. Zonder deze poort zou je op een
-  // verhuisd account een like zien staan die nooit de deur uit is gegaan, en dat
-  // is de halve toestand die erger is dan een duidelijke weigering.
-  try {
-    const s = db.prepare('SELECT moved_to FROM sites WHERE slug = ?').get(slug);
-    if (movedLock(s).locked) { console.warn('[AP] reactie geweigerd, account verhuisd:', slug, kind); return; }
-  } catch { /* geen sites-tabel = geen verhuizing */ }
-  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
-  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
-  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
-  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
-  // maakte dezelfde like onvindbaar vanaf de andere pagina.
-  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
-  setMyReaction(slug, flagUri, kind, !!on);
-  if (kind === 'boost') {
-    if (!on) unmarkBoosted(slug, flagUri);
-    else if (opts.note) upsertBoostedNote(slug, opts.note);
-    else markBoosted(slug, flagUri);
-  } else if (on) markLiked(slug, flagUri);
-  else unmarkLiked(slug, flagUri);
-}
-
-export function getTimelineReaction(slug, noteId) {
-  try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
-}
-// Boost a REMOTE post that may not be in your timeline (you don't follow the author):
-// store it in ap_timeline (INSERT OR IGNORE → no dup for followed posts) so it shows in
-// the Cirkel with a Boost badge, then flag it boosted.
-export function upsertBoostedNote(slug, note) {
-  if (!slug || !note || !note.object_uri) return;
-  const id = note.object_uri;
-  // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
-  // rendered a bare text tile); fall back to the image-only list for older callers.
-  const media = (note.media && note.media !== '[]')
-    ? note.media
-    : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
-  try {
-    const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
-      note.actor_icon || '', note.actor_url || '', note.content || '', note.url || null,
-      new Date().toISOString(), media, note.sensitive ? 1 : 0, note.cw || null);
-    if (!r.changes) {
-      // Row already cached (INSERT OR IGNORE) → refresh it with the freshly
-      // resolved note. Without this a row cached without its cover (or with
-      // stale content) stayed stale forever — even boosting again didn't heal it.
-      // Keep the CACHED media when the resolve yielded none: an empty re-resolve
-      // used to clobber a good media_json (the followed copy had the video, the
-      // boost wiped it to []).
-      db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
-                  nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
-        .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
-    }
-  } catch { /* ignore */ }
-  markBoosted(slug, id);
-}
-export function boostedCount(slug) {
-  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
-  // bevatten van iets dat er (nog) niet in staat.
-  try {
-    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
-      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
-      WHERE r.site_slug = ? AND r.kind = 'boost'`);
-    return _boostedCount.get(slug).n;
-  } catch { return 0; }
-}
Index: src/services/ap-timeline.js
===================================================================
--- src/services/ap-timeline.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,583 +1,0 @@
-/**
- * ap-timeline.js — de leeskant van de fediverse-tijdlijn (stap 5 van shaer-drc).
- *
- * Alles wat een client of route uit ap_timeline en de gesprekken LEEST:
- * de tijdlijn zelf, de feed-cursor met long-poll, de gesprekslijsten en
- * leesmarkeringen, en de serialisatiehulpen (bijlagen, emoji, object-links,
- * citaten) die een rij naar de C2S-vorm vertalen.
- *
- * De SCHRIJFKANT blijft waar hij was: de inbox, de backfill en self-heal
- * schrijven via tlStmts, dat hierom mee-exporteert. Een module importeert
- * nooit uit ActivityPubService; de ene uitzondering op "alleen omlaag" --
- * getReactionsFor, uit het reactiecluster -- komt daarom binnen via
- * wireTimeline, hetzelfde injectiepatroon als guardianship en ap-c2s.
- */
-import db, { isoSql, NU_ISO } from '../config/database.js';
-
-// De helper woont sinds shaer-a937 in config/database.js: elke plek die
-// sorteert had hem nodig, en twee kopieen van dezelfde regel lopen uit elkaar.
-// Bovenaan, want de eerste statements hieronder gebruiken hem al.
-const STEMPEL = isoSql;
-
-// Het ene werktuig uit de dienstlaag. ActivityPubService vult het onderaan
-// zijn eigen evaluatie; een aanroep voor de koppeling is een programmeerfout.
-let getReactionsFor;
-export function wireTimeline(deps) {
-  ({ getReactionsFor } = deps);
-}
-
-let _insTl, _listTl, _delTl;
-export function tlStmts() {
-  if (!_insTl) {
-    _insTl = db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, author_handle, author_icon, author_url, content, url, published, media_json, nsfw, cw, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`);
-    _listTl = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? ORDER BY ${STEMPEL('COALESCE(published, created_at)')} DESC LIMIT ? OFFSET ?`);
-    _delTl = db.prepare('DELETE FROM ap_timeline WHERE id = ?');
-  }
-  return { ins: _insTl, list: _listTl, del: _delTl };
-}
-/**
- * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
- *
- * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
- * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
- * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
- * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
- * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
- *
- * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
- * heten en hangt het van de driver af welke wint. Eén extra query per pagina
- * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
- */
-export function getTimeline(slug, limit, offset) {
-  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
-  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
-  for (const r of rows) {
-    const x = reacties.get(r.id);
-    r.liked = !!(x && x.liked);
-    r.boosted = !!(x && x.boosted);
-  }
-  return rows;
-}
-
-/**
- * The direct notes addressed to this account: a plain DM, a guardian's wave
- * (§5), a ward's 🛟 help request (§5.2.1). They live in ap_mentions and NOT in
- * the timeline, because a note addressed to named people is a message and not a
- * post (belongsInTimeline).
- *
- * A client that only reads the timeline therefore sees none of them, which is
- * exactly what happened to Shaer: Berichten showed your own replies (those come
- * from your outbox) and nothing that was said to you. The C2S inbox read serves
- * both, so the app has one door for everything that arrives.
- *
- * A public mention from someone you follow is stored in both tables; those are
- * skipped here and stay a post.
- */
-// Inbound replies on YOUR posts, for the app's message stream. They live in
-// ap_interactions (the web's comment machinery) and deliberately NOT in
-// ap_mentions (the mention store returns early for replies-to-us), so the
-// C2S read missed them entirely: a reply arrived at the other side
-// everywhere EXCEPT in the other's app (Robins melding, 30-7: "komt niet
-// binnen bij de ander").
-const REPLY_COLUMNS = `
-      i.object_uri, i.actor_uri, i.actor_name, i.actor_handle, i.actor_icon, i.actor_url,
-      i.content, i.published, i.created_at, i.parent_uri, i.post_id,
-      i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json`;
-
-/** Dezelfde antwoordrijen, maar op object-uri -- voor de verschil-lezing. */
-export function replyRowsByUri(slug, uris) {
-  const list = (uris || []).filter((u) => typeof u === 'string' && u);
-  if (!list.length) return [];
-  try {
-    const holes = list.map(() => '?').join(',');
-    return db.prepare(`SELECT ${REPLY_COLUMNS} FROM ap_interactions i
-                        JOIN posts p ON p.id = i.post_id
-                        JOIN sites s ON s.id = p.site_id
-                       WHERE s.slug = ? AND i.kind = 'reply' AND i.object_uri IN (${holes})`)
-      .all(slug, ...list);
-  } catch { return []; }
-}
-
-/** Tijdlijnrijen op id, met dezelfde afgeleide liked/boosted als getTimeline. */
-export function timelineRowsByIds(slug, ids) {
-  const list = (ids || []).filter((u) => typeof u === 'string' && u);
-  if (!list.length) return [];
-  try {
-    const holes = list.map(() => '?').join(',');
-    const rows = db.prepare(`SELECT * FROM ap_timeline WHERE slug = ? AND id IN (${holes})`).all(slug, ...list);
-    const reacties = getReactionsFor(slug, rows.map((r) => r.id));
-    for (const r of rows) {
-      const x = reacties.get(r.id);
-      r.liked = !!(x && x.liked);
-      r.boosted = !!(x && x.boosted);
-    }
-    return rows;
-  } catch { return []; }
-}
-
-export function getReplyMessages(slug, limit) {
-  try {
-    return db.prepare(`
-      SELECT ${REPLY_COLUMNS}
-      FROM ap_interactions i
-      JOIN posts p ON p.id = i.post_id
-      JOIN sites s ON s.id = p.site_id
-      WHERE s.slug = ? AND i.kind = 'reply'
-      ORDER BY ${STEMPEL('COALESCE(i.published, i.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
-  } catch { return []; }
-}
-
-/**
- * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
- * (shaer-n05).
- *
- * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
- * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
- * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
- * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
- *
- * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
- * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
- * andere server niet te vertrouwen is.
- *
- * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
- * mee; de vorm mag veranderen zonder dat dat iets breekt.
- */
-export function feedCursor(slug) {
-  try {
-    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
-    return String((r && r.n) || 0);
-  } catch { return '0'; }
-}
-
-/**
- * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
- * bewerkt, of weg.
- *
- * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
- * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
- * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
- * plek waar ze samen te lezen zijn.
- */
-export function feedChangesSince(slug, rev, limit = 200) {
-  try {
-    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
-                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
-      .all(slug, parseInt(rev, 10) || 0, limit);
-  } catch { return []; }
-}
-
-// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
-// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
-// krijgen gewoon meteen antwoord in plaats van een fout.
-const FEED_WAIT_MAX = 4;
-const _wachters = new Map();
-
-/**
- * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
- *
- * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
- * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
- * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
- * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
- * vertraging.
- */
-export async function waitForFeedChange(slug, opts = {}) {
-  const tickMs = Math.max(50, opts.tickMs || 1000);
-  const waitMs = Math.max(0, opts.waitMs || 0);
-  const since = String(opts.since || '');
-  let cursor = feedCursor(slug);
-  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
-  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
-
-  const bezet = _wachters.get(slug) || 0;
-  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
-  _wachters.set(slug, bezet + 1);
-  try {
-    const einde = Date.now() + waitMs;
-    while (Date.now() < einde) {
-      if (opts.signal && opts.signal.aborted) break;   // client hing op
-      const rest = Math.min(tickMs, einde - Date.now());
-      await new Promise((r) => setTimeout(r, rest));
-      cursor = feedCursor(slug);
-      if (cursor !== since) return { cursor, changed: true, waited: true };
-    }
-    return { cursor, changed: false, waited: true };
-  } finally {
-    const n = (_wachters.get(slug) || 1) - 1;
-    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
-  }
-}
-
-// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────────
-//
-// De oude lezing gaf de nieuwste 60 berichten over ALLE gesprekken samen. Dat
-// knipt geschiedenis weg zonder dat iemand het merkt, en het is bij DM's veel
-// erger dan bij posts: dat zijn er meer en het zijn kortere berichten, dus een
-// druk gesprek kan de 60 in zijn eentje opeten en de rest uit de lezing duwen.
-// Viel het laatste bericht van iemand erbuiten, dan verdween die persoon
-// helemaal uit Messages -- de avatarhemel plaatst mensen op de leeftijd van hun
-// laatste bericht, dus geen bericht is geen gezicht.
-//
-// Vandaar twee lezingen. Deze geeft EEN rij per tegenpartij, hoe druk iemand
-// ook is, en conversationHistory hieronder geeft het gesprek zelf met een
-// cursor. Wat de client van de hemel nodig heeft -- wie, wanneer, en waarmee --
-// zit in die ene nieuwste note.
-//
-// Een gesprek is hier hetzelfde als in de app: incoming zijn de ap_mentions
-// (die tabel IS de aan ons gerichte post), uitgaand zijn de eigen notes met
-// visibility 'direct'. Een publiek antwoord is geen gesprek en hoort niet als
-// gezicht in de hemel.
-/**
- * EEN STEMPEL IN EEN VORM, en dat is hier geen netheid maar de volgorde zelf.
- *
- * Drie vormen kwamen samen in deze unie: `2026-08-13 19:26:17` van SQLite's
- * CURRENT_TIMESTAMP, `2026-08-13T18:21:57Z` uit een object, en dezelfde met
- * milliseconden. Als TEKST vergeleken staat op plek 10 een spatie tegen een
- * T -- en een spatie is kleiner. Dus sorteerde binnen dezelfde dag alles wat
- * JIJ stuurde vóór alles wat binnenkwam, ongeacht de klok (Barts melding 14-8:
- * een bericht van 00:30 stond boven een antwoord van 20:22 de avond ervoor).
- *
- * strftime leest alle drie en geeft er een vorm voor terug, in UTC. Lukt het
- * niet, dan blijft de rauwe waarde staan -- dan is die ene rij verkeerd
- * gesorteerd in plaats van de hele lijst.
- *
- * Dit gaat ook de client aan: `new Date('2026-08-13 19:26:17')` leest in
- * JavaScript als LOKALE tijd en `...T19:26:17Z` als UTC. Dezelfde rij gaf dus
- * een leeftijd die twee uur verschilde per vorm.
- */
-
-const CONVERSATION_UNION = `
-  SELECT m.actor_uri AS other, ${STEMPEL('COALESCE(m.published, m.created_at)')} AS stamp,
-         'in' AS direction, m.object_uri AS ref
-    FROM ap_mentions m
-   WHERE m.slug = @slug AND m.actor_uri IS NOT NULL AND m.actor_uri <> ''
-  UNION ALL
-  SELECT j.value AS other, ${STEMPEL('o.created_at')} AS stamp,
-         'out' AS direction, o.id AS ref
-    FROM ap_outbox o
-    JOIN json_each(COALESCE(NULLIF(o.to_actors, ''), json_array(o.to_actor))) j
-   WHERE o.site_slug = @slug AND o.visibility = 'direct'
-     AND j.value IS NOT NULL AND j.value <> ''`;
-
-/**
- * Een rij per tegenpartij: zijn nieuwste bericht, nieuwste gesprek eerst.
- *
- * Compleet van vorm -- het aantal rijen is het aantal mensen, niet het aantal
- * berichten -- dus de hemel kan niemand meer kwijtraken doordat een ander druk
- * was. Zonder limiet, en dat mag: dit schaalt met je kring.
- */
-export function conversationHeads(slug) {
-  try {
-    // Twee rijen per persoon, niet een: het nieuwste bericht (dat bepaalt waar
-    // iemand in de hemel hangt) EN het nieuwste bericht VAN HEM.
-    //
-    // Die tweede is er omdat het nieuwste bericht van jou kan zijn, en dan
-    // draagt het jouw byline. De hemel zoekt de naam en het gezicht van de
-    // ander in een bericht van de ander -- vond hij dat niet, dan viel hij
-    // terug op het staartje van de actor-uri en heette tante opeens
-    // 'hotelbreakfast'. Op het toestel gezien, 10-8.
-    //
-    // Valt het samen (het nieuwste is al van hem), dan is het een rij; dubbel
-    // sturen doen we niet.
-    return db.prepare(`
-      SELECT other, stamp, direction, ref FROM (
-        SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
-          FROM (${CONVERSATION_UNION})
-      ) WHERE rn = 1
-      UNION
-      SELECT other, stamp, direction, ref FROM (
-        SELECT *, ROW_NUMBER() OVER (PARTITION BY other ORDER BY stamp DESC, ref DESC) AS rn
-          FROM (${CONVERSATION_UNION}) WHERE direction = 'in'
-      ) WHERE rn = 1
-      ORDER BY stamp DESC, ref DESC`).all({ slug });
-  } catch { return []; }
-}
-
-/**
- * Een gesprek, nieuwste eerst, met een cursor.
- *
- * BEIDE KANTEN ONDER EEN LIMIET. In de oude lezing werden jouw kant
- * (getSentNotes) en hun kant apart afgekapt, waardoor een gesprek eenzijdig
- * kon lijken -- alsof iemand nooit geantwoord had. Hier is de limiet er een
- * voor het gesprek als geheel.
- *
- * `before` is de cursor van het OUDSTE bericht dat je al hebt; je krijgt wat
- * daarvoor ligt. Er komt er een extra op om te weten of er nog meer is: de
- * client hoort dat te weten zonder te moeten gokken, en zonder dat weten kan
- * 'load more' niet eerlijk verschijnen.
- *
- * DE CURSOR IS SAMENGESTELD -- '<stempel>|<ref>' -- en niet alleen de stempel.
- * Twee berichten in dezelfde seconde is bij DM's geen randgeval maar een
- * gesprek, en met 'stamp < before' zou alles wat die grensseconde deelt stil
- * overgeslagen worden. Je zou het niet merken: de pagina komt gewoon, er
- * ontbreekt alleen iets in het midden.
- */
-const cursorOf = (r) => (r ? `${r.stamp}|${r.ref}` : null);
-
-export function conversationHistory(slug, other, { before = null, limit = 60 } = {}) {
-  try {
-    const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
-    const sep = String(before || '').indexOf('|');
-    const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
-    const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
-    const rows = db.prepare(`
-      SELECT other, stamp, direction, ref FROM (${CONVERSATION_UNION})
-       WHERE other = @other
-         AND (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
-       ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, other, bStamp, bRef, n: n + 1 });
-    const more = rows.length > n;
-    const page = more ? rows.slice(0, n) : rows;
-    return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
-  } catch { return { rows: [], more: false, oldest: null }; }
-}
-
-// De kolommen die een bericht tot kaart maken. Een constante, want de
-// gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
-// een kaart die op de ene plek een plaatje heeft en op de andere niet.
-const MESSAGE_COLUMNS = `
-      m.object_uri, m.note_url, m.actor_uri, m.actor_name, m.actor_handle, m.actor_icon, m.actor_url,
-      m.content, m.published, m.created_at, m.wave, m.help_request, m.in_reply_to,
-      m.emoji_json, m.actor_emoji_json, m.media_json, m.quote_json, m.embed_json`;
-
-/** Dezelfde berichtrijen, maar op object-uri -- voor een gesprek. */
-export function messageRowsByUri(slug, uris) {
-  const lijst = (uris || []).filter((u) => typeof u === 'string' && u);
-  if (!lijst.length) return [];
-  try {
-    const gaten = lijst.map(() => '?').join(',');
-    return db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM ap_mentions m
-                        WHERE m.slug = ? AND m.object_uri IN (${gaten})`).all(slug, ...lijst);
-  } catch { return []; }
-}
-
-/**
- * Tot waar deze lezer elk gesprek gelezen heeft (shaer-frontend-3tx).
- *
- * De markering komt uit AS2 `Read`-activiteiten, en die zijn OPTELLEND: het
- * lezen van bericht N maakt niets anders ongelezen. Daarom is achteruit gaan
- * geen regel die iemand moet onthouden maar een eigenschap van het model --
- * markRead neemt het maximum. Een 'zet mijn markering op X' zou een toestel
- * dat een week uit stond je gelezen berichten weer op ongelezen laten zetten.
- */
-export function readMarkers(slug) {
-  try {
-    return new Map(db.prepare('SELECT other, cursor FROM ap_read_markers WHERE slug = ?')
-      .all(slug).map((r) => [r.other, r.cursor]));
-  } catch { return new Map(); }
-}
-
-/**
- * Markeer een gesprek als gelezen tot en met dit bericht.
- *
- * Het object van de Read is een berichturi; welk gesprek dat is en waar het in
- * de tijd staat weet de server zelf, dus de client hoeft niets uit te rekenen
- * en kan er ook niet naast zitten.
- */
-export function markRead(slug, objectUri) {
-  try {
-    const rij = db.prepare(`SELECT other, stamp, ref FROM (${CONVERSATION_UNION})
-                             WHERE ref = @ref ORDER BY stamp DESC LIMIT 1`)
-      .get({ slug, ref: String(objectUri || '') });
-    if (!rij) return null;
-    const cursor = `${rij.stamp}|${rij.ref}`;
-    db.prepare(`INSERT INTO ap_read_markers (slug, other, cursor) VALUES (?,?,?)
-                ON CONFLICT(slug, other) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), at = CURRENT_TIMESTAMP`)
-      .run(slug, rij.other, cursor);
-    return { other: rij.other, cursor };
-  } catch { return null; }
-}
-
-/**
- * Hoeveel er per gesprek nog ongelezen is, en of daar een zwaai bij zit.
- *
- * Een COUNT en geen bijgehouden getal (Barts besluit): niets om op te hogen
- * bij bezorging, niets om te verlagen bij lezen, en bij een verwijdering klopt
- * het vanzelf weer.
- *
- * Een zwaai telt apart, want dat is geen gesprek maar een zetje van een
- * guardian -- die hoort een eigen teken te krijgen en niet opgeteld te worden.
- * Eigen berichten tellen nooit mee: je hebt jezelf gelezen.
- */
-export function unreadPerConversation(slug, { messagesAllowed = true, guardians = new Set() } = {}) {
-  try {
-    // DE POORT TELT MEE. Staat messages dicht, dan toont de app die berichten
-    // niet -- en dan mag een badge ze ook niet aankondigen, want dat getal
-    // vertelt precies wat de poort verbergt. Wat er altijd door mag telt wel:
-    // het guardian-kanaal en de boei. Zelfde regel als bij de serialisatie.
-    const rijen = db.prepare(`
-      SELECT u.other AS other,
-             COUNT(*) AS n,
-             MAX(CASE WHEN m.wave = 1 THEN 1 ELSE 0 END) AS wave
-        FROM (${CONVERSATION_UNION}) u
-        LEFT JOIN ap_read_markers r ON r.slug = @slug AND r.other = u.other
-        LEFT JOIN ap_mentions m ON m.slug = @slug AND m.object_uri = u.ref
-       WHERE u.direction = 'in'
-         AND (r.cursor IS NULL OR (u.stamp || '|' || u.ref) > r.cursor)
-         AND (@open = 1 OR m.help_request = 1 OR u.other IN (SELECT value FROM json_each(@guardians)))
-       GROUP BY u.other`)
-      .all({ slug, open: messagesAllowed ? 1 : 0, guardians: JSON.stringify([...guardians]) });
-    return new Map(rijen.map((r) => [r.other, { n: r.n, wave: !!r.wave }]));
-  } catch { return new Map(); }
-}
-
-export function getDirectMessages(slug, limit) {
-  try {
-    return db.prepare(`
-      SELECT ${MESSAGE_COLUMNS}
-      FROM ap_mentions m
-      WHERE m.slug = ?
-        AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
-      ORDER BY ${STEMPEL('COALESCE(m.published, m.created_at)')} DESC LIMIT ?`).all(slug, limit || 60);
-  } catch { return []; }
-}
-
-/**
- * A stored stamp as an ISO instant. SQLite's CURRENT_TIMESTAMP writes
- * 'YYYY-MM-DD HH:MM:SS' in UTC, which Date.parse reads as LOCAL time; on a
- * server two hours ahead that dated every message two hours early and put the
- * conversation in the wrong order. A `published` from the wire is already ISO
- * and passes through untouched.
- */
-export function isoStamp(v) {
-  if (!v) return undefined;
-  const s = String(v);
-  if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s)) return `${s.replace(' ', 'T')}Z`;
-  const t = Date.parse(s);
-  return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
-}
-
-// Inbox C2S read: a timeline row's media_json ([{url, type}], written on the
-// inbound Create) → AS2 `attachment` array, so a client (Shaer) can render a
-// friend's images/audio/video natively, exactly like own outbox posts. The
-// stored `type` is the mediaType and may be ''. Malformed JSON yields
-// undefined and never blocks the item.
-export function timelineAttachments(mediaJson) {
-  try {
-    const list = mediaJson ? JSON.parse(mediaJson) : [];
-    const rows = (Array.isArray(list) ? list : [])
-      .filter((m) => m && m.url)
-      .map((m) => {
-        const a = { type: 'Document', mediaType: m.type || undefined, url: m.url };
-        if (m.poster) a.icon = { type: 'Image', url: m.poster }; // the video's still (shaer-zowq)
-        return a;
-      });
-    return rows.length ? rows : undefined;
-  } catch { return undefined; }
-}
-
-// FEP-9098 custom emojis. Inbound: keep the note's Emoji tags (as JSON) so we
-// can serve them back. `extractEmojiTags` returns the JSON to store (or null);
-// `timelineEmojis` turns the stored JSON back into an AS2 `tag` array for the
-// C2S inbox read, so a client (Shaer) can render :shortcode: as an image.
-export function extractEmojiTags(tag) {
-  const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
-  const emojis = arr.filter((t) => t && (Array.isArray(t.type) ? t.type[0] : t.type) === 'Emoji'
-    && typeof t.name === 'string' && t.icon);
-  return emojis.length ? JSON.stringify(emojis) : null;
-}
-// ── Gate-filters voor de C2S-serialisatie (shaer-ahy.1, 8-8) ──────
-//
-// Dezelfde regel als bij de embeds: de poort zit bij de AFLEVERING. Een
-// bijlage die de client alleen verbergt is wel degelijk geleverd, dus wat
-// dicht is wordt hier nooit geserialiseerd. Puur, zodat de regels los van de
-// routes te toetsen zijn.
-
-/** Bijlagen door de beeld- en muziekpoort. Leeg wordt undefined, zoals de
- *  serialisatie dat overal doet. */
-export function gateAttachments(atts, { images = true, audio = true } = {}) {
-  if (!Array.isArray(atts)) return atts;
-  const out = atts.filter((a) => {
-    const mt = String((a && a.mediaType) || '');
-    if (!images && mt.startsWith('image/')) return false;
-    if (!audio && (mt.startsWith('audio/') || (a && a.type === 'Audio'))) return false;
-    return true;
-  });
-  return out.length ? out : undefined;
-}
-
-/** Tag-array zonder de FEP-9098 Emoji-tags, voor een dichte emoji-poort. De
- *  :shortcode: blijft als tekst staan -- dat is eerlijk: er STAAT iets, het
- *  wordt alleen niet als plaatje van een vreemde server gerenderd. */
-export function stripEmojiTags(tags) {
-  if (!Array.isArray(tags)) return tags;
-  const out = tags.filter((t) => (Array.isArray(t && t.type) ? t.type[0] : (t && t.type)) !== 'Emoji');
-  return out.length ? out : undefined;
-}
-
-export function timelineEmojis(emojiJson) {
-  try { const arr = emojiJson ? JSON.parse(emojiJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
-  catch { return undefined; }
-}
-
-// FEP-e232 object links (quotes / inline references). Inbound: keep the note's
-// Link tags whose mediaType marks an AP object (the AS2-profiled ld+json, or
-// activity+json as its equivalent) as JSON, so the C2S inbox read can serve
-// them back and a client (Shaer) can render the quote/reference. Mirrors
-// extractEmojiTags. Plain hyperlinks (text/html) and Mentions are dropped.
-export function extractObjectLinkTags(tag) {
-  const arr = Array.isArray(tag) ? tag : (tag ? [tag] : []);
-  const links = arr.filter((t) => {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link') return false;
-    if (typeof t.href !== 'string' || !t.href) return false;
-    const mt = String(t.mediaType || '').toLowerCase();
-    return (mt.startsWith('application/ld+json') && mt.includes('activitystreams'))
-      || mt.startsWith('application/activity+json');
-  });
-  return links.length ? JSON.stringify(links) : null;
-}
-export function timelineObjectLinks(linkJson) {
-  try { const arr = linkJson ? JSON.parse(linkJson) : null; return (Array.isArray(arr) && arr.length) ? arr : undefined; }
-  catch { return undefined; }
-}
-
-// FEP-044f quote posts: a quote is usually NOT an FEP-e232 tag but an
-// object-level property. FEP-044f §"how to recognise" lists them all:
-// `quote` (the FEP property, a string or an embedded Link/object), and the
-// de-facto `quoteUrl` (as:), `quoteUri` (fedibird), `_misskey_quote` (misskey).
-// This returns the quoted object's URL from whichever is present.
-export function extractQuoteUrl(note) {
-  if (!note || typeof note !== 'object') return null;
-  const q = note.quote ?? note.quoteUrl ?? note.quoteUri ?? note['_misskey_quote'];
-  if (!q) return null;
-  if (typeof q === 'string') return q || null;
-  if (typeof q === 'object') return (typeof q.id === 'string' && q.id) || (typeof q.href === 'string' && q.href) || null;
-  return null;
-}
-
-// The note's object-link tags for storage: real FEP-e232 Link tags PLUS any
-// FEP-044f object-level quote, normalised to one FEP-e232-shaped Link (rel
-// _misskey_quote) so the client's single object-link path renders them all.
-// Deduped by href. Returns the JSON to store (or null if the note has neither).
-export function extractLinkJson(note) {
-  const links = [];
-  const fromTag = extractObjectLinkTags(note && note.tag);
-  if (fromTag) { try { links.push(...JSON.parse(fromTag)); } catch { /* ignore */ } }
-  const qUrl = extractQuoteUrl(note);
-  if (qUrl && !links.some((l) => l && l.href === qUrl)) {
-    links.push({ type: 'Link', mediaType: 'application/activity+json', href: qUrl,
-      rel: ['https://misskey-hub.net/ns#_misskey_quote'], name: qUrl });
-  }
-  return links.length ? JSON.stringify(links) : null;
-}
-
-// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
-// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
-export function quoteHrefOf(note) {
-  const direct = extractQuoteUrl(note);
-  if (direct) return direct;
-  const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
-  for (const t of arr) {
-    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
-    const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
-    if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
-  }
-  return null;
-}
-
-// Turn the stored quote snapshot back into the object the C2S inbox read serves
-// as `shaer:quote`, so the client can render the embedded quote card.
-export function timelineQuote(quoteJson) {
-  try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
-  catch { return undefined; }
-}
Index: src/services/ap-transport.js
===================================================================
--- src/services/ap-transport.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,403 +1,0 @@
-/**
- * ap-transport.js — het transport onder de federatie (stap 3 van shaer-drc).
- *
- * Alles wat hier woont raakt het netwerk of de sleutels, en niets erin weet
- * iets van Notes, feeds of guardianship:
- *   - de SSRF-poort (safeFetch en zijn wachters) voor ELKE uitgaande fetch
- *   - de RSA-sleutels per actor
- *   - HTTP Signatures: tekenen (deliver, signedGetHeaders) en controleren
- *     (verifyRequest)
- *   - de bezorging met wachtrij en backoff (deliverWithRetry en de worker)
- *   - de ondertekende GET (signedGetJson) en zijn onbetekende broer (apGetJson)
- *
- * Verhuisd uit ActivityPubService.js, dat alles her-exporteert: bestaande
- * importeurs merken niets. De afhankelijkheden wijzen alleen omlaag (db,
- * ap-core, Node zelf) -- er mag hier nooit iets uit de dienstlaag bij.
- */
-import crypto from 'crypto';
-import fs from 'fs';
-import dns from 'dns';
-import net from 'net';
-import db from '../config/database.js';
-import { actorId } from './ap-core.js';
-
-// ── SSRF guard for outbound fetches ───────────────────────────────
-// Remote URLs (actor/keyId/webfinger/inbox/inReplyTo) are attacker-controlled, so
-// every outbound fetch must refuse hosts that resolve to private/loopback ranges
-// (cloud metadata, internal services) — on the initial host AND each redirect hop.
-function isBlockedIp(ip) {
-  if (!ip) return true;
-  const v = net.isIP(ip);
-  if (v === 4) {
-    const o = ip.split('.').map(Number);
-    return o[0] === 127 || o[0] === 10 || o[0] === 0
-      || (o[0] === 172 && o[1] >= 16 && o[1] <= 31)
-      || (o[0] === 192 && o[1] === 168)
-      || (o[0] === 169 && o[1] === 254)
-      || (o[0] === 100 && o[1] >= 64 && o[1] <= 127); // CGNAT
-  }
-  if (v === 6) {
-    const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
-    return s === '::1' || s === '::' || s.startsWith('fc') || s.startsWith('fd') || s.startsWith('fe80')
-      || s.startsWith('::ffff:127.') || s.startsWith('::ffff:10.') || s.startsWith('::ffff:192.168.')
-      || s.startsWith('::ffff:169.254.') || s.startsWith('::ffff:172.');
-  }
-  return true; // not an IP literal we recognise → refuse
-}
-/**
- * Uitzonderingen op de SSRF-poort, voor een testkudde op de eigen machine
- * (shaer-6wt: honderd wards met een guardian, Barts opdracht 8-8).
- *
- * WAAROM DIT MAG BESTAAN. De bescherming hierboven is er omdat een actor-URI van
- * een VREEMDE komt: een aanvaller die "http://169.254.169.254/" doorgeeft laat
- * ons zijn werk doen. Deze lijst gaat niet over vreemden -- hij staat in de
- * omgeving van deze server, wordt door de beheerder gezet, en is leeg tenzij
- * iemand hem expliciet vult.
- *
- * WAAROM HIJ ZO SMAL IS. Geen vlag die "loopback is oke" zegt, maar een lijst
- * van precieze host:poort-paren. `[::1]:3060` opent niet 127.0.0.1, niet poort
- * 3061, en niets in het interne netwerk. Een brede vlag zou de bescherming in
- * een dev-omgeving uitzetten, en dev-omgevingen worden productie.
- *
- *   AP_ALLOW_HOSTS="[::1]:3060,[::1]:3061"
- */
-const AP_ALLOW_HOSTS = new Set(
-  String(process.env.AP_ALLOW_HOSTS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean),
-);
-function isAllowedTestHost(u) {
-  if (!AP_ALLOW_HOSTS.size) return false;
-  return AP_ALLOW_HOSTS.has(u.host.toLowerCase());
-}
-async function assertPublicHost(hostname) {
-  // URL.hostname geeft een IPv6-literal MET blokhaken ("[::1]"), en net.isIP
-  // herkent die vorm niet. Zonder strippen viel elk IPv6-adres door naar de
-  // DNS-tak, waar het strandde op ENOTFOUND: geweigerd, maar per ongeluk en met
-  // de verkeerde reden. isBlockedIp strippde ze al -- die verwachtte dus input
-  // die hier nooit aankwam.
-  const naakt = String(hostname || '').replace(/^\[|\]$/g, '');
-  if (net.isIP(naakt)) { if (isBlockedIp(naakt)) throw new Error('ssrf-blocked-ip'); return; }
-  const addrs = await dns.promises.lookup(naakt, { all: true });
-  if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
-}
-// One honest name on ALL outbound federation traffic (Robins vraag, 31-7):
-// safeFetch went out with the bare Node default before, and polite fediverse
-// citizens say who they are (some instances even refuse anonymous UAs). A
-// caller-provided User-Agent (the EmbedResolver) still wins.
-let _uaVer = '1.0';
-try { _uaVer = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url))).version || _uaVer; } catch { /* keep default */ }
-const KLONKT_UA = `Klonkt/${_uaVer} (+https://klonkt.com)`;
-
-export async function safeFetch(url, opts = {}, maxRedirects = 3) {
-  let target = url;
-  for (let hop = 0; ; hop++) {
-    const u = new URL(target); // throws on malformed → caller's catch
-    if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('ssrf-bad-scheme');
-    // Alleen op de precieze host:poort uit AP_ALLOW_HOSTS, en per hop opnieuw:
-    // een omleiding naar een ANDER intern adres blijft geweigerd.
-    if (!isAllowedTestHost(u)) await assertPublicHost(u.hostname);
-    const r = await fetch(target, {
-      ...opts,
-      headers: { 'User-Agent': KLONKT_UA, ...(opts.headers || {}) },
-      redirect: 'manual',
-      signal: AbortSignal.timeout(8000),
-    });
-    const loc = (r.status >= 300 && r.status < 400) ? r.headers.get('location') : null;
-    if (loc && hop < maxRedirects) { target = new URL(loc, target).toString(); continue; }
-    return r;
-  }
-}
-
-// ── RSA keys per actor (lazy, cached in DB) ───────────────────────
-// Prepared lazily (NOT at module load) — the ap_keys table is created in
-// initializeDatabase(), which runs after this module is imported.
-let _sel, _ins;
-function keyStmts() {
-  if (!_sel) {
-    _sel = db.prepare('SELECT public_pem, private_pem FROM ap_keys WHERE slug = ?');
-    _ins = db.prepare('INSERT OR IGNORE INTO ap_keys (slug, public_pem, private_pem, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)');
-  }
-  return { sel: _sel, ins: _ins };
-}
-
-export function getOrCreateKeys(slug) {
-  const { sel, ins } = keyStmts();
-  const row = sel.get(slug);
-  if (row) return row;
-  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
-    modulusLength: 2048,
-    publicKeyEncoding: { type: 'spki', format: 'pem' },
-    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
-  });
-  ins.run(slug, publicKey, privateKey);
-  return sel.get(slug) || { public_pem: publicKey, private_pem: privateKey };
-}
-
-// ── HTTP Signatures + delivery ────────────────────────────────────
-// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
-export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
-  const body = JSON.stringify(bodyObj);
-  const u = new URL(inboxUrl);
-  const date = new Date().toUTCString();
-  const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
-  const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
-  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
-  const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
-  const r = await safeFetch(inboxUrl, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
-    body,
-  });
-  return r.status;
-}
-
-export async function fetchActor(url, opts = {}) {
-  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
-  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
-  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
-  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
-  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
-  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
-  // onbetekende GET (shaer-afq).
-  //
-  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
-  // actor-document op, en dat serveert Klonkt publiek.
-  //
-  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
-  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
-  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
-  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
-  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
-  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
-  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
-  let doc = null;
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (r.ok) {
-      const len = Number(r.headers.get('content-length') || 0);
-      if (len > 2_000_000) return null; // refuse oversized actor docs
-      doc = await r.json();
-    }
-  } catch { /* val door naar de ondertekende poging */ }
-  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
-  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
-  // telt als mislukt.
-  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
-  if (!opts.asSlug) return doc;
-  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
-  return (signed && signed.id) ? signed : doc;
-}
-
-// ── Delivery queue with retries ───────────────────────────────────
-// Outbound deliveries are tried immediately; on failure (down server, timeout,
-// non-2xx) they're queued and retried with backoff so a briefly-offline follower
-// doesn't silently miss the post. The signing key is NOT stored — the worker
-// re-derives it from the actor slug at send time.
-const DELIVERY_MAX_ATTEMPTS = 6;
-const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
-let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
-function deliveryStmts() {
-  if (!_insDeliv) {
-    _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)');
-    _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30");
-    _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?');
-    _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?');
-  }
-  return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv };
-}
-export function enqueueDelivery(slug, inbox, activity) {
-  if (!slug || !inbox || !activity) return;
-  try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ }
-}
-// Record delivery health per follower so the followers list can flag dead accounts.
-// Keyed by inbox: a shared-inbox POST reaches every follower behind it, so all of them
-// are marked. A non-follower inbox (inline @mention) simply matches 0 rows.
-let _fDelivOk, _fDelivErr;
-function markFollowerDelivery(slug, inbox, ok) {
-  if (!slug || !inbox) return;
-  try {
-    if (!_fDelivOk) {
-      _fDelivOk = db.prepare('UPDATE ap_followers SET last_delivery_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
-      _fDelivErr = db.prepare('UPDATE ap_followers SET last_error_at = CURRENT_TIMESTAMP WHERE slug = ? AND (inbox = ? OR shared_inbox = ?)');
-    }
-    (ok ? _fDelivOk : _fDelivErr).run(slug, inbox, inbox);
-  } catch { /* health tracking is non-fatal */ }
-}
-// Deliver now; queue for retry if it fails.
-export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) {
-  if (!inbox) return;
-  try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) { markFollowerDelivery(slug, inbox, true); return; } } catch { /* queue below */ }
-  enqueueDelivery(slug, inbox, activity);
-}
-let _processingDeliv = false;
-export async function processDeliveryQueue() {
-  if (_processingDeliv) return; // re-entrancy guard: 30 rows × 8s can exceed the 60s tick → no double-delivery
-  _processingDeliv = true;
-  try {
-    let rows;
-    try { rows = deliveryStmts().due.all(); } catch { return; }
-    if (!rows || !rows.length) return;
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    for (const row of rows) {
-      let ok = false;
-      try {
-        const keys = getOrCreateKeys(row.slug);
-        const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem);
-        ok = st >= 200 && st < 300;
-      } catch { ok = false; }
-      if (ok) { markFollowerDelivery(row.slug, row.inbox, true); deliveryStmts().del.run(row.id); continue; }
-      const attempts = row.attempts + 1;
-      if (attempts >= DELIVERY_MAX_ATTEMPTS) { markFollowerDelivery(row.slug, row.inbox, false); deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; }
-      // Index the backoff on the CURRENT attempt count (row.attempts) so the first
-      // retry uses the 1-min tier instead of skipping it.
-      const mins = DELIVERY_BACKOFF_MIN[Math.min(row.attempts, DELIVERY_BACKOFF_MIN.length - 1)];
-      deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id);
-    }
-  } finally { _processingDeliv = false; }
-}
-let _delivTimer = null;
-export function startDeliveryWorker() {
-  if (_delivTimer) return;
-  _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
-  if (_delivTimer.unref) _delivTimer.unref();
-}
-
-/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
- *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
-let _signSlug;
-export function anySigningSlug() {
-  if (_signSlug !== undefined) return _signSlug;
-  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
-  catch { _signSlug = null; }
-  return _signSlug;
-}
-
-// Best-effort verification of an incoming signed request. Returns the sender's
-// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
-// Max clock skew for the signed Date header (replay window). Generous default to tolerate
-// federating servers with drifting clocks; an operator can widen it via env.
-const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
-export async function verifyRequest(req, asSlug = null) {
-  const sigH = req.headers['signature'];
-  if (!sigH) return null;
-  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
-  if (!p.keyId || !p.signature) return null;
-  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
-  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
-  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
-  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
-  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
-  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
-  if (!pem) return null;
-  // Bind the key to the actor it speaks for. Without this we hand back whatever
-  // `id` the fetched document claims, so anyone could host a document carrying a
-  // VICTIM's id next to their OWN public key, sign with their own private half,
-  // and be believed: the victim's server is never contacted. The caller decides on
-  // `verified.id`, so the identity has to come from where the key was FETCHED,
-  // never from what the document says about itself.
-  // Adds conditions only, and there is no exemption list on purpose: an
-  // "unless it's a known peer" escape hatch is exactly the door this closes.
-  // Note this does not narrow what we accept in practice, since the line above
-  // already requires the embedded publicKey object (an array or a bare URI
-  // reference never worked here).
-  const key = actor.publicKey;
-  try {
-    if (new URL(p.keyId).host !== new URL(actor.id).host) return null;   // same origin as the key
-    if (key.id && key.id !== p.keyId) return null;                       // this key, not a neighbour's
-    if (key.owner && key.owner !== actor.id) return null;                // and it belongs to this actor
-  } catch { return null; }                                               // unparseable id or keyId
-  const hs = (p.headers || '(request-target) host date').split(/\s+/);
-  // Behind a reverse proxy the raw Host header is the backend bind (e.g. localhost:3000, when
-  // the proxy doesn't preserve it — Apache .htaccess [P] proxying), but the sender signed the
-  // HTTP-Signature over the PUBLIC host. Try each candidate host (the configured PUBLIC_BASE_URL
-  // host, the proxy's X-Forwarded-Host, and the raw Host) and accept if the signature verifies
-  // against any. An attacker can't forge a match (no private key), so this only rescues the
-  // legitimate proxied case. Also normalise a leading double-slash in the request-target.
-  let _pubHost = null;
-  if (process.env.PUBLIC_BASE_URL) { try { _pubHost = new URL(process.env.PUBLIC_BASE_URL).host; } catch { /* ignore */ } }
-  const _hosts = [...new Set([_pubHost, req.headers['x-forwarded-host'], req.headers['host']].filter(Boolean))];
-  const _target = `${req.method.toLowerCase()} ${String(req.originalUrl || '').replace(/^\/{2,}/, '/')}`;
-  const _sig = Buffer.from(p.signature, 'base64');
-  let ok = false;
-  for (const _h of _hosts) {
-    const line = hs.map((x) => x === '(request-target)'
-      ? `(request-target): ${_target}`
-      : x === 'host' ? `host: ${_h}`
-      : `${x}: ${req.headers[x] || ''}`).join('\n');
-    try { if (crypto.verify('sha256', Buffer.from(line), pem, _sig)) { ok = true; break; } } catch { /* try next host */ }
-  }
-  // Replay defence: the Date header must be signed and recent. A captured signed request
-  // replayed later (or with a swapped body) is rejected.
-  if (ok) {
-    if (!hs.includes('date')) ok = false;
-    else {
-      const t = Date.parse(req.headers['date'] || '');
-      if (isNaN(t) || Math.abs(Date.now() - t) > SIG_MAX_SKEW_MS) ok = false;
-    }
-  }
-  // Digest is MANDATORY when the request carries a body: without a signed digest the body
-  // isn't covered by the signature and could be swapped on a replay.
-  if (ok && req.rawBody && req.rawBody.length) {
-    if (!hs.includes('digest')) ok = false;
-    else {
-      const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
-      if (req.headers['digest'] !== exp) ok = false;
-    }
-  }
-  return ok ? actor : null;
-}
-
-// A generic SSRF-safe AP GET (collections / pages).
-/**
- * A signed GET as one of our local actors (friends-history, 30-7): the remote
- * server can then recognise the caller and serve what THAT caller may see,
- * exactly like the guardian's authorized fetch. The signature covers
- * (request-target) host date, the set verifyRequest checks.
- */
-/**
- * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
- * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
- * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
- * krijgt daar terecht een 403).
- */
-export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !slug) return null;
-  const me = actorId(base, slug);
-  const keys = getOrCreateKeys(slug);
-  const u = new URL(url);
-  const date = new Date().toUTCString();
-  const target = `${u.pathname}${u.search || ''}`;
-  const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
-  const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
-  return {
-    Accept: accept,
-    Date: date,
-    Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
-  };
-}
-
-export async function signedGetJson(slug, url, onStatus) {
-  try {
-    const headers = signedGetHeaders(slug, url);
-    if (!headers) return apGetJson(url);
-    const r = await safeFetch(url, { headers });
-    // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
-    // niet", en dat is te weinig om een WEIGERING van een STORING te
-    // onderscheiden. Wie geen callback meegeeft merkt hier niets van.
-    if (typeof onStatus === 'function') onStatus(r.status);
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 3_000_000) return null;
-    return await r.json();
-  } catch { return null; }
-}
-
-export async function apGetJson(url) {
-  try {
-    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 3_000_000) return null;
-    return await r.json();
-  } catch { return null; }
-}
Index: src/services/ensurePrimarySite.js
===================================================================
--- src/services/ensurePrimarySite.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,52 +1,0 @@
-import { v4 as uuid } from 'uuid';
-import db from '../config/database.js';
-
-// A Klonkt instance should ALWAYS have a primary site — it carries the identity
-// (title, theme, profile) and is the anchor point for the whole instance.
-// The register flow already creates one, but an admin created via a script
-// (or an empty sites table for any reason) left the instance without a site:
-// no settings, dashboard would crash.
-//
-// This helper runs at boot (and is idempotent): as soon as there is an admin
-// but no site yet, it creates a default site owned by the first god/admin.
-
-function defaultTitle() {
-  try {
-    const base = process.env.PUBLIC_BASE_URL;
-    if (base) {
-      const host = new URL(base).hostname.replace(/^www\./, '');
-      const label = host.split('.')[0];
-      if (label) return label.charAt(0).toUpperCase() + label.slice(1);
-    }
-  } catch { /* fall back to generic */ }
-  return 'Mijn site';
-}
-
-export function ensurePrimarySite() {
-  const count = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
-  if (count > 0) return null; // a site already exists — nothing to do
-
-  const owner = db.prepare(
-    "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1"
-  ).get();
-  if (!owner) return null; // no admin yet -> no owner, nothing to create
-
-  const siteId = uuid();
-  const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway
-  db.prepare(`
-    INSERT INTO sites (
-      id, slug, title, description, tagline, owner_id,
-      language, palette, accent, profile_photo,
-      is_public, robots_index, require_login_to_comment, enable_audio_player,
-      feed_view_default, is_primary
-    ) VALUES (?, ?, ?, '', '', ?, 'en', 'klonkt', '#e8b04b', NULL, 1, 1, 1, 1, 'grid', 1)
-  `).run(siteId, slug, defaultTitle(), owner.id);
-
-  // site_members-entry zodat de owner door canAdminSite-checks komt.
-  db.prepare(
-    "INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')"
-  ).run(siteId, owner.id);
-
-  console.log(`[ensurePrimarySite] standaard-site '${slug}' aangemaakt (owner ${owner.id})`);
-  return { siteId, slug };
-}
Index: src/services/guardianship/availability.js
===================================================================
--- src/services/guardianship/availability.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,294 +1,0 @@
-/**
- * Guardian availability (FEP-633c §3.6): away, dormant, and the lapse.
- *
- * The port of the Shaer test daemon's availability.rs, validated there first
- * (shaer-8z7): same states, same rules, same refusals. Guardianship demands
- * attention; `shaer:guardians` is a public claim about safety, and a guardian
- * who no longer answers makes it untrue. It also quietly breaks the §3.5
- * arithmetic: a majority of a set with absent members can be unreachable.
- *
- * Three states per (ward, guardian), and one rule above everything else:
- * ONE ANSWER RESTORES EVERYTHING, at any moment up to and including a
- * running lapse. Neither away nor dormant is misconduct; neither leaves a
- * mark.
- *
- * Time is always a parameter here, never read from a clock inside the rules,
- * so a fourteen-day window is a number in a test and not a wait.
- */
-import db from '../../config/database.js';
-import { listGuardians, removeRelation } from './relations.js';
-
-/** Deployment numbers (§3.6.2 keeps them out of the spec on purpose: any
- *  number written there would punish exactly the long-term ill). Matched to
- *  the daemon's defaults so the two backends behave the same under test. */
-export const POLICY = {
-  requestTtlMs: 7 * 24 * 3600 * 1000,   // how long a request may sit unanswered
-  missesForDormant: 3,                   // how many missed requests make dormant
-};
-
-/** The lapse window. Irreversible per §3.5, so it always runs in full. */
-export const LAPSE_WINDOW_MS = 14 * 24 * 3600 * 1000;
-
-/** Marker detection: the away declaration rides a direct note (§2.4). */
-export function isAway(object) {
-  return !!object && (object['shaer:away'] === true || object.away === true);
-}
-
-/** AS2 endTime → epoch ms. A number passes through; a string goes through
- *  Date.parse (which reads ISO 8601, offsets included). null when absent or
- *  unreadable: an absence without an end is refused, never guessed. */
-export function parseEndTime(v) {
-  if (typeof v === 'number' && Number.isFinite(v)) return v;
-  if (typeof v !== 'string' || !v.trim()) return null;
-  const t = Date.parse(v);
-  return Number.isNaN(t) ? null : t;
-}
-
-// ── Attention (the per-guardian state) ─────────────────────────────────────
-
-function attentionRow(wardSlug, guardianUri) {
-  return db.prepare('SELECT * FROM ap_guardian_attention WHERE ward_slug = ? AND guardian_uri = ?')
-    .get(wardSlug, guardianUri) || { ward_slug: wardSlug, guardian_uri: guardianUri, state: 'active', away_until: null };
-}
-
-/** What the stored state means at `now`: an away past its end is simply
- *  active again, silently (§3.6.1). */
-export function effective(wardSlug, guardianUri, now) {
-  const row = attentionRow(wardSlug, guardianUri);
-  if (row.state === 'away') return (row.away_until && now < row.away_until) ? 'away' : 'active';
-  return row.state;
-}
-
-/** The away end, when there is a running one (for display). */
-export function awayUntil(wardSlug, guardianUri, now) {
-  const row = attentionRow(wardSlug, guardianUri);
-  return (row.state === 'away' && row.away_until && now < row.away_until) ? row.away_until : null;
-}
-
-/** Declare absence with an end (§3.6.1). The declaration is itself an
- *  answer, so it first restores: declaring away while dormant clears the
- *  dormancy, without a mark. Declaring away is the responsible act. */
-export function declareAway(wardSlug, guardianUri, untilMs) {
-  db.prepare('DELETE FROM ap_attention_requests WHERE ward_slug = ? AND guardian_uri = ?').run(wardSlug, guardianUri);
-  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
-              VALUES (?,?, 'away', ?)
-              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'away', away_until = excluded.away_until`)
-    .run(wardSlug, guardianUri, untilMs);
-}
-
-/** A directly addressed request went out to this guardian (a §3.5 decision
- *  naming them, or an explicit check-in). Requests during a declared absence
- *  are not recorded: away MUST NOT count as evidence (§3.6.1). */
-export function recordRequest(wardSlug, guardianUri, requestId, now) {
-  if (effective(wardSlug, guardianUri, now) === 'away') return;
-  db.prepare(`INSERT OR IGNORE INTO ap_attention_requests (ward_slug, guardian_uri, request_id, asked_at)
-              VALUES (?,?,?,?)`).run(wardSlug, guardianUri, requestId, now);
-}
-
-/** Missed requests: unanswered ones older than the policy TTL. */
-export function misses(wardSlug, guardianUri, now) {
-  const r = db.prepare(`SELECT COUNT(*) AS n FROM ap_attention_requests
-                        WHERE ward_slug = ? AND guardian_uri = ? AND asked_at <= ?`)
-    .get(wardSlug, guardianUri, now - POLICY.requestTtlMs);
-  return r ? r.n : 0;
-}
-
-/** Promote to dormant when the evidence says so. Returns true only on the
- *  transition itself: THAT is the moment the notification duty of §3.6.2
- *  fires (protocol AND the §6 handle), and it is the caller's job — wired
- *  through onDormant below so every call site notifies the same way. */
-export function observe(wardSlug, guardianUri, now) {
-  if (effective(wardSlug, guardianUri, now) !== 'active') return false;
-  if (misses(wardSlug, guardianUri, now) < POLICY.missesForDormant) return false;
-  db.prepare(`INSERT INTO ap_guardian_attention (ward_slug, guardian_uri, state, away_until)
-              VALUES (?,?, 'dormant', NULL)
-              ON CONFLICT(ward_slug, guardian_uri) DO UPDATE SET state = 'dormant', away_until = NULL`)
-    .run(wardSlug, guardianUri);
-  notifyDormant(wardSlug, guardianUri);
-  return true;
-}
-
-/** The notification duty of §3.6.2, wired once (ActivityPubService). The
- *  one-answer rule is worthless to someone who does not know an answer is
- *  wanted; the §6 handle exists for precisely this moment. */
-let _onDormant = null;
-export function wireAvailability({ onDormant } = {}) { _onDormant = onDormant || null; }
-function notifyDormant(wardSlug, guardianUri) {
-  try { if (_onDormant) _onDormant(wardSlug, guardianUri); } catch { /* best-effort */ }
-}
-
-/**
- * One answer restores everything (§3.6). Any activity from an actor that
- * guards someone on this server restores it to active for those wards and
- * cancels any lapse running against it, up to the last moment of the window.
- * Returns what changed, so a caller can log or announce it.
- */
-export function oneAnswer(guardianUri, now) {
-  if (!guardianUri) return { restored: [], cancelledLapses: [] };
-  const restored = [];
-  for (const row of db.prepare(`SELECT ward_slug, state FROM ap_guardian_attention WHERE guardian_uri = ?`).all(guardianUri)) {
-    if (row.state !== 'active') restored.push(row.ward_slug);
-  }
-  const hadRequests = db.prepare('SELECT DISTINCT ward_slug FROM ap_attention_requests WHERE guardian_uri = ?').all(guardianUri);
-  for (const r of hadRequests) if (!restored.includes(r.ward_slug)) restored.push(r.ward_slug);
-  db.prepare("UPDATE ap_guardian_attention SET state = 'active', away_until = NULL WHERE guardian_uri = ?").run(guardianUri);
-  db.prepare('DELETE FROM ap_attention_requests WHERE guardian_uri = ?').run(guardianUri);
-
-  const cancelledLapses = [];
-  for (const l of db.prepare('SELECT * FROM ap_lapses WHERE target_uri = ? AND cancelled = 0 AND applied = 0').all(guardianUri)) {
-    if (lapseOutcome(l, now) === 'open') {
-      db.prepare('UPDATE ap_lapses SET cancelled = 1 WHERE id = ?').run(l.id);
-      cancelledLapses.push({ id: l.id, wardSlug: l.ward_slug, wardUri: l.ward_uri, set: JSON.parse(l.set_json) });
-    }
-  }
-  return { restored, cancelledLapses };
-}
-
-/** The available set of §3.5: the guardians minus away and dormant members.
- *  Observation (and thus the dormancy promotion) happens here, so reading the
- *  set is what moves the clock's consequences. */
-export function availableSet(wardSlug, guardianUris, now) {
-  return guardianUris.filter((g) => {
-    observe(wardSlug, g, now);
-    return effective(wardSlug, g, now) === 'active';
-  });
-}
-
-/** The guardians queue items (§3.6.1: never public, owner-only): the real
- *  size of the ward's safety net. Same shape the daemon serves. */
-export function statusesFor(wardSlug, guardianUris, now) {
-  return guardianUris.map((g) => {
-    observe(wardSlug, g, now);
-    const running = db.prepare(`SELECT id FROM ap_lapses WHERE ward_slug = ? AND target_uri = ? AND cancelled = 0 AND applied = 0`)
-      .get(wardSlug, g);
-    return {
-      id: g,
-      'shaer:availability': effective(wardSlug, g, now),
-      'shaer:awayUntil': awayUntil(wardSlug, g, now),
-      'shaer:lapse': running && lapseOutcome(db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(running.id), now) === 'open' ? running.id : null,
-    };
-  });
-}
-
-// ── The lapse (§3.6.3): release in absentia ────────────────────────────────
-
-/** Read a shaer:Lapse object, or null when this is a different Offer. */
-export function parseLapse(object) {
-  if (!object || typeof object !== 'object') return null;
-  const type = Array.isArray(object.type) ? object.type[0] : object.type;
-  if (type !== 'shaer:Lapse' && type !== 'Lapse') return null;
-  const ward = object['shaer:ward'] || object.ward;
-  const target = typeof object.object === 'string' ? object.object : (object.object && object.object.id);
-  return (typeof ward === 'string' && typeof target === 'string') ? { ward, target } : null;
-}
-
-/** Strict majority of the set (§3.5 default). */
-export function lapseThreshold(setSize) { return Math.floor(setSize / 2) + 1; }
-
-/** Pure outcome: cancelled beats everything; the window always runs in full
- *  (§3.5, irreversible), then a strict majority completes, else it fails
- *  closed. */
-export function lapseOutcome(row, now) {
-  if (!row) return null;
-  if (row.cancelled) return 'cancelled';
-  if (now - row.opened_at < row.window_ms) return 'open';
-  const accepts = JSON.parse(row.accepts_json).length;
-  return accepts >= lapseThreshold(JSON.parse(row.set_json).length) ? 'completed' : 'failed';
-}
-
-/**
- * Open a lapse on this server (we host the ward). Refusals mirror the
- * daemon's, status for status:
- *  - not_a_guardian: the target does not guard this ward
- *  - would_emancipate: removing the last guardian is §3.4, never a lapse
- *  - not_dormant: a lapse opens only against a guardian already dormant
- *  - not_in_available_set: only an available co-guardian proposes
- */
-export function openLapse({ id, wardSlug, wardUri, target, openedBy, now, windowMs = LAPSE_WINDOW_MS }) {
-  const guardians = listGuardians(wardSlug).map((g) => g.other_uri);
-  if (!guardians.includes(target)) return { error: 'not_a_guardian' };
-  if (guardians.length <= 1) return { error: 'would_emancipate' };
-  observe(wardSlug, target, now);
-  if (effective(wardSlug, target, now) !== 'dormant') return { error: 'not_dormant' };
-  const set = availableSet(wardSlug, guardians, now).filter((g) => g !== target);
-  if (!set.includes(openedBy)) return { error: 'not_in_available_set' };
-  // The proposal carries the proposer's own accept (§3.1's one-step clause,
-  // exactly as §5.6 applies it).
-  db.prepare(`INSERT INTO ap_lapses (id, ward_slug, ward_uri, target_uri, opened_by, set_json, accepts_json, opened_at, window_ms)
-              VALUES (?,?,?,?,?,?,?,?,?)`)
-    .run(id, wardSlug, wardUri, target, openedBy, JSON.stringify(set), JSON.stringify([openedBy]), now, windowMs);
-  return { lapse: db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id), set, threshold: lapseThreshold(set.length) };
-}
-
-/** Record a vote from a set member. Answers from outside the snapshot are
- *  refused, not counted: a stranger cannot make up the majority. */
-export function lapseVote(id, actor, accept, now) {
-  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
-  if (!row) return null;
-  const outcome = lapseOutcome(row, now);
-  if (outcome !== 'open') return { error: outcome === 'cancelled' ? 'cancelled' : 'closed' };
-  const set = JSON.parse(row.set_json);
-  if (!set.includes(actor)) return { error: 'not_in_set' };
-  const accepts = new Set(JSON.parse(row.accepts_json));
-  const rejects = new Set(JSON.parse(row.rejects_json));
-  if (accept) { rejects.delete(actor); accepts.add(actor); }
-  else { accepts.delete(actor); rejects.add(actor); }
-  db.prepare('UPDATE ap_lapses SET accepts_json = ?, rejects_json = ? WHERE id = ?')
-    .run(JSON.stringify([...accepts]), JSON.stringify([...rejects]), id);
-  return { outcome: 'open', accepts: accepts.size, threshold: lapseThreshold(set.length) };
-}
-
-/**
- * Evaluate a lapse at `now`, executing the removal exactly once when the
- * window has closed with a majority. The refusal to empty shaer:guardians
- * stands as a second lock under this one: even a completed lapse must not
- * take the last guardian (that is emancipation, §3.4).
- */
-export function settleLapse(id, now) {
-  const row = db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id);
-  if (!row) return null;
-  const outcome = lapseOutcome(row, now);
-  if (outcome !== 'completed' || row.applied) return { outcome, applied: !!row.applied, row };
-  if (listGuardians(row.ward_slug).length <= 1) {
-    return { outcome, applied: false, refused: 'would_emancipate', row };
-  }
-  removeRelation(row.ward_slug, 'ward', row.target_uri);
-  db.prepare('UPDATE ap_lapses SET applied = 1 WHERE id = ?').run(id);
-  return { outcome, applied: true, row };
-}
-
-/** The offers-queue items for running lapses this account is a party to:
- *  the ward itself, or a co-located guardian in the set. Same shape as the
- *  daemon's, so the Shaer clients render them as-is. */
-export function lapseQueueItems(slug, me, now) {
-  const items = [];
-  for (const row of db.prepare('SELECT * FROM ap_lapses WHERE applied = 0 AND cancelled = 0').all()) {
-    settleLapse(row.id, now);   // reads are where lazy completion happens
-    if (lapseOutcome(row, now) !== 'open') continue;
-    const set = JSON.parse(row.set_json);
-    if (row.ward_slug !== slug && !set.includes(me)) continue;
-    const accepts = JSON.parse(row.accepts_json);
-    const rejects = JSON.parse(row.rejects_json);
-    items.push({
-      id: row.id,
-      type: 'Offer',
-      actor: row.opened_by,
-      object: { type: 'shaer:Lapse', 'shaer:ward': row.ward_uri, object: row.target_uri },
-      'shaer:set': set,
-      'shaer:accepts': accepts.length,
-      'shaer:threshold': lapseThreshold(set.length),
-      'shaer:myVote': accepts.includes(me) || rejects.includes(me),
-      'shaer:outcome': 'open',
-      'shaer:closesAt': row.opened_at + row.window_ms,
-    });
-  }
-  return items;
-}
-
-export function getLapse(id) { return db.prepare('SELECT * FROM ap_lapses WHERE id = ?').get(id); }
-
-export default {
-  POLICY, LAPSE_WINDOW_MS, isAway, parseEndTime, effective, awayUntil, declareAway,
-  recordRequest, misses, observe, oneAnswer, availableSet, statusesFor, wireAvailability,
-  parseLapse, lapseThreshold, lapseOutcome, openLapse, lapseVote, settleLapse, lapseQueueItems, getLapse,
-};
Index: src/services/guardianship/context.js
===================================================================
--- src/services/guardianship/context.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,43 +1,0 @@
-/**
- * Guardianship (FEP-633c "Guardians") — JSON-LD vocabulary.
- *
- * One source of truth for the shaer namespace and the terms Klonkt emits.
- * ActivityPubService spreads SHAER_CONTEXT into its AP_CONTEXT term block, so
- * every outgoing document declares the namespace and strict JSON-LD
- * processors resolve the terms instead of dropping them.
- */
-
-/** The term block merged into AP_CONTEXT. */
-export const SHAER_CONTEXT = {
-  // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
-  // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
-  shaer: 'https://ns.klonkt.com/shaer#',
-};
-
-/** The Relationship value in the adoption Offer (FEP-633c §3), both forms. */
-export const GUARDIAN_RELATIONSHIP = 'https://ns.klonkt.com/shaer#Guardian';
-export const GUARDIAN_RELATIONSHIP_COMPACT = 'shaer:Guardian';
-
-/** True when an Offer's relationship names the guardian relation. */
-export function isGuardianRelationship(value) {
-  return value === GUARDIAN_RELATIONSHIP || value === GUARDIAN_RELATIONSHIP_COMPACT;
-}
-
-/**
- * True when an actor document carries `shaer:guardians` — i.e. it is a ward,
- * and therefore not a valid guardian (§1). The one question §4 asks, in both
- * places it asks it: before committing a guardianship (§4.2) and before
- * delivering an escalation to one (§4.1).
- *
- * §2.1 allows the list as an array of URIs, a single URI, or a Collection, so
- * all three are read here rather than in each caller.
- */
-export function carriesGuardians(doc) {
-  const g = doc && doc['shaer:guardians'];
-  if (Array.isArray(g)) return g.length > 0;
-  if (typeof g === 'string') return g.length > 0;
-  if (g && typeof g === 'object') return Array.isArray(g.items) ? g.items.length > 0 : true;
-  return false;
-}
-
-export default { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship, carriesGuardians };
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,168 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the direct-note delivery leg.
- *
- * A direct note (private mention, shaer-tqc) is the ward's call-for-help
- * carrier: addressed to specific actors only, no Public, no followers
- * fan-out. Moved here from ActivityPubService (guardianship refactor);
- * behavior is unchanged.
- *
- * This module has NO import back into ActivityPubService: the AP helpers it
- * needs (actor fetch, key material, delivery, note building) are provided
- * once via wireDelivery(deps) at ActivityPubService load time.
- */
-import crypto from 'crypto';
-import db, { NU_ISO } from '../../config/database.js';
-import { carriesGuardians } from './context.js';
-
-const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
-
-let deps = null;
-/** Called once by ActivityPubService with the shared AP helpers. */
-export function wireDelivery(d) { deps = d; }
-
-// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
-// safest bucket they match.
-export function c2sVisibility(object) {
-  const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-  const to = arr(object.to), cc = arr(object.cc);
-  const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
-  const isFollowers = (x) => /\/followers\/?$/.test(x);
-  if (to.some(isPublic)) return 'public';
-  if (cc.some(isPublic)) return 'quiet';
-  if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
-  if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
-  return 'direct';
-}
-
-// A direct note: a NEW conversation (or a direct reply) addressed to specific
-// actors only. Stored in ap_outbox with visibility 'direct' + the recipient
-// list, delivered to exactly those inboxes: no followers fan-out, no Public,
-// so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
-// guardian on any instance receives it as a private mention (the ward
-// call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark, gateRequest }) {
-  // EEN MARKERING IS EEN ANTWOORD op de hulpvraag waar hij over gaat (Barts
-  // vraag, 26-8: "welke context heeft 'Ik kijk hiernaar' als ik erop klik?" --
-  // geen). De verwijzing reisde al mee als shaer:-veld, maar dat veld haalt de
-  // berichtenlezing niet, en de tik-route van de clients volgt inReplyTo.
-  // Dus zeggen we het ook in gewoon AS2: dan opent een tik de draad met de
-  // schermafdruk erbij, en threaden andere fediverse-servers hem net zo goed.
-  if (!inReplyTo && helpMark && helpMark.noteUri) inReplyTo = helpMark.noteUri;
-  const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
-          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
-  if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
-  const me = actorId(base, site.slug);
-  // Resolve every recipient for a mention anchor + a delivery inbox.
-  const resolved = [];
-  const teapots = [];
-  for (const uri of list) {
-    // An actor we host is read from our own database, not fetched from our own
-    // hostname: that request has to leave the machine and come back, and when
-    // it does not, the recipient is silently dropped from the note. Everything
-    // that decides anything still runs below, for local and remote alike.
-    // ONDERTEKEND ophalen als het onbetekend niet lukt (asSlug). Een instance
-    // met Mastodons secure mode -- infosec.exchange bijvoorbeeld -- antwoordt
-    // 401 op een anonieme GET van het actor-document. Zonder document geen
-    // inbox, dus viel de ontvanger hier stil weg, en met de laatste ontvanger
-    // gaf deliverDirectNote null terug: "502 direct_failed", zonder te zeggen
-    // wie er niet bereikbaar was.
-    //
-    // Dezelfde les als bij het volgen vanaf een boost (Robins melding, 31-7):
-    // die weg kreeg toen signedGetJson, deze niet. fetchActor probeert nog
-    // steeds ONBETEKEND eerst -- dat blijft de veiligheidskeuze -- en tekent
-    // alleen deze ene URL als dat mislukt.
-    const a = (localActor && localActor(uri))
-      || await fetchActor(uri, { asSlug: site.slug }).catch(() => null);
-    if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
-    // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
-    // guardians of its own goes nowhere. There is no grand-guardian, so we
-    // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
-    // this one target and keep delivering to the rest, because a malformed
-    // guardian must never cost a child the guardians who are fine.
-    //
-    // Only for a call for help. An ordinary direct note is not an escalation,
-    // and a ward is perfectly entitled to message another ward.
-    if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
-    resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
-  }
-  if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
-  if (!resolved.length) {
-    // Every guardian was malformed. §4 does not say what to do here because
-    // §4.1 assumes there are others to continue to — but a ward whose whole
-    // safety net is broken has just called for help into nothing, which is the
-    // one outcome this FEP exists to prevent. Say so loudly; the caller can
-    // tell "nobody was reachable" from "nobody was valid".
-    if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
-    return null;
-  }
-  const mention = resolved.map((r) => {
-    const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
-    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
-  }).join('');
-  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
-  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
-  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
-  // een leeggepoetste editor mag geen leeg bericht versturen.
-  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
-  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
-  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
-  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
-  const content = rich
-    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
-    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
-  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
-  // Attachments: same rules as deliverReply (own /media/ uploads only,
-  // image/audio/video, max 4) — the help-buoy capture rides this.
-  const media = (Array.isArray(attachments) ? attachments : [])
-    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
-      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
-    .slice(0, 4)
-    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
-  const id = crypto.randomUUID();
-  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at)
-              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,${NU_ISO})`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null);
-  const row = getOutboxRow(id);
-  const note = buildReplyNote(base, site, row);
-  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
-  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
-  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
-  // mede-guardians er staat aan over.
-  if (helpMark && helpMark.noteUri) {
-    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
-  }
-  // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
-  // feature reist mee -- geen vrije tekst, zie gatereq.js.
-  if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
-  const create = {
-    '@context': AP_CONTEXT,
-    id: note.id + '#create', type: 'Create', actor: me,
-    published: note.published, to: note.to, cc: note.cc, object: note,
-  };
-  const keys = getOrCreateKeys(site.slug);
-  const keyId = `${me}#main-key`;
-  let delivered = 0;
-  // A recipient on this machine takes the loopback (deliverToActor), which
-  // hands the Create to the same inbox handler an HTTP POST would reach: the
-  // note is stored, the mention is stored, and a shaer:away on it is applied,
-  // all by the code that does it for everyone else. A hairpin POST to our own
-  // hostname is not that code path, it is a second one that only appears to be.
-  for (const r of resolved.filter((x) => x.local)) {
-    const res = await deliverTo(site, r.uri, create).catch(() => null);
-    if (res && res.delivered) delivered++;
-  }
-  // Remote: one POST per inbox, so two guardians on the same server share it.
-  for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
-    let ok = false;
-    try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
-    if (ok) delivered++;
-    else enqueueDelivery(site.slug, inbox, create);
-  }
-  console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
-  return { id, content, delivered, teapots };
-}
-
-export default { wireDelivery, c2sVisibility, deliverDirectNote };
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,181 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.3) — follow-gating for wards.
- *
- * A `Follow` targeting a ward is NOT auto-accepted. It is held pending and
- * routed to the ward's guardians, who approve or deny. A committed guardian's
- * own Follow is auto-accepted (it needs no gate). Quorum policy per ward:
- * 'any' (one guardian suffices, default), 'all', or 'none' (open).
- *
- * This module is the store + the decision; the AP plumbing (sending the
- * Accept, inserting the follower) stays in ActivityPubService.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_follows
-        (id, ward_slug, follower_uri, follower_inbox, follower_shared_inbox, follower_name, follower_handle, follower_icon, activity_json, quorum, created_at)
-        VALUES (?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_pending_follows WHERE id = ?'),
-      byWard: db.prepare("SELECT * FROM ap_pending_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      approvers: db.prepare('SELECT guardian_uri FROM ap_pending_follow_approvals WHERE follow_id = ?'),
-      approve: db.prepare('INSERT OR IGNORE INTO ap_pending_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      setStatus: db.prepare('UPDATE ap_pending_follows SET status = ? WHERE id = ?'),
-      del: db.prepare('DELETE FROM ap_pending_follows WHERE id = ?'),
-    };
-  }
-  return _s;
-}
-
-/** Record a gated follow awaiting guardian approval. */
-export function recordPending(wardSlug, f) {
-  stmts().ins.run(
-    f.id, wardSlug, f.follower, f.inbox, f.sharedInbox || null,
-    f.name || null, f.handle || null, f.icon || null,
-    JSON.stringify(f.activity || null), f.quorum || 'any',
-  );
-  return stmts().get.get(f.id);
-}
-
-export function getPending(id) { return stmts().get.get(id); }
-
-/** Pending follows for a local ward (its guardians decide). */
-export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
-
-/**
- * Record a guardian's decision on a pending follow. Returns
- * { outcome: 'approved'|'rejected'|'waiting', follow } so the caller can
- * send the Accept/Reject. A single reject denies; approvals meet the quorum.
- */
-/**
- * Hoeveel guardians moeten ja zeggen voor een volgverzoek (Barts besluit, 8-8).
- *
- * EENVOUDIGE MEERDERHEID: 1 van 1, 1 van 2, 2 van 3, 2 van 4. Bart: "1/2 is
- * voldoende."
- *
- * BEWUST SOEPELER DAN DE POORTDREMPEL, en dat verschil hoort uitgelegd. Een gate
- * opent een deur voor alles wat daarna komt; die vraagt om een STRIKTE
- * meerderheid (thresholdFor in gated.js: 2 van 2, 3 van 4). Een volgverzoek gaat
- * over een persoon, is met ontvolgen terug te draaien, en stond hier tot vandaag
- * op 'any' -- een enkele ja, hoeveel guardians er ook waren. Dit is dus geen
- * versoepeling maar een AANSCHERPING voor iedereen met drie of meer guardians.
- *
- * De 'all'-stand die hier stond is weg. Hij werd nergens gezet -- elke schrijver
- * gaf 'any' mee -- dus het was een keuze die niemand kon maken en die alleen in
- * de weg stond bij het lezen van deze regel.
- */
-export function followThreshold(setSize) {
-  return Math.max(1, Math.ceil(setSize / 2));
-}
-
-/**
- * Een race naar de drempel, net als de poorttelling: zodra het aantal gehaald
- * is, is het besluit gevallen.
- *
- * TODO (shaer-8vt): wie antwoordt weet niet dat hij de doorslag geeft. Bij 1 van
- * 2 is de eerste ja meteen de beslissing, en het scherm zegt dat nergens. Dat is
- * hetzelfde gat als bij de gate-voorstellen en het hoort daar samen opgelost.
- */
-export function decide(id, guardianUri, decision, guardiansOfWard) {
-  const follow = stmts().get.get(id);
-  if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
-  stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
-  const rows = db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(id);
-  if (rows.some((r) => r.decision === 'reject')) {
-    stmts().setStatus.run('denied', id);
-    return { outcome: 'rejected', follow };
-  }
-  const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
-  const guardians = (guardiansOfWard || []).filter(Boolean);
-  const enough = approvers.size >= followThreshold(guardians.length);
-  if (enough) {
-    stmts().setStatus.run('accepted', id);
-    return { outcome: 'approved', follow };
-  }
-  return { outcome: 'waiting', follow };
-}
-
-export function remove(id) { stmts().del.run(id); }
-
-// ── Guardian-side copy (cross-instance, modelled on the guardian offer): a
-//    gated follow on a REMOTE ward this account guards, forwarded here as an
-//    Offer(Follow). The decision is Accept/Reject sent back to ward_inbox. ──
-let _r = null;
-function rstmts() {
-  if (!_r) {
-    _r = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
-         direction, target_uri, target_handle, created_at)
-        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
-      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      del: db.prepare('DELETE FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
-    };
-  }
-  return _r;
-}
-
-/**
- * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
- *
- * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
- * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
- * follower en staat het doel in het Follow-object -- die werd hiervoor
- * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
- */
-export function recordReview(guardianSlug, r) {
-  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
-    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
-  return rstmts().get.get(guardianSlug, r.id);
-}
-
-/**
- * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
- * lezen (offers en outgoing-follows doen het net zo).
- */
-export function reviewQueueItem(r, me, guardianCount) {
-  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
-  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
-  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
-  const stemmen = (() => {
-    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
-    catch { return []; }
-  })();
-  const uitgaand = r.direction === 'outgoing';
-  return {
-    id: r.id,
-    type: 'Follow',
-    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
-    actor: uitgaand ? r.ward_uri : r.follower_uri,
-    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
-    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
-    'shaer:ward': r.ward_uri,
-    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
-    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
-    'shaer:follower': uitgaand ? undefined : r.follower_uri,
-    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
-    'shaer:quorum': 'all',
-    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
-    'shaer:guardianCount': guardianCount || undefined,
-    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
-    published: r.created_at,
-  };
-}
-
-/** De openstaande reviews van een guardian, per richting. */
-export function listReviewsByDirection(guardianSlug, direction) {
-  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
-}
-export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
-export function listReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
-export function removeReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
-
-export default {
-  recordPending, getPending, listForWard, decide, remove,
-  recordReview, getReview, listReviews, removeReview,
-  listReviewsByDirection, reviewQueueItem,
-};
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,483 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.6): gated settings the guardians decide together.
- *
- * The point of this file is that it works when the guardians are NOT on the
- * ward's server, which is the ordinary case: a child on the family instance, a
- * grandparent on theirs. A guardian proposes with an `Offer` of a
- * `shaer:GatedSetting` addressed to the ward's server; the other guardians
- * answer; the ward's server tallies and enforces, because it is the one that
- * serves the feed.
- *
- * The tally is a §3.5 decision: a snapshotted set, a threshold (strict
- * majority), a window. A setting is reversible (a permission granted can be
- * withdrawn), so it settles as a race to the threshold and fails closed.
- */
-import db from '../../config/database.js';
-import { listGuardians } from './relations.js';
-import * as availability from './availability.js';
-
-/** The window a gated-setting decision stays open. Reversible, so a day. */
-export const GATED_WINDOW_MS = 24 * 60 * 60 * 1000;
-
-/** Strict majority of the set: 1 of 1, 2 of 2, 2 of 3, 3 of 4. */
-export function thresholdFor(setSize) {
-  return Math.floor(setSize / 2) + 1;
-}
-
-/**
- * Tally one decision. Pure, so the rule can be tested without a database.
- *
- * @param {Array<{guardian_uri: string, value: number|boolean}>} votes
- * @param {string[]} guardianSet  the guardians at the moment the decision opened
- * @param {number} ageMs          how long the decision has been open
- * @returns {{state: 'settled'|'open'|'expired', value?: boolean}}
- */
-export function tallyGatedSetting(votes, guardianSet, ageMs, windowMs = GATED_WINDOW_MS) {
-  const set = new Set((guardianSet || []).filter(Boolean));
-  if (!set.size) return { state: 'expired' };            // nobody may decide
-  const need = thresholdFor(set.size);
-  // Only answers from the snapshotted set count, one per guardian.
-  const seen = new Map();
-  for (const v of (votes || [])) {
-    if (!set.has(v.guardian_uri)) continue;
-    seen.set(v.guardian_uri, v.value === true || v.value === 1);
-  }
-  const yes = [...seen.values()].filter(Boolean).length;
-  const no = seen.size - yes;
-  // Race to the threshold, in both directions: settle the moment it is reached,
-  // and give up the moment it can no longer be reached.
-  if (yes >= need) return { state: 'settled', value: true };
-  if (no >= need) return { state: 'settled', value: false };
-  const undecided = set.size - seen.size;
-  if (yes + undecided < need && no + undecided < need) return { state: 'expired' };
-  if (ageMs >= windowMs) return { state: 'expired' };    // fails closed
-  return { state: 'open' };
-}
-
-/** The column a feature maps onto. Unknown features are refused, not guessed. */
-const FEATURES = {
-  'shaer:externalEmbeds': 'external_embeds',
-  'shaer:externalPlayback': 'external_playback',
-  'shaer:externalThreads': 'external_threads',
-  'shaer:images': 'gate_images',
-  'shaer:messages': 'gate_messages',
-  'shaer:compose': 'gate_compose',
-  'shaer:replies': 'gate_replies',
-  'shaer:music': 'gate_music',
-  'shaer:quoteCards': 'gate_quote_cards',
-  'shaer:customEmoji': 'gate_custom_emoji',
-  'shaer:accountMove': 'gate_account_move',
-  'shaer:following': 'gate_following',
-};
-/**
- * De gates die deze Klonkt kent, met hun SOORT.
- *
- * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
- * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
- * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
- * te zijn en geen nieuw stuk scherm.
- *
- * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
- * een guardian niet hoeven raden:
- *
- *   setting     een stand, aan of uit, terug te draaien
- *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
- *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
- *
- * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
- * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
- */
-export const GATE_CATALOGUE = [
-  // Werkend: er is een kolom, de tally kan erover beslissen en de server dwingt
-  // hem af bij het serveren (of, voor compose/messages/move, bij het INNEMEN:
-  // wat de ward niet mag versturen wordt aan de outbox geweigerd).
-  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
-  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
-  // Sinds 8-8 ("maak ze allemaal functioneel", Bart): de hele setting-familie
-  // schakelt echt. De bead-nummers blijven staan, want elk van deze heeft nog
-  // een app-kant (wat de UI toont als de poort dicht is) en die woont daar.
-  { feature: 'shaer:externalThreads', kind: 'setting', reversible: true, bead: 'shaer-9y2' },
-  { feature: 'shaer:images', kind: 'setting', reversible: true, bead: 'shaer-6p5' },
-  { feature: 'shaer:messages', kind: 'setting', reversible: true, bead: 'shaer-3ow' },
-  { feature: 'shaer:compose', kind: 'setting', reversible: true, bead: 'shaer-qgev' },
-  // MEEDOEN AAN EEN GESPREK IS OOK IETS (Bart, 8-8). Dit stond hier bewust niet:
-  // een antwoord gold als meedoen en niet als eigen podium, dus compose liet het
-  // door. Bart heeft dat teruggedraaid -- wie mag antwoorden staat los van wie
-  // mag posten, en het hoort een eigen poort te zijn die je kunt zien.
-  //
-  // Los van compose en niet eronder: je kunt willen dat een kind wel meepraat
-  // maar geen eigen podium heeft, en ook precies andersom.
-  { feature: 'shaer:replies', kind: 'setting', reversible: true, bead: 'shaer-r4c' },
-  { feature: 'shaer:music', kind: 'setting', reversible: true, bead: 'shaer-rmz' },
-  { feature: 'shaer:quoteCards', kind: 'setting', reversible: true, bead: 'shaer-mls' },
-  { feature: 'shaer:customEmoji', kind: 'setting', reversible: true, bead: 'shaer-ytw' },
-  { feature: 'shaer:accountMove', kind: 'setting', reversible: true, bead: 'shaer-tge' },
-  // Wie de ward mag VOLGEN, en wie de ward mag volgen: twee poorten, want twee
-  // vragen. Ze stonden hier als één rij, en dan telt het paneel de ene richting
-  // en zwijgt over de andere -- een guardian ziet "follows: 3 wachtend" en weet
-  // niet of er drie vreemden bij zijn kind willen of dat zijn kind drie keer
-  // heeft gevraagd of het iemand mag volgen. Dat zijn niet dezelfde zorg.
-  //
-  // Inkomend is vast: §5.3 EIST dat een Follow naar een ward langs de guardians
-  // gaat, dus die staat aan en blijft aanstaan. Tonen mag, verzetten niet.
-  // fixedValue false: de poort staat DICHT en blijft dicht -- een Follow naar
-  // een ward gaat altijd langs de guardians. Dezelfde polariteit als de rest
-  // van de familie, waar `value` "mag het zonder tussenkomst?" betekent.
-  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true, fixedValue: false },
-  // Uitgaand is verstelbaar, en dat verschil is opzet. De FEP zegt over deze
-  // richting niets: §5.3 gaat alleen over een Follow die op een ward AF komt.
-  // Wat je verder gated is expliciet aan de implementatie gelaten, dus dit is
-  // onze keuze en niet die van de spec -- en dan hoort hij ook echt te kunnen
-  // worden losgelaten, want een kind dat ouder wordt hoort niet eeuwig te
-  // blijven vragen wie het mag volgen (shaer-p729, shaer-yeo5).
-  { feature: 'shaer:following', kind: 'perRequest', reversible: true, bead: 'shaer-p729' },
-
-  // GEPLAND, en dat is bij deze twee geen achterstand maar een besluit.
-  //
-  // publicProfile is niet een veld dat je wegfiltert: het is het hele publieke
-  // web-oppervlak van een site (de Krant, de AP-objecten, de scrape-vraag van
-  // shaer-hj0). Dat dichtzetten zonder dat ontwerp is een half slot, en een
-  // half slot leest als een heel slot -- gevaarlijker dan geen.
-  //
-  // available: false is geen detail. featureColumn() kent deze naam niet, dus
-  // een voorstel strandt op unknown_feature, en de rij leest als "hier is nog
-  // niets van", niet als een gesloten poort.
-  { feature: 'shaer:publicProfile', kind: 'setting', reversible: true, available: false, bead: 'shaer-hj0' },
-  // De enige die gezag OVERDRAAGT, en daarmee de enige die niet terug te draaien
-  // is zodra het kind hem gebruikt (shaer-90v). Telt met de lapse-vorm: volle
-  // set, volle venster. Die vorm hoort daar beslist te worden, niet hier
-  // geimproviseerd: een verkeerd gemaakte onafhankelijkheid is een kind zonder
-  // vangnet.
-  { feature: 'shaer:independence', kind: 'handover', reversible: false, available: false, bead: 'shaer-90v' },
-];
-
-/**
- * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
- * getoetst kunnen worden zonder database of scherm.
- *
- * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
- * @param guardianCount  aantal guardians, of null als we het niet weten
- * @param proposals      [{feature, value, status}] lopende voorstellen
- * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
- */
-export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {}, requested = {} } = {}) {
-  return GATE_CATALOGUE.map((g) => {
-    // Een stand kan drie dingen zijn: beslist-aan, beslist-uit, of de standaard
-    // omdat er nooit iets besloten is. Dat derde als "uit" tonen zou een besluit
-    // suggereren dat niemand nam.
-    const raw = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
-    let beslist = raw && typeof raw === 'object' ? !!raw.decided : (raw === true || raw === false);
-    let value = raw && typeof raw === 'object' ? raw.value : raw;
-    // Een VASTE poort heeft geen kolom om een stand in te bewaren, want er valt
-    // niets te bewaren: hij staat zoals de spec hem zet. Zonder dit viel hij
-    // door naar "nooit besloten" en las het paneel eeuwig "onbekend" -- wat een
-    // vraag suggereert die er niet is. §5.3 EIST dat een Follow naar een ward
-    // langs de guardians gaat, dus dat is beslist, alleen niet door ons.
-    if (g.fixed) { value = !!g.fixedValue; beslist = true; }
-    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
-    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
-    // ooit dat een voorstel nooit geopend kon worden.
-    const bovenliggend = settings[g.needs];
-    const bovenWaarde = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.value : bovenliggend;
-    const bovenBeslist = bovenliggend && typeof bovenliggend === 'object' ? bovenliggend.decided : (bovenWaarde === true || bovenWaarde === false);
-    // Alleen dichthouden als we ZEKER weten dat het bovenliggende uit staat.
-    const blockedBy = (g.needs && bovenBeslist && bovenWaarde === false) ? g.needs : null;
-    return {
-      feature: g.feature,
-      kind: g.kind,
-      reversible: !!g.reversible,
-      value,
-      decided: beslist,
-      // Vast staat vast: tonen mag, verzetten niet.
-      // Wat er niet is, valt niet te verzetten. Een knop die op unknown_feature
-      // strandt is erger dan geen knop.
-      available: g.available !== false,
-      // Vast is iets anders dan geblokkeerd of afwezig, en alle drie maken ze
-      // `adjustable` false. Een client die alleen dat ziet weet niet WAAROM er
-      // geen knop is; met dit veld kan hij "altijd" zeggen in plaats van een
-      // stand te tonen alsof er ooit nog iets aan verandert.
-      fixed: !!g.fixed,
-      adjustable: g.available !== false && !g.fixed && !blockedBy,
-      blockedBy: blockedBy || undefined,
-      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
-      // leest als een feit, en dit is precies waar een guardian op afgaat.
-      threshold: (guardianCount && guardianCount > 0)
-        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
-      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
-      waiting: waiting[g.feature] || undefined,
-      // Het kind vroeg hier zelf om (shaer-8ru). Apart van `waiting`: drie
-      // onbekenden die je kind willen volgen is iets anders dan je kind dat
-      // een keer vraagt of muziek aan mag, en een gedeeld getal maakt daar
-      // hetzelfde van.
-      requested: requested[g.feature] || undefined,
-    };
-  });
-}
-
-export function featureColumn(feature) {
-  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
-}
-
-/**
- * Record one guardian's answer and settle if the threshold is now reached.
- * Returns the tally state so a caller can report it.
- */
-export function recordGatedVote(slug, feature, guardianUri, value) {
-  const column = featureColumn(feature);
-  if (!column) return { state: 'expired', error: 'unknown_feature' };
-  const all = listGuardians(slug).map((g) => g.other_uri);
-  if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' };
-  // A vote is an answer, whatever it is a vote on (§3.6): the voter is
-  // restored first, so it always counts itself back into the set below.
-  availability.oneAnswer(guardianUri, Date.now());
-  // §3.5: the threshold runs over the AVAILABLE set. Membership is checked
-  // against the full list above: any guardian may answer, and answering is
-  // exactly what brings it back in.
-  const guardians = availability.availableSet(slug, all, Date.now());
-
-  // The window opens with the first answer, and a stale decision starts over:
-  // a proposal from last month should not silently count toward today's.
-  const existing = db.prepare('SELECT MIN(opened_at) AS opened FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .get(slug, feature);
-  let openedAt = existing && existing.opened ? new Date(existing.opened).getTime() : Date.now();
-  if (Number.isNaN(openedAt) || Date.now() - openedAt >= GATED_WINDOW_MS) {
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-    openedAt = Date.now();
-  }
-  db.prepare(`INSERT INTO ap_gated_votes (slug, feature, guardian_uri, value, opened_at)
-              VALUES (?,?,?,?,?)
-              ON CONFLICT(slug, feature, guardian_uri) DO UPDATE SET value = excluded.value`)
-    .run(slug, feature, guardianUri, value ? 1 : 0, new Date(openedAt).toISOString());
-
-  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .all(slug, feature);
-  const result = tallyGatedSetting(votes, guardians, Date.now() - openedAt);
-  if (result.state === 'settled') {
-    db.prepare(`UPDATE sites SET ${column} = ? WHERE slug = ?`).run(result.value ? 1 : 0, slug);
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-  } else if (result.state === 'expired') {
-    db.prepare('DELETE FROM ap_gated_votes WHERE slug = ? AND feature = ?').run(slug, feature);
-  }
-  return { ...result, need: thresholdFor(guardians.length), of: guardians.length };
-}
-
-/**
- * Wat er blijft hangen als deze gate opengaat (shaer-nf9).
- *
- * BARTS ZIN KLOPT NIET LETTERLIJK, en dat is precies waarom dit hier staat. "Een
- * geopende poort gaat niet meer dicht" is onwaar over de INSTELLING -- shaer-ahy
- * eist het tegendeel en de code doet het: een voorstel draagt true of false. Maar
- * het GEVOLG is wel onomkeerbaar. De poort gaat later weer dicht; wat er in de
- * tussentijd doorheen kwam komt niet terug. Een kind dat iets gezien heeft, heeft
- * het gezien.
- *
- * Dat verschil moet in de tekst, om twee redenen. Een waarschuwing die aantoonbaar
- * onwaar is neemt de rest van het scherm mee in zijn val zodra iemand het merkt.
- * En de ware versie is ZWAARDER: "je kunt dit terugdraaien maar niet ongedaan
- * maken" zet je harder stil dan een verbod dat niet blijkt te kloppen.
- *
- * ONBEKEND KRIJGT DE ZWAARSTE TEKST. Een mede-guardian elders kan een feature
- * voorstellen die onze catalogus niet kent, en dan weten wij niet wat het doet.
- * Bij twijfel waarschuwen we zwaarder, niet lichter -- de faalstand die hier pijn
- * doet is een guardian die iets doorlaat omdat het scherm er licht over deed.
- */
-export function gateConsequence(feature) {
-  const g = GATE_CATALOGUE.find((x) => x.feature === feature);
-  if (!g) return 'unknown';
-  return g.reversible === false ? 'irreversible' : 'reversible';
-}
-
-/**
- * Zou het antwoord van deze guardian het besluit AFMAKEN (shaer-8vt)?
- *
- * De telling is een race naar de drempel: zodra het aantal gehaald is, is het
- * gevallen. Bij 2 van 3 is de tweede ja dus meteen de beslissing, en bij een
- * volgverzoek met twee guardians is de EERSTE ja dat al. Wie antwoordt weet dat
- * niet, en het scherm zei het nergens.
- *
- * EEN JA/NEE, GEEN TELLING, en dat is een besluit. Een getal ("1 van 2") reist
- * mee, veroudert onderweg en leest daarna als een feit; de beschikbare set
- * schuift bovendien met 3.6 mee. En hoeveel guardians een kind heeft, en wie er
- * al gestemd heeft, is niet vanzelf iets dat elke mede-guardian hoort te zien.
- * Een waarschuwing veroudert ook, maar hij CLAIMT niets -- en dat scheelt.
- *
- * BIJ TWIJFEL WAARSCHUWEN. De twee fouten zijn niet gelijk: zeggen dat je
- * beslist terwijl dat niet zo is maakt iemand voorzichtiger dan nodig; niets
- * zeggen terwijl hij wel beslist laat hem het onwetend doen.
- */
-export function isDecisive(votes, need) {
-  const v = Number.isFinite(votes) ? votes : 0;
-  const n = Number.isFinite(need) ? need : 1;
-  return (n - v) <= 1;
-}
-
-/** The open decision for a feature, for showing progress ("1 of 2"). */
-export function gatedProgress(slug, feature) {
-  const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?')
-    .all(slug, feature);
-  // Progress over the available set (§3.5), like the tally itself.
-  const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now());
-  return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length };
-}
-
-// ── The federated shape (§5.6) ────────────────────────────────────
-// An Offer of a shaer:GatedSetting, answered with Accept/Reject. Parsing lives
-// here so both the inbox and the outbox read it the same way.
-
-/** Read a shaer:GatedSetting object, or null when this is a different Offer. */
-export function parseGatedSetting(object) {
-  if (!object || typeof object !== 'object') return null;
-  const type = Array.isArray(object.type) ? object.type[0] : object.type;
-  if (type !== 'shaer:GatedSetting' && type !== 'GatedSetting') return null;
-  const ward = object['shaer:ward'] || object.ward;
-  const feature = object['shaer:feature'] || object.feature;
-  const value = object['shaer:value'] !== undefined ? object['shaer:value'] : object.value;
-  if (typeof ward !== 'string' || typeof feature !== 'string') return null;
-  return { ward, feature, value: value === true || value === 1 || value === 'true' };
-}
-
-/** Build the Offer a guardian sends to the ward's server. */
-export function buildGatedOffer(offerId, actor, ward, feature, value) {
-  return {
-    id: offerId,
-    type: 'Offer',
-    actor,
-    to: [ward],
-    object: {
-      type: 'shaer:GatedSetting',
-      'shaer:ward': ward,
-      'shaer:feature': feature,
-      'shaer:value': !!value,
-    },
-  };
-}
-
-// ── The guardian-side copy (the missing leg of §5.6) ──────────────
-// A proposal addressed to the ward's server reaches only the proposer and the
-// ward. The other guardians never learn it exists, so a threshold of two can
-// never be met and every proposal expires unanswered. The ward's server
-// therefore FORWARDS it, exactly as it forwards a gated follow (§5.3): each
-// guardian stores a copy it can answer, and the answer travels back to the
-// ward, which tallies.
-
-let _rs = null;
-function rstmts() {
-  if (!_rs) {
-    _rs = {
-      ins: db.prepare(`INSERT INTO ap_gated_reviews (id, guardian_slug, ward_uri, ward_inbox, proposer, feature, value, decisive)
-                       VALUES (?,?,?,?,?,?,?,?)
-                       ON CONFLICT(guardian_slug, id) DO UPDATE SET value = excluded.value, ward_inbox = excluded.ward_inbox, decisive = excluded.decisive`),
-      get: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
-      bySlug: db.prepare('SELECT * FROM ap_gated_reviews WHERE guardian_slug = ? ORDER BY created_at DESC'),
-      del: db.prepare('DELETE FROM ap_gated_reviews WHERE guardian_slug = ? AND id = ?'),
-      delAll: db.prepare('DELETE FROM ap_gated_reviews WHERE id = ?'),
-    };
-  }
-  return _rs;
-}
-
-export function recordGatedReview(guardianSlug, r) {
-  // decisive ontbreekt bij een oudere server -> 1, want bij twijfel waarschuwen.
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.proposer || null, r.feature, r.value ? 1 : 0, r.decisive === false ? 0 : 1);
-  return rstmts().get.get(guardianSlug, r.id);
-}
-export function getGatedReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
-export function listGatedReviews(guardianSlug) { return rstmts().bySlug.all(guardianSlug); }
-export function removeGatedReview(guardianSlug, id) { rstmts().del.run(guardianSlug, id); }
-/** Drop every guardian's copy once the decision has settled or lapsed. */
-export function clearGatedReviews(id) { rstmts().delAll.run(id); }
-
-export function rememberGatedOffer(offerId, slug, feature, value, proposer) {
-  try {
-    db.prepare('INSERT OR REPLACE INTO ap_gated_offers (offer_id, slug, feature, value, proposer) VALUES (?,?,?,?,?)')
-      .run(offerId, slug, feature, value ? 1 : 0, proposer || null);
-  } catch { /* non-fatal */ }
-}
-
-export function recallGatedOffer(offerId) {
-  try { return db.prepare('SELECT * FROM ap_gated_offers WHERE offer_id = ?').get(offerId) || null; }
-  catch { return null; }
-}
-
-// ── The proposer's own record (5.6) ───────────────────────────────
-// "Where did my proposal go?" had no answer: the status was a button caption
-// that did not survive a refresh. The ward's server tallies elsewhere, so the
-// proposer keeps its own row and the ward's server ANSWERS the Offer when the
-// decision settles: Accept when it settled on the proposed value, Reject when
-// it settled on the opposite. An open row past the window renders as expired,
-// because an expired decision settles on nothing and nobody writes home.
-
-export function recordSent(offerId, guardianSlug, wardUri, feature, value) {
-  try {
-    db.prepare(`INSERT OR REPLACE INTO ap_gated_sent (offer_id, guardian_slug, ward_uri, feature, value)
-                VALUES (?,?,?,?,?)`).run(offerId, guardianSlug, wardUri, feature, value ? 1 : 0);
-  } catch { /* non-fatal */ }
-}
-
-export function recallSent(offerId) {
-  try { return db.prepare('SELECT * FROM ap_gated_sent WHERE offer_id = ?').get(offerId) || null; }
-  catch { return null; }
-}
-
-/**
- * De stand van een gate zoals DEZE guardian hem kent.
- *
- * Er zijn geen lokale accounts: elke ward woont op een andere server, dus de
- * kolom op onze eigen sites-tabel is voor een ward altijd leeg. Wat een guardian
- * wel heeft is de UITSLAG van besluiten -- een geaccepteerd voorstel met waarde
- * true betekent dat de poort openging.
- *
- * Geeft { value, decided }:
- *   decided true   we hebben een aangenomen besluit gezien; value is die waarde
- *   decided false  we hebben er geen; value is de standaard voor een ward (uit)
- *
- * Dat verschil hoort zichtbaar te blijven. "Uit" en "voor zover wij weten uit"
- * zijn niet hetzelfde, en het tweede is wat we meestal hebben.
- *
- * BEKEND GAT: dit ziet alleen onze EIGEN voorstellen. Antwoordde je op dat van
- * een mede-guardian, dan komt de uitslag wel binnen (gated_outcome) maar wordt
- * hij niet bewaard -- handshake.js legt alleen vast voor sent-rijen die van ons
- * zijn. Een gate die een ander heeft geopend leest hier dus als "uit". Dat is de
- * onveilige kant en het hoort gerepareerd te worden.
- */
-export function knownSetting(guardianSlug, wardUri, feature) {
-  try {
-    const r = db.prepare(`SELECT value FROM ap_gated_sent
-                           WHERE guardian_slug = ? AND ward_uri = ? AND feature = ? AND status = 'accepted'
-                           ORDER BY created_at DESC LIMIT 1`).get(guardianSlug, wardUri, feature);
-    if (r) return { value: !!r.value, decided: true };
-  } catch { /* val terug op de standaard */ }
-  return { value: false, decided: false };
-}
-
-export function settleSent(offerId, outcome) {
-  try { db.prepare('UPDATE ap_gated_sent SET status = ? WHERE offer_id = ?').run(outcome, offerId); } catch { /* non-fatal */ }
-}
-
-/** The latest proposal per feature this guardian sent to this ward. */
-export function listSent(guardianSlug, wardUri) {
-  try {
-    return db.prepare(`SELECT * FROM ap_gated_sent WHERE guardian_slug = ? AND ward_uri = ?
-                       GROUP BY feature HAVING MAX(created_at) ORDER BY created_at DESC`).all(guardianSlug, wardUri);
-  } catch { return []; }
-}
-
-/**
- * What a sent row means on a screen. Pure, so the rule is testable: an answer
- * wins, and silence past the window is not "still running", it is over.
- */
-export function sentStatus(row, now) {
-  if (!row) return null;
-  if (row.status === 'accepted' || row.status === 'rejected') return row.status;
-  const opened = new Date(String(row.created_at).includes('T') ? row.created_at : `${row.created_at}Z`.replace(' ', 'T')).getTime();
-  if (Number.isFinite(opened) && now - opened >= GATED_WINDOW_MS) return 'expired';
-  return 'open';
-}
-
-export default {
-  GATE_CATALOGUE, gateRows, knownSetting,
-  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, gateConsequence, isDecisive, GATED_WINDOW_MS,
-  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
-  recordGatedReview, getGatedReview, listGatedReviews, removeGatedReview, clearGatedReviews,
-  recordSent, recallSent, settleSent, listSent, sentStatus,
-};
Index: src/services/guardianship/gatereq.js
===================================================================
--- src/services/guardianship/gatereq.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,99 +1,0 @@
-/**
- * Een kind dat zelf om een poort vraagt (shaer-8ru, Barts opdracht 8-8).
- *
- * Tot nu toe liep alles over de guardians: zij zien de catalogus, zij stellen
- * voor, zij tellen. Het kind liep tegen een dichte deur en had geen woorden.
- * Dit is die woorden -- niet meer dan dat.
- *
- * EEN VRAAG IS GEEN STEM, en dat is de hele grens. De ward stelt niet voor en
- * stemt niet mee; het verzoek landt bij zijn guardians als iets om over te
- * beslissen, en pas als EEN GUARDIAN het oppakt wordt het een voorstel dat
- * langs de gewone tally gaat. Zou een verzoek zelf een voorstel zijn, dan kon
- * een kind zijn eigen poort openen door hard genoeg te vragen.
- *
- * NIET DE REDDINGSBOEI, en dat verschil moet scherp blijven. Een hulpvraag is
- * een noodgeval en gaat door elke dichte deur heen. Dit is een wens. Ze door
- * elkaar laten lopen zou de boei devalueren tot "het kind wil iets", en dan
- * kijkt er op een dag niemand meer op als hij afgaat.
- *
- * GEEN VRIJE TEKST. Een verzoek draagt alleen de naam van de feature. Dat is
- * niet gierig maar precies de reden dat hij langs de messages-poort MAG: een
- * kind met berichten dicht kan nog steeds om iets vragen, zonder dat daarmee
- * een kanaal ontstaat om omheen die poort te praten. Wil het kind uitleggen
- * waarom, dan is dat een gesprek, en gesprekken hebben hun eigen poort.
- */
-
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_gate_requests (slug, ward_uri, feature, note_uri)
-                       VALUES (?,?,?,?)`),
-      bySlug: db.prepare(`SELECT * FROM ap_gate_requests WHERE slug = ? AND handled_at IS NULL
-                          ORDER BY created_at DESC`),
-      handle: db.prepare(`UPDATE ap_gate_requests SET handled_at = CURRENT_TIMESTAMP
-                          WHERE slug = ? AND ward_uri = ? AND feature = ? AND handled_at IS NULL`),
-    };
-  }
-  return _s;
-}
-
-/** Leg vast dat dit kind om deze poort vroeg. Nooit dragend: een verzoek dat
- *  niet opgeslagen kan worden mag geen inkomend bericht laten stranden. */
-export function record(slug, wardUri, feature, noteUri = null) {
-  if (!slug || !wardUri || !feature) return;
-  try { stmts().ins.run(slug, wardUri, feature, noteUri); } catch { /* nooit dragend */ }
-}
-
-/** De openstaande verzoeken van de kinderen van deze guardian. */
-export function listOpen(slug) {
-  try { return stmts().bySlug.all(slug); } catch { return []; }
-}
-
-/**
- * Afgehandeld: er is een voorstel van gemaakt, of een guardian legde hem weg.
- *
- * Verdwijnt niet uit de tabel. Er wordt niets herschreven, er wordt toegevoegd
- * -- zelfde regel als bij de hulpvraag, en om dezelfde reden: wat een kind
- * gevraagd heeft hoort terug te vinden te zijn, ook als het antwoord nee was.
- */
-export function markHandled(slug, wardUri, feature) {
-  try { stmts().handle.run(slug, wardUri, feature); } catch { /* nooit dragend */ }
-}
-
-/**
- * Hoeveel verzoeken er per feature openstaan voor dit kind, voor de
- * waiting-kolom van het paneel. Zo staat de vraag bij de poort waar hij over
- * gaat, en niet in een aparte lijst die je apart moet openen.
- */
-export function waitingFor(slug, wardUri) {
-  const uit = {};
-  for (const r of listOpen(slug)) {
-    if (r.ward_uri !== wardUri) continue;
-    uit[r.feature] = (uit[r.feature] || 0) + 1;
-  }
-  return uit;
-}
-
-/** Het verzoek als bericht. Bewust dezelfde vorm als de zwaai en de
- *  hulpmarkering: een gewone directe note met een shaer:-markering, zodat hij
- *  over de bestaande bezorging reist en niet over een eigen kanaal. */
-export function requestNote({ id, me, feature, to }) {
-  return {
-    id, type: 'Note', attributedTo: me, to,
-    'shaer:gateRequest': feature,
-    // Vaste tekst, geen invoer van het kind: zie de kop over vrije tekst.
-    content: '<p>Mag dit aan?</p>',
-  };
-}
-
-/** Leest een binnengekomen note als poortverzoek, of null als hij er geen is. */
-export function parseRequest(object) {
-  if (!object || typeof object !== 'object') return null;
-  const f = object['shaer:gateRequest'] || object.gateRequest;
-  return (typeof f === 'string' && f) ? { feature: f } : null;
-}
-
-export default { record, listOpen, markHandled, waitingFor, requestNote, parseRequest };
Index: src/services/guardianship/handshake.js
===================================================================
--- src/services/guardianship/handshake.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,635 +1,0 @@
-/**
- * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
- * distributed across instances.
- *
- * The candidate Offers a Relationship{subject: ward, object: candidate},
- * addressed to the ward AND every existing guardian of the ward. Each party
- * (ward, existing guardians, and finally the candidate) Accepts, addressed to
- * all the others, so every instance's copy of the tally converges. The
- * candidate's Accept is the LAST one and carries the escalation handle in
- * `result`: that return is the atomic commit (§3.1.3). Only then does the
- * ward gain the guardian in shaer:guardians and the guardian gain the ward.
- * A single Reject from any party voids the offer (§3.2).
- *
- * The state machine lives in offers.js (a faithful port of the Shaer test
- * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
- * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
- */
-import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT, carriesGuardians } from './context.js';
-import * as offers from './offers.js';
-import * as relations from './relations.js';
-import * as gated from './gated.js';
-import * as availability from './availability.js';
-
-let deps = null;
-export function wireHandshake(d) { deps = d; }
-
-const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
-const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
-
-/**
- * FEP-633c §3.2/§3.3 — ending a guardianship.
- *
- * "After commit, either side MAY end the relationship with `Undo` of the
- * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
- * existing guardian, removes the guardian from `shaer:guardians`."
- *
- * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
- * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
- * has its own flow and is explicitly not a single party's call. So an Undo that
- * would leave a ward with nobody is refused here rather than quietly performed.
- */
-export function parseUndoRelationship(activity) {
-  const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
-  if (type !== 'Undo') return null;
-  return parseRelationship(activity && activity.object);
-}
-
-/**
- * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
- * (shaer-8vt, Barts correctie 8-8).
- *
- * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
- * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
- * berekenen bij het doorsturen bevriest een antwoord dat verandert.
- *
- * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
- * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
- * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
- */
-function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
-  try {
-    const p = gated.gatedProgress(site.slug, gsOffer.feature);
-    if (!gated.isDecisive(p.votes, p.need)) return;   // nog niets veranderd
-    const me = deps.selfId(site.slug);
-    const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
-    for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
-      if (gestemd.has(g)) continue;
-      deps.deliverTo(site, g, {
-        id: offerId, type: 'Offer', actor: me, to: [g],
-        object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
-        'shaer:proposer': gsOffer.proposer || undefined,
-        'shaer:decisive': true,
-      }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
-    }
-  } catch { /* nooit dragend */ }
-}
-
-/** Parse a Relationship object into {ward, candidate} or null. */
-export function parseRelationship(rel) {
-  if (!rel || typeof rel !== 'object') return null;
-  const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
-  if (type !== 'Relationship') return null;
-  if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
-  const ward = idOf(rel.subject);
-  const candidate = idOf(rel.object);
-  return ward && candidate ? { ward, candidate } : null;
-}
-
-/**
- * The existing guardians of a ward: local list, or the remote actor's
- * shaer:guardians.
- *
- * Geexporteerd sinds shaer-lgo: de markeerroute had zijn EIGEN afleiding, en
- * die was fout voor precies het geval dat telt (zie routes/guardian.js).
- */
-export async function existingGuardiansOf(wardUri) {
-  const local = deps.localSlug(wardUri);
-  if (local) return relations.listGuardians(local).map((r) => r.other_uri);
-  const doc = await deps.fetchActor(wardUri).catch(() => null);
-  const g = doc && doc['shaer:guardians'];
-  return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
-}
-
-function offerActivity(offerId, ward, candidate, recipients) {
-  return {
-    id: offerId, type: 'Offer', actor: candidate, to: recipients,
-    object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
-  };
-}
-
-/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
-async function fanout(site, recipients, activity) {
-  let anyDelivered = false;
-  for (const uri of [...new Set(recipients)]) {
-    const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
-    if (r && r.delivered !== false) anyDelivered = true;
-  }
-  return anyDelivered;
-}
-
-/**
- * §5.6, the closing of the loop: a settled gated decision answers the Offer
- * that opened it. Accept when it settled on the proposed value, Reject when on
- * the opposite. Without this the proposer's screen can only ever say
- * "waiting", forever, whatever actually happened: the tally lives on the
- * ward's server and nobody else may read it, so the ward's server must speak.
- */
-function answerGatedProposer(site, offerId, r) {
-  const o = gated.recallGatedOffer(offerId);
-  if (!o || !o.proposer) return;
-  const me = deps.selfId(site.slug);
-  if (o.proposer === me) return;   // the ward proposed to itself: nothing to write home
-  const agreed = r.value === !!o.value;
-  deps.deliverTo(site, o.proposer, {
-    id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
-    type: agreed ? 'Accept' : 'Reject',
-    actor: me, to: [o.proposer], object: offerId,
-  }).catch(() => { /* the delivery queue retries */ });
-}
-
-/** Apply the local side of a commit: the ward writes its guardian, the
- *  candidate writes its ward. Each instance writes only what it hosts.
- *  other_handle is the human @handle for display (from the offer); the FEP
- *  escalation handle (candidate inbox) lives on the offer row, not here. */
-function applyCommitLocally(offer) {
-  const wardSlug = deps.localSlug(offer.ward_uri);
-  const candSlug = deps.localSlug(offer.candidate_uri);
-  if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
-  if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
-}
-
-/**
- * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
- *
- * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
- * because guardianship state can change in between: a candidate that was free
- * when it offered may have been adopted before the ward accepted. So the check
- * runs against a freshly dereferenced actor document, at the moment the
- * relationship would become real.
- *
- * Three answers, and the third is not a failure of this check but a failure to
- * perform it:
- *   'ok'          — free of guardians, may serve
- *   'malformed'   — carries shaer:guardians; a teapot (§4)
- *   'unverified'  — the actor could not be read at all
- */
-async function candidateFitness(candidateUri) {
-  // A candidate on this instance needs no dereference: our own tables are the
-  // document, and fresher than anything we could fetch from ourselves. This is
-  // also the co-located case (ward and guardian on one Klonkt), where there is
-  // no network to be unreachable on.
-  const local = deps.localSlug(candidateUri);
-  if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
-
-  const doc = await deps.fetchActor(candidateUri).catch(() => null);
-  if (!doc) return 'unverified';
-  return carriesGuardians(doc) ? 'malformed' : 'ok';
-}
-
-/** Commit this local copy of the offer when the tally is complete (ward +
- *  candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
- *  inbox (§6 minimum); the commit is order-independent, so whichever accept
- *  lands last triggers it on every copy. */
-async function maybeCommit(slug, offerId) {
-  const offer = offers.getOffer(slug, offerId);
-  if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
-
-  const fitness = await candidateFitness(offer.candidate_uri);
-
-  // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
-  // handshake concerns exactly one candidate, so there is no remaining
-  // well-formed target to continue to; committing anyway would leave the ward
-  // counting a guardian whose escalations get dropped. Voiding is all this
-  // function does; saying so on the wire belongs to whoever was acting.
-  if (fitness === 'malformed') {
-    offers.recordReject(slug, offerId, offer.ward_uri);   // voids this copy (§3.2)
-    notify(slug, {
-      kind: 'offer_rejected', offer: offerId,
-      reason: 'not_a_teapot', candidate: offer.candidate_uri,
-    });
-    return { done: null, refused: 'not_a_teapot', offer };
-  }
-
-  // Could not read the candidate: neither commit nor void. Refusing outright
-  // would let a momentary outage destroy a multi-party adoption; committing
-  // would record a guardian nobody checked. The offer stays pending and the
-  // next accept retries.
-  if (fitness === 'unverified') return { done: null, refused: null };
-
-  const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
-  if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
-  return { done, refused: null };
-}
-
-/**
- * End a guardianship from the local guardian's side and let it travel (§3.2).
- *
- * One path for both callers: the button in the Guardian PWA and an `Undo` a
- * Guardian app POSTs to its own outbox. Addressed like the Offer that started
- * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
- * believing the relation still stands.
- */
-export async function endGuardianship(site, wardUri) {
-  const me = deps.selfId(site.slug);
-  if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
-  const set = await existingGuardiansOf(wardUri);
-  const others = set.filter((g) => g !== me);
-  // Only a set we actually read counts as proof. A remote ward whose server is
-  // down reads as an empty set; refusing on that would trap the guardian, and
-  // the ward's server checks again on arrival anyway.
-  if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
-  const recipients = [wardUri, ...others];
-  const undo = {
-    id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
-    type: 'Undo', actor: me, to: recipients,
-    object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
-  };
-  const delivered = await fanout(site, recipients, undo);
-  relations.removeRelation(site.slug, 'guardian', wardUri);
-  // A ward we host ourselves never receives its own delivery: an inbox on this
-  // machine is not reachable over HTTP from this machine (and should not be).
-  // The commit path has the same shape and solves it the same way — each
-  // instance writes what it hosts (applyCommitLocally).
-  const wardSlug = deps.localSlug(wardUri);
-  if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
-  notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
-  return { status: 202, delivered, guardiansLeft: others.length };
-}
-
-/**
- * The ward's side of an ended guardianship: drop that guardian, unless doing so
- * would empty the set. §3.3 only permits this while more than one remains;
- * emptying it is emancipation (§3.4) and no single party decides that.
- */
-function dropGuardianFromWard(wardSlug, guardianUri) {
-  const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
-  if (!set.includes(guardianUri)) return false;   // already gone: an Undo is idempotent
-  if (set.length <= 1) {
-    notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
-    return false;
-  }
-  relations.removeRelation(wardSlug, 'ward', guardianUri);
-  notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
-  return true;
-}
-
-/** The receiving side of that Undo. Returns true when consumed. */
-function applyInboundUndo(site, activity) {
-  const rel = parseUndoRelationship(activity);
-  if (!rel) return false;
-  const me = deps.selfId(site.slug);
-  const actor = idOf(activity.actor);
-  const ward = rel.ward;
-  const guardian = rel.candidate;   // in an Undo the Relationship's object is the leaving guardian
-
-  if (ward === me) {
-    // I am the ward. Only the guardian itself may end its own relation here;
-    // the ward-co-signed variant of §3.2 needs a second signature and is not
-    // built, so it is refused rather than half-honoured.
-    if (actor !== guardian) return false;
-    dropGuardianFromWard(site.slug, guardian);
-    return true;
-  }
-
-  // I am one of the other guardians: nothing of mine changes, but being left
-  // as one of fewer is exactly the kind of thing a guardian should hear about.
-  if (relations.getRelation(site.slug, 'guardian', ward)) {
-    notify(site.slug, { kind: 'coguardian_left', ward, guardian });
-    return true;
-  }
-  return false;
-}
-
-// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
-
-/**
- * Handle a guardianship activity POSTed to the local outbox. Returns null when
- * it is not ours, else {status, ...} for the route.
- */
-export async function handleOutbox(site, activity) {
-  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
-  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
-  const me = deps.selfId(site.slug);
-  // One answer restores everything (§3.6): any C2S activity from this actor
-  // is that answer, for every local ward it guards. Runs before anything is
-  // even looked at, so the target of a running lapse cancels it by doing
-  // anything at all — including trying to vote on it.
-  try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
-
-  // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
-  //    button in the Guardian PWA, so an app and the dashboard cannot drift.
-  if (type === 'Undo') {
-    const rel = parseUndoRelationship(activity);
-    if (!rel) return null;
-    if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
-    return endGuardianship(site, rel.ward);
-  }
-
-  // ── Offer: the local site is the guardian-candidate. ───────────────────
-  if (type === 'Offer') {
-    // §3.6.3 over C2S: a guardian here proposes releasing a dormant
-    // co-guardian. A ward we host opens locally; a remote ward gets the
-    // proposal delivered, because the ward's server is the one that tallies
-    // and enforces (the §5.6 line: a guardian next door must not have more
-    // say than one far away).
-    const lp = availability.parseLapse(activity.object);
-    if (lp) {
-      // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
-      // enforces, wherever it lives. A local ward is reached by the same
-      // deliverTo, which loops back into the inbox handler; co-location is a
-      // transport detail and never a shortcut past the decision.
-      const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-      const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
-      const delivered = await fanout(site, [lp.ward], offer);
-      return { status: 202, id, url: id, delivered };
-    }
-    const rel = parseRelationship(activity.object);
-    if (!rel) return null;
-    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };   // fixed initiator (§3.1)
-    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };  // §1
-    const existing = await existingGuardiansOf(rel.ward);
-    const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-    offers.start(site.slug, {
-      offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
-      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
-    });
-    // The Offer IS the candidate's agreement to serve: record it as the
-    // candidate's accept. So a FREE ward commits on its own single accept (no
-    // second guardian to co-approve yet); once it IS a ward, adding another
-    // guardian still needs an existing guardian to co-accept.
-    offers.recordAccept(site.slug, offerId, me);
-    // Addressed to the ward AND every existing guardian (§3.1.1).
-    const recipients = [rel.ward, ...existing];
-    const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
-    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
-    return { status: 202, id: offerId, url: offerId, delivered };
-  }
-
-  // ── Accept / Reject: the local site is a party answering an offer. ─────
-  const offerId = idOf(activity.object);
-  if (!offerId) return { status: 400, error: 'missing_offer' };
-  // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
-  // and gated follows use, which is exactly why the Shaer clients need no
-  // new verbs for it.
-  if (availability.getLapse(offerId)) {
-    const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
-    if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
-    return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
-  }
-  let offer = offers.getOffer(site.slug, offerId);
-  if (!offer) return { status: 404, error: 'no_such_offer' };
-  const others = offers.parties(offer).filter((p) => p !== me);
-
-  if (type === 'Reject') {
-    offers.recordReject(site.slug, offerId, me);
-    await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
-    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
-    return { status: 202, id: offerId, url: offerId };
-  }
-
-  // §1 is flat in BOTH directions. The Offer path above bars a ward from
-  // offering to guard; this is the mirror: an actor that already guards wards
-  // must not become a ward itself. Only the ward's own accept can create that
-  // state, so the candidate and the existing guardians pass through untouched.
-  //
-  // Without it the inconsistency would also be invisible. actorProps() picks
-  // one role with an if/else and would publish shaer:guardians while dropping
-  // shaer:isGuardian, so this account keeps routing its wards' escalations
-  // locally while every remote §4 check reads it as malformed and drops it —
-  // a ward believing it is watched over when it is not, silent on both sides.
-  if (me === offer.ward_uri && relations.listWards(site.slug).length) {
-    return { status: 403, error: 'a_guardian_cannot_be_guarded' };
-  }
-
-  // Accept: record my accept, broadcast it to the other parties, and commit
-  // this copy if the tally is now complete (order-independent, §3.1.3).
-  offers.recordAccept(site.slug, offerId, me);
-  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
-  const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
-  if (refused) {
-    // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
-    // implementation unaware of §4 still handles correctly. Who is told WHY is
-    // not uniform, and deliberately so.
-    const answer = (to, withReason) => ({
-      id: `${me}/answers/${Date.now().toString(36)}`,
-      type: 'Reject', actor: me, to, object: offerId,
-      ...(withReason ? { 'shaer:notATeapot': true } : {}),
-    });
-    const candidate = voided && voided.candidate_uri;
-    // The ward and its existing guardians MUST learn the reason: they are
-    // parties, the condition is public data (§2.1), and a bare void would
-    // leave a ward believing an adoption completed that did not.
-    const family = others.filter((u) => u !== candidate);
-    if (family.length) await fanout(site, family, answer(family, true));
-    // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
-    // refusal that names itself technical also discloses that every human
-    // party already accepted and only the protocol objected — which, where a
-    // guardianship is contested, is not theirs to learn. The kind path for an
-    // merely misconfigured candidate is the check on the Offer, before anyone
-    // has consented to anything.
-    if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
-    return { status: 202, id: offerId, url: offerId, committed: false, refused };
-  }
-  return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
-}
-
-// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
-
-/**
- * Handle an inbound guardianship activity for the local site `site` (the inbox
- * owner). Returns true when consumed.
- */
-export async function handleInbox(site, activity) {
-  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
-  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
-  if (type === 'Undo') return applyInboundUndo(site, activity);
-  const me = deps.selfId(site.slug);
-  const actor = idOf(activity.actor);
-
-  // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
-  // tallies and enforces, so the decision lands here, not on the proposer.
-  if (type === 'Offer') {
-    const gs = gated.parseGatedSetting(activity.object);
-    if (gs) {
-      const offerId = idOf(activity);
-      // ── I am the WARD: record, tally, and forward to the other guardians.
-      if (gs.ward === me) {
-        gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
-        // The proposer's Offer carries its own agreement (§3.1's one-step clause).
-        const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
-        // The forward is the leg that was missing. A proposal addressed to the
-        // ward's server reaches only the proposer and the ward; the other
-        // guardians never learn it exists, so a threshold of two can never be
-        // met and every proposal expires unanswered. The ward's server is the
-        // one that knows the authoritative guardian list, which is exactly why
-        // §5.3 forwards a gated follow from here too.
-        if (r.state === 'open') {
-          for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
-            if (g === actor) continue;   // the proposer already answered
-            // The forward goes out AS THE WARD, because the ward's key signs
-            // it. Keeping the proposer in `actor` made every receiver answer
-            // 401 signer mismatch, and rightly so: the body claimed one author
-            // and the signature proved another. §5.3 forwards a gated follow
-            // the same way. Who proposed it rides along separately, for the
-            // guardian's screen.
-            // Zou DIT antwoord het besluit afmaken (shaer-8vt)? De telling loopt
-            // hier, op de server van het kind, en nergens anders -- zonder dit
-            // veld kan een guardian elders onmogelijk weten dat hij de doorslag
-            // geeft. Een ja/nee en geen getal: zie isDecisive.
-            const p = gated.gatedProgress(site.slug, gs.feature);
-            deps.deliverTo(site, g, {
-              id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
-              'shaer:proposer': actor,
-              'shaer:decisive': gated.isDecisive(p.votes, p.need),
-            }).catch(() => { /* the delivery queue retries */ });
-          }
-        } else {
-          gated.clearGatedReviews(offerId);   // settled at once: nothing left to ask
-          answerGatedProposer(site, offerId, r);
-        }
-        notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
-        return true;
-      }
-      // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
-      //    guardian can answer; the answer goes back to the ward, which tallies.
-      if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
-        const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
-        gated.recordGatedReview(site.slug, {
-          id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
-          // A forward is signed by the ward, so `actor` is the ward; the
-          // guardian who opened it travels in shaer:proposer.
-          proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
-          feature: gs.feature, value: gs.value,
-          // Ontbreekt het veld (een oudere server), dan WAARSCHUWEN we: niets
-          // zeggen terwijl je beslist is de gevaarlijke kant (shaer-8vt).
-          decisive: activity['shaer:decisive'] !== false,
-        });
-        notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
-        return true;
-      }
-      return false;   // not our ward, and not a ward we guard
-    }
-    // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
-    // ward. The ward's server opens, tallies and (after the full window)
-    // executes, exactly as it does for the gated settings above.
-    const lp = availability.parseLapse(activity.object);
-    if (lp) {
-      if (lp.ward !== me) return false;   // not our ward
-      const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
-      const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
-      if (r.error) {
-        notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
-        return true;   // consumed: the refusal is the answer
-      }
-      // The target is notified like any dormancy marking (§3.6.2): in
-      // protocol (a copy of the Offer, so one answer can cancel it) AND the
-      // §6 handle, which for a committed guardian is its inbox — the same
-      // door this delivery knocks on.
-      deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
-      notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
-      return true;
-    }
-    const rel = parseRelationship(activity.object);
-    if (!rel) return false;
-    // I must be a party: the ward, or one of the existing guardians in `to`.
-    const recipients = arr(activity.to);
-    const existing = recipients.filter((u) => u !== rel.ward);
-    if (rel.ward !== me && !existing.includes(me)) return false;
-    // §4.2: check the candidate here too, and refuse before anyone accepts.
-    // At this point no party has consented, so saying why discloses nothing
-    // about anyone's position, and a candidate that is merely misconfigured
-    // can find that out and fix it. The commit-time check stays REQUIRED as
-    // the backstop for a candidate whose state changes in between.
-    if (await candidateFitness(rel.candidate) === 'malformed') {
-      notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
-      await fanout(site, [rel.candidate], {
-        id: `${me}/answers/${Date.now().toString(36)}`,
-        type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
-      });
-      return true;
-    }
-    offers.start(site.slug, {
-      offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
-      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
-    });
-    // The Offer carries the candidate's agreement (see the C2S side): record it
-    // so this copy's tally matches — a free ward then commits on its own accept.
-    offers.recordAccept(site.slug, idOf(activity), rel.candidate);
-    notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
-    return true;
-  }
-
-  // Accept / Reject of an offer we (also) track.
-  const offerId = idOf(activity.object);
-  // §5.6, the answer coming HOME: the ward's server settled a decision we
-  // proposed and answers our Offer. Accept = it settled on what we proposed,
-  // Reject = on the opposite. Only the ward may say so: the answer must come
-  // from the ward the proposal was about, or anyone could close our books.
-  const sent = gated.recallSent(offerId);
-  if (sent && sent.guardian_slug === site.slug) {
-    if (actor !== sent.ward_uri) return false;   // not the ward's voice: not an outcome
-    const outcome = type === 'Accept' ? 'accepted' : 'rejected';
-    gated.settleSent(offerId, outcome);
-    notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
-    return true;
-  }
-  // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
-  // references the offer, so the value comes from the proposal we stored. A
-  // Reject is a vote for the opposite, not a shrug: it is still an answer.
-  const gsOffer = gated.recallGatedOffer(offerId);
-  if (gsOffer && gsOffer.slug === site.slug) {
-    const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
-    const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
-    if (r.state === 'settled') answerGatedProposer(site, offerId, r);
-    // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
-    // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
-    // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
-    // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
-    // dat het moet spreken.
-    //
-    // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
-    // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
-    // antwoord verandert -- anders is dit een bericht per stem per guardian.
-    else herzieDoorslag(site, offerId, gsOffer, actor);
-    notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
-    return true;
-  }
-  // §3.6.3: a set member answering a running lapse. Irreversible, so even a
-  // full tally leaves it open until the window closes (§3.5); the completion
-  // happens lazily on reads (queues) once the window has run.
-  if (availability.getLapse(offerId)) {
-    const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
-    notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
-    return true;
-  }
-  let offer = offers.getOffer(site.slug, offerId);
-  if (!offer) return false;
-  if (!offers.isParty(offer, actor)) return false;
-
-  if (type === 'Reject') {
-    offers.recordReject(site.slug, offerId, actor);
-    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
-    return true;
-  }
-
-  offers.recordAccept(site.slug, offerId, actor);
-  await maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete (§4.2 may refuse)
-  return true;
-}
-
-/**
- * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
- * candidate could not be read.
- *
- * Waiting for a further activity from a party is not enough: the commit is
- * triggered by the LAST `Accept`, so if that one has already arrived nothing
- * will ever poke it again and the handshake would sit until its window closed.
- * The ward's dashboard polling its own offers queue is this instance's
- * schedule, exactly as a read settles a lapse (§3.6.3).
- *
- * Deliberately not awaited by the read: a poll should render what is true now,
- * not block on someone else's slow server. A retry that succeeds shows up in
- * the next poll, which is the same second or two later.
- */
-export async function retryDeferred(slug) {
-  for (const o of offers.listDeferred(slug)) {
-    await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
-  }
-}
-
-function notify(slug, ev) {
-  try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
-}
-
-export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred, existingGuardiansOf };
Index: src/services/guardianship/help.js
===================================================================
--- src/services/guardianship/help.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,159 +1,0 @@
-/**
- * Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
- *
- * Een hulpvraag (FEP-633c 5.2.1) gaat naar ALLE guardians van een kind, die op
- * verschillende servers zitten. Zonder gedeelde staat denken er twee dat de
- * ander het oppakt -- en dat is precies het scenario waar de reddingsboei voor
- * bestaat.
- *
- * DE FAALSTAND IS HIER NIET VEILIG, en dat maakt dit anders dan elke gate. Bij
- * een gate is "dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt
- * dat het geregeld is", en dat is gevaarlijker dan geen markering. Daaruit volgt
- * de regel die overal in dit bestand terugkomt: bij twijfel is een hulpvraag
- * OPEN.
- *
- * Twee besluiten van Bart (7-8) zitten in de vorm:
- *
- *   OPGEPIKT mag stapelen en vervalt niet, maar VEROUDERT zichtbaar. Twee mensen
- *   die tegelijk reageren op een kind is geen probleem; twee die allebei niets
- *   doen omdat de ander het "geclaimd" had, wel. En een signaal dat vanzelf
- *   verdwijnt laat een hulpvraag er onaangeroerd uitzien terwijl er iemand mee
- *   bezig is.
- *
- *   AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
- *   bevestiging, en leeft de vraag daarna nog, dan wordt hij OPNIEUW GESTELD --
- *   een nieuwe hulpvraag. Er wordt niets herschreven, er wordt toegevoegd.
- */
-
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_help_state (note_uri, guardian_uri, kind, guardian_handle)
-                       VALUES (?,?,?,?)`),
-      forNote: db.prepare('SELECT * FROM ap_help_state WHERE note_uri = ? ORDER BY created_at ASC'),
-      forNotes: db.prepare('SELECT * FROM ap_help_state WHERE note_uri IN (SELECT value FROM json_each(?))'),
-    };
-  }
-  return _s;
-}
-
-/** Leg vast dat iemand deze hulpvraag heeft opgepikt of afgesloten. */
-export function record(noteUri, guardianUri, kind, handle = null) {
-  if (!noteUri || !guardianUri) return;
-  const k = kind === 'handled' ? 'handled' : 'pickup';
-  try { stmts().ins.run(noteUri, guardianUri, k, handle); } catch { /* nooit dragend */ }
-}
-
-/**
- * De staat van een hulpvraag, uit zijn rijen. Puur, zodat de regels te toetsen
- * zijn zonder database of scherm.
- *
- * `oldestPickupAt` is het TIJDSTIP van de oudste oppik, niet de leeftijd. Daar
- * tekent het scherm mee dat een signaal oud wordt -- niets verdwijnt, maar je
- * ziet wel dat er misschien niets meer gebeurt.
- *
- * EEN TIJDSTIP, GEEN LEEFTIJD, en dat is geen smaak. Hier stond `ageMs`, een
- * verschil met `now`, en dus veranderde dit antwoord elke milliseconde. Zodra
- * het paneel een ETag kreeg (9-8) kon die daardoor nooit meer gelijk zijn: de
- * 304 kwam nooit, de lange poll keerde meteen terug, en de browser kwam in een
- * lus van ongeveer een seconde waarin de scrollpositie werd vermalen. Een
- * levende klok in een antwoord maakt dat antwoord onvergelijkbaar met zichzelf.
- *
- * De leeftijd is een weergavedetail en wordt in de client uitgerekend.
- */
-export function helpStatus(rows, now = Date.now()) {
-  const list = rows || [];
-  const pickups = list.filter((r) => r.kind === 'pickup');
-  const done = list.find((r) => r.kind === 'handled') || null;
-  const stamp = (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? null : t; };
-  const oudste = pickups.map(stamp).filter((t) => t !== null).sort((a, b) => a - b)[0];
-  return {
-    // Namen erbij: "door wie" was de hele vraag. Zonder dat is het een vinkje.
-    pickedUpBy: pickups.map((r) => ({ uri: r.guardian_uri, handle: r.guardian_handle || null, at: r.created_at })),
-    handled: done ? { uri: done.guardian_uri, handle: done.guardian_handle || null, at: done.created_at } : null,
-    // Alleen betekenisvol zolang er niets is afgesloten.
-    oldestPickupAt: (!done && oudste) ? new Date(oudste).toISOString() : null,
-    // Waar het scherm op afgaat. Bij twijfel OPEN: een lege lijst, een rij die we
-    // niet kunnen lezen, wat dan ook -- alles wat geen expliciete afsluiting is,
-    // is een hulpvraag die nog op iemand wacht.
-    open: !done,
-  };
-}
-
-/**
- * Een hulpvraag van iemand die je NIET MEER bewaakt.
- *
- * Het loslaat-scherm belooft dit al letterlijk: "je krijgt geen hulpvragen meer
- * van ze". Nieuwe komen inderdaad niet meer binnen, maar wat er al lag bleef in
- * de open lijst staan -- en was niet af te sluiten, want de markeerroute eist
- * dat het nog je ward is en antwoordt anders met 403. De knop stond er dus wel
- * en deed niets.
- *
- * Zo'n vraag is niet AFGEHANDELD -- dat zou een claim zijn over een kind waar je
- * niets meer over te zeggen hebt, en die claim wordt ook nog rondgestuurd. Hij is
- * niet meer van jou. Dat is een derde uitkomst en die hoort als zodanig te lezen.
- *
- * Veilig omdat een guardianship nooit bij de LAATSTE guardian eindigt (3.4,
- * emancipatie): er blijft altijd iemand over voor wie de vraag wel open staat.
- */
-export function withWardship(status, stillWard) {
-  if (stillWard) return status;
-  return { ...status, open: false, formerWard: true };
-}
-
-/** De staat van een hulpvraag zoals die nu is opgeslagen. */
-export function statusOf(noteUri, now = Date.now()) {
-  if (!noteUri) return helpStatus([], now);
-  try { return helpStatus(stmts().forNote.all(noteUri), now); } catch { return helpStatus([], now); }
-}
-
-/** Idem voor een hele lijst in een query, zodat een paneel geen N+1 wordt. */
-export function statusFor(noteUris, now = Date.now()) {
-  const uit = new Map();
-  const lijst = [...new Set((noteUris || []).filter(Boolean))];
-  if (!lijst.length) return uit;
-  let rijen = [];
-  try { rijen = stmts().forNotes.all(JSON.stringify(lijst)); } catch { rijen = []; }
-  const perNote = new Map();
-  for (const r of rijen) {
-    if (!perNote.has(r.note_uri)) perNote.set(r.note_uri, []);
-    perNote.get(r.note_uri).push(r);
-  }
-  for (const uri of lijst) uit.set(uri, helpStatus(perNote.get(uri) || [], now));
-  return uit;
-}
-
-/**
- * De markering als bericht. Bewust een gewone directe note met een
- * shaer:-markering, zoals de zwaai en de afwezigheidsmelding: dan reist het over
- * de bestaande bezorging, en de WARD leest het als wat het is -- er komt iemand.
- */
-export function markerNote({ id, me, noteUri, kind, to }) {
-  const k = kind === 'handled' ? 'handled' : 'pickup';
-  return {
-    id, type: 'Note', attributedTo: me, to,
-    // Ook als antwoord, in gewoon AS2 (26-8): de markering gaat ergens over,
-    // en inReplyTo is hoe je dat zegt zonder dialect. Zie deliverDirectNote,
-    // die dit voor de echte bezorging net zo doet.
-    inReplyTo: noteUri,
-    [k === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup']: noteUri,
-    content: k === 'handled'
-      ? '<p>Deze hulpvraag is afgehandeld.</p>'
-      : '<p>Ik kijk hiernaar.</p>',
-  };
-}
-
-/** Leest een binnengekomen note als markering, of null als hij er geen is. */
-export function parseMarker(object) {
-  if (!object || typeof object !== 'object') return null;
-  const pickup = object['shaer:helpPickup'];
-  const handled = object['shaer:helpHandled'];
-  if (typeof handled === 'string' && handled) return { kind: 'handled', noteUri: handled };
-  if (typeof pickup === 'string' && pickup) return { kind: 'pickup', noteUri: pickup };
-  return null;
-}
-
-export default { record, helpStatus, withWardship, statusOf, statusFor, markerNote, parseMarker };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,37 +1,0 @@
-/**
- * Guardianship (FEP-633c "Guardians") — the module.
- *
- * Klonkt's kid-safety feature as one cohesive unit:
- *  - context.js:   the shaer JSON-LD namespace + Relationship vocabulary
- *  - offers.js:    the multi-party handshake state (a port of the Shaer daemon)
- *  - relations.js: the COMMITTED ward ↔ guardian relations + actor props
- *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
- *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
- *  - notes.js:     the shaer:helpRequest flag on direct notes
- *  - delivery.js:  the direct-note leg a ward's call-for-help rides
- *
- * The shared blocklist (Shaer's "in Orbit") lives NEXT TO this module in
- * BlocklistService. ActivityPubService wires the AP helpers in once and
- * delegates; nothing here imports ActivityPubService back.
- */
-export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
-export { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed, externalPlaybackAllowed, wardGateAllowed } from './notes.js';
-export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
-export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship, existingGuardiansOf } from './handshake.js';
-export { offersCollection, followsCollection, outgoingFollowsCollection, logCollection, wardsCollection, guardiansCollection, helpCollection } from './queues.js';
-export * as availability from './availability.js';
-export { wireAvailability } from './availability.js';
-export * as follows from './follows.js';
-export * as outgoing from './outgoing.js';
-export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
-export {
-  listGuardians, listWards, isGuardian, getRelation, removeRelation,
-  actorProps as guardianshipActorProps,
-} from './relations.js';
-
-// §5.6 gated settings (decided by the guardians, enforced by the ward's server)
-export * as gated from './gated.js';
-export * as queues from './queues.js';
-// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
-export * as help from './help.js';
-export * as gatereq from './gatereq.js';
Index: src/services/guardianship/notes.js
===================================================================
--- src/services/guardianship/notes.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,102 +1,0 @@
-/**
- * Guardianship (FEP-633c) — note properties.
- *
- * The shaer:helpRequest flag (spec 5.2.1): a ward's call for help, only ever
- * on direct notes. Everyone who does not speak shaer can ignore it.
- */
-import { listGuardians } from './relations.js';
-
-/**
- * shaer:hasGuardians (§2.2): an advisory OBJECT hint that the author is a ward,
- * so a remote server can route interactions to the guardians WITHOUT fetching
- * the actor. Stamped on every object a ward publishes; MUST be safely ignorable.
- */
-export function hasGuardiansProps(slug) {
-  try { return (slug && listGuardians(slug).length) ? { 'shaer:hasGuardians': true } : {}; }
-  catch { return {}; }
-}
-
-/**
- * May EXTERNAL (non-fediverse) embeds be shown to this account?
- *
- * A gated feature in the FEP-633c sense: a ward's world outside the fediverse
- * is the guardians' call. `setting` is `sites.external_embeds`:
- *   null/undefined → auto: off for a ward, on for anyone else
- *   0 → off, 1 → on (the guardians decided)
- *
- * Pure, so the rule is testable on its own. The gate is applied SERVER-side:
- * a blocked embed is never serialised into the feed, because an embed that the
- * client merely hides has still been delivered.
- */
-export function externalEmbedsAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/**
- * May a player run INSIDE the app/page for this account? The heavier sibling
- * of the setting above, and deliberately separate: seeing that a video exists
- * is not the same decision as handing the screen to a third party's player,
- * with its engine, its end-screen and its next-video machine. Same shape, same
- * default (off for a ward), and it only ever matters once embeds are allowed:
- * you cannot play what you may not see.
- */
-export function externalPlaybackAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/**
- * Dezelfde regel voor de hele gate-familie (8-8, "maak ze allemaal
- * functioneel"): een expliciete 0/1 van de guardians wint, anders de
- * automatiek -- dicht voor een ward, open voor de rest. EEN implementatie,
- * zodat er geen tweede plek is die er anders over kan gaan denken; de twee
- * benoemde varianten hierboven blijven bestaan omdat er tests en aanroepen
- * aan hangen, en doen exact hetzelfde.
- */
-export function wardGateAllowed(setting, isWard) {
-  if (setting === 0 || setting === 1) return setting === 1;
-  return !isWard;
-}
-
-/** True when an incoming object carries the ward hint (§2.2). Register-only for
- *  now; acted on later at reddings-boei / escalation routing. */
-export function objectHasGuardians(o) {
-  return !!o && (o['shaer:hasGuardians'] === true || o.hasGuardians === true);
-}
-
-/** Extra JSON-LD properties for an outgoing note built from an ap_outbox row. */
-export function helpRequestProps(post) {
-  return (post && post.visibility === 'direct' && post.help_request)
-    ? { 'shaer:helpRequest': true }
-    : {};
-}
-
-/** True when an incoming (C2S or S2S) note object carries the flag. */
-export function isHelpRequest(object) {
-  return !!object && (object['shaer:helpRequest'] === true || object.helpRequest === true);
-}
-
-/** shaer:wave: a gentle "thinking of you" from a guardian to its ward. A
- *  private nudge, never a feed post; non-shaer clients see a plain DM. */
-export function waveProps(post) {
-  return (post && post.visibility === 'direct' && post.wave)
-    ? { 'shaer:wave': true }
-    : {};
-}
-
-/** True when an incoming note is a wave. */
-export function isWave(object) {
-  return !!object && (object['shaer:wave'] === true || object.wave === true);
-}
-
-/** shaer:away (3.6.1): a guardian declaring itself away to its ward, with an
- *  end. Rides a direct note like the help request, so a ward on a plain
- *  server reads a human message; endTime is plain AS2. */
-export function awayProps(post) {
-  return (post && post.visibility === 'direct' && post.away_until)
-    ? { 'shaer:away': true, endTime: new Date(post.away_until).toISOString() }
-    : {};
-}
-
-export default { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed, externalPlaybackAllowed };
Index: src/services/guardianship/offers.js
===================================================================
--- src/services/guardianship/offers.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,163 +1,0 @@
-/**
- * Guardianship (FEP-633c §3) — the multi-party handshake state.
- *
- * A faithful port of the Shaer test daemon's `Handshake`, persisted per local
- * site (so the two implementations behave identically and the clients speak
- * one contract). One row in ap_guardian_offers per offer this instance is a
- * party to; the accepts accumulate in ap_guardian_offer_accepts.
- *
- * The offer commits only when the guardian-candidate returns the handle
- * (§3.1.3) after ward + candidate + at least one existing guardian have
- * accepted (§3.1.2). A single Reject from any party voids it (§3.2). This is
- * the core safety property: no single party creates a guardianship alone, and
- * no new guardian is added without an existing guardian's consent.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      insOffer: db.prepare(`INSERT OR IGNORE INTO ap_guardian_offers
-        (offer_id, slug, ward_uri, candidate_uri, existing_guardians, status, ward_handle, candidate_handle, created_at)
-        VALUES (?,?,?,?,?, 'pending', ?, ?, CURRENT_TIMESTAMP)`),
-      getOffer: db.prepare('SELECT * FROM ap_guardian_offers WHERE slug=? AND offer_id=?'),
-      offerAnywhere: db.prepare('SELECT * FROM ap_guardian_offers WHERE offer_id=? LIMIT 1'),
-      setStatus: db.prepare('UPDATE ap_guardian_offers SET status=?, handle=COALESCE(?, handle) WHERE slug=? AND offer_id=?'),
-      listBySlug: db.prepare("SELECT * FROM ap_guardian_offers WHERE slug=? AND status='pending' ORDER BY created_at DESC"),
-      insAccept: db.prepare('INSERT OR IGNORE INTO ap_guardian_offer_accepts (offer_id, slug, party_uri, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      accepts: db.prepare('SELECT party_uri FROM ap_guardian_offer_accepts WHERE slug=? AND offer_id=?'),
-    };
-  }
-  return _s;
-}
-
-const parties = (o) => [o.ward_uri, o.candidate_uri, ...JSON.parse(o.existing_guardians || '[]')];
-const isParty = (o, actor) => !!actor && parties(o).includes(actor);
-const acceptsOf = (o) => stmts().accepts.all(o.slug, o.offer_id).map((r) => r.party_uri);
-
-/** ward + candidate + (no existing guardians OR at least one existing) accepted. */
-export function readyToCommit(o) {
-  if (!o || o.status !== 'pending') return false;
-  const acc = new Set(acceptsOf(o));
-  const existing = JSON.parse(o.existing_guardians || '[]');
-  const existingOk = existing.length === 0 || existing.some((g) => acc.has(g));
-  return acc.has(o.ward_uri) && acc.has(o.candidate_uri) && existingOk;
-}
-
-/** Start tracking an offer on `slug` (idempotent). */
-export function start(slug, { offerId, ward, candidate, existingGuardians = [], wardHandle = null, candidateHandle = null }) {
-  stmts().insOffer.run(offerId, slug, ward, candidate, JSON.stringify(existingGuardians || []), wardHandle, candidateHandle);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-export function getOffer(slug, offerId) { return stmts().getOffer.get(slug, offerId); }
-export function findOfferAnywhere(offerId) { return stmts().offerAnywhere.get(offerId); }
-
-/** Record an Accept from one party; ignored if not a party or already resolved. */
-export function recordAccept(slug, offerId, party) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
-  stmts().insAccept.run(offerId, slug, party);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/** A single Reject from any party voids the handshake (§3.2). */
-export function recordReject(slug, offerId, party) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !isParty(o, party)) return o;
-  stmts().setStatus.run('void', null, slug, offerId);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/** Commit (only when ready): store the returned handle, mark committed. */
-export function commit(slug, offerId, handle) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending' || !readyToCommit(o)) return null;
-  stmts().setStatus.run('committed', handle || null, slug, offerId);
-  return stmts().getOffer.get(slug, offerId);
-}
-
-/**
- * How long a guardianship handshake stays open (§3.5). Adding a guardian is a
- * reversible decision, but not a quick one: the ward, the candidate and every
- * existing guardian have to answer, and they are people, sometimes on holiday.
- * A week is long enough that nobody is rushed and short enough that a forgotten
- * offer does not sit in a child's queue for a month looking like a live choice.
- */
-export const OFFER_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
-
-/** SQLite writes CURRENT_TIMESTAMP as UTC 'YYYY-MM-DD HH:MM:SS', which
- *  Date.parse reads as LOCAL time — hours out, and enough to expire an offer
- *  early or late. Same correction as ActivityPubService.isoStamp. */
-const stampMs = (v) => {
-  const s = String(v || '');
-  return Date.parse(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
-};
-
-export const closesAt = (o) => stampMs(o.created_at) + OFFER_WINDOW_MS;
-
-/**
- * §3.5 fails closed: once the window has run, a handshake that never completed
- * is over. WHICH failure it was matters (§4.2), so the two get different
- * terminal states and neither of them is `void`:
- *
- *   'expired'     — the parties never all answered. Nothing to say about anyone.
- *   'unverified'  — everyone answered; the candidate could never be read, so
- *                   the check never got to run. The parties MUST be told this
- *                   and MUST NOT be told the candidate was refused. It was not:
- *                   nobody ever managed to look.
- */
-export function expireIfDue(slug, offerId, now = Date.now()) {
-  const o = stmts().getOffer.get(slug, offerId);
-  if (!o || o.status !== 'pending') return null;
-  const due = closesAt(o);
-  if (!Number.isFinite(due) || due > now) return null;
-  const status = readyToCommit(o) ? 'unverified' : 'expired';
-  stmts().setStatus.run(status, null, slug, offerId);
-  return { ...o, status };
-}
-
-/** Pending offers where `me` is a party — the offers queue (daemon shape).
- *  Reads are where lazy completion happens, as with the lapses (§3.6.3): a
- *  closed window is settled here rather than by a sweeper nobody runs. */
-export function listForParty(slug, me, now = Date.now()) {
-  if (!stmts().listBySlug.get) return [];
-  for (const o of stmts().listBySlug.all(slug)) expireIfDue(slug, o.offer_id, now);
-  return stmts().listBySlug.all(slug).filter((o) => isParty(o, me));
-}
-
-/** Handshakes whose tally is complete but which are not committed: the §4.2
- *  deferred set, waiting on a candidate nobody could dereference. */
-export function listDeferred(slug) {
-  if (!stmts().listBySlug.get) return [];
-  return stmts().listBySlug.all(slug).filter((o) => readyToCommit(o));
-}
-
-/** One offer as the offers-queue item the Shaer clients parse. */
-export function queueItem(o, me) {
-  const acc = acceptsOf(o).sort();
-  return {
-    id: o.offer_id,
-    type: 'Offer',
-    actor: o.candidate_uri,
-    object: { type: 'Relationship', subject: o.ward_uri, relationship: 'shaer:Guardian', object: o.candidate_uri },
-    'shaer:ward': o.ward_uri,
-    'shaer:candidate': o.candidate_uri,
-    'shaer:existingGuardians': JSON.parse(o.existing_guardians || '[]'),
-    'shaer:acceptedBy': acc,
-    'shaer:needsMyAccept': !acc.includes(me),
-    'shaer:readyToCommit': readyToCommit(o),
-    'shaer:iAmCandidate': me === o.candidate_uri,
-    'shaer:wardHandle': o.ward_handle || undefined,
-    'shaer:candidateHandle': o.candidate_handle || undefined,
-    published: o.created_at,
-  };
-}
-
-export { parties, isParty, acceptsOf };
-export default {
-  start, getOffer, findOfferAnywhere, recordAccept, recordReject, commit,
-  readyToCommit, listForParty, queueItem, parties, isParty, acceptsOf,
-  OFFER_WINDOW_MS, closesAt, expireIfDue, listDeferred,
-};
Index: src/services/guardianship/outgoing.js
===================================================================
--- src/services/guardianship/outgoing.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,126 +1,0 @@
-/**
- * Guardianship (FEP-633c §5.3, the other direction) — gating a ward's OWN
- * follows. Bead shaer-p729; the design is in docs/ward-outbound-follows-design.md,
- * and the spec question it answers is shaer-yeo5.
- *
- * The inbound gate in `follows.js` decides who may follow a ward. This one
- * decides who a ward may follow. Until now that went out unchecked: the
- * guardians got a note afterwards (1a2f206), which is informing, not gating —
- * the door is already open by the time the message arrives.
- *
- * The rule (Barts besluit): every outgoing follow waits for a guardian, EXCEPT
- * where the target already follows the ward through the gate. A guardian
- * already said yes to that person; asking the same question twice only teaches
- * people to stop reading the question.
- */
-import db from '../../config/database.js';
-import { followThreshold } from './follows.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      ins: db.prepare(`INSERT OR IGNORE INTO ap_pending_outgoing_follows
-        (id, ward_slug, target_uri, target_inbox, target_name, target_handle, target_icon, quorum, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
-      get: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE id = ?'),
-      byTarget: db.prepare('SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
-      byWard: db.prepare("SELECT * FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
-      approve: db.prepare('INSERT OR IGNORE INTO ap_outgoing_follow_approvals (follow_id, guardian_uri, decision, created_at) VALUES (?,?,?,CURRENT_TIMESTAMP)'),
-      answers: db.prepare('SELECT guardian_uri, decision FROM ap_outgoing_follow_approvals WHERE follow_id = ?'),
-      setStatus: db.prepare('UPDATE ap_pending_outgoing_follows SET status = ? WHERE id = ?'),
-      del: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE id = ?'),
-      delByTarget: db.prepare('DELETE FROM ap_pending_outgoing_follows WHERE ward_slug = ? AND target_uri = ?'),
-      gateApproved: db.prepare('SELECT 1 FROM ap_followers WHERE slug = ? AND actor_uri = ? AND gate_approved = 1'),
-    };
-  }
-  return _s;
-}
-
-/**
- * Does this target already follow the ward, with a guardian's blessing?
- *
- * Only a gate-approved follower counts. A follower a free actor picked up
- * before it was ever a ward was never seen by a guardian, so following them
- * back is a new question, not a settled one. (Rows that predate the marker are
- * grandfathered at migration; see config/database.js.)
- */
-export function isMutual(wardSlug, targetUri) {
-  return !!stmts().gateApproved.get(wardSlug, targetUri);
-}
-
-/** Record an outgoing follow awaiting guardian approval. */
-export function recordPending(wardSlug, f) {
-  stmts().ins.run(
-    f.id, wardSlug, f.target, f.inbox || null,
-    f.name || null, f.handle || null, f.icon || null, f.quorum || 'any',
-  );
-  return stmts().byTarget.get(wardSlug, f.target);
-}
-
-export function getPending(id) { return stmts().get.get(id); }
-export function findFor(wardSlug, targetUri) { return stmts().byTarget.get(wardSlug, targetUri); }
-
-/** Outgoing follows this ward is waiting on — the guardian's queue. */
-export function listForWard(wardSlug) { return stmts().byWard.all(wardSlug); }
-
-/**
- * A guardian's answer. Same shape and the same quorum arithmetic as the
- * inbound gate, so the two directions cannot drift apart in how they count:
- * a single reject denies outright, approvals accumulate toward the quorum.
- */
-export function decide(id, guardianUri, decision, guardiansOfWard) {
-  const follow = stmts().get.get(id);
-  if (!follow || follow.status !== 'pending') return { outcome: 'gone', follow };
-  stmts().approve.run(id, guardianUri, decision === 'reject' ? 'reject' : 'approve');
-  const rows = stmts().answers.all(id);
-  if (rows.some((r) => r.decision === 'reject')) {
-    stmts().setStatus.run('denied', id);
-    return { outcome: 'rejected', follow };
-  }
-  const approvers = new Set(rows.filter((r) => r.decision === 'approve').map((r) => r.guardian_uri));
-  const guardians = (guardiansOfWard || []).filter(Boolean);
-  // Dezelfde eenvoudige meerderheid als bij een inkomend volgverzoek
-  // (followThreshold): het is dezelfde vraag, alleen omgedraaid. Twee
-  // verschillende drempels voor "mag dit kind met deze persoon te maken hebben"
-  // zou een guardian nooit kunnen uitleggen.
-  const enough = approvers.size >= followThreshold(guardians.length);
-  if (enough) {
-    stmts().setStatus.run('approved', id);
-    return { outcome: 'approved', follow };
-  }
-  return { outcome: 'waiting', follow };
-}
-
-/** The ward changed its mind, or blocked the target: the request is gone. */
-export function withdraw(wardSlug, targetUri) { stmts().delByTarget.run(wardSlug, targetUri); }
-export function remove(id) { stmts().del.run(id); }
-
-/** One request as the queue item the Shaer clients parse, mirroring the
- *  inbound gated-follow item so a dashboard can render both side by side. */
-export function queueItem(o, me) {
-  const rows = stmts().answers.all(o.id);
-  return {
-    id: o.id,
-    type: 'Follow',
-    // De ward is de ACTOR van zijn eigen Follow, en dat hoort een actor-URI te
-    // zijn: hier stond de slug ('mee'), en elke lezer vergelijkt dit veld met
-    // actor-URI's. Een ward zag zijn eigen verzoeken daardoor nooit als de
-    // zijne -- ze vielen in de bak "hoort niet bij een ward die je hebt", met
-    // knoppen erbij die hij niet mag gebruiken. `listForWard(slug)` levert per
-    // definitie de verzoeken van de LEZER, dus dat is `me`.
-    actor: me,
-    object: o.target_uri,
-    'shaer:direction': 'outgoing',
-    'shaer:target': o.target_uri,
-    'shaer:targetHandle': o.target_handle || undefined,
-    'shaer:quorum': o.quorum || 'any',
-    'shaer:approvals': rows.filter((r) => r.decision === 'approve').length,
-    'shaer:myVote': rows.some((r) => r.guardian_uri === me),
-    published: o.created_at,
-  };
-}
-
-export default {
-  isMutual, recordPending, getPending, findFor, listForWard, decide, withdraw, remove, queueItem,
-};
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,328 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the owner-only dashboard queues.
- *
- * Three OrderedCollections on the actor (shaer:queues), same contract as the
- * Shaer test daemon so the iOS/Android dashboards read them as-is:
- *  - offers:  pending handshake offers where I am a party (§3), with the full
- *             accept tally so the client shows the right action
- *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
- *  - wards:   my committed wards
- */
-import * as offers from './offers.js';
-import { pagedCollection } from '../ap-core.js';
-import * as relations from './relations.js';
-import * as availability from './availability.js';
-import * as outgoing from './outgoing.js';
-import * as follows from './follows.js';
-import * as gated from './gated.js';
-import * as gatereq from './gatereq.js';
-import * as help from './help.js';
-import db from '../../config/database.js';
-import * as handshake from './handshake.js';
-
-// De @context zet de route erop (queueRoute), dus hier bewust niet
-// pagedCollection uit ap-core -- die voegt hem toe en dan staat hij er twee
-// keer. Wel dezelfde paginavelden, om dezelfde reden: een lezer die de
-// paginaweg volgt hoort niet dood te lopen (Funkwhale, 11-8).
-// Dezelfde bouwer als de rest (shaer-sk4). Hier stond een eigen kopie die
-// `first` en `last` allebei op ?page=1 zette en nooit sneed -- de vorm die
-// Robin op 13-8 aanwees, in het tweede exemplaar. Een tweede spelling van
-// dezelfde zaak loopt vanzelf uit elkaar; nu is er een.
-const collection = (id, items) => {
-  // ZONDER @context: de route zet hem erop, en twee keer maakt het document
-  // ongeldig. Vastgelegd in ap-outbox-paging.test.js, en die test ving dit ook
-  // meteen toen ik hem hier vergat.
-  const { '@context': _weg, ...rest } = pagedCollection(id, items);
-  return rest;
-};
-
-/** Pending offers where the local site is a party, each with its accept
- *  tally. The same collection carries the running lapses (§3.6.3) this
- *  account is a party to, exactly as the daemon serves them, so the Shaer
- *  clients render both without a second fetch. */
-export function offersCollection(id, slug, me) {
-  // §4.2: a handshake whose candidate could not be dereferenced is deferred,
-  // not decided, and the last Accept may already have landed — so nothing else
-  // would ever retry it. This poll is the schedule. Not awaited: the read
-  // answers with what is true now, and a retry that succeeds surfaces in the
-  // next one. `listForParty` settles closed windows on the way past.
-  handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
-  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
-  items.push(...availability.lapseQueueItems(slug, me, Date.now()));
-  return collection(id, items);
-}
-
-/**
- * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
- * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
- * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
- *
- * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
- * op deze:
- *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
- *   - ap_pending_follows: een ward op deze instance
- * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
- */
-export function followsCollection(id, slug, me) {
-  const items = follows.listReviewsByDirection(slug, 'incoming')
-    .map((r) => follows.reviewQueueItem(r, me));
-  for (const w of relations.listWards(slug)) {
-    const wardSlug = slugOf(w.other_uri);
-    if (!wardSlug) continue;
-    for (const p of follows.listForWard(wardSlug)) {
-      items.push({
-        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
-        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
-        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
-        'shaer:quorum': p.quorum || 'any', published: p.created_at,
-      });
-    }
-  }
-  return collection(id, items);
-}
-
-/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
-function slugOf(uri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
-  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
-}
-
-/**
- * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
- * guardian wederzijds uitsluit: je bent het een of het ander.
- *
- *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
- *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
- *
- * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
- * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
- * follow" kon nooit iets tonen.
- */
-export function outgoingFollowsCollection(id, slug, me) {
-  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
-  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
-    items.push(follows.reviewQueueItem(r, me));
-  }
-  return collection(id, items);
-}
-
-/** The guardian's committed wards, with cached handle for display. */
-export function wardsCollection(id, slug) {
-  const items = relations.listWards(slug)
-    .map((r) => ({
-      id: r.other_uri,
-      'shaer:handle': r.other_handle || undefined,
-      since: r.created_at,
-      // Alles wat voor dit kind gated is, met soort, drempel en lopend voorstel
-      // (shaer-ahy.1). Zonder dit kon een app wel een ward TONEN maar niets over
-      // hem zeggen -- en dat is precies de helft van het antwoord op "wat mag
-      // dit kind". Dezelfde rijen als het PWA-paneel, uit dezelfde functie.
-      'shaer:gates': wardGates(slug, r.other_uri),
-    }));
-  return collection(id, items);
-}
-
-/** The ward's guardians with their availability (§3.6.1: never public,
- *  owner-only): the real size of the safety net. Same shape as the daemon. */
-export function guardiansCollection(id, slug) {
-  const uris = relations.listGuardians(slug).map((r) => r.other_uri);
-  return collection(id, availability.statusesFor(slug, uris, Date.now()));
-}
-
-/**
- * Het logboek (§4.2): wat er is besloten, en waarom.
- *
- * Geen wachtrij, en daarom een eigen sleutel op de actor. Elk item draagt zijn
- * soort en, als die er was, de REDEN -- want zonder die reden merkte een ward
- * een weigering alleen doordat er iets uit een lijst verdween.
- *
- * `type` is geen AS2-werkwoord: de meeste soorten zijn er geen. Een lapse-stem
- * of een opgepakte hulpvraag is geen Accept, en het zo noemen zou netter lezen
- * dan het is.
- *
- * De lezer levert `listEvents` aan; deze module kent ActivityPubService niet en
- * houdt dat zo (zie de kop van delivery.js).
- */
-export function logCollection(id, slug, listEvents) {
-  const items = (typeof listEvents === 'function' ? listEvents(slug) : []).map((e) => {
-    const { id: n, kind, created, ...rest } = e;
-    const item = { id: `${id}/${n}`, type: 'shaer:Event', 'shaer:kind': kind, published: created };
-    for (const [k, v] of Object.entries(rest)) {
-      if (v === undefined || v === null) continue;
-      item[k.startsWith('shaer:') ? k : `shaer:${k}`] = v;
-    }
-    return item;
-  });
-  return collection(id, items);
-}
-
-export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection, helpCollection, helpItemsFor, wardGates, wardGuardianStatuses, logCollection };
-
-// ── Wat er voor een ward gated is (shaer-ahy.1) ─────────────────────────
-//
-// STOND IN routes/guardian.js, en daar kon alleen de PWA erbij. De Shaer-apps
-// lezen dezelfde toestand via de wards-queue, en een tweede berekening naast
-// deze zou vroeg of laat een ander antwoord geven op dezelfde vraag -- dat is
-// hier geen schoonheidsfoutje maar twee guardians die een verschillend beeld
-// van hetzelfde kind krijgen. Een plek dus, en beide schermen lezen eruit.
-/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
- *  spirit; the co-guardians are among the owners of the relationship). Null
- *  for a remote ward: its server tracks availability, not us. */
-export function wardGuardianStatuses(wardUri) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
-  const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
-  try {
-    const uris = relations.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
-    const st = Object.fromEntries(
-      availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
-    );
-    return uris.map((u) => ({
-      uri: u.uri,
-      handle: u.handle,
-      availability: (st[u.uri] || {})['shaer:availability'] || 'active',
-      awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
-      lapse: (st[u.uri] || {})['shaer:lapse'] || null,
-    }));
-  } catch { return null; }
-}
-/**
- * De gate-rijen van een ward voor het paneel.
- *
- * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
- * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
- * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
- * getal wordt er geen drempel verzonnen.
- */
-export function wardGates(mySlug, wardUri) {
-  const statuses = wardGuardianStatuses(wardUri);
-  // Per richting geteld, want het zijn twee zorgen. "follows: 3 wachtend" liet
-  // een guardian niet zien of er drie vreemden bij zijn kind willen of dat zijn
-  // kind drie keer heeft gevraagd of het iemand mag volgen (shaer-p729).
-  const wachtendIn = follows.listReviewsByDirection(mySlug, 'incoming')
-    .filter((r) => r.ward_uri === wardUri).length;
-  const wachtendUit = follows.listReviewsByDirection(mySlug, 'outgoing')
-    .filter((r) => r.ward_uri === wardUri).length;
-  return gated.gateRows({
-    // Uit de BESLUITEN, niet uit onze eigen kolom. Er zijn geen lokale accounts:
-    // elke ward woont elders, dus wardEmbedSetting() gaf voor iedere ward null en
-    // stond er in het paneel overal "onbekend". Wat een guardian wel heeft is de
-    // uitslag van wat hij voorstelde.
-    settings: Object.fromEntries(gated.GATE_CATALOGUE
-      .filter((g) => g.available !== false && gated.featureColumn(g.feature))
-      .map((g) => [g.feature, gated.knownSetting(mySlug, wardUri, g.feature)])),
-    guardianCount: statuses ? statuses.length : null,
-    proposals: gated.listSent(mySlug, wardUri).map((p) => ({
-      feature: p.feature, value: !!p.value, status: gated.sentStatus(p, Date.now()),
-    })),
-    waiting: {
-      'shaer:follows': wachtendIn || undefined,
-      'shaer:following': wachtendUit || undefined,
-    },
-    // De vraag van het kind zelf staat APART van wat er in een wachtrij staat
-    // (shaer-8ru). Allebei "n waiting" noemen maakt van twee verschillende
-    // dingen een getal: drie onbekenden die je kind willen volgen is iets heel
-    // anders dan je kind dat een keer vraagt of muziek aan mag. Wel bij de poort
-    // waar het over gaat, want een aparte lijst vergeet je.
-    requested: gatereq.waitingFor(mySlug, wardUri),
-  });
-}
-
-
-// ── Hulpvragen met hun staat (shaer-lgo, shaer-ahy.1) ───────────────────
-//
-// De PWA had dit al; de apps kregen alleen de losse notes uit de feed en wisten
-// dus NIET of er al iemand op af was. Daarom bleef een afgehandeld verzoek daar
-// gewoon staan -- Barts melding. De staat wordt hier een keer berekend, zoals bij
-// wardGates: twee berekeningen zouden twee guardians een ander beeld geven van
-// hetzelfde kind.
-
-/**
- * De hulpvragen van deze guardian, met wie erop af is en of het dicht is.
- *
- * OPEN VRAGEN WORDEN NOOIT AFGEKAPT, en dat is geen ruimhartigheid maar de reden
- * dat de app iets mag CONCLUDEREN uit afwezigheid (Barts punt, 8-8).
- *
- * Dit stond op 50, en ik noemde 'tientallen hulpvragen bij een guardian' een
- * randgeval. Bart wees op de jeugdzorgmedewerker: die heeft geen handvol wards
- * maar een caseload, en voor hem is dat een gewone dinsdag. De gebruiker die dit
- * het hardst nodig heeft was precies degene voor wie het brak.
- *
- * Met een afkap op alles zag een app een oudere vraag niet in de queue, vond geen
- * staat, en toonde hem -- terecht, want bij twijfel OPEN -- als openstaand. Een
- * allang afgehandelde hulpvraag die weer om aandacht vraagt. Nu geldt: staat hij
- * niet in de queue, dan is hij NIET open. Die gevolgtrekking klopt alleen zolang
- * we open vragen volledig leveren.
- *
- * De geschiedenis mag wel afgekapt: die vraagt niets, en wat eraf valt is nog
- * steeds op de server te vinden.
- */
-export function helpItemsFor(slug, historyLimit = 50) {
-  let rijen = [];
-  try {
-    // Alle velden die een kaart kan tonen, niet alleen die van de queue: de
-    // PWA had hierom een EIGEN kopie van deze query -- mét een afkap op 50,
-    // waardoor de fix hierboven aan het paneel voorbijging (Barts 429-jacht,
-    // 9-8). Een tweede weg naar dezelfde staat is precies wat er bij de
-    // reply-gate al misging; nu is dit de enige weg, en dan hoort hij ook te
-    // dragen wat een kaart nodig heeft. De extra kolommen kosten de queue
-    // niets: die leest ze gewoon niet.
-    rijen = db.prepare(
-      `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
-              emoji_json, actor_emoji_json, media_json, quote_json, embed_json
-       FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC`,
-    ).all(slug);
-  } catch { return []; }
-  const staat = help.statusFor(rijen.map((r) => r.object_uri));
-  const mijn = new Set(relations.listWards(slug).map((w) => w.other_uri));
-  const alles = rijen.map((r) => ({
-    ...r,
-    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
-    // niet is, is de gevaarlijke fout -- niet andersom.
-    state: help.withWardship(
-      staat.get(r.object_uri) || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null },
-      mijn.has(r.actor_uri),
-    ),
-  }));
-  const open = alles.filter((h) => h.state.open);
-  const rest = alles.filter((h) => !h.state.open).slice(0, historyLimit);
-  return [...open, ...rest];
-}
-
-/** Dezelfde vragen als collectie voor de apps (5.2.1). */
-export function helpCollection(id, slug) {
-  const items = helpItemsFor(slug).map((h) => ({
-    id: h.object_uri,
-    type: 'Note',
-    attributedTo: h.actor_uri,
-    'shaer:handle': h.actor_handle || undefined,
-    content: h.content || '',
-    published: h.published || h.created_at,
-    'shaer:helpRequest': true,
-    // De staat als platte velden: een app hoeft hem niet af te leiden, en kan
-    // hem dus ook niet anders afleiden dan het paneel.
-    'shaer:open': h.state.open,
-    'shaer:handledBy': h.state.handled ? (h.state.handled.handle || h.state.handled.uri) : undefined,
-    'shaer:handledAt': h.state.handled ? h.state.handled.at : undefined,
-    'shaer:pickedUpBy': h.state.pickedUpBy.map((p) => p.handle || p.uri),
-    // HOE OUD het oudste oppakken is (shaer-lgo). Barts besluit was dat
-    // "opgepikt" niet vervalt maar zichtbaar VEROUDERT -- het verschil tussen
-    // "er is iemand mee bezig" en "er was ooit iemand mee bezig". Het paneel
-    // toonde dat al; de apps konden het niet, want dit veld bleef hier liggen.
-    // Een oppak van vijf minuten zag er daar uit als een van vijf dagen, en de
-    // faalstand is hier nou juist "iedereen denkt dat het geregeld is".
-    //
-    // Als TIJDSTIP en niet als leeftijd: een leeftijd maakt elk antwoord anders
-    // en dan kan de ETag nooit gelijk zijn. De client rekent zelf terug, precies
-    // zoals guardian.js het doet.
-    'shaer:oldestPickupAt': h.state.oldestPickupAt || undefined,
-    'shaer:formerWard': h.state.formerWard || undefined,
-  }));
-  const coll = collection(id, items);
-  // Het teken dat de app mag concluderen uit afwezigheid: elke OPEN vraag zit
-  // hierin. Ontbreekt deze vlag (een oudere server), dan valt de app terug op
-  // bij-twijfel-open, en dat is de veilige kant.
-  coll['shaer:openComplete'] = true;
-  return coll;
-}
Index: src/services/guardianship/relations.js
===================================================================
--- src/services/guardianship/relations.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,103 +1,0 @@
-/**
- * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations
- * (ap_guardianships). Pending offers live in offers.js; a row here means the
- * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL
- * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri
- * guards the site.
- */
-import db from '../../config/database.js';
-
-let _s = null;
-function stmts() {
-  if (!_s) {
-    _s = {
-      commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
-                          VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP)
-                          ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`),
-      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
-      bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' ORDER BY created_at DESC"),
-      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
-    };
-  }
-  return _s;
-}
-
-// ── Reads ────────────────────────────────────────────────────────────────
-
-/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
-export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); }
-
-/** Accepted wards of a local guardian (the wards queue). */
-export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); }
-
-/** A site is a guardian once it stands in any accepted guardian relation. */
-export function isGuardian(slug) { return listWards(slug).length > 0; }
-
-export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
-
-// ── Writes (only the handshake commit lands here) ────────────────────────
-
-/** The local ward gains a guardian (commit, §3.1.4). */
-export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) {
-  stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId);
-  return stmts().one.get(wardSlug, 'ward', guardianUri);
-}
-
-/** The local guardian gains a ward (commit, §3.1.4). */
-export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) {
-  stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId);
-  return stmts().one.get(guardianSlug, 'guardian', wardUri);
-}
-
-/** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */
-export function removeRelation(slug, role, otherUri) {
-  stmts().del.run(slug, role, otherUri);
-  return { ok: true };
-}
-
-// ── Actor document (FEP-633c §2) ─────────────────────────────────────────
-
-/**
- * Guardianship props for a local actor doc. `id` is the actor URI.
- * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1)
- * - shaer:isGuardian: true once the site guards anyone
- * - shaer:queues: the owner-only dashboard collections
- *
- * §1 mutual exclusion: a ward (has guardians) is never a guardian, so
- * shaer:isGuardian is suppressed if guardians exist; the offer path already
- * bars a ward from offering.
- */
-export function actorProps(id, slug) {
-  const props = {
-    'shaer:queues': {
-      offers: `${id}/queues/offers`,
-      follows: `${id}/queues/follows`,
-      // Both directions of §5.3, kept apart on purpose: a guardian must be able
-      // to tell "someone wants to follow your ward" from "your ward wants to
-      // follow someone". Same mechanism, opposite question, different words in
-      // the interface (shaer-p729).
-      outgoingFollows: `${id}/queues/outgoing-follows`,
-      wards: `${id}/queues/wards`,
-      guardians: `${id}/queues/guardians`,
-      help: `${id}/queues/help`,
-    },
-    // NAAST de wachtrijen, niet erin. Alles onder shaer:queues wacht op een
-    // antwoord; dit is wat er al besloten is, met de reden erbij (§4.2).
-    // Geschiedenis onderbrengen bij een woord dat "wachtend" betekent maakt van
-    // twee dingen één, en dat is precies de fout die de rest van deze module
-    // net heeft opgeruimd.
-    'shaer:log': `${id}/log`,
-  };
-  const guardians = listGuardians(slug).map((r) => r.other_uri);
-  if (guardians.length) {
-    props['shaer:guardians'] = guardians;   // a ward
-  } else if (isGuardian(slug)) {
-    props['shaer:isGuardian'] = true;        // a guardian (never both, §1)
-  }
-  return props;
-}
-
-export default {
-  listGuardians, listWards, isGuardian, getRelation,
-  commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps,
-};
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,3073 +1,0 @@
-// i18n — eenvoudige interface-vertaling (UI-strings), bezoeker-instelbaar.
-//
-// Taalkeuze: req.session.lang (gezet via /lang/:code) → anders browser-taal
-// (Accept-Language) → anders 'nl'. De helper t(lang, key, vars) zoekt de string op
-// in DICT[lang], valt terug op 'nl', dan op de key zelf. Alleen INTERFACE-teksten;
-// door gebruikers geschreven content (posts, sitenaam, bio) wordt niet vertaald.
-
-export const SUPPORTED = ['nl', 'en', 'de'];
-export const LANG_NAMES = { nl: 'Nederlands', en: 'English', de: 'Deutsch' };
-
-const DICT = {
-  nl: {
-    'nav.back_to_site': '← Terug naar site',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Home',
-    'nav.archive': 'Archief',
-    'nav.search': 'Zoeken',
-    'nav.theme': 'Thema wisselen',
-    'nav.theme_label': 'Thema', 'nav.dark': 'Donker', 'nav.light': 'Licht',
-    'nav.install': 'App installeren',
-    'nav.login': 'Inloggen',
-    'nav.logout': 'Uitloggen',
-    'nav.admin': 'Beheer',
-    'nav.account': 'Account',
-    'nav.profile': 'Profiel',
-    'nav.favorites': 'Favorieten',
-    'nav.new_post': 'Nieuwe post',
-    'nav.language': 'Taal',
-    'nav.notifications': 'Meldingen',
-    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'notif.report_about': 'Over de post', 'notif.report_noreason': 'Geen reden opgegeven.', 'notif.mentioned': 'noemde je in een post', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
-    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
-    'switch.agenda': 'Agenda',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Cirkels',
-    'switch.grid': 'Grid', 'switch.reader': 'Lezen', 'switch.timeline': 'Tijdlijn', 'read.to_top': 'Terug naar boven', 'read.pinned': 'Vastgepind', 'read.next': 'Volgende', 'read.prev': 'Vorige bericht', 'read.nav': 'Door de berichten', 'read.hint': 'Tik boven- of onderaan om een bericht terug of verder', 'asite.reader_full_page': 'Lezen op desktop: elk bericht een eigen scherm (op mobiel altijd)',
-    'asite.feed_alt': 'Tweede weergave', 'asite.feed_alt_reader': 'Lezen', 'asite.feed_alt_timeline': 'Tijdlijn', 'asite.feed_alt_auto': 'Lezen op mobiel, Tijdlijn op desktop',
-    'switch.reader_solo_only': 'Lezen kan alleen in Solo — in de cirkel staan berichten van anderen',
-    'postnav.newer': 'Nieuwer',
-    'postnav.older': 'Ouder',
-    'postnav.newest': 'Nieuwste post',
-    'postnav.oldest': 'Oudste post',
-    'footer.subscribe_cta': 'Blijf op de hoogte',
-    'footer.subscribe': 'Inschrijven',
-    'footer.install': 'Installeer app',
-    'common.email_placeholder': 'jouw@email.nl',
-    'common.back_to_admin': '← Beheer',
-    // Agenda (publiek)
-    'agenda.title': 'Agenda',
-    'agenda.empty': 'Geen aangekondigde evenementen op dit moment.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Mis geen evenement',
-    'agenda.notify_sub': 'Laat je e-mail achter en je krijgt een seintje bij een nieuw evenement. Uitschrijven kan altijd.',
-    'agenda.notify_btn': 'Houd me op de hoogte',
-    'agenda.msg_done': '✓ Je staat op de lijst — je hoort het zodra er een evenement wordt aangekondigd.',
-    'agenda.msg_check': '✉ Check je mail om je aanmelding te bevestigen.',
-    // Downloads (publiek)
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Gratis te downloaden — laat je e-mailadres achter en je krijgt het bestand.',
-    'downloads.empty': 'Er staan momenteel geen downloads klaar.',
-    'downloads.btn': '⬇ Download',
-    // Beheer-dashboard
-    'admin.title': 'Beheer',
-    'adash.prem_active': 'Premium actief',
-    'adash.prem_unlinked': 'Premium niet gekoppeld',
-    'adash.prem_layeroff': 'Premium-laag uit',
-    'admin.tagline_solo': 'Solo-modus — jouw site.',
-    'admin.tagline_cirkels': 'Cirkels-modus: jouw site, verbonden met de fediverse.',
-    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
-    'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_folreq_t': 'Volgverzoek', 'push.n_folreq_b': '{who} wil je volgen — jij beslist', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.follow_out_line': 'wil {who} volgen', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_guard_folin_t': 'Volgverzoek', 'push.n_guard_folin_b': '{who} wil {ward} volgen', 'push.n_guard_folout_t': 'Je ward wil iemand volgen', 'push.n_guard_folout_b': '{ward} vraagt of het {who} mag volgen', 'guardian.panel_history': 'Geschiedenis ({n})', 'guardian.log_show': 'Toon geschiedenis', 'guardian.log_hide': 'Verberg geschiedenis', 'guardian.ev_offer_rejected': 'Aanbod afgewezen', 'guardian.ev_offer_refused': 'Aanbod geweigerd', 'guardian.ev_committed': 'Guardianship vastgelegd', 'guardian.ev_guardian_left': 'Guardian gestopt', 'guardian.ev_coguardian_left': 'Mede-guardian gestopt', 'guardian.ev_gated_outcome': 'Poort besloten', 'guardian.ev_lapse_opened': 'Vrijgave voorgesteld', 'guardian.evr_not_a_teapot': 'de kandidaat is zelf een ward', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_former_ward': 'Niet meer jouw ward. Hun andere guardians zijn er nog voor ze.', 'guardian.warn_reversible': 'Wat hier doorheen komt, komt niet meer terug. Je kunt deze instelling later weer dichtzetten — wat je kind gezien heeft niet.', 'guardian.warn_irreversible': 'Dit is niet terug te draaien. Hierna beslist zij het zelf, en jullie kunnen dat niet meer terugnemen.', 'guardian.warn_unknown': 'Wij kennen deze instelling niet, dus we weten niet wat er doorheen komt of hoe ver het reikt. Vraag het degene die het voorstelde voordat je ja zegt.', 'guardian.warn_decides': 'JOUW ANTWOORD BESLIST DIT. Met jou erbij is de drempel gehaald en gaat het meteen in.', 'guardian.warn_not_last': 'Er moet daarna nog iemand antwoorden voordat dit ingaat.', 'guardian.warn_tally_elsewhere': 'Hoeveel guardians al geantwoord hebben zien wij niet — dat telt de server van het kind. Jouw ja kan de doorslag geven.', 'guardian.warn_go': 'Ja, stel dit voor', 'guardian.warn_back': 'Nee, toch niet', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_unavailable': 'nog niet beschikbaar', 'guardian.gate_planned_note': 'Dit bestaat nog niet op deze server.', 'guardian.gates_summary': '{n} poorten - {on} aan, {wait} wachten', 'guardian.gates_show': 'Toon poorten', 'guardian.gates_hide': 'Verberg poorten', 'guardian.gate_images': 'Plaatjes', 'guardian.gate_messages': 'Berichten', 'guardian.gate_asked': 'Je kind vroeg hier zelf om.', 'guardian.gate_replies': 'Antwoorden in een gesprek', 'guardian.gate_compose': 'Zelf posten', 'guardian.gate_music': 'Muziek', 'guardian.gate_quoteCards': 'Quote-kaarten', 'guardian.gate_customEmoji': 'Eigen emoji', 'guardian.gate_publicProfile': 'Publiek zichtbaar', 'guardian.gate_accountMove': 'Verhuizen', 'guardian.gate_independence': 'Zelfstandig worden', 'guardian.gate_externalThreads': 'Reacties van onbekenden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_following': 'Zelf iemand volgen', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_default_off': 'uit (nog niets over besloten)', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_always': 'Altijd', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose_open': 'Voorstellen: openzetten', 'guardian.gate_propose_close': 'Voorstellen: dichtzetten', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
-    'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
-    'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
-    'ppk.t': 'Maak je passkey', 'ppk.h': 'Je bent supporter, mooi.', 'ppk.sub': 'Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder account en zonder cookie. We bewaren geen naam of e-mailadres.', 'ppk.make': 'Maak passkey', 'ppk.unsupported': 'Passkeys worden niet ondersteund in deze browser.', 'ppk.follow': 'Volg de vraag van je apparaat…', 'ppk.done': 'Gelukt. Je passkey is aangemaakt.', 'ppk.failed': 'Aanmaken mislukt ({err}). Probeer opnieuw.', 'ppk.cancelled': 'Geannuleerd.',
-    'pres.t': 'Ontgrendelen', 'pres.notpatron_h': 'Nog geen supporter', 'pres.notpatron_p': 'Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het daarna opnieuw vanaf de post.', 'pres.tier_h': 'Een niveau hoger nodig', 'pres.tier_p': 'Deze post vraagt vanaf €{need}. Jouw steun is nu €{have}. Verhoog je steun en probeer opnieuw.', 'pres.expired_h': 'Aanvraag verlopen', 'pres.expired_p': 'Deze ontgrendel-link is verlopen of al gebruikt. Ga terug naar de post en probeer het opnieuw.', 'pres.declined_h': 'Ontgrendelen afgebroken', 'pres.declined_p': 'Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.', 'pres.join': 'Word supporter op Patreon', 'pres.back_post': 'Terug naar de post', 'pres.back_site': 'Terug naar de site',
-    'admin.tagline_hub': 'Hub-modus — bedrijfssite met gebruikers, elk hun eigen Klonkt Hub.',
-    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Gebruikers', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Afbeeldingen', 'admin.media_videos': "Video's", 'admin.videos_count': "video's", 'admin.videos_empty': "Nog geen video's geupload.", 'admin.videos_del_confirm': 'Deze video verwijderen?', 'admin.media_count': 'afbeeldingen', 'admin.media_unused': 'ongebruikt', 'admin.media_cleanup': 'Ongebruikt opruimen', 'admin.media_cleanup_confirm': 'Alle ongebruikte afbeeldingen verwijderen?', 'admin.media_empty': 'Nog geen afbeeldingen geüpload.', 'admin.media_copy': 'Kopieer URL', 'admin.media_del_confirm': 'Deze afbeelding verwijderen?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Reacties', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Luisteraars',
-    'alis.lis_title': 'Luisteraars',
-    'alis.lis_intro': 'Accounts die je BIBLIOTHEEK volgen. Ze krijgen je muziek, en met opzet niet je gewone posts — wie zich op een platenkast abonneert heeft niet om de Krant gevraagd.',
-    'alis.lis_empty': 'Nog niemand volgt de bibliotheek.',
-    'alis.lis_since': 'Sinds',
-    'alis.lis_last': 'Laatste bezorging',
-    'alis.lis_never': 'nog niets bezorgd',
-    'alis.lis_count': 'luisteraar(s)',
-    'alis.lis_error': 'bezorging mislukt',
-    'ple.titel': 'Titel *',
-    'ple.artiest': 'Artiest',
-    'ple.jaar': 'Jaar',
-    'ple.type': 'Type',
-    'ple.k_album': 'Album (genummerd)',
-    'ple.k_playlist': 'Playlist (track-covers)', 'ple.k_mixtape': 'Mixtape (bandje: alleen vooruit en achteruit)',
-    'ple.uitgave': 'Uitgavedatum',
-    'ple.mb_release': 'MusicBrainz release-id',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Foto kiezen…',
-    'ple.cover_url': 'https://… of upload',
-    'ple.tracks_in': 'Tracks in playlist',
-    'ple.sleep_hint': '(sleep ⠿ om te ordenen)',
-    'ple.beschikbaar': 'Beschikbare tracks',
-    'ple.zoek': 'Zoek…',
-    'ple.geen_res': 'Geen resultaten.',
-    'ple.leeg_sel': 'Klik tracks rechts om toe te voegen.',
-    'ple.t_edit': 'Playlist bewerken',
-    'ple.t_new': 'Nieuwe playlist',
-    'ple.dialoog': 'Playlist-editor',
-    'ple.sluiten': 'Sluiten',
-    'ple.annuleren': 'Annuleren',
-    'ple.opslaan': 'Opslaan',
-    'ple.bezig_opslaan': 'Opslaan…',
-    'ple.aanmaken': 'Aanmaken',
-    'ple.versleep': 'Verslepen',
-    'ple.verwijder': 'Verwijderen',
-    'ple.geen_audio': 'Track heeft geen audio-bestand',
-    'ple.e_geen_tracks': 'Geen audio-tracks beschikbaar. Upload eerst tracks via Beheer → Audio.',
-    'ple.e_tracks': 'Tracks ophalen mislukt',
-    'ple.e_opslaan': 'Opslaan mislukt: ',
-    'ple.e_mislukt': 'Mislukt: ',
-    'ple.e_alleen_afb': 'Alleen afbeeldingen',
-    'ple.bezig': 'Uploaden…',
-    'ple.e_upload': 'Upload mislukt',
-    'admin.b_settings': '⚙️ Instellingen', "mig.title": "Migreren", "mig.plan_new": "op je nieuwe Klonkt", "mig.plan_from_old": "Je kijkt naar de instantie die VERTREKT. Stap 1, 3 en 4 doe je op je nieuwe Klonkt.", "mig.plan_title": "Zo gaat een verhuizing", "mig.plan_hint": "De volgorde is niet vrijblijvend. Stap 2 moet voor stap 3, want je oude Klonkt geeft niets af aan een adres dat hij niet als zijn opvolger kent. Werk je met een zip, dan mag je 2 juist tot het laatst bewaren: een bestand vraagt niemand om toestemming.", "mig.plan_here": "hier", "mig.plan_old": "op je oude Klonkt", "mig.plan_1": "Je vorige account koppelen", "mig.plan_1_why": "Doe je dit niet, dan weigert je oude Klonkt de verhuizing.", "mig.plan_2": "De verhuizing aankondigen", "mig.plan_2_why": "Je volgers verhuizen mee. Daarna gaat je oude account op slot: posten, volgen en reageren kan daar niet meer.", "mig.plan_3": "Je berichten en muziek ophalen", "mig.plan_3_why": "Rechtstreeks van je oude Klonkt, of via een zip als die al offline is.", "mig.plan_4": "Je volglijst terugzetten", "mig.plan_5": "Later: als je het oude domein opzegt", "mig.plan_5_why": "Verwijder daar eerst je oude berichten. Dan verdwijnt een boost van iemand anders netjes, in plaats van te blijven staan als een kapotte kaart met een dode link. Haal daarna hierboven bij stap 1 je vorige account weg: dat adres beheer je dan niet meer. Hier zit nog geen knop voor.", "mig.follows_note": "Of plak de inhoud van je CSV hierboven. Het bestand wint als je allebei invult.", "asite.moved_to_migrate": "Aliassen en verhuizen staan nu bij Migreren, samen met exporteren, importeren en ophalen.", "mig.alias_title": "Stap 1: je vorige account koppelen", "mig.alias_hint": "Vertel welk account vroeger van jou was. Je oude Klonkt kijkt hiernaar voordat hij je volgers laat verhuizen, en de ophaalknop hieronder heeft het ook nodig.", "mig.alias_label": "Je vorige accounts, één per regel", "mig.alias_note": "Zoals je ze aan iemand zou geven: @jij@mastodon.social. Maximaal vijf.", "mig.alias_btn": "Opslaan", "mig.move_title": "Stap 2: de verhuizing aankondigen", "mig.move_hint": "Dit vertelt al je volgers dat je account voortaan ergens anders woont. Zij verhuizen mee, en dit account gaat daarna op slot: posten, volgen, liken en reageren kan niet meer.", "mig.move_label": "Je nieuwe adres", "mig.move_warn": "Dit is de enige knop op deze pagina die je niet kunt terugdraaien. Je oude account gaat er daarna van op slot. Werk je met de ophaalknop, dan MOET dit eerst: anders geeft je oude Klonkt niets af.", "mig.move_btn": "Verhuizing aankondigen", "mig.move_confirm": "Dit kondigt je verhuizing aan bij al je volgers en zet dit account op slot. Zeker weten?", "mig.move_done": "Dit account is al verhuisd naar", "mig.r_links_fixed": "berichten waarvan de links nu naar hier wijzen", "mig.r_posts_updated": "berichten gerepareerd (plaatjes lokaal gemaakt)", "mig.r_tracks_updated": "nummers aangevuld", "mig.r_tracks_missing": "{n} nummer(s) zijn NIET aangekomen, want het geluidsbestand ontbrak. Ze zijn met opzet niet aangemaakt: een nummer dat in de lijst staat en niet afspeelt is erger dan een nummer dat er niet is.", "mig.c_tracks": "nummers", "mig.c_playlists": "playlists", "mig.audio_missing": "Let op: van {n} nummer(s) is het geluidsbestand niet te vinden. Die gaan niet mee.", "mig.audio_none": "Let op: deze site heeft muziek, maar er gaat geen enkel nummer mee. Waarschijnlijk staan de bestanden ergens anders dan de database denkt.", "mig.pull_title": "Ophalen bij je oude Klonkt", "mig.pull_hint": "Ben je al verhuisd, dan haalt deze Klonkt je berichten rechtstreeks bij de oude op. Je hoeft niets over te typen: dat je verhuisd bent is het bewijs.", "mig.pull_source": "Je oude account", "mig.pull_source_hint": "Overgenomen uit stap 1. Klopt het niet, pas het hier aan.", "mig.pull_btn": "Berichten ophalen", "mig.pull_done": "Opgehaald", "mig.pull_failed": "Ophalen lukte niet", "mig.r_blocks": "blokkades overgenomen", "mig.state": "{n} berichten hebben een verwijzing van hun oude adres.", "mig.state_done": "De lijst is compleet.", "mig.state_busy": "De lijst is nog niet compleet.", "mig.e_no_source": "Er is geen oud account bekend. Vul het adres hierboven in.", "mig.e_unreachable": "De oude Klonkt is niet bereikbaar.", "mig.e_not_moved_here": "Dat account is niet naar hier verhuisd. Kondig de verhuizing eerst aan op je oude Klonkt.", "mig.e_no_backreference": "Dit account zegt nergens dat dat oude account van jou was. Vul het eerst in bij stap 1 hierboven, anders kan je oude Klonkt niet zien dat jij het bent.", "mig.e_no_outbox": "De oude Klonkt heeft geen berichtenlijst.", "mig.e_partial": "Halverwege gestopt. Wat binnen is blijft staan; probeer het nog eens.", "mig.e_config": "Deze Klonkt weet zijn eigen adres niet.", "mig.e_crash": "Er ging iets onverwachts mis.", "mig.e_points_at": "Dat account wijst naar:", "mig.lead": "Neem je berichten, muziek en foto’s mee naar een andere Klonkt, of haal ze hiernaartoe.", "mig.export_title": "Meenemen", "mig.export_hint": "Dit maakt een zip met je berichten, de reacties erop, en de media die erbij hoort.", "mig.c_posts": "berichten", "mig.c_replies": "reacties", "mig.c_media": "mediabestanden", "mig.c_following": "volgingen", "mig.c_size": "groot", "mig.missing": "Let op: van {n} mediaverwijzing(en) staat het bestand niet meer op schijf. Die gaan dus niet mee.", "mig.too_big": "Dit archief is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.", "mig.export_btn": "Archief downloaden (.zip)", "mig.export_none": "Er valt nog niets te exporteren.", "mig.import_title": "Hierheen halen", "mig.import_hint": "Kies je bestand. Je krijgt eerst te zien wat er zou gebeuren; er verandert nog niets.", "mig.file_label": "Je archiefbestand (.zip)", "mig.overwrite_label": "Berichten die er al staan vervangen", "mig.overwrite_hint": "Normaal blijft alles wat hier al staat met rust. Zet je dit aan, dan wordt een bericht met dezelfde naam vervangen, ook als het iets anders was. Dat komt niet terug.", "mig.check_btn": "Controleren", "mig.check_hint": "Dit verandert nog niets. Je krijgt eerst te zien wat er zou gebeuren.", "mig.r_dry": "Wat er zou gebeuren", "mig.r_done": "Ge\u00efmporteerd", "mig.r_would": "zouden erbij komen", "mig.r_imported": "erbij gekomen", "mig.r_skipped": "overgeslagen (staan er al)", "mig.r_overwritten": "overschreven", "mig.r_media": "mediabestanden", "mig.r_media_missing": "media ontbrak in het archief", "mig.r_new_ids": "Dit archief komt van een ander webadres. Je berichten staan hier dus op een nieuw adres. Andere servers weten dat nog niet, dus reacties en boosts die daar naar het oude adres wijzen blijven daar staan.", "mig.r_confirm_hint": "Ziet het er goed uit? Kies hetzelfde bestand nog een keer om het echt te doen.", "mig.r_confirm_btn": "Nu echt importeren", "mig.follows_title": "Wie je volgt", "mig.follows_hint": "Je volglijst zit ook in het archief, en je kunt hem los downloaden en terugzetten bij Connect.", "mig.follows_btn": "Naar Connect", "admin.b_migrate": "\ud83d\udce6 Migreren", 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
-    'admin.t_admin': 'Beheer', 'admin.t_settings': 'Instellingen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Perskit bewerken', 'admin.t_newsletter': 'Nieuwsbrief', 'admin.t_playlists': 'Afspeellijsten', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Nieuwe site', 'admin.t_editsite': 'Bewerken: {title}', 'admin.t_stats': 'Statistieken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Gebruikers', 'admin.t_hub': 'Mijn Klonkt Hub', 'admin.t_manual': 'Handleiding', 'aset.premium_gate': '{feature} is premium — koppel Patreon in Beheer → Instellingen.',
-    'admin.b_makesite': '🎨 Maak je site aan', 'admin.b_circle': '🔗 Cirkel', 'admin.b_stats': '📊 Statistieken',
-    'admin.b_newsletter': '✉️ Nieuwsbrief', 'admin.b_perskit': '📰 Perskit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-bio', 'admin.b_agenda': '📅 Agenda',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Handleiding', 'admin.b_fediverse': 'Mijn reacties',
-    'admin.st_users': 'Gebruikers', 'admin.st_sites': 'Sites', 'admin.st_posts': 'Posts', 'admin.st_published': 'Gepubliceerd',
-    'admin.sec_posts': 'Posts', 'admin.sec_sites': 'Sites', 'admin.sec_users': 'Users',
-    'admin.draft': 'Concept', 'admin.edit': 'Bewerken', 'admin.view': 'Bekijk',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Titel', 'admin.th_owner': 'Eigenaar', 'admin.th_created': 'Aangemaakt',
-    'admin.th_username': 'Gebruikersnaam', 'admin.th_email': 'E-mail', 'admin.th_role': 'Rol', 'admin.th_joined': 'Lid sinds',
-    // Welkom / first-run
-    'welcome.title': 'Welkom bij Klonkt',
-    'welcome.tagline': 'Een zelf-gehost publicatieplatform.',
-    'welcome.have_account': 'Al een account? Inloggen',
-    'welcome.note': 'Maak je beheerdersaccount aan om te beginnen — daarna is registratie gesloten.',
-    'welcome.nosite': 'Hoi {user}! Er is nog geen site ingesteld.',
-    // Auth (login/register)
-    'auth.admin_login_title': 'Beheerder inloggen',
-    'auth.username_or_email': 'Gebruikersnaam of e-mail',
-    'auth.password': 'Wachtwoord',
-    'auth.forgot': 'Wachtwoord vergeten?',
-    'auth.public_sub': 'Log in om te reageren en je favorieten te bewaren.',
-    'auth.admin_box_q': 'Beheerder?',
-    'auth.admin_box_sub': 'Log in met gebruikersnaam & wachtwoord',
-    'auth.create_admin': 'Beheerder aanmaken',
-    'auth.reg_intro': 'Eerste keer opzetten — maak je beheerdersaccount aan. Dit kan maar één keer.',
-    'setup.title': 'Stel je Klonkt in',
-    'setup.intro': 'Welkom! Even je site klaarzetten — dit duurt een minuutje. Kies eerst je taal.',
-    'setup.lang_label': 'Taal',
-    'setup.f_sitename': 'Naam van je site',
-    'setup.sitename_ph': 'bijv. jouw artiestennaam',
-    'setup.submit': 'Mijn site aanmaken',
-    'setup.username_note': 'Dit wordt je adres op de fediverse en kun je later niet meer wijzigen:',
-    'changelog.title': 'Wijzigingen', 'changelog.empty': 'Geen wijzigingenoverzicht beschikbaar.',
-    'auth.f_username': 'Gebruikersnaam (3-32 tekens, letters/cijfers/_-)',
-    'auth.f_email': 'E-mail',
-    'auth.f_password': 'Wachtwoord (min 8 tekens)',
-    // Bottom-tab (mobiel)
-    'tab.home': 'Home', 'tab.search': 'Zoek', 'tab.write': 'Schrijven', 'tab.profile': 'Profiel',
-    // Reacties + gerelateerd (post-pagina)
-    'comments.heading_one': '{n} reactie', 'comments.heading_other': '{n} reacties',
-    'comments.empty': 'Nog geen reacties.',
-    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
-    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
-    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interacteer via de fediverse', 'fedi.report_open': 'Deze post rapporteren', 'fedi.report_where': 'De melding gaat naar de instance en hun moderator(s).', 'fedi.report_ph': 'Wat is er mis? (optioneel)', 'fedi.report_send': 'Rapporteren', 'fedi.reported_title': 'Gerapporteerd', 'fedi.reported_done': 'Je melding is naar de server van de gebruiker gestuurd. Hun moderators bekijken het.', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.mod_remove_confirm': 'Deze reactie uit je thread verwijderen? Hij komt niet terug, ook niet via thread-aanvulling.', 'fedi.mod_report_confirm': 'Deze reactie rapporteren bij de server van de auteur?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interacteer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.moved_title': 'Dit account is verhuisd', 'tl.moved_lead': 'Nieuwe berichten, volgen, liken en reageren doe je voortaan vanaf', 'tl.moved_hint': 'Lezen blijft hier gewoon werken, en reacties op je oude posts komen nog binnen. Wil je terug? Maak het verhuisadres leeg bij Uiterlijk.', 'tl.move_title': 'Je volglijst meenemen', 'tl.move_hint': 'Verhuis je naar een ander adres? Je volgers krijgen dat vanzelf te horen, maar wie JIJ volgt niet. Neem die lijst hiermee mee. Werkt ook van en naar Mastodon.', 'tl.move_export': 'Lijst downloaden (CSV)', 'tl.move_import_file': 'Kies je gedownloade CSV-bestand:', 'tl.move_import_lbl': 'Of plak de lijst hier:', 'tl.move_import': 'Iedereen volgen', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.tab_followers': 'Volgers', 'tl.followers': 'Volgers', 'tl.followers_lead': 'Wie jou volgt in de fediverse, met de laatste geslaagde bezorging. Rood = nog nooit bezorgd of laatste poging mislukt — kandidaat om op te ruimen na een check.', 'tl.empty_followers': 'Nog geen volgers.', 'tl.last_delivery': 'Laatste bezorging', 'tl.never_delivered': 'Nog nooit bezorgd', 'tl.delivery_failed': 'laatste poging mislukt', 'tl.remove_follower': 'Verwijderen', 'tl.folreq_title': 'Volgverzoeken', 'tl.folreq_sub': 'Deze wachten op jouw ja of nee. Tot die tijd ziet de aanvrager niets van je posts.', 'tl.folreq_accept': 'Accepteer', 'tl.folreq_deny': 'Weiger', 'tl.approve_toggle': 'Volgers eerst goedkeuren', 'tl.approve_toggle_hint': 'Aan: volgverzoeken wachten hier op jouw ja. Uit: iedereen mag direct volgen.', 'tl.remove_confirm': 'Deze volger verwijderen? Een actief account moet je dan opnieuw volgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'jij volgt', 'tl.dir_follower': 'volgt jou', 'tl.dir_mutual': 'wederzijds', 'tl.connect_empty': 'Nog geen connecties. Volg iemand hierboven om te beginnen.', 'tl.unreachable': 'Niet bereikbaar', 'tl.unreachable_lead': 'Deze volgers konden we niet bereiken (nooit bezorgd of laatste poging mislukt). Ruim ze op na een handmatige check.', 'msg.tab': 'Berichten', 'msg.title': 'Berichten', 'msg.filter_all': 'Alles', 'msg.filter_msgs': 'Berichten', 'msg.filter_conv': 'Gesprekken', 'msg.filter_act': 'Activiteit', 'msg.filter_mod': 'Moderatie', 'msg.filter_sent': 'Verzonden', 'msg.search_ph': 'Zoeken in berichten…', 'msg.no_match': 'Niets gevonden.', 'msg.poll_done': 'Je peiling is afgelopen', 'msg.poll_total': '{n} stemmers', 'msg.you': 'Jij', 'msg.sent_reply': 'reageerde via de fediverse', 'msg.and_more': 'en {n} anderen', 'msg.liked_many': 'liketen je post', 'msg.boosted_many': 'boostten je post', 'msg.private': 'privé', 'msg.private_hint': 'Alleen aan jou gericht; staat niet op de publieke postpagina.', 'msg.new': 'Nieuw sinds je laatste bezoek', 'msg.empty': 'Nog geen berichten. Reacties, vermeldingen en activiteit verschijnen hier.', 'oauth.title': 'App toegang geven', 'oauth.wants_access': 'wil verbinding maken met je Klonkt-account.', 'oauth.post_as': 'Plaatsen als', 'oauth.scope_read': 'Je berichten, reacties en meldingen lezen', 'oauth.scope_write': 'Namens jou posten, reageren, liken en volgen', 'oauth.allow': 'Toestaan', 'oauth.deny': 'Weigeren', 'oauth.foot': 'Je kunt de toegang later intrekken. Geef alleen apps toegang die je vertrouwt.', 're.title': 'Reactie schrijven', 're.bold': 'Vet', 're.italic': 'Cursief', 're.link': 'Link invoegen', 're.list': 'Opsomming', 're.quote': 'Citaat', 're.lang': 'Taal van je reactie', 're.attach': 'Media toevoegen (afbeelding, audio, video)', 're.attach_err': 'Upload mislukt', 're.to': 'Aan:', 're.mention_del': 'Deze persoon niet meer adresseren', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'feed.load_more': 'Meer laden', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.open': 'open', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.', 'poll.voted_title': 'Stem verstuurd', 'poll.voted_done': 'Je stem is verstuurd naar de poll. De uitslag werkt bij zodra de maker die doorstuurt.',
-    'comments.to_start': 'om de conversatie te starten.',
-    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
-    'comments.delete_confirm': 'Deze reactie verwijderen?',
-    'comments.reply_to': 'Antwoord aan {name}…',
-    'comments.add_as': 'Reageer als', 'comments.placeholder': 'Deel je gedachten…',
-    'comments.post': 'Plaats reactie', 'comments.login_to_comment': 'Log in om te reageren',
-    'comments.pending': 'Je reactie wacht op goedkeuring. Hij verschijnt zodra een beheerder hem goedkeurt.',
-    'related.title': 'Gerelateerde posts',
-    // Zoeken + like
-    'search.placeholder': 'Zoek posts en nummers…', 'search.button': 'Zoek',
-    'search.error': 'Kon die zoekopdracht niet uitvoeren. Probeer een eenvoudiger term.',
-    'search.results_one': '{n} resultaat voor “{q}”', 'search.results_other': '{n} resultaten voor “{q}”',
-    'search.section_tracks': 'Nummers', 'search.section_posts': 'Posts',
-    'search.empty': 'Niets gevonden.', 'search.in_post': 'in post →',
-    'search.section_events': 'Evenementen', 'search.section_pages': 'Pagina’s',
-    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Perskit', 'search.page_archive': 'Archief',
-    'search.suggest_all': 'Alle resultaten →', 'search.suggest_empty': 'Geen resultaten', 'search.suggest_typing': 'Typ om te zoeken…',
-    'like.login_title': 'Log in om deze post te liken', 'like.fedi_title': 'Like deze post vanaf je eigen fediverse-account',
-    // === Beheer-sub-pagina's ===
-    'aset.title': 'Instellingen',
-    'aset.back_admin': 'Beheer',
-    'aset.mode': 'Modus',
-    'aset.mode_help': 'Bepaalt hoe deze installatie werkt. Wisselen is veilig: er wordt niets verwijderd — Solo verbergt alleen de multi-onderdelen en toont je primaire site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'één site (de jouwe).',
-    'aset.solo_desc': 'Geen gebruikers-directory, geen site-wissel.',
-    'aset.premium_badge': 'premium',
-    'aset.circle': 'Cirkels',
-    'aset.circle_title': 'solo + federatie.',
-    'aset.circle_desc': 'Eén eigen site die de publieke posts van andere Klonkt-sites toont. Asymmetrisch: jij bepaalt wie in jouw cirkel zit.',
-    'aset.save': 'Opslaan',
-    'aset.name': 'Naam',
-    'aset.name_ph': 'bijv. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'bijv. Onafhankelijk muzieklabel',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Korte introtekst onder de titel.',
-    'aset.hero_image': 'Hero-afbeelding (URL)',
-    'aset.hero_image_hint': 'optioneel; achtergrond van de hero',
-    'aset.hero_upload': '…of upload een afbeelding',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max 5 MB; vervangt de URL hierboven',
-    'aset.hero_overlay': 'Donkere overlay',
-    'aset.hero_overlay_hint': 'maakt de hero donkerder zodat de tekst leesbaar blijft',
-    'aset.preview_overlay': 'Voorbeeld (met overlay):',
-    'aset.hero_preview_alt': 'Hero-voorbeeld',
-    'aset.your_circle': 'Je cirkel',
-    'aset.your_circle_help': 'Beheer welke andere Klonkt-sites je in je cirkel toont, en of jouw site in cirkels van anderen mag verschijnen. Asymmetrisch: jij bepaalt wie je volgt.',
-    'aset.manage_circle': 'Beheer je cirkel',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Ontgrendel de premium-modules (nieuwsbrief, downloads, statistieken, EPK, link-in-bio, agenda) met je',
-    'aset.premium_lifetime': '$16-lifetime',
-    'aset.premium_help_2': 'Patreon-steun. De app en alle updates blijven gratis.',
-    'aset.premium_active': 'Premium actief.',
-    'aset.lifetime_support': 'Lifetime-steun:',
-    'aset.patreon_disconnect': 'Patreon ontkoppelen',
-    'aset.patreon_no_lifetime': 'Patreon gekoppeld, maar nog geen $16 lifetime',
-    'aset.now': 'nu',
-    'aset.patreon_support_again': 'Steun de campagne en koppel opnieuw.',
-    'aset.patreon_reconnect': 'Opnieuw koppelen',
-    'aset.patreon_not_connected': 'Nog niet gekoppeld.',
-    'aset.patreon_connect': 'Koppel Patreon',
-    'aset.status_set': 'Status: ingesteld',
-    'aset.not_set_yet': 'Nog niet ingesteld.',
-    'aset.newsletter': 'Nieuwsbrief',
-    'aset.newsletter_help_1': 'Toon een',
-    'aset.newsletter_footer_field': 'aanmeldveld in de footer',
-    'aset.newsletter_help_2': 'van je site zodat bezoekers zich op elke pagina kunnen inschrijven. (De volledige aanmeldpagina blijft op',
-    'aset.newsletter_show_footer': 'Aanmeldveld in de footer tonen',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Laat je site meedoen met de fediverse: mensen op Mastodon (of een andere Klonkt) kunnen je volgen, liken en reageren — en die reacties verschijnen onder je posts. Zet je dit uit, dan federeert je site niet en zijn er geen reacties (een rustige, op zichzelf staande blog).',
-    'aset.fediverse_toggle': 'Fediverse aan (volgen, liken, reageren)', 'aset.mode': 'Modus', 'aset.mode_help': 'Kies hoe je site werkt.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'Een op zichzelf staande blog — geen fediverse, geen reacties. Rustig en privé.', 'aset.mode_cirkels': 'Cirkels', 'aset.mode_18plus': 'De fediverse is een open netwerk dat ook volwassen (18+) inhoud kan bevatten — je moet volwassen genoeg zijn om mee te doen.', 'aset.mode_18plus_confirm': 'Cirkels verbindt je site met de fediverse, een open netwerk met ook 18+-inhoud. Bevestig dat je volwassen genoeg bent om dit te activeren.', 'aset.mode_cirkels_help': 'Doe mee met de fediverse (ActivityPub): mensen op Mastodon of een andere Klonkt kunnen je volgen, liken en reageren, en je kunt een cirkel van sites volgen.',
-    'aset.email_smtp': 'E-mail (SMTP)',
-    'aset.smtp_help_1': 'Nodig om de',
-    'aset.smtp_help_newsletter': 'nieuwsbrief te versturen',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'show-notify',
-    'aset.smtp_help_3': '-mails te sturen en wachtwoord-reset per mail te laten werken. Vul de gegevens van je mailprovider in (bv. je hostingmail, Gmail-app-wachtwoord, Brevo, Mailgun…).',
-    'aset.via_env': 'via .env',
-    'aset.smtp_not_set': 'Nog niet ingesteld — versturen werkt nog niet.',
-    'aset.smtp_host': 'SMTP-host',
-    'aset.smtp_port': 'Poort',
-    'aset.smtp_port_hint': '587 (STARTTLS) of 465 (SSL)',
-    'aset.smtp_user': 'Gebruikersnaam',
-    'aset.smtp_pass': 'Wachtwoord',
-    'aset.smtp_pass_set_hint': 'ingesteld; leeg laten = ongewijzigd',
-    'aset.smtp_pass_ph_set': '•••••••• (ingesteld)',
-    'aset.smtp_pass_ph': 'app-wachtwoord',
-    'aset.smtp_from': 'Afzender',
-    'aset.smtp_from_hint': 'optioneel; standaard = gebruikersnaam',
-    'aset.smtp_from_ph': 'Jouw Naam <jij@jouwprovider.nl>',
-    'aset.smtp_save': 'SMTP opslaan',
-    'aset.clear': 'Wissen',
-    'aset.test_mail_to': 'Testmail sturen naar',
-    'aset.send_test_mail': 'Testmail sturen',
-    'asite.back_admin': 'Beheer',
-    'asite.title_new': 'Nieuwe site',
-    'asite.title_edit': 'Uiterlijk',
-    'asite.identity': 'Identiteit',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(vast)',
-    'asite.slug_placeholder': 'jouwslug',
-    'asite.field_title': 'Titel',
-    'asite.field_title_hint': '— getoond in de kop en als weergavenaam',
-    'asite.owner': 'Eigenaar',
-    'asite.owner_hint': '— wie deze Klonkt zelf mag beheren',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— korte oneliner',
-    'asite.bio': 'Bio / omschrijving',
-    'asite.bio_hint': '— getoond in de profielkop en gebruikt voor SEO',
-    'asite.profile_photo': 'Profielfoto',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg of https://…',
-    'asite.photo_upload': '📷 Uploaden',
-    'asite.photo_clear': 'Verwijder',
-    'asite.language': 'Taal (ISO-code)',
-    'asite.profile_enabled': 'Profielkop tonen onder de navigatie',
-    'asite.appearance': 'Vormgeving',
-    'asite.accent_color': 'Accentkleur',
-    'asite.theme_default': 'Standaardthema voor nieuwe bezoekers',
-    'asite.theme_auto': 'Auto (volg device-voorkeur)',
-    'asite.theme_light': 'Licht',
-    'asite.theme_dark': 'Donker',
-    'asite.palette': 'Palet',
-    'asite.behavior': 'Gedrag',
-    'asite.is_public': 'Publieke site (uitvinken voor een besloten kring)',
-    'asite.robots_index': 'Zoekmachines mogen indexeren (sitemap.xml is verborgen indien uit)',
-    'asite.require_login_comment': 'Inloggen vereist om te reageren',
-    'asite.enable_audio': 'Audiospeler + embeds inschakelen', 'asite.approve_followers': 'Volgers eerst goedkeuren (volgverzoeken wachten op jouw ja op de Connect-pagina)',
-    'asite.links': 'Social / streaming-links',
-    'asite.links_hint': 'Getoond als merk-iconen op de profielkop. Voeg er zoveel toe als je wilt.',
-    'asite.aliases': 'Fediverse-aliassen',
-    'asite.move': 'Verhuizen (fediverse)',
-    'asite.move_hint': 'Kondig aan je volgers aan dat dit account ergens anders verder gaat. Het nieuwe profiel moet dit adres eerst als alias claimen; volgers verhuizen dan vanzelf mee. Een account met guardians kan nog niet verhuizen.',
-    'asite.move_confirm': 'Weet je het zeker? Je volgers krijgen te horen dat dit account is verhuisd.',
-    'asite.move_btn': 'Kondig verhuizing aan',
-    'asite.moved_to': 'Verhuisd naar',
-    'asite.aliases_hint': 'Eén per regel: je oude account als @naam@server of als actor-URL. Nodig om volgers van een oud account hierheen te verhuizen; de oude server controleert of dit profiel het oude claimt.',
-    'asite.link_add': '+ Link toevoegen',
-    'asite.feed_view': 'Feed-weergave',
-    'asite.feed_default': 'Standaardweergave voor de homepage',
-    'asite.feed_reader': 'Lezen (hele berichten, één per scherm)',
-    'asite.feed_grid': 'Grid (kaarten)',
-    'asite.feed_switch': 'Tijdlijn ↔ grid-schakelaar boven de feed tonen',
-    'asite.show_search': 'Zoekknop in de navigatie tonen',
-    'asite.show_archive': 'Archief-link in de navigatie tonen',
-    'asite.seo': 'SEO & social',
-    'asite.seo_pointer': 'Titel-sjabloon, canonical, deel-afbeelding, verificatie-metas en meer staan nu op een eigen pagina:',
-    'asite.seo_link': '🔎 SEO & vindbaarheid',
-    'asite.custom_legend': 'Eigen CSS & HTML',
-    'asite.optional': '(optioneel)',
-    'asite.custom_css': 'Eigen CSS (geïnjecteerd als &lt;style&gt; in &lt;head&gt;)',
-    'asite.custom_head': 'Eigen &lt;head&gt;-HTML (analytics, extra meta&rsquo;s)',
-    'asite.custom_foot': 'Eigen footer-HTML',
-    'asite.submit_create': 'Site aanmaken',
-    'asite.submit_save': 'Wijzigingen opslaan',
-    'aseo.back': 'Beheer',
-    'aseo.title': 'SEO & vindbaarheid',
-    'aseo.tagline_pre': 'Geavanceerde SEO van',
-    'aseo.tagline_post': '— hoe je site in zoekmachines en bij het delen op social media verschijnt.',
-    'aseo.index_legend': 'Indexeren',
-    'aseo.index_label': 'Zoekmachines mogen deze site indexeren',
-    'aseo.index_hint_pre': 'uit =',
-    'aseo.index_hint_post': '+ sitemap.xml verborgen',
-    'aseo.title_legend': 'Titel & omschrijving',
-    'aseo.title_template': 'Titel-sjabloon',
-    'aseo.title_template_hint_pre': 'gebruik',
-    'aseo.title_template_hint_and': 'en',
-    'aseo.default_desc': 'Standaard-omschrijving',
-    'aseo.default_desc_hint': 'meta description / og:description als een pagina er geen heeft',
-    'aseo.default_desc_ph': 'Korte omschrijving van je site (max ~160 tekens werkt het best)',
-    'aseo.canonical': 'Canonieke basis-URL',
-    'aseo.canonical_hint': 'de productie-HTTPS-URL, voorkomt dubbele-content-straf',
-    'aseo.author': 'Auteur',
-    'aseo.author_hint': 'meta author-tag',
-    'aseo.author_ph': 'Jouw naam',
-    'aseo.social_legend': 'Delen op social media',
-    'aseo.og_image': 'Standaard deel-afbeelding (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter-card; ~1200×630px',
-    'aseo.og_theme': 'Deel-kaart licht of donker',
-    'aseo.og_theme_hint': 'de automatisch gemaakte deel-afbeelding',
-    'aseo.og_theme_auto': 'Automatisch (volgt site-thema)',
-    'aseo.og_theme_light': 'Licht',
-    'aseo.og_theme_dark': 'Donker',
-    'aseo.og_locale': 'Taal-locale',
-    'aseo.og_locale_hint_pre': 'og:locale, bv.',
-    'aseo.og_locale_hint_or': 'of',
-    'aseo.twitter': 'Twitter / X-handle',
-    'aseo.twitter_hint': 'met @',
-    'aseo.fb_app': 'Facebook App-ID',
-    'aseo.fb_app_hint': 'fb:app_id (optioneel)',
-    'aseo.publisher_legend': 'Uitgever (JSON-LD / rich results)',
-    'aseo.type': 'Type',
-    'aseo.type_person': 'Persoon',
-    'aseo.type_org': 'Organisatie / bedrijf',
-    'aseo.publisher_name': 'Naam',
-    'aseo.publisher_name_hint': 'valt terug op de site-titel',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Zoekmachine-verificatie',
-    'aseo.verify_google': 'Google site-verificatie',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'SEO opslaan',
-    'aaud.title': 'Audio tracks',
-    'aaud.tagline_pre': 'MP3’s op site-niveau. Gebruik',
-    'aaud.tagline_post': 'in een post om een play-knop in te voegen.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Artiest',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(toegepast op alle bestanden)',
-    'aaud.optional': 'Optioneel',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)',
-    'aaud.choose_cover': 'Kies cover',
-    'aaud.no_file': 'Geen bestand gekozen',
-    'aaud.drag_here': 'Sleep audio hierheen',
-    'aaud.or_click': 'of klik om bestanden te kiezen',
-    'aaud.start_upload': 'Start upload',
-    'aaud.clear_list': 'Wis lijst',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track zonder audio',
-    'aaud.add_link_track_title': 'Een track zonder audiobestand — alleen titel + open-in links',
-    'aaud.no_tracks': 'Nog geen tracks. Upload er een hierboven.',
-    'aaud.untitled': '(zonder titel)',
-    'aaud.copy_click': 'Klik om te kopiëren',
-    'aaud.play': 'Afspelen',
-    'aaud.pause': 'Pauzeren',
-    'aaud.edit': 'Bewerken',
-    'aaud.delete': 'Verwijderen',
-    'aaud.delete_confirm': 'Track verwijderen?',
-    'aaud.dl_on': 'Download-voor-email staat AAN — klik om uit te zetten',
-    'aaud.dl_off': 'Download-voor-email staat uit — klik om aan te zetten',
-    'aaud.fedi_on': 'Op de fediverse gedeeld (speelt overal inline, bestand downloadbaar) — klik om uit te zetten',
-    'aaud.fedi_off': 'Niet op de fediverse gedeeld (alleen webspeler, bestand verborgen) — klik om te delen',
-    'aaud.embed_player': 'Embedbare speler',
-    'aseo.mb_legend': 'MusicBrainz Koppeling',
-    'aseo.mb_linked': 'Gekoppeld aan',
-    'aseo.mb_unlink': 'Ontkoppelen',
-    'aseo.mb_open': 'Bekijk op MusicBrainz',
-    'aseo.mb_pick': 'Dit ben ik',
-    'aseo.mb_none': 'Niets gevonden. Sta je er nog niet in? Dan kun je jezelf aanmelden op musicbrainz.org — dat kan alleen daar, niet vanuit Klonkt.',
-    'aseo.mb_busy': 'Zoeken…',
-    'aseo.mb_fail': 'MusicBrainz is even niet bereikbaar.',
-    'aseo.mb_hint': 'Hier koppel je je MusicBrainz artiest id aan je domein, met terug-weg validatie van je "social networking" profiel pagina.',
-    'aseo.mb_search_label': 'Zoek op je naam',
-    'aseo.mb_search_hint': 'je artiestennaam of je MusicBrainz id als je die kent',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Opzoeken',
-    'aseo.mb_verified': 'Wederzijds: de MusicBrainz-pagina wijst terug naar dit domein.',
-    'aseo.mb_unverified': 'Nog eenzijdig. Zet dit domein op je MusicBrainz-pagina onder "social networking", dan is de koppeling van twee kanten bevestigd.',
-    'aseo.mb_checking': 'Terug-weg controleren…',
-    'aaud.embed_hint': 'Plak deze code op je eigen website/blog om je muziek met deze speler in te sluiten:',
-    'aaud.preview_player': 'Speler-voorbeeld openen',
-    'aaud.st_queued': 'Wachten',
-    'aaud.st_uploading': 'Uploaden…',
-    'aaud.st_transcoding': 'Converteren…',
-    'aaud.st_done': 'Klaar',
-    'aaud.st_error': 'Fout',
-    'aaud.err_unexpected': 'Onverwacht serverantwoord',
-    'aaud.failed': 'Mislukt',
-    'aaud.copied': 'gekopieerd',
-    'aaud.new_track': 'Nieuwe track',
-    'aaud.create_failed': 'Track aanmaken mislukt',
-    'aaud.editor_not_loaded': 'Track editor niet geladen',
-    'aaud.change_failed': 'Kon niet wijzigen',
-    'astat.title': 'Statistieken',
-    'astat.intro': 'Cookievrij gemeten — geen tracking-cookies, geen toestemmingsbanner. Bezoekers worden per dag geteld via een dagelijks roterende, anonieme hash (IP/browser worden niet bewaard). Je eigen beheerder-bezoeken en bekende bots/crawlers tellen niet mee.',
-    'astat.your_ip': 'Jouw IP', 'astat.ip_counted': 'wordt meegeteld.', 'astat.ip_not_counted': 'wordt NIET meegeteld.', 'astat.ip_exclude': 'Tel mijn bezoeken niet mee', 'astat.ip_count': 'Wel meetellen',
-    'astat.visitor_days': 'Bezoeker-dagen ({n}d)',
-    'astat.pageviews_days': 'Weergaven ({n}d)',
-    'astat.plays_total': 'Plays (totaal)',
-    'astat.postviews_total': 'Post-weergaven (totaal)',
-    'astat.alltime_pre': 'All-time:',
-    'astat.alltime_mid': 'weergaven',
-    'astat.alltime_post': 'bezoeker-dagen.',
-    'astat.help_summary': 'Wat betekenen deze cijfers?',
-    'astat.help_vd_term': 'Bezoeker-dagen',
-    'astat.help_vd_a': 'het aantal unieke bezoekers',
-    'astat.help_vd_em': 'per dag, bij elkaar opgeteld',
-    'astat.help_vd_b': '. Eén persoon die 5 dagen langskomt = 5 bezoeker-dagen. Cookieloos kan er niet over dagen heen geteld worden, dus dit is géén aantal unieke personen — het echte aantal mensen ligt (vaak fors) lager.',
-    'astat.help_pv_term': 'Weergaven',
-    'astat.help_pv': 'hoe vaak de home/feed of een post geladen is (ook bij klikken binnen de site). Andere pagina’s (agenda, downloads, links) tellen hier niet in mee.',
-    'astat.help_plays_term': 'Plays',
-    'astat.help_plays': 'totaal aantal keren dat een track is gestart.',
-    'astat.help_postviews_term': 'Post-weergaven',
-    'astat.help_postviews': 'totaal over alle posts samen.',
-    'astat.help_footer': 'Beheerder-bezoeken en bekende bots/crawlers worden overgeslagen. Het ruwe IP wordt nooit bewaard. Goed voor trends; neem absolute aantallen met een korrel zout.',
-    'astat.period': 'Periode:',
-    'astat.last_n_days': 'Laatste {n} dagen',
-    'astat.lg_visitor_days': 'Bezoeker-dagen',
-    'astat.lg_pageviews': 'Weergaven',
-    'astat.bar_title': '{day} — {pv} weergaven, {vd} bezoeker-dagen',
-    'astat.top_posts': 'Populairste posts',
-    'astat.no_views': 'Nog geen weergaven.',
-    'astat.most_played': 'Meest geluisterd',
-    'astat.no_plays': 'Nog geen plays.',
-    'astat.sources': 'Bronnen (waar bezoekers vandaan komen)',
-    'astat.linkbio_clicks': 'Link-in-bio klikken',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Canonieke playlists. Bewerk een playlist hier en de wijzigingen werken door in álle posts die hem gebruiken via',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'Nieuwe playlist',
-    'apl.none': 'Nog geen playlists.',
-    'apl.none_sub': 'Maak er een via de knop hierboven, of via de 📃 knop in de post-editor.',
-    'apl.pill_playlist': 'playlist',
-    'apl.pill_album': 'album', 'apl.pill_mixtape': 'mixtape',
-    'apl.track': 'track',
-    'apl.tracks': 'tracks',
-    'apl.copy_click': 'Klik om te kopiëren',
-    'apl.edit': 'Bewerken',
-    'apl.delete': 'Verwijderen',
-    'apl.copied': 'gekopieerd',
-    'apl.delete_confirm': 'Playlist "{title}" verwijderen? Posts die deze playlist embedden tonen vanaf nu een placeholder.',
-    'apl.delete_failed': 'Verwijderen mislukt',
-    'ausr.back': 'Beheer',
-    'ausr.title': 'Gebruikers',
-    'ausr.tagline_a': 'Beheer gebruikers, rollen, en verwijderingen. Rol',
-    'ausr.tagline_b': '= alles bekijken (incl. Beheer), niets wijzigen — handig voor demo\'s.',
-    'ausr.empty': 'Geen gebruikers.',
-    'ausr.you': 'jij',
-    'ausr.t_sites': 'Sites',
-    'ausr.t_posts': 'Posts',
-    'ausr.t_joined': 'Geregistreerd op',
-    'ausr.l_sites': 'sites',
-    'ausr.l_posts': 'posts',
-    'ausr.l_joined': 'lid sinds',
-    'ausr.new_klonkt': 'Nieuwe Klonkt voor deze gebruiker',
-    'ausr.new_klonkt_for': 'Nieuwe Klonkt voor {name}',
-    'ausr.role_label': 'Rol',
-    'ausr.role_kijker': 'kijker',
-    'ausr.role_member': 'member',
-    'ausr.role_admin': 'admin',
-    'ausr.role_god': 'god',
-    'ausr.delete': 'Verwijderen',
-    'ausr.del_warn': 'Dit verwijdert ook hun site + {n} post(s).',
-    'ausr.del_confirm': 'Gebruiker {name} verwijderen?',
-    'ausr.del_undo': 'Dit kan niet ongedaan worden.',
-    'asit2.back': 'Beheer',
-    'asit2.title': 'Sites',
-    'asit2.tagline': 'Beheer alle sites op deze installatie.',
-    'asit2.new_site': 'Nieuwe site',
-    'asit2.empty': 'Nog geen sites.',
-    'asit2.empty_sub': 'Maak er een via de knop hierboven.',
-    'asit2.pill_primary': 'primair',
-    'asit2.pill_primary_title': 'Hoofd-/labelsite van deze installatie',
-    'asit2.pill_public': 'public',
-    'asit2.pill_public_title': 'Publiek zichtbaar',
-    'asit2.pill_private': 'private',
-    'asit2.pill_private_title': 'Niet publiek',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Niet geïndexeerd door zoekmachines',
-    'asit2.by': 'door',
-    'asit2.t_posts': 'Aantal posts',
-    'asit2.l_posts': 'posts',
-    'asit2.t_created': 'Aangemaakt op',
-    'asit2.l_created': 'aangemaakt',
-    'asit2.make_primary': 'Maak primair',
-    'asit2.make_primary_title': 'Maak primaire/hoofd-site',
-    'asit2.make_primary_confirm': 'Deze site instellen als de primaire/hoofd-site?',
-    'asit2.edit': 'Bewerken',
-    'asit2.delete': 'Verwijderen',
-    'asit2.delete_confirm': 'Site verwijderen? Lukt alleen als er geen posts zijn.',
-    'acom.back': 'Beheer',
-    'acom.title': 'Reactie-moderatie',
-    'acom.mode_for_site': 'Modus voor deze site:',
-    'acom.mode_trust_a': 'reacties worden automatisch goedgekeurd. Zet om naar',
-    'acom.mode_moderate_word': 'modereren',
-    'acom.site_settings': 'site-instellingen',
-    'acom.mode_trust_b': 'om ze in de wachtrij te zetten.',
-    'acom.mode_moderate_hint': 'nieuwe reacties moeten worden goedgekeurd voordat ze bij posts verschijnen.',
-    'acom.pending': 'In afwachting ({n})',
-    'acom.nothing_waiting': 'Niets in de wachtrij.',
-    'acom.reply': 'antwoord',
-    'acom.on': 'op',
-    'acom.approve': 'Goedkeuren',
-    'acom.reject': 'Afwijzen',
-    'acom.recent': 'Recente beslissingen',
-    'acom.nothing_yet': 'Nog niets.',
-    'acir.title': 'Cirkels',
-    'acir.back_settings': 'Instellingen',
-    'acir.circles': 'Cirkels',
-    'acir.settings': 'Instellingen',
-    'acir.mode_off_1': 'De modus staat niet op',
-    'acir.mode_off_2': '. Zet \'m aan bij',
-    'acir.mode_off_3': 'om je cirkel-feed te tonen op',
-    'acir.mode_off_4': '. Je kunt hieronder al wel bronnen klaarzetten.',
-    'acir.visibility_title': 'Mijn zichtbaarheid',
-    'acir.all_public': 'al-publieke',
-    'acir.visibility_help_1': 'Doe je mee aan cirkels? Dit maakt je',
-    'acir.visibility_help_2': 'posts ophaalbaar voor andere Klonkt-sites via een ondertekende feed',
-    'acir.visibility_help_3': 'Het is een deelname-keuze, geen privacy-slot: je posts blijven sowieso openbaar op je site, ook als dit uit staat. Wil je iets afschermen, maak die post dan niet-publiek.',
-    'acir.show_in_circles': 'Toon mijn site in cirkels van anderen',
-    'acir.save': 'Opslaan',
-    'acir.add_title': 'Klonkt-site toevoegen',
-    'acir.add_help': 'Plak de basis-URL van een andere Klonkt-site. Asymmetrisch: jij toont hen, los van of zij jou tonen.',
-    'acir.url': 'URL',
-    'acir.label': 'Label',
-    'acir.optional': 'optioneel',
-    'acir.name_auto': 'De naam wordt automatisch van de site overgenomen — alleen de URL is nodig.',
-    'acir.label_ph': 'bijv. Joost Klein',
-    'acir.add': 'Toevoegen',
-    'acir.in_circle': 'In mijn cirkel ({n})',
-    'acir.no_sources': 'Nog geen bronnen. Voeg er hierboven een toe.',
-    'acir.sync_all': 'Alles nu synchroniseren',
-    'acir.st_active': 'actief',
-    'acir.posts': 'posts',
-    'acir.last': 'laatst',
-    'acir.st_mismatch': 'versie-mismatch',
-    'acir.mismatch_reason': 'Deze site draait een andere Klonkt-protocolversie — bijwerken nodig om te federeren.',
-    'acir.st_error': 'fout',
-    'acir.st_paused': 'gepauzeerd',
-    'acir.refresh': 'Verversen',
-    'acir.remove': 'Verwijder',
-    'acir.remove_confirm': 'Verwijderen uit je cirkel?',
-    'ashow.back_admin': 'Beheer',
-    'ashow.title': 'Agenda',
-    'ashow.show_toggle': 'Agenda tonen op de site',
-    'ashow.show_toggle_hint': '(Agenda-knop in de balk + de agendapagina)',
-    'ashow.save': 'Opslaan',
-    'ashow.off': 'uit',
-    'ashow.off_notice_1': 'De agenda staat nu',
-    'ashow.off_notice_2': '— bezoekers zien geen Agenda-knop en de agendapagina is niet bereikbaar. Zet \'m aan om je evenementen te tonen.',
-    'ashow.subscribers': 'abonnee(s) voor evenement-aankondigingen.',
-    'ashow.smtp_warn': '⚠ SMTP niet ingesteld — evenementen worden opgeslagen, maar notify-mails kunnen pas verstuurd worden zodra je SMTP invult.',
-    'ashow.f_date': 'Datum',
-    'ashow.f_time': 'Tijd (optioneel)',
-    'ashow.f_city': 'Plaats',
-    'ashow.f_country': 'Land (optioneel)',
-    'ashow.f_venue': 'Locatie/zaal (optioneel)',
-    'ashow.f_ticket': 'Ticket-URL (optioneel)',
-    'ashow.f_notes': 'Notitie (optioneel)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Abonnees per e-mail op de hoogte brengen',
-    'ashow.smtp_required': '(SMTP vereist)',
-    'ashow.add_event': '+ Evenement toevoegen',
-    'ashow.del_confirm': 'Evenement verwijderen??',
-    'ashow.empty': 'Nog geen evenementen.',
-    'anews.title': 'Nieuwsbrief',
-    'anews.confirmed': 'bevestigd',
-    'anews.pending': 'in afwachting',
-    'anews.unsub': 'uitgeschreven',
-    'anews.smtp_warn_1': '⚠ SMTP is nog niet ingesteld. Aanmeldingen worden wél verzameld, maar versturen kan pas als je SMTP-gegevens invult',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Aanmeldlink om te delen:',
-    'anews.subject': 'Onderwerp',
-    'anews.subject_ph': 'Nieuwe single uit!',
-    'anews.body': 'Bericht',
-    'anews.body_ph': 'Schrijf je update…',
-    'anews.send_confirm': 'Nieuwsbrief versturen naar {n} bevestigde abonnee(s)?',
-    'anews.send_btn': 'Versturen naar {n} abonnee(s)',
-    'anews.sent_heading': 'Verstuurd',
-    'anews.recipients': 'ontvanger(s)',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Laatste wijzigingen',
-    'aupd.back_admin': 'Beheer',
-    'aupd.version_heading': 'Versie van deze Klonkt',
-    'aupd.app_version': 'App-versie',
-    'aupd.current': 'Huidig',
-    'aupd.current_unknown': 'onbekend (nog niet via de update-knop bijgewerkt)',
-    'aupd.latest': 'Nieuwste',
-    'aupd.latest_failed': 'kon de nieuwste versie niet ophalen',
-    'aupd.no_source': 'Geen update-bron bereikbaar',
-    'aupd.uptodate': 'Up-to-date',
-    'aupd.update_available': 'Update beschikbaar',
-    'aupd.behind_one': '{n} commit achter',
-    'aupd.behind_many': '{n} commits achter',
-    'aupd.run_confirm': 'De site wordt naar de nieuwste versie gebracht en herstart kort. Doorgaan?',
-    'aupd.redeploy': 'Opnieuw uitrollen',
-    'aupd.update_now': 'Nu bijwerken',
-    'aupd.help': 'Bijwerken haalt de nieuwste code op en herstart deze site kort (~10s). Doe dit rustig — er gaat niets verloren (je posts, instellingen en cirkel blijven staan).',
-    'aupd.manual_hint': 'Werk bij vanaf GitHub door dit op je server uit te voeren:',
-    'aepk.title': 'Perskit bewerken',
-    'aepk.saved': 'Perskit opgeslagen',
-    'aepk.back_admin': 'Beheer',
-    'aepk.view_epk': 'Bekijk perskit',
-    'aepk.text_heading': 'Tekst',
-    'aepk.text_help': 'De perskit (/pers) toont je sitenaam + foto, deze bio en contact, plus automatisch je meest beluisterde nummers en recente posts. Laat de bio leeg om de site-tagline te gebruiken; laat contact leeg om niets te tonen (je login-mail wordt nooit automatisch getoond).',
-    'aepk.bio_label': 'Pers-bio',
-    'aepk.bio_ph': 'Korte beschrijving van jou/het project voor pers & boekers.',
-    'aepk.contact_label': 'Pers-contact',
-    'aepk.contact_ph': 'bv. pers@jouwdomein.nl of een boekingslink',
-    'aepk.tracks_label': 'Nummers op de perskit',
-    'aepk.tracks_hint': '(kies er max {n}; laat leeg voor automatisch de top {n} meest beluisterd)',
-    'aepk.no_tracks': 'Nog geen nummers — voeg eerst audio toe in Beheer → Audio.',
-    'aepk.untitled': '(zonder titel)',
-    'aepk.save': 'Opslaan',
-    'ahelp.back': 'Beheer',
-    'ahelp.title': 'Handleiding',
-    'ahelp.intro': 'Uitleg van alle functies. Typ hieronder om te zoeken op een onderwerp of instructie.',
-    'ahelp.search_placeholder': 'Zoek… (bv. \'agenda\', \'foto\', \'cirkel\', \'downloads\')',
-    'ahelp.search_aria': 'Zoek in de handleiding',
-    'ahelp.premium': 'premium',
-    'ahelp.empty': 'Geen onderwerpen gevonden voor je zoekopdracht.',
-    'ahelp.s_newpost_h': 'Nieuwe post schrijven',
-    'ahelp.s_newpost_b': 'Beheer → <strong>Nieuwe post</strong>. Bovenaan kies je het <strong>type</strong> (Post · Foto · Video · Audio) — dat bepaalt de invoer eronder. Geef een titel en schrijf je inhoud. Onderaan kies je de status: <em>concept</em> (niet zichtbaar) of <em>gepubliceerd</em>. Concepten staan bovenaan in je Beheer-overzicht zodat je ze terugvindt.',
-    'ahelp.s_excerpt_h': 'Samenvatting & Cirkel Preview',
-    'ahelp.s_excerpt_b': 'Het veld <strong>Samenvatting & Cirkel Preview</strong> (de excerpt) is de korte previewtekst onder een post in lijsten, én de samenvatting die andere sites tonen als ze je post via een <strong>Cirkel</strong> overnemen. Laat je \'m leeg, dan wordt automatisch het begin van de post gebruikt.',
-    'ahelp.s_pin_h': 'Post pinnen / volgorde',
-    'ahelp.s_pin_b': 'In de post-editor kun je een post <strong>pinnen</strong> met een rang (1 = bovenaan). Gepinde posts staan vooraan in de tijdlijn/grid, op volgorde van hun rang. Rang leeg of 0 = niet gepind.',
-    'ahelp.s_schedule_h': 'Publiceren plannen & alleen voor vrienden',
-    'ahelp.s_schedule_b': 'In de editor kun je een <strong>publicatiedatum</strong> in de toekomst zetten; de post verschijnt dan automatisch op dat moment. Met <strong>Alleen voor vrienden</strong> zien niet-ingelogde bezoekers alleen een teaser + login-uitnodiging; ingelogde vrienden zien alles.',
-    'ahelp.s_images_h': 'Afbeeldingen in posts',
-    'ahelp.s_images_b': 'Afbeeldingen in de tekst en de cover-afbeelding worden altijd <strong>volledig</strong> getoond op de volledige breedte (niet bijgesneden), met hun natuurlijke hoogte.',
-    'ahelp.s_audio_h': 'Audio & nummers toevoegen',
-    'ahelp.s_audio_b': 'Beheer → <strong>Audio</strong>. Upload een bestand of voeg een <em>link-only</em> nummer toe (zonder upload, alleen "open in"-links). Per nummer vul je titel, artiest, cover, en optioneel album/positie in. In een post toon je een nummer met de shortcode <code>[[track:id]]</code>, een album met <code>[[album:Naam]]</code>, een playlist met <code>[[playlist:id]]</code>. <strong>Sneller:</strong> kies in een post bovenaan type <em>Audio</em> en sleep het bestand er direct in — het wordt omgezet en meteen in de post gezet.',
-    'ahelp.s_credit_h': 'Credit, licentie & "open in"',
-    'ahelp.s_credit_b': 'Per nummer kun je een <strong>eigenaar/credit</strong> (met © -knop) en een <strong>licentie</strong> instellen — die worden ook in de mp3-metadata geschreven. Met de <strong>open-in</strong>-velden (Spotify / YouTube / SoundCloud) verschijnen knoppen om het nummer op die platforms te openen.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Markeer een nummer als <strong>downloadbaar</strong> in Beheer → Audio (⬇-knop). Bezoekers vinden ze op de <strong>/downloads</strong>-pagina en laten hun e-mail achter om het bestand te krijgen (komt op je mailinglijst). Wil je downloads prominent in de feed? Maak een gewone post met slug <code>downloads</code> en pin \'m.',
-    'ahelp.s_albums_h': 'Albums & playlists',
-    'ahelp.s_albums_b': 'Geef nummers hetzelfde <strong>album</strong> + een <strong>positie</strong> om een album te vormen. Playlists maak je in Beheer → <strong>Playlists</strong>. Beide toon je in een post met <code>[[album:Naam]]</code> of <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Agenda / evenementen',
-    'ahelp.s_agenda_b': 'Beheer → <strong>Agenda</strong>. Zet bovenaan <strong>"Agenda tonen op de site"</strong> aan — dan verschijnt de Agenda-knop in de balk en is de agendapagina bereikbaar. Voeg evenementen toe (datum, plaats, locatie, tickets). Bezoekers kunnen zich (los van de nieuwsbrief) aanmelden voor een seintje bij een nieuw evenement.',
-    'ahelp.s_presskit_h': 'Perskit',
-    'ahelp.s_presskit_b': 'Een deelbare perspagina op <strong>/pers</strong>. Bewerk \'m via de <strong>✎ Bewerken</strong>-knop op die pagina zelf (alleen jij ziet die). Stel een korte pers-bio + contact in, en kies <strong>tot 5 nummers</strong> die getoond worden (of laat leeg = automatisch de top-5 meest beluisterd).',
-    'ahelp.s_circles_h': 'Cirkels (federatie)',
-    'ahelp.s_circles_b': 'Een <strong>Cirkel</strong> is je eigen samengestelde feed. Open <strong>Fediverse → Volgend</strong>, volg accounts en <strong>licht ze uit</strong> (✨). Uitgelichte accounts én posts die je <strong>boost</strong> (🔁) verschijnen in je <strong>/cirkel</strong>-feed — een geboooste post krijgt een Boost-badge. Boost je een post van iemand die je niet volgt, dan komt die er ook in. Dit is lokaal: er gaat niets automatisch de fediverse op — naar je eigen volgers boosten doe je bewust per post.',
-    'ahelp.s_stats_h': 'Statistieken',
-    'ahelp.s_stats_b': 'Beheer → <strong>Statistieken</strong>. Cookievrij gemeten. <strong>Bezoeker-dagen</strong> = unieke bezoekers per dag, opgeteld (géén aantal personen). <strong>Weergaven</strong> = home/feed- en post-loads. Beheerder-bezoeken en bots tellen niet mee. Goed voor trends; absolute aantallen met een korrel zout.',
-    'ahelp.s_newsletter_h': 'Nieuwsbrief',
-    'ahelp.s_newsletter_b': 'Beheer → <strong>Nieuwsbrief</strong>: stel een bericht op en stuur het naar je bevestigde abonnees. Bezoekers melden zich aan via de footer of <strong>/nieuwsbrief</strong>. Versturen vereist dat e-mail (SMTP) is ingesteld.',
-    'ahelp.s_linkbio_h': 'Link-in-bio',
-    'ahelp.s_linkbio_b': 'Een Linktree-achtige pagina op <strong>/links</strong> met je profiel-links. De kliks per link zie je terug in Statistieken.',
-    'ahelp.s_embed_h': 'Embedbare speler',
-    'ahelp.s_embed_b': 'Beheer → Audio toont een kopieerbare <code>&lt;iframe&gt;</code>-code (<strong>/embed</strong>) waarmee je je speler op een andere website kunt insluiten.',
-    'ahelp.s_appearance_h': 'Uiterlijk (thema, foto, accent)',
-    'ahelp.s_appearance_b': 'Beheer → <strong>Uiterlijk</strong>: stel je sitenaam, tagline, profielfoto, accentkleur en kleurpalet in, en de standaard feed-weergave (Tijdlijn of Grid).',
-    'ahelp.s_tenancy_h': 'Solo- / Cirkel-modus',
-    'ahelp.s_tenancy_b': 'Bovenaan <strong>Beheer → Instellingen</strong> kies je de modus. <em>Solo</em> = een op zichzelf staande blog: geen fediverse, geen reacties. <em>Cirkels</em> = je site doet mee aan de fediverse (ActivityPub): mensen kunnen je volgen en reageren, en je krijgt de Fediverse-sectie + je Cirkel-feed. Wisselen kan veilig — er wordt niets verwijderd.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'In de modus <strong>Cirkels</strong> doet je site mee aan de fediverse (Mastodon e.d.). Open de <strong>Fediverse</strong>-sectie via de wereldbol in de menubalk (of de bel voor meldingen). Vijf tabs: <strong>News</strong> (de berichten van wie je volgt — hier ⭐ liken en 🔁 boosten, nog eens klikken = ongedaan), <strong>Volgend</strong> (accounts volgen via een @handle of profiel-URL, en uitlichten ✨ voor je Cirkel), <strong>Reacties</strong> (je verzonden reacties + de sleep-naar-bladwijzerbalk <em>interactie-bookmarklet</em> om vanaf elke fediverse-post te reageren), <strong>Meldingen</strong> (nieuwe volgers, likes, boosts en reacties op jouw posts) en <strong>Blokkeren</strong> (een account of heel domein blokkeren). Onder elke post zie je bij "Vanuit de fediverse" de inkomende reacties, likes en boosts; als eigenaar kun je daar direct reageren, liken of boosten. Bezoekers gebruiken de knop "Interact via the fediverse" om vanaf hun eigen account te reageren. Klik je profielfoto voor een samenvatting van je profiel; bezoekers vinden daar ook een "Volg via de fediverse"-knop. Je posts worden automatisch aan je volgers bezorgd.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'Premium-functies (Statistieken, Agenda, Downloads, Perskit, Nieuwsbrief, Link-in-bio, Embed) ontgrendel je in Beheer → Instellingen door Patreon te koppelen ($16 lifetime). Updates en de kernapp blijven altijd gratis.',
-    'ahelp.s_password_h': 'Wachtwoord vergeten / resetten',
-    'ahelp.s_password_b': 'Resetten gaat via de <strong>command-line scripts</strong> op de server. Draai in de projectmap <code>npm run reset-admin</code>: zonder argument reset dat de god-user en print het nieuwe wachtwoord. Een specifieke gebruiker: <code>npm run reset-admin -- &lt;gebruiker|e-mail&gt;</code>. Zelf een wachtwoord kiezen (minstens 8 tekens): <code>npm run reset-admin -- &lt;gebruiker|e-mail&gt; &lt;wachtwoord&gt;</code>. Log daarna in via <strong>/auth/login</strong>. De resetlink-per-mail op <strong>/auth/reset-request</strong> werkt alleen als e-mail (SMTP) is ingesteld; de command-line werkt altijd.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Beheer → <strong>Updates</strong> (alleen god) toont de huidige versie en of er een nieuwere is. Met "Nu bijwerken" haal je de laatste versie binnen.',
-    // === Publieke + overige pagina's ===
-    'pedit.title_new': 'Nieuwe post',
-    'pedit.title_edit': 'Post bewerken',
-    'pedit.f_title': 'Titel',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'auto van titel als leeg',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(komma-gescheiden)',
-    'pedit.f_excerpt': 'Samenvatting & Cirkel Preview',
-    'pedit.excerpt_hint': 'Wordt ook gebruikt als samenvatting in <strong>Cirkels</strong> (andere sites die je post tonen). Leeg laten = begin van de post.',
-    'pedit.s_cover': 'Cover',
-    'pedit.f_cover_url': 'Cover URL',
-    'pedit.cover_url_placeholder': '/media/…  of https://…  (of upload met de knop)',
-    'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
-    'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
-    'pedit.f_language': 'Taal',
-    'pedit.language_hint': 'voor het fediverse-taalfilter',
-    'pedit.cover_upload_btn': 'Upload nieuwe cover',
-    'pedit.s_content': 'Content',
-    'pedit.content_hint': 'Sleep een afbeelding om in te voegen · selecteer tekst om op te maken',
-    'pedit.tb_done': 'Klaar',
-    'pedit.tb_done_title': 'Klaar met bewerken',
-    'pedit.tb_bold': 'Vet',
-    'pedit.tb_bold_title': 'Vet (Ctrl+B)',
-    'pedit.tb_italic': 'Cursief',
-    'pedit.tb_italic_title': 'Cursief (Ctrl+I)',
-    'pedit.tb_underline': 'Onderstreept',
-    'pedit.tb_h2': 'Kop',
-    'pedit.tb_h3': 'Subkop',
-    'pedit.tb_p': 'Paragraaf',
-    'pedit.tb_ul': 'Lijst',
-    'pedit.tb_ol': 'Genummerde lijst',
-    'pedit.tb_quote': 'Quote',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Ctrl+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Afbeelding',
-    'pedit.tb_image_title': 'Afbeelding invoegen',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Track invoegen',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Playlist invoegen',
-    'pedit.tb_embed': 'Media embedden',
-    'pedit.tb_embed_title': 'Embed (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Wis opmaak',
-    'pedit.tb_fullscreen': 'Volledig scherm',
-    'pedit.editor_aria': 'Content',
-    'pedit.editor_placeholder': 'Begin met schrijven…',
-    'pedit.tap_to_edit': 'Tik om te bewerken',
-    'pedit.tap_to_write': 'Tik om te schrijven…',
-    'pedit.chars': 'tekens',
-    'pedit.s_publication': 'Publicatie',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Gepubliceerd',
-    'pedit.status_draft': 'Concept',
-    'pedit.status_archived': 'Gearchiveerd',
-    'pedit.f_type': 'Type',
-    'pedit.type_post': 'Post',
-    'pedit.type_foto': 'Foto',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'Wat voor post?',
-    'pedit.audio_up_drop': 'Sleep audio hierheen of klik om te kiezen',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — wordt automatisch omgezet en meteen in je post gezet. Details pas je later aan via de track.',
-    'pedit.audio_up_busy': 'Uploaden…',
-    'pedit.audio_up_done': 'In je post gezet',
-    'pedit.audio_up_fail': 'Mislukt',
-    'pedit.video_up_title': 'Video toevoegen',
-    'pedit.video_up_ph': 'Plak een video-URL (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Invoegen',
-    'pedit.foto_up_hint': 'Zet je foto als cover hieronder, of voeg foto’s toe in de tekst met de afbeelding-knop in de werkbalk.',
-    'pedit.pin_label': 'Vastpinnen bovenaan',
-    'pedit.pin_up': 'Hoger zetten',
-    'pedit.pin_down': 'Lager zetten',
-    'pedit.pin_top': 'bovenaan',
-    'pedit.pin_nth_suffix': 'e van boven',
-    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'pedit.fedi_audio_label': 'Audio openbaar delen op de fediverse (speelt inline in apps; bestand downloadbaar)', 'pedit.fedi_audio_oneway': 'Let op: openen is permanent', 'pedit.fedi_audio_locked': 'Deze audio is openbaar gedeeld op de fediverse. Dat is permanent — het bestand is al verspreid.', 'pedit.nsfw_cw_ph': 'Waarschuwingstekst (optioneel, standaard: Gevoelige inhoud)', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen', 'post.share': 'Deel', 'post.share_copied': 'Link gekopieerd ✓', 'pedit.poll_label': 'Peiling toevoegen', 'pedit.poll_locked': 'Er is al gestemd — de opties kunnen niet meer wijzigen.', 'pedit.poll_option_ph': 'Optie', 'pedit.poll_add': 'Optie toevoegen', 'pedit.poll_remove': 'Optie verwijderen', 'pedit.poll_multiple': 'Meerdere keuzes toestaan', 'pedit.poll_duration': 'Looptijd', 'pedit.poll_dur_5m': '5 minuten', 'pedit.poll_dur_30m': '30 minuten', 'pedit.poll_dur_1h': '1 uur', 'pedit.poll_dur_6h': '6 uur', 'pedit.poll_dur_12h': '12 uur', 'pedit.poll_dur_1d': '1 dag', 'pedit.poll_dur_3d': '3 dagen', 'pedit.poll_dur_7d': '7 dagen',
-    'pedit.fan_only_label': 'Alleen voor vrienden',
-    'pedit.schedule_label': 'Publicatie inplannen',
-    'pedit.schedule_hint': 'Staat dit uit, dan gaat je post meteen live. Aan = kies hieronder wanneer \'ie verschijnt.',
-    'pedit.publish_at_label': 'Datum & tijd',
-    'pedit.scheduled_for': 'Ingepland voor {d}',
-    'pedit.scheduled_prefix': 'Ingepland voor',
-    'pedit.cancel': 'Annuleren',
-    'pedit.publish': 'Publish',
-    'pedit.save': 'Opslaan',
-    'pedit.js_link_prompt': 'Link URL (https://… of /pad)',
-    'pedit.js_uploading': 'Uploaden…',
-    'pedit.js_uploaded': 'Geüpload',
-    'pedit.js_inserted': 'Ingevoegd',
-    'pedit.js_failed': 'Mislukt',
-    'pedit.js_embed_prompt': 'Plak een media-URL om in te sluiten (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Geef een volledige URL (https://…).',
-    'pedit.js_no_tracks_found': 'Geen tracks gevonden voor',
-    'pedit.js_no_tracks_yet': 'Nog geen tracks. Upload via Beheer naar Audio.',
-    'pedit.js_tracks_loading': 'Tracks laden…',
-    'pedit.js_tracks_load_fail': 'Kon tracks niet laden',
-    'pedit.js_playlist_editor_missing': 'Playlist editor niet geladen',
-    'pedit.js_playlist_existing': 'Bestaande playlists:',
-    'pedit.js_playlist_choose': 'Kies een nummer om in te voegen, leeg = nieuwe maken:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Track invoegen',
-    'pedit.tp_close': 'Sluiten',
-    'pedit.tp_search_placeholder': 'Zoek op titel of artiest…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Afbeelding bewerken',
-    'imed.rotate_left': 'Linksom draaien', 'imed.rotate_right': 'Rechtsom draaien',
-    'imed.flip_h': 'Horizontaal spiegelen', 'imed.flip_v': 'Verticaal spiegelen',
-    'imed.zoom_in': 'Inzoomen', 'imed.zoom_out': 'Uitzoomen', 'imed.reset': 'Herstellen',
-    'imed.cancel': 'Annuleren', 'imed.apply': 'Toepassen',
-    'acct.back_home': 'Terug naar home',
-    'acct.title': 'Account',
-    'acct.subtitle': 'Profiel en avatar.', 'acct.oauth_apps': 'Verbonden apps', 'acct.oauth_hint': 'Apps die je toegang tot je account hebt gegeven via OAuth. Trek in wat je niet meer vertrouwt of gebruikt.', 'acct.oauth_none': 'Nog geen apps verbonden.', 'acct.oauth_unknown_app': 'Onbekende app', 'acct.oauth_last_used': 'laatst gebruikt', 'acct.oauth_never': 'nooit', 'acct.oauth_revoke': 'Intrekken', 'acct.oauth_revoked': 'App-toegang ingetrokken.', 'acct.oauth_revoke_none': 'Die toegang bestond niet meer.',
-    'acct.viewer_mode': 'Kijker-modus',
-    'acct.viewer_note': 'Dit is een demo-account. Je kunt alles bekijken, maar niets wijzigen — ook geen foto of bio.',
-    'acct.profile': 'Profiel',
-    'acct.avatar_change': 'Klik om je foto te wijzigen',
-    'acct.member_since': 'Lid sinds', 'acct.photo_site_hint': 'Je profielfoto stel je in bij je <a href="/admin">site-instellingen</a> — één foto, overal gebruikt.',
-    'acct.avatar_remove': 'Foto verwijderen',
-    'acct.username': 'Gebruikersnaam',
-    'acct.email': 'E-mailadres',
-    'acct.email_ph': 'jij@email.nl',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'Een korte regel over jezelf',
-    'acct.bio_empty': 'Geen bio',
-    'acct.save': 'Opslaan',
-    'acct.site': 'Site',
-    'acct.site_name': 'Site-naam',
-    'acct.site_name_hint': '— getoond in de kop van je site',
-    'acct.tagline': 'Tagline',
-    'acct.tagline_hint': '— korte oneliner (optioneel)',
-    'acct.site_save': 'Site opslaan',
-    'acct.password_change': 'Wachtwoord wijzigen',
-    'acct.password_current': 'Huidig wachtwoord',
-    'acct.password_new': 'Nieuw wachtwoord',
-    'acct.password_min': '(min 8 tekens)',
-    'acct.password_confirm': 'Bevestig nieuw wachtwoord',
-    'acct.login': 'Inloggen',
-    'acct.login_google_only': 'Dit account heeft geen wachtwoord ({email}). Gebruik "Wachtwoord vergeten" om er een in te stellen.',
-    'news.this_artist': 'deze artiest',
-    'news.form_title': 'Blijf op de hoogte',
-    'news.form_sub_before': 'Schrijf je in voor de nieuwsbrief van ',
-    'news.form_sub_after': ' — nieuwe muziek, shows en updates, rechtstreeks in je inbox. Uitschrijven kan altijd met één klik.',
-    'news.email_ph': 'jouw@email.nl',
-    'news.subscribe': 'Inschrijven',
-    'news.check_title': 'Bijna klaar ✉',
-    'news.check_sub_before': 'We hebben een bevestigingsmail gestuurd naar ',
-    'news.check_sub_after': '. Klik op de link in die mail om je inschrijving te bevestigen.',
-    'news.your_address': 'je adres',
-    'news.done_title': 'Je bent ingeschreven ✓',
-    'news.done_sub_before': 'Bedankt — je staat op de lijst van ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Inschrijving bevestigd ✓',
-    'news.confirmed_sub_before': 'Top! Je ontvangt voortaan de nieuwsbrief van ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Uitgeschreven',
-    'news.unsubbed_sub': 'Je bent afgemeld. Je ontvangt geen nieuwsbrieven meer. Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven.',
-    'news.invalid_title': 'Ongeldig e-mailadres',
-    'news.invalid_sub': 'Controleer je adres en probeer \'t opnieuw.',
-    'news.back': '← Terug',
-    'news.smtperror_title': 'Even niet gelukt',
-    'news.smtperror_sub': 'De bevestigingsmail kon niet verstuurd worden. Probeer \'t later nog eens.',
-    'news.badtoken_title': 'Link ongeldig of verlopen',
-    'news.badtoken_sub': 'Deze link werkt niet meer. Schrijf je eventueel opnieuw in.',
-    'news.to_subscribe': 'Naar inschrijven',
-    'news.error_title': 'Er ging iets mis',
-    'news.error_sub': 'Probeer \'t later nog eens.',
-    'dl.ready_title': 'Bedankt! ⬇',
-    'dl.ready_sub': 'Je download zou nu moeten starten. Gebeurt er niets?',
-    'dl.manual': 'Download handmatig starten',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Laat je e-mailadres achter en je krijgt het bestand. Je komt dan ook op de nieuwsbrieflijst — uitschrijven kan altijd.',
-    'dl.email_ph': 'jouw@email.nl',
-    'epk.kicker': 'Perskit',
-    'epk.edit': 'Bewerken',
-    'epk.contact_booking': 'Contact / boeking',
-    'epk.view_site': 'Bekijk de site →',
-    'epk.press_photo': 'Persfoto',
-    'epk.most_played': 'Meest beluisterd',
-    'epk.recent': 'Recent',
-    'fgate.title': 'Alleen voor vrienden',
-    'fgate.sub': 'Dit bericht is voor wie deze site volgt.',
-    'owa.title': 'Aanmelden bij een andere site', 'owa.sub': 'Deze site vraagt je server te bevestigen wie je bent. Ga je door, dan weet die site je adres.', 'owa.as': 'Je meldt je aan als', 'owa.choose': 'Met welke van je sites?', 'owa.go': 'Ja, meld me aan', 'owa.cancel': 'Nee, terug', 'owa.fine': 'Er gaat geen wachtwoord naar die site. Alleen je adres, en alleen als je hier ja zegt.', 'fgate.owa_label': 'Log in met je eigen fediverse-adres', 'fgate.owa_go': 'Ga verder', 'fgate.owa_hint': 'Je server bevestigt wie je bent. Hier heb je geen account en geen wachtwoord nodig — volg je deze site, dan ben je binnen.', 'fgate.owa_failed': 'Dat adres kon ik niet bereiken. Klopt de spelling?', 'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
-    'lbio.empty': 'Nog geen links ingesteld.',
-    'lbio.back_to_site': 'naar de site',
-    'myst.overview': 'Overzicht',
-    'myst.title': 'Mijn Klonkt Hub',
-    'myst.quick_links_aria': 'Beheer-snelkoppelingen',
-    'myst.new_post': 'Nieuwe post',
-    'myst.appearance': 'Uiterlijk',
-    'myst.comments': 'Reacties',
-    'myst.view_site': 'Bekijk mijn site',
-    'myst.account': 'Account',
-    'myst.posts': 'Posts',
-    'myst.published': 'Gepubliceerd',
-    'myst.draft_count_one': '{n} concept',
-    'myst.draft_count_many': '{n} concepten',
-    'myst.draft_badge': 'Concept',
-    'myst.untitled': '(zonder titel)',
-    'myst.edit': 'Bewerken',
-    'myst.view': 'Bekijk',
-    'arst.title': 'Wachtwoord resetten',
-    'arst.request_new': 'Vraag een nieuwe reset-link aan',
-    'arst.back_login': 'Terug naar inloggen',
-    'arst.set_for': 'Stel een nieuw wachtwoord in voor {username}.',
-    'arst.new_pw': 'Nieuw wachtwoord (min 8 tekens)',
-    'arst.confirm_pw': 'Bevestig nieuw wachtwoord',
-    'arst.submit': 'Wachtwoord instellen',
-    'arrq.title': 'Wachtwoord resetten',
-    'arrq.sent': 'Als er een account bestaat voor dat e-mailadres, is er een reset-link verstuurd.',
-    'arrq.no_mailserver': '<strong>Geen e-mailserver ingesteld</strong> — reset-link hieronder.',
-    'arrq.no_mail_cli': 'Geen e-mail ingesteld? De beheerder kan ook <code>npm run reset-admin</code> draaien op de server.',
-    'arrq.back_login': '← Terug naar inloggen',
-    'arrq.tagline': 'Vul je e-mail in; we sturen een reset-link.',
-    'arrq.email': 'E-mail',
-    'arrq.submit': 'Stuur reset-link',
-    'adir.home': 'Home',
-    'adir.logout': 'Uitloggen',
-    'adir.login': 'Inloggen',
-    'adir.title': 'Leden',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Zoek op naam…',
-    'adir.search_aria': 'Zoek leden',
-    'adir.search_btn': 'Zoeken',
-    'adir.clear': 'Wissen',
-    'adir.empty_q': 'Geen leden gevonden voor "{q}".',
-    'adir.empty': 'Nog geen leden.',
-    'adir.posts_one': '{n} post',
-    'adir.posts_many': '{n} posts',
-    'adir.pager_aria': 'Paginering',
-    'adir.prev': '← Vorige',
-    'adir.page_info': 'Pagina {page} van {pages}',
-    'adir.next': 'Volgende →',
-    'pusr.post_one': 'post',
-    'pusr.post_many': 'posts',
-    'pusr.on_this_site': 'op deze site',
-    'pusr.total': '{n} totaal',
-    'pusr.joined': 'lid sinds {date}',
-    'pusr.send_dm': 'Stuur DM',
-    'pusr.empty': 'Nog geen posts op deze site.',
-    'pusr.posts_heading': 'Posts',
-    'pusr.untitled': '(zonder titel)',
-    'phub.main_badge': 'Hoofdpagina',
-    'phub.view_page': 'Bekijk de pagina',
-    'phub.members': 'Leden',
-    'phub.post_one': 'post',
-    'phub.post_many': 'posts',
-    'phub.all_members': 'Alle {n} leden',
-    'phub.latest_posts': 'Laatste posts van onze leden.',
-    'cfeed.title': 'Cirkels',
-    'cfeed.sub': 'Wat er speelt bij de andere sites in mijn cirkel.',
-    'cfeed.count': '{n} sites in mijn cirkel',
-    'cfeed.empty': 'Nog niets in mijn cirkel.',
-    'cfeed.close': 'Sluiten',
-    'cfeed.grid_view': 'Grid-weergave',
-    'cpost.back': 'Cirkel',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Lees verder bij {source}',
-    'vblk.title': 'Kijker-modus',
-    'vblk.text_before': 'Dit is een alleen-lezen account. Je kunt alles bekijken, maar',
-    'vblk.text_strong': 'niets wijzigen',
-    'vblk.text_after': '— opslaan, uploaden, verwijderen en reageren zijn uitgeschakeld.',
-    'vblk.back': 'Terug',
-    'vblk.to_home': 'Naar de hoofdpagina',
-    'phome.moved_lead': 'Dit account is verhuisd. Je vindt me nu hier:',
-    'phome.moved_hint': 'Volg je me al? Dan verhuist je server je waarschijnlijk vanzelf mee. Zo niet: volg het nieuwe adres.',
-    'phome.empty_title': 'Hier is het nog stil.',
-    'phome.empty_sub': 'Nog geen posts. Spannend.',
-    'phome.write_first': 'Schrijf je eerste post',
-    'phome.grid_view': 'Grid-weergave',
-    'fav.title': 'Favorieten',
-    'fav.sub': "Posts die je hebt geliket. Tik op het ♥ bij een post om 'm hier te bewaren.",
-    'fav.empty': 'Je hebt nog geen favorieten. Open een post en tik op het hartje ♡.',
-    'chlog.back': 'Terug',
-    'chlog.title': 'Wijzigingen',
-    'chlog.app_version': 'App-versie',
-    'chlog.fed_proto': 'Federatie-proto',
-    'chlog.manage_updates': 'Updates beheren',
-    'e404.title': 'Pagina niet gevonden',
-    'e404.sub': 'Deze pagina bestaat niet (meer). Misschien is de link verouderd of verkeerd getypt.',
-    'e404.home': 'Naar de homepagina',
-    'e404.archive': 'Archief',
-    'ptype.eyebrow': 'Type',
-    'ptype.count_one': '{n} post',
-    'ptype.count_many': '{n} posts',
-    'ptype.empty': 'Nog geen posts van dit type.',
-    'ptype.untitled': '(zonder titel)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} post',
-    'ptag.count_many': '{n} posts',
-    'ptag.empty': 'Nog geen posts met deze tag.',
-    'ptag.untitled': '(zonder titel)',
-    'parch.back': 'Terug',
-    'parch.title': 'Archief',
-    'parch.count_one': '{n} bericht',
-    'parch.count_many': '{n} berichten',
-    'parch.empty': 'Nog geen berichten.',
-    'prin.tagline': 'Je directe berichten op deze site.',
-    'prin.empty': 'Nog geen gesprekken. Open iemands profiel en klik op "DM sturen" om er een te beginnen.',
-    'prin.empty_conv': 'Leeg gesprek',
-    'prcv.back_aria': 'Terug naar inbox',
-    'prcv.inbox': 'Inbox',
-    'prcv.unknown': 'Onbekend',
-    'prcv.view_profile': 'Bekijk profiel',
-    'prcv.placeholder': 'Bericht…',
-    'prcv.send': 'Stuur',
-    'acct.lang_label': 'Taal',
-    'acct.lang_hint': '— jouw persoonlijke interface-taal; reist mee over apparaten en sessies.',
-    'aset.default_lang': 'Standaardtaal voor bezoekers',
-    'aset.default_lang_hint': 'Wat nieuwe bezoekers zien voordat ze zelf een taal kiezen. Een ingelogde gebruiker met een eigen voorkeur ziet díe.',
-    'aset.default_lang_auto': 'Automatisch (browser-taal)',
-    'aset.timezone': 'Tijdzone',
-    'aset.timezone_hint': 'In welke tijdzone datums en geplande publicaties worden getoond en opgeslagen. Automatisch = serverstandaard (UTC).',
-    'aset.timezone_auto': 'Automatisch (UTC)',
-  },
-  en: {
-    'nav.back_to_site': '← Back to site',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Home',
-    'nav.archive': 'Archive',
-    'nav.search': 'Search',
-    'nav.theme': 'Toggle theme',
-    'nav.theme_label': 'Theme', 'nav.dark': 'Dark', 'nav.light': 'Light',
-    'nav.install': 'Install app',
-    'nav.login': 'Log in',
-    'nav.logout': 'Log out',
-    'nav.admin': 'Admin',
-    'nav.account': 'Account',
-    'nav.profile': 'Profile',
-    'nav.favorites': 'Favorites',
-    'nav.new_post': 'New post',
-    'nav.language': 'Language',
-    'nav.notifications': 'Notifications',
-    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'notif.report_about': 'About the post', 'notif.report_noreason': 'No reason given.', 'notif.mentioned': 'mentioned you in a post', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
-    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
-    'switch.agenda': 'Agenda',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Circles',
-    'switch.grid': 'Grid', 'switch.reader': 'Reader', 'switch.timeline': 'Timeline', 'read.to_top': 'Back to top', 'read.pinned': 'Pinned', 'read.next': 'Next', 'read.prev': 'Previous post', 'read.nav': 'Through the posts', 'read.hint': 'Tap the top or bottom to move a post back or on', 'asite.reader_full_page': 'Reader on desktop: one screen per post (always on mobile)',
-    'asite.feed_alt': 'Second view', 'asite.feed_alt_reader': 'Reader', 'asite.feed_alt_timeline': 'Timeline', 'asite.feed_alt_auto': 'Reader on mobile, Timeline on desktop',
-    'switch.reader_solo_only': 'Reader is Solo only — a circle shows other people\u2019s posts',
-    'postnav.newer': 'Newer',
-    'postnav.older': 'Older',
-    'postnav.newest': 'Newest post',
-    'postnav.oldest': 'Oldest post',
-    'footer.subscribe_cta': 'Stay in the loop',
-    'footer.subscribe': 'Subscribe',
-    'footer.install': 'Install app',
-    'common.email_placeholder': 'you@email.com',
-    'common.back_to_admin': '← Admin',
-    'agenda.title': 'Agenda',
-    'agenda.empty': 'No announced events at the moment.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Never miss an event',
-    'agenda.notify_sub': 'Leave your email and we’ll let you know about new events. Unsubscribe any time.',
-    'agenda.notify_btn': 'Keep me posted',
-    'agenda.msg_done': '✓ You’re on the list — we’ll let you know when an event is announced.',
-    'agenda.msg_check': '✉ Check your email to confirm your sign-up.',
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Free to download — leave your email and you’ll get the file.',
-    'downloads.empty': 'No downloads available right now.',
-    'downloads.btn': '⬇ Download',
-    'admin.title': 'Admin',
-    'adash.prem_active': 'Premium active',
-    'adash.prem_unlinked': 'Premium not linked',
-    'adash.prem_layeroff': 'Premium layer off',
-    'admin.tagline_solo': 'Solo mode — your site.',
-    'admin.tagline_cirkels': 'Circles mode: your site, connected to the fediverse.',
-    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
-    'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_folreq_t': 'Follow request', 'push.n_folreq_b': '{who} wants to follow you — you decide', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.follow_out_line': 'wants to follow {who}', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_guard_folin_t': 'Follow request', 'push.n_guard_folin_b': '{who} wants to follow {ward}', 'push.n_guard_folout_t': 'Your ward wants to follow someone', 'push.n_guard_folout_b': '{ward} is asking to follow {who}', 'guardian.panel_history': 'History ({n})', 'guardian.log_show': 'Show history', 'guardian.log_hide': 'Hide history', 'guardian.ev_offer_rejected': 'Offer rejected', 'guardian.ev_offer_refused': 'Offer refused', 'guardian.ev_committed': 'Guardianship committed', 'guardian.ev_guardian_left': 'Guardian left', 'guardian.ev_coguardian_left': 'Co-guardian left', 'guardian.ev_gated_outcome': 'Gate decided', 'guardian.ev_lapse_opened': 'Release proposed', 'guardian.evr_not_a_teapot': 'the candidate is a ward themselves', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_former_ward': 'No longer your ward. Their other guardians are still there for them.', 'guardian.warn_reversible': 'What comes through does not go back. You can close this setting again later — what your child has seen, you cannot.', 'guardian.warn_irreversible': 'This cannot be undone. From then on they decide for themselves, and you cannot take that back.', 'guardian.warn_unknown': 'We do not know this setting, so we cannot tell what comes through or how far it reaches. Ask whoever proposed it before you agree.', 'guardian.warn_decides': 'YOUR ANSWER SETTLES THIS. With yours the threshold is met and it takes effect straight away.', 'guardian.warn_not_last': 'Someone else still has to answer before this takes effect.', 'guardian.warn_tally_elsewhere': 'We cannot see how many guardians have answered — the child\u2019s server counts that. Your yes may be the deciding one.', 'guardian.warn_go': 'Yes, propose this', 'guardian.warn_back': 'No, go back', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_unavailable': 'not available yet', 'guardian.gate_planned_note': 'This does not exist on this server yet.', 'guardian.gates_summary': '{n} gates - {on} on, {wait} waiting', 'guardian.gates_show': 'Show gates', 'guardian.gates_hide': 'Hide gates', 'guardian.gate_images': 'Images', 'guardian.gate_messages': 'Messages', 'guardian.gate_asked': 'Your child asked for this.', 'guardian.gate_replies': 'Replying in a conversation', 'guardian.gate_compose': 'Posting', 'guardian.gate_music': 'Music', 'guardian.gate_quoteCards': 'Quote cards', 'guardian.gate_customEmoji': 'Custom emoji', 'guardian.gate_publicProfile': 'Publicly visible', 'guardian.gate_accountMove': 'Moving account', 'guardian.gate_independence': 'Becoming independent', 'guardian.gate_externalThreads': 'Replies from strangers', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_following': 'Following others', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_default_off': 'off (nothing decided yet)', 'guardian.gate_unknown': 'unknown', 'guardian.gate_always': 'Always', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose_open': 'Propose: open', 'guardian.gate_propose_close': 'Propose: close', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
-    'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
-    'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
-    'ppk.t': 'Create your passkey', 'ppk.h': 'You are a supporter, nice.', 'ppk.sub': 'Now create a passkey. It becomes your key for paid posts, without an account and without a cookie. We store no name or email address.', 'ppk.make': 'Create passkey', 'ppk.unsupported': 'Passkeys are not supported in this browser.', 'ppk.follow': 'Follow the prompt on your device…', 'ppk.done': 'Done. Your passkey has been created.', 'ppk.failed': 'Creating failed ({err}). Try again.', 'ppk.cancelled': 'Cancelled.',
-    'pres.t': 'Unlock', 'pres.notpatron_h': 'Not a supporter yet', 'pres.notpatron_p': 'You are not (yet) an active supporter of this site on Patreon. Become a supporter and then try again from the post.', 'pres.tier_h': 'A higher tier is needed', 'pres.tier_p': 'This post asks from €{need}. Your support is currently €{have}. Raise your support and try again.', 'pres.expired_h': 'Request expired', 'pres.expired_p': 'This unlock link has expired or was already used. Go back to the post and try again.', 'pres.declined_h': 'Unlocking cancelled', 'pres.declined_p': 'Nothing was connected. You can try again from the post.', 'pres.join': 'Become a supporter on Patreon', 'pres.back_post': 'Back to the post', 'pres.back_site': 'Back to the site',
-    'admin.tagline_hub': 'Hub mode — a company site with users, each their own Klonkt Hub.',
-    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Users', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Images', 'admin.media_videos': 'Videos', 'admin.videos_count': 'videos', 'admin.videos_empty': 'No videos uploaded yet.', 'admin.videos_del_confirm': 'Delete this video?', 'admin.media_count': 'images', 'admin.media_unused': 'unused', 'admin.media_cleanup': 'Delete unused', 'admin.media_cleanup_confirm': 'Delete all unused images?', 'admin.media_empty': 'No images uploaded yet.', 'admin.media_copy': 'Copy URL', 'admin.media_del_confirm': 'Delete this image?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Comments', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Listeners',
-    'alis.lis_title': 'Listeners',
-    'alis.lis_intro': 'Accounts following your LIBRARY. They get your music, and deliberately not your ordinary posts — subscribing to a record shelf is not asking for the newspaper.',
-    'alis.lis_empty': 'Nobody follows the library yet.',
-    'alis.lis_since': 'Since',
-    'alis.lis_last': 'Last delivery',
-    'alis.lis_never': 'nothing delivered yet',
-    'alis.lis_count': 'listener(s)',
-    'alis.lis_error': 'delivery failed',
-    'ple.titel': 'Title *',
-    'ple.artiest': 'Artist',
-    'ple.jaar': 'Year',
-    'ple.type': 'Type',
-    'ple.k_album': 'Album (numbered)',
-    'ple.k_playlist': 'Playlist (track covers)', 'ple.k_mixtape': 'Mixtape (a tape: forwards and back only)',
-    'ple.uitgave': 'Release date',
-    'ple.mb_release': 'MusicBrainz release ID',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Choose image…',
-    'ple.cover_url': 'https://… or upload',
-    'ple.tracks_in': 'Tracks in playlist',
-    'ple.sleep_hint': '(drag ⠿ to reorder)',
-    'ple.beschikbaar': 'Available tracks',
-    'ple.zoek': 'Search…',
-    'ple.geen_res': 'No results.',
-    'ple.leeg_sel': 'Click tracks on the right to add them.',
-    'ple.t_edit': 'Edit playlist',
-    'ple.t_new': 'New playlist',
-    'ple.dialoog': 'Playlist editor',
-    'ple.sluiten': 'Close',
-    'ple.annuleren': 'Cancel',
-    'ple.opslaan': 'Save',
-    'ple.bezig_opslaan': 'Saving…',
-    'ple.aanmaken': 'Create',
-    'ple.versleep': 'Drag',
-    'ple.verwijder': 'Remove',
-    'ple.geen_audio': 'Track has no audio file',
-    'ple.e_geen_tracks': 'No audio tracks available. Upload some first via Admin → Audio.',
-    'ple.e_tracks': 'Could not load tracks',
-    'ple.e_opslaan': 'Saving failed: ',
-    'ple.e_mislukt': 'Failed: ',
-    'ple.e_alleen_afb': 'Images only',
-    'ple.bezig': 'Uploading…',
-    'ple.e_upload': 'Upload failed',
-    'admin.b_settings': '⚙️ Settings', "mig.title": "Migrate", "mig.plan_new": "on your new Klonkt", "mig.plan_from_old": "You are looking at the instance that is LEAVING. Steps 1, 3 and 4 happen on your new Klonkt.", "mig.plan_title": "How a move works", "mig.plan_hint": "The order matters. Step 2 must come before step 3, because your old Klonkt gives nothing to an address it does not know as its successor. Working from a zip? Then step 2 can wait until last: a file asks nobody for permission.", "mig.plan_here": "here", "mig.plan_old": "on your old Klonkt", "mig.plan_1": "Link your previous account", "mig.plan_1_why": "Skip this and your old Klonkt refuses the move.", "mig.plan_2": "Announce the move", "mig.plan_2_why": "Your followers come along. Your old account locks afterwards: no more posting, following or replying there.", "mig.plan_3": "Fetch your posts and music", "mig.plan_3_why": "Straight from your old Klonkt, or from a zip if it is already offline.", "mig.plan_4": "Restore your following list", "mig.plan_5": "Later: when you give up the old domain", "mig.plan_5_why": "Delete your old posts there first. Then someone else’s boost disappears cleanly instead of sitting there as a broken card with a dead link. After that, remove your previous account at step 1 above: you no longer control that address. There is no button for this yet.", "mig.follows_note": "Or paste the contents of your CSV above. The file wins if you fill in both.", "asite.moved_to_migrate": "Aliases and moving now live under Migrate, together with export, import and fetch.", "mig.alias_title": "Step 1: link your previous account", "mig.alias_hint": "Tell us which account used to be yours. Your old Klonkt checks this before it moves your followers, and the fetch button below needs it too.", "mig.alias_label": "Your previous accounts, one per line", "mig.alias_note": "The way you would give them to someone: @you@mastodon.social. Five at most.", "mig.alias_btn": "Save", "mig.move_title": "Step 2: announce the move", "mig.move_hint": "This tells all your followers that your account now lives elsewhere. They move with you, and this account locks afterwards: no more posting, following, liking or replying.", "mig.move_label": "Your new address", "mig.move_warn": "This is the only button on this page you cannot undo. Your old account locks afterwards. Using the fetch button? Then this MUST come first: otherwise your old Klonkt hands over nothing.", "mig.move_btn": "Announce the move", "mig.move_confirm": "This announces your move to all your followers and locks this account. Are you sure?", "mig.move_done": "This account already moved to", "mig.r_links_fixed": "posts whose links now point here", "mig.r_posts_updated": "posts repaired (images made local)", "mig.r_tracks_updated": "tracks completed", "mig.r_tracks_missing": "{n} track(s) did NOT arrive, because the audio file was absent. They were deliberately not created: a track that sits in the list and will not play is worse than one that is missing.", "mig.c_tracks": "tracks", "mig.c_playlists": "playlists", "mig.audio_missing": "Note: for {n} track(s) the audio file cannot be found. Those will not travel.", "mig.audio_none": "Note: this site has music, but not a single track is travelling. The files are probably somewhere other than the database thinks.", "mig.pull_title": "Fetch from your old Klonkt", "mig.pull_hint": "If you already moved, this Klonkt fetches your posts straight from the old one. Nothing to type over: having moved is the proof.", "mig.pull_source": "Your old account", "mig.pull_source_hint": "Taken from step 1. Change it here if it is wrong.", "mig.pull_btn": "Fetch posts", "mig.pull_done": "Fetched", "mig.pull_failed": "Fetching did not work", "mig.r_blocks": "blocks carried over", "mig.state": "{n} posts have a pointer from their old address.", "mig.state_done": "The list is complete.", "mig.state_busy": "The list is not complete yet.", "mig.e_no_source": "No old account is known. Fill in the address above.", "mig.e_unreachable": "The old Klonkt cannot be reached.", "mig.e_not_moved_here": "That account has not moved here. Announce the move on your old Klonkt first.", "mig.e_no_backreference": "This account does not say anywhere that the old account was yours. Fill it in at step 1 above, otherwise your old Klonkt cannot tell it is you.", "mig.e_no_outbox": "The old Klonkt has no post list.", "mig.e_partial": "Stopped halfway. What arrived stays; try again.", "mig.e_config": "This Klonkt does not know its own address.", "mig.e_crash": "Something unexpected went wrong.", "mig.e_points_at": "That account points at:", "mig.lead": "Take your posts, music and photos to another Klonkt, or bring them here.", "mig.export_title": "Take with you", "mig.export_hint": "This builds a zip with your posts, their replies, and the media that belongs to them.", "mig.c_posts": "posts", "mig.c_replies": "replies", "mig.c_media": "media files", "mig.c_following": "follows", "mig.c_size": "in size", "mig.missing": "Note: for {n} media reference(s) the file is no longer on disk. Those will not travel.", "mig.too_big": "This archive is too large for the web interface. Use scripts/export-archive.mjs on the server.", "mig.export_btn": "Download archive (.zip)", "mig.export_none": "Nothing to export yet.", "mig.import_title": "Bring here", "mig.import_hint": "Pick your file. You will see what would happen first; nothing changes yet.", "mig.file_label": "Your archive file (.zip)", "mig.overwrite_label": "Replace posts that are already here", "mig.overwrite_hint": "Normally everything already here is left alone. Turn this on and a post with the same name gets replaced, even if it was something else. That does not come back.", "mig.check_btn": "Check", "mig.check_hint": "This changes nothing yet. You will see what would happen first.", "mig.r_dry": "What would happen", "mig.r_done": "Imported", "mig.r_would": "would be added", "mig.r_imported": "added", "mig.r_skipped": "skipped (already here)", "mig.r_overwritten": "overwritten", "mig.r_media": "media files", "mig.r_media_missing": "media missing from the archive", "mig.r_new_ids": "This archive comes from a different web address, so your posts live at a new address here. Other servers do not know that yet, so replies and boosts pointing at the old address stay where they are.", "mig.r_confirm_hint": "Looks right? Pick the same file once more to actually do it.", "mig.r_confirm_btn": "Import for real", "mig.follows_title": "Who you follow", "mig.follows_hint": "Your following list is in the archive too, and you can download and restore it separately under Connect.", "mig.follows_btn": "Go to Connect", "admin.b_migrate": "\ud83d\udce6 Migrate", 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
-    'admin.t_admin': 'Admin', 'admin.t_settings': 'Settings', 'admin.t_audio': 'Audio tracks', 'admin.t_epk': 'Edit press kit', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'New site', 'admin.t_editsite': 'Edit: {title}', 'admin.t_stats': 'Statistics', 'admin.t_updates': 'Updates', 'admin.t_users': 'Users', 'admin.t_hub': 'My Klonkt Hub', 'admin.t_manual': 'Manual', 'aset.premium_gate': '{feature} is a premium feature — connect Patreon in Admin → Settings.',
-    'admin.b_makesite': '🎨 Create your site', 'admin.b_circle': '🔗 Circle', 'admin.b_stats': '📊 Statistics',
-    'admin.b_newsletter': '✉️ Newsletter', 'admin.b_perskit': '📰 Press kit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-bio', 'admin.b_agenda': '📅 Agenda',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Manual', 'admin.b_fediverse': 'My replies',
-    'admin.st_users': 'Users', 'admin.st_sites': 'Sites', 'admin.st_posts': 'Posts', 'admin.st_published': 'Published',
-    'admin.sec_posts': 'Posts', 'admin.sec_sites': 'Sites', 'admin.sec_users': 'Users',
-    'admin.draft': 'Draft', 'admin.edit': 'Edit', 'admin.view': 'View',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Title', 'admin.th_owner': 'Owner', 'admin.th_created': 'Created',
-    'admin.th_username': 'Username', 'admin.th_email': 'Email', 'admin.th_role': 'Role', 'admin.th_joined': 'Joined',
-    'welcome.title': 'Welcome to Klonkt',
-    'welcome.tagline': 'A self-hosted publishing platform.',
-    'welcome.have_account': 'Already have an account? Log in',
-    'welcome.note': 'Create your admin account to get started — registration closes after that.',
-    'welcome.nosite': 'Hi {user}! No site is configured yet.',
-    'auth.admin_login_title': 'Admin login',
-    'auth.username_or_email': 'Username or email',
-    'auth.password': 'Password',
-    'auth.forgot': 'Forgot password?',
-    'auth.public_sub': 'Log in to comment and save your favorites.',
-    'auth.admin_box_q': 'Admin?',
-    'auth.admin_box_sub': 'Log in with username & password',
-    'auth.create_admin': 'Create admin',
-    'auth.reg_intro': 'First-time setup — create your admin account. This can only be done once.',
-    'setup.title': 'Set up your Klonkt',
-    'setup.intro': 'Welcome! Let’s get your site ready — it takes a minute. Pick your language first.',
-    'setup.lang_label': 'Language',
-    'setup.f_sitename': 'Your site’s name',
-    'setup.sitename_ph': 'e.g. your artist name',
-    'setup.submit': 'Create my site',
-    'setup.username_note': 'This becomes your address on the fediverse and can’t be changed later:',
-    'changelog.title': 'Changelog', 'changelog.empty': 'No changelog available.',
-    'auth.f_username': 'Username (3-32 chars, letters/digits/_-)',
-    'auth.f_email': 'Email',
-    'auth.f_password': 'Password (min 8 chars)',
-    'tab.home': 'Home', 'tab.search': 'Search', 'tab.write': 'Write', 'tab.profile': 'Profile',
-    'comments.heading_one': '{n} comment', 'comments.heading_other': '{n} comments',
-    'comments.empty': 'No comments yet.',
-    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
-    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
-    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.report_open': 'Report this post', 'fedi.report_where': 'The report goes to the instance and their moderator(s).', 'fedi.report_ph': 'What is wrong? (optional)', 'fedi.report_send': 'Report', 'fedi.reported_title': 'Reported', 'fedi.reported_done': 'Your report has been sent to their server. Their moderators will review it.', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.mod_remove_confirm': 'Remove this reply from your thread? It will not come back, not even via thread-filling.', 'fedi.mod_report_confirm': 'Report this reply to its author’s server?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.moved_title': 'This account has moved', 'tl.moved_lead': 'New posts, follows, likes and replies now happen at', 'tl.moved_hint': 'Reading keeps working here, and replies to your old posts still arrive. Want to come back? Clear the move target under Appearance.', 'tl.move_title': 'Take your following list with you', 'tl.move_hint': 'Moving to another address? Your followers are told automatically, but who YOU follow is not. Take that list with you here. Works to and from Mastodon too.', 'tl.move_export': 'Download list (CSV)', 'tl.move_import_file': 'Choose your downloaded CSV file:', 'tl.move_import_lbl': 'Or paste the list here:', 'tl.move_import': 'Follow everyone', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.tab_followers': 'Followers', 'tl.followers': 'Followers', 'tl.followers_lead': 'Who follows you on the fediverse, with the last successful delivery. Red = never delivered or last attempt failed — a candidate to clean up after a check.', 'tl.empty_followers': 'No followers yet.', 'tl.last_delivery': 'Last delivery', 'tl.never_delivered': 'Never delivered', 'tl.delivery_failed': 'last attempt failed', 'tl.remove_follower': 'Remove', 'tl.folreq_title': 'Follow requests', 'tl.folreq_sub': 'These wait for your yes or no. Until then the requester sees none of your posts.', 'tl.folreq_accept': 'Accept', 'tl.folreq_deny': 'Deny', 'tl.approve_toggle': 'Approve followers first', 'tl.approve_toggle_hint': 'On: follow requests wait here for your yes. Off: anyone may follow right away.', 'tl.remove_confirm': 'Remove this follower? An active account would have to follow you again.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'you follow', 'tl.dir_follower': 'follows you', 'tl.dir_mutual': 'mutual', 'tl.connect_empty': 'No connections yet. Follow someone above to get started.', 'tl.unreachable': 'Unreachable', 'tl.unreachable_lead': 'We could not reach these followers (never delivered or last attempt failed). Clean them up after a manual check.', 'msg.tab': 'Messages', 'msg.title': 'Messages', 'msg.filter_all': 'All', 'msg.filter_msgs': 'Messages', 'msg.filter_conv': 'Conversations', 'msg.filter_act': 'Activity', 'msg.filter_mod': 'Moderation', 'msg.filter_sent': 'Sent', 'msg.search_ph': 'Search messages…', 'msg.no_match': 'Nothing found.', 'msg.poll_done': 'Your poll has ended', 'msg.poll_total': '{n} voters', 'msg.you': 'You', 'msg.sent_reply': 'replied via the fediverse', 'msg.and_more': 'and {n} others', 'msg.liked_many': 'liked your post', 'msg.boosted_many': 'boosted your post', 'msg.private': 'private', 'msg.private_hint': 'Addressed to you only; not shown on the public post page.', 'msg.new': 'New since your last visit', 'msg.empty': 'No messages yet. Replies, mentions and activity show up here.', 'oauth.title': 'Authorize app', 'oauth.wants_access': 'wants to connect to your Klonkt account.', 'oauth.post_as': 'Post as', 'oauth.scope_read': 'Read your posts, replies and notifications', 'oauth.scope_write': 'Post, reply, like and follow on your behalf', 'oauth.allow': 'Allow', 'oauth.deny': 'Deny', 'oauth.foot': 'You can revoke access later. Only authorize apps you trust.', 're.title': 'Write a reply', 're.bold': 'Bold', 're.italic': 'Italic', 're.link': 'Insert link', 're.list': 'Bullet list', 're.quote': 'Quote', 're.lang': 'Language of your reply', 're.attach': 'Add media (image, audio, video)', 're.attach_err': 'Upload failed', 're.to': 'To:', 're.mention_del': 'Stop addressing this person', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'feed.load_more': 'Load more', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.open': 'open', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.', 'poll.voted_title': 'Vote sent', 'poll.voted_done': 'Your vote is on its way to the poll. The results refresh once the author sends the update.',
-    'comments.to_start': 'to start the conversation.',
-    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
-    'comments.delete_confirm': 'Delete this comment?',
-    'comments.reply_to': 'Reply to {name}…',
-    'comments.add_as': 'Add a comment as', 'comments.placeholder': 'Share your thoughts…',
-    'comments.post': 'Post comment', 'comments.login_to_comment': 'Log in to comment',
-    'comments.pending': 'Your comment is awaiting moderation. It will appear once an admin approves it.',
-    'related.title': 'Related posts',
-    'search.placeholder': 'Search posts and tracks…', 'search.button': 'Search',
-    'search.error': 'Couldn’t run that search. Try a simpler term.',
-    'search.results_one': '{n} result for “{q}”', 'search.results_other': '{n} results for “{q}”',
-    'search.section_tracks': 'Tracks', 'search.section_posts': 'Posts',
-    'search.empty': 'Nothing found.', 'search.in_post': 'in post →',
-    'search.section_events': 'Events', 'search.section_pages': 'Pages',
-    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Press kit', 'search.page_archive': 'Archive',
-    'search.suggest_all': 'All results →', 'search.suggest_empty': 'No results', 'search.suggest_typing': 'Type to search…',
-    'like.login_title': 'Log in to like this post', 'like.fedi_title': 'Like this post from your own fediverse account',
-    // === Admin sub-pages ===
-    'aset.title': 'Settings',
-    'aset.back_admin': 'Admin',
-    'aset.mode': 'Mode',
-    'aset.mode_help': 'Determines how this installation works. Switching is safe: nothing is deleted — Solo just hides the multi-site features and shows your primary site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'one site (yours).',
-    'aset.solo_desc': 'No user directory, no site switching.',
-    'aset.premium_badge': 'premium',
-    'aset.circle': 'Circles',
-    'aset.circle_title': 'solo + federation.',
-    'aset.circle_desc': 'A single site of your own that shows the public posts of other Klonkt sites. Asymmetric: you decide who is in your circle.',
-    'aset.save': 'Save',
-    'aset.name': 'Name',
-    'aset.name_ph': 'e.g. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'e.g. Independent music label',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Short intro text below the title.',
-    'aset.hero_image': 'Hero image (URL)',
-    'aset.hero_image_hint': 'optional; background of the hero',
-    'aset.hero_upload': '…or upload an image',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max 5 MB; replaces the URL above',
-    'aset.hero_overlay': 'Dark overlay',
-    'aset.hero_overlay_hint': 'darkens the hero so the text stays readable',
-    'aset.preview_overlay': 'Preview (with overlay):',
-    'aset.hero_preview_alt': 'Hero preview',
-    'aset.your_circle': 'Your circle',
-    'aset.your_circle_help': 'Manage which other Klonkt sites you show in your circle, and whether your site may appear in other people\'s circles. Asymmetric: you decide who you follow.',
-    'aset.manage_circle': 'Manage your circle',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Unlock the premium modules (newsletter, downloads, statistics, press kit, link-in-bio, show agenda) with your',
-    'aset.premium_lifetime': '$16 lifetime',
-    'aset.premium_help_2': 'Patreon support. The app and all updates stay free.',
-    'aset.premium_active': 'Premium active.',
-    'aset.lifetime_support': 'Lifetime support:',
-    'aset.patreon_disconnect': 'Disconnect Patreon',
-    'aset.patreon_no_lifetime': 'Patreon connected, but no $16 lifetime yet',
-    'aset.now': 'now',
-    'aset.patreon_support_again': 'Support the campaign and reconnect.',
-    'aset.patreon_reconnect': 'Reconnect',
-    'aset.patreon_not_connected': 'Not connected yet.',
-    'aset.patreon_connect': 'Connect Patreon',
-    'aset.status_set': 'Status: configured',
-    'aset.not_set_yet': 'Not configured yet.',
-    'aset.newsletter': 'Newsletter',
-    'aset.newsletter_help_1': 'Show a',
-    'aset.newsletter_footer_field': 'sign-up field in the footer',
-    'aset.newsletter_help_2': 'of your site so visitors can subscribe from any page. (The full sign-up page stays at',
-    'aset.newsletter_show_footer': 'Show sign-up field in the footer',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Let your site join the fediverse: people on Mastodon (or another Klonkt) can follow, like and reply — and those replies show up under your posts. Turn this off and your site does not federate and has no comments (a quiet, standalone blog).',
-    'aset.fediverse_toggle': 'Fediverse on (follow, like, comment)', 'aset.mode': 'Mode', 'aset.mode_help': 'Choose how your site works.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'A standalone blog — no fediverse, no comments. Quiet and self-contained.', 'aset.mode_cirkels': 'Circles', 'aset.mode_18plus': 'The fediverse is an open network that can also contain adult (18+) content — you must be old enough to take part.', 'aset.mode_18plus_confirm': 'Circles connects your site to the fediverse, an open network that also contains 18+ content. Confirm that you are old enough to enable this.', 'aset.mode_cirkels_help': 'Join the fediverse (ActivityPub): people on Mastodon or another Klonkt can follow, like and reply, and you can follow a circle of sites.',
-    'aset.email_smtp': 'Email (SMTP)',
-    'aset.smtp_help_1': 'Needed to',
-    'aset.smtp_help_newsletter': 'send the newsletter',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'show-notify',
-    'aset.smtp_help_3': 'emails and to make password reset by email work. Enter your mail provider\'s details (e.g. your hosting mail, a Gmail app password, Brevo, Mailgun…).',
-    'aset.via_env': 'via .env',
-    'aset.smtp_not_set': 'Not configured yet — sending does not work yet.',
-    'aset.smtp_host': 'SMTP host',
-    'aset.smtp_port': 'Port',
-    'aset.smtp_port_hint': '587 (STARTTLS) or 465 (SSL)',
-    'aset.smtp_user': 'Username',
-    'aset.smtp_pass': 'Password',
-    'aset.smtp_pass_set_hint': 'set; leave empty = unchanged',
-    'aset.smtp_pass_ph_set': '•••••••• (set)',
-    'aset.smtp_pass_ph': 'app password',
-    'aset.smtp_from': 'Sender',
-    'aset.smtp_from_hint': 'optional; defaults to username',
-    'aset.smtp_from_ph': 'Your Name <you@yourprovider.com>',
-    'aset.smtp_save': 'Save SMTP',
-    'aset.clear': 'Clear',
-    'aset.test_mail_to': 'Send test mail to',
-    'aset.send_test_mail': 'Send test mail',
-    'asite.back_admin': 'Admin',
-    'asite.title_new': 'New site',
-    'asite.title_edit': 'Appearance',
-    'asite.identity': 'Identity',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(fixed)',
-    'asite.slug_placeholder': 'yourslug',
-    'asite.field_title': 'Title',
-    'asite.field_title_hint': '— shown in the header and as the display name',
-    'asite.owner': 'Owner',
-    'asite.owner_hint': '— who may manage this Klonkt themselves',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— short one-liner',
-    'asite.bio': 'Bio / description',
-    'asite.bio_hint': '— shown in the profile header and used for SEO',
-    'asite.profile_photo': 'Profile photo',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg or https://…',
-    'asite.photo_upload': '📷 Upload',
-    'asite.photo_clear': 'Remove',
-    'asite.language': 'Language (ISO code)',
-    'asite.profile_enabled': 'Show profile header below the navigation',
-    'asite.appearance': 'Appearance',
-    'asite.accent_color': 'Accent color',
-    'asite.theme_default': 'Default theme for new visitors',
-    'asite.theme_auto': 'Auto (follow device preference)',
-    'asite.theme_light': 'Light',
-    'asite.theme_dark': 'Dark',
-    'asite.palette': 'Palette',
-    'asite.behavior': 'Behavior',
-    'asite.is_public': 'Public site (uncheck for a private circle)',
-    'asite.robots_index': 'Allow search engines to index (sitemap.xml is hidden when off)',
-    'asite.require_login_comment': 'Login required to comment',
-    'asite.enable_audio': 'Enable audio player + embeds', 'asite.approve_followers': 'Approve followers first (follow requests wait for your yes on the Connect page)',
-    'asite.links': 'Social / streaming links',
-    'asite.links_hint': 'Shown as brand icons in the profile header. Add as many as you like.',
-    'asite.aliases': 'Fediverse aliases',
-    'asite.move': 'Move (fediverse)',
-    'asite.move_hint': 'Announce to your followers that this account continues elsewhere. The new profile must claim this address as an alias first; followers then move along automatically. An account with guardians cannot move yet.',
-    'asite.move_confirm': 'Are you sure? Your followers will be told this account has moved.',
-    'asite.move_btn': 'Announce move',
-    'asite.moved_to': 'Moved to',
-    'asite.aliases_hint': 'One per line: your old account as @name@server or as an actor URL. Needed to move followers from an old account to this one; the old server checks that this profile claims the old one.',
-    'asite.link_add': '+ Add link',
-    'asite.feed_view': 'Feed display',
-    'asite.feed_default': 'Default view for the homepage',
-    'asite.feed_reader': 'Reader (whole posts, one per screen)',
-    'asite.feed_grid': 'Grid (cards)',
-    'asite.feed_switch': 'Show timeline ↔ grid switch above the feed',
-    'asite.show_search': 'Show search button in the navigation',
-    'asite.show_archive': 'Show archive link in the navigation',
-    'asite.seo': 'SEO & social',
-    'asite.seo_pointer': 'Title template, canonical, share image, verification metas and more now live on their own page:',
-    'asite.seo_link': '🔎 SEO & discoverability',
-    'asite.custom_legend': 'Custom CSS & HTML',
-    'asite.optional': '(optional)',
-    'asite.custom_css': 'Custom CSS (injected as &lt;style&gt; in &lt;head&gt;)',
-    'asite.custom_head': 'Custom &lt;head&gt; HTML (analytics, extra metas)',
-    'asite.custom_foot': 'Custom footer HTML',
-    'asite.submit_create': 'Create site',
-    'asite.submit_save': 'Save changes',
-    'aseo.back': 'Admin',
-    'aseo.title': 'SEO & discoverability',
-    'aseo.tagline_pre': 'Advanced SEO for',
-    'aseo.tagline_post': '— how your site appears in search engines and when shared on social media.',
-    'aseo.index_legend': 'Indexing',
-    'aseo.index_label': 'Allow search engines to index this site',
-    'aseo.index_hint_pre': 'off =',
-    'aseo.index_hint_post': '+ sitemap.xml hidden',
-    'aseo.title_legend': 'Title & description',
-    'aseo.title_template': 'Title template',
-    'aseo.title_template_hint_pre': 'use',
-    'aseo.title_template_hint_and': 'and',
-    'aseo.default_desc': 'Default description',
-    'aseo.default_desc_hint': 'meta description / og:description when a page has none',
-    'aseo.default_desc_ph': 'Short description of your site (max ~160 characters works best)',
-    'aseo.canonical': 'Canonical base URL',
-    'aseo.canonical_hint': 'the production HTTPS URL, prevents duplicate-content penalties',
-    'aseo.author': 'Author',
-    'aseo.author_hint': 'meta author tag',
-    'aseo.author_ph': 'Your name',
-    'aseo.social_legend': 'Sharing on social media',
-    'aseo.og_image': 'Default share image (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter card; ~1200×630px',
-    'aseo.og_theme': 'Share card light or dark',
-    'aseo.og_theme_hint': 'the auto-generated share image',
-    'aseo.og_theme_auto': 'Auto (follows site theme)',
-    'aseo.og_theme_light': 'Light',
-    'aseo.og_theme_dark': 'Dark',
-    'aseo.og_locale': 'Language locale',
-    'aseo.og_locale_hint_pre': 'og:locale, e.g.',
-    'aseo.og_locale_hint_or': 'or',
-    'aseo.twitter': 'Twitter / X handle',
-    'aseo.twitter_hint': 'with @',
-    'aseo.fb_app': 'Facebook App ID',
-    'aseo.fb_app_hint': 'fb:app_id (optional)',
-    'aseo.publisher_legend': 'Publisher (JSON-LD / rich results)',
-    'aseo.type': 'Type',
-    'aseo.type_person': 'Person',
-    'aseo.type_org': 'Organization / company',
-    'aseo.publisher_name': 'Name',
-    'aseo.publisher_name_hint': 'falls back to the site title',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Search engine verification',
-    'aseo.verify_google': 'Google site verification',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'Save SEO',
-    'aaud.title': 'Audio tracks',
-    'aaud.tagline_pre': 'Site-level MP3s. Use',
-    'aaud.tagline_post': 'in a post to insert a play button.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Artist',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(applied to all files)',
-    'aaud.optional': 'Optional',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optional, applied to all files — jpg/png/webp/gif, max 5 MB)',
-    'aaud.choose_cover': 'Choose cover',
-    'aaud.no_file': 'No file chosen',
-    'aaud.drag_here': 'Drag audio here',
-    'aaud.or_click': 'or click to choose files',
-    'aaud.start_upload': 'Start upload',
-    'aaud.clear_list': 'Clear list',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track without audio',
-    'aaud.add_link_track_title': 'A track without an audio file — title + open-in links only',
-    'aaud.no_tracks': 'No tracks yet. Upload one above.',
-    'aaud.untitled': '(untitled)',
-    'aaud.copy_click': 'Click to copy',
-    'aaud.play': 'Play',
-    'aaud.pause': 'Pause',
-    'aaud.edit': 'Edit',
-    'aaud.delete': 'Delete',
-    'aaud.delete_confirm': 'Delete track?',
-    'aaud.dl_on': 'Download-for-email is ON — click to turn off',
-    'aaud.dl_off': 'Download-for-email is off — click to turn on',
-    'aaud.fedi_on': 'Shared on the fediverse (plays inline everywhere, file downloadable) — click to turn off',
-    'aaud.fedi_off': 'Not shared on the fediverse (web player only, file hidden) — click to share',
-    'aaud.embed_player': 'Embeddable player',
-    'aseo.mb_legend': 'MusicBrainz link',
-    'aseo.mb_linked': 'Linked to',
-    'aseo.mb_unlink': 'Unlink',
-    'aseo.mb_open': 'View on MusicBrainz',
-    'aseo.mb_pick': 'This is me',
-    'aseo.mb_none': 'Nothing found. Not in there yet? You can add yourself on musicbrainz.org — that can only be done there, not from Klonkt.',
-    'aseo.mb_busy': 'Searching…',
-    'aseo.mb_fail': 'MusicBrainz is unreachable right now.',
-    'aseo.mb_hint': 'Link your MusicBrainz artist id to your domain here, with back-way validation from your "social networking" profile page.',
-    'aseo.mb_search_label': 'Search for your name',
-    'aseo.mb_search_hint': 'your artist name, or your MusicBrainz id if you know it',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Look up',
-    'aseo.mb_verified': 'Mutual: the MusicBrainz page points back at this domain.',
-    'aseo.mb_unverified': 'Still one-sided. Add this domain to your MusicBrainz page under "social networking" and the link is confirmed from both ends.',
-    'aseo.mb_checking': 'Checking the back-way…',
-    'aaud.embed_hint': 'Paste this code on your own website/blog to embed your music with this player:',
-    'aaud.preview_player': 'Open player preview',
-    'aaud.st_queued': 'Waiting',
-    'aaud.st_uploading': 'Uploading…',
-    'aaud.st_transcoding': 'Converting…',
-    'aaud.st_done': 'Done',
-    'aaud.st_error': 'Error',
-    'aaud.err_unexpected': 'Unexpected server response',
-    'aaud.failed': 'Failed',
-    'aaud.copied': 'copied',
-    'aaud.new_track': 'New track',
-    'aaud.create_failed': 'Could not create track',
-    'aaud.editor_not_loaded': 'Track editor not loaded',
-    'aaud.change_failed': 'Could not change',
-    'astat.title': 'Statistics',
-    'astat.intro': 'Measured cookie-free — no tracking cookies, no consent banner. Visitors are counted per day via a daily-rotating, anonymous hash (IP/browser are not stored). Your own admin visits and known bots/crawlers are not counted.',
-    'astat.your_ip': 'Your IP', 'astat.ip_counted': 'is being counted.', 'astat.ip_not_counted': 'is NOT counted.', 'astat.ip_exclude': "Don't count my visits", 'astat.ip_count': 'Count my visits',
-    'astat.visitor_days': 'Visitor-days ({n}d)',
-    'astat.pageviews_days': 'Views ({n}d)',
-    'astat.plays_total': 'Plays (total)',
-    'astat.postviews_total': 'Post views (total)',
-    'astat.alltime_pre': 'All-time:',
-    'astat.alltime_mid': 'views',
-    'astat.alltime_post': 'visitor-days.',
-    'astat.help_summary': 'What do these numbers mean?',
-    'astat.help_vd_term': 'Visitor-days',
-    'astat.help_vd_a': 'the number of unique visitors',
-    'astat.help_vd_em': 'per day, added together',
-    'astat.help_vd_b': '. One person who visits on 5 days = 5 visitor-days. Without cookies it is impossible to count across days, so this is not a count of unique people — the real number of people is (often much) lower.',
-    'astat.help_pv_term': 'Views',
-    'astat.help_pv': 'how often the home/feed or a post has been loaded (including clicks within the site). Other pages (agenda, downloads, links) are not counted here.',
-    'astat.help_plays_term': 'Plays',
-    'astat.help_plays': 'total number of times a track has been started.',
-    'astat.help_postviews_term': 'Post views',
-    'astat.help_postviews': 'total across all posts combined.',
-    'astat.help_footer': 'Admin visits and known bots/crawlers are skipped. The raw IP is never stored. Good for trends; take absolute numbers with a grain of salt.',
-    'astat.period': 'Period:',
-    'astat.last_n_days': 'Last {n} days',
-    'astat.lg_visitor_days': 'Visitor-days',
-    'astat.lg_pageviews': 'Views',
-    'astat.bar_title': '{day} — {pv} views, {vd} visitor-days',
-    'astat.top_posts': 'Most popular posts',
-    'astat.no_views': 'No views yet.',
-    'astat.most_played': 'Most listened',
-    'astat.no_plays': 'No plays yet.',
-    'astat.sources': 'Sources (where visitors come from)',
-    'astat.linkbio_clicks': 'Link-in-bio clicks',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Canonical playlists. Edit a playlist here and the changes carry through to every post that uses it via',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'New playlist',
-    'apl.none': 'No playlists yet.',
-    'apl.none_sub': 'Create one with the button above, or via the 📃 button in the post editor.',
-    'apl.pill_playlist': 'playlist',
-    'apl.pill_album': 'album', 'apl.pill_mixtape': 'mixtape',
-    'apl.track': 'track',
-    'apl.tracks': 'tracks',
-    'apl.copy_click': 'Click to copy',
-    'apl.edit': 'Edit',
-    'apl.delete': 'Delete',
-    'apl.copied': 'copied',
-    'apl.delete_confirm': 'Delete playlist "{title}"? Posts that embed this playlist will now show a placeholder.',
-    'apl.delete_failed': 'Delete failed',
-    'ausr.back': 'Admin',
-    'ausr.title': 'Users',
-    'ausr.tagline_a': 'Manage users, roles, and deletions. The',
-    'ausr.tagline_b': 'role = view everything (including Admin), change nothing — handy for demos.',
-    'ausr.empty': 'No users.',
-    'ausr.you': 'you',
-    'ausr.t_sites': 'Sites',
-    'ausr.t_posts': 'Posts',
-    'ausr.t_joined': 'Registered on',
-    'ausr.l_sites': 'sites',
-    'ausr.l_posts': 'posts',
-    'ausr.l_joined': 'joined',
-    'ausr.new_klonkt': 'New Klonkt for this user',
-    'ausr.new_klonkt_for': 'New Klonkt for {name}',
-    'ausr.role_label': 'Role',
-    'ausr.role_kijker': 'viewer',
-    'ausr.role_member': 'member',
-    'ausr.role_admin': 'admin',
-    'ausr.role_god': 'god',
-    'ausr.delete': 'Delete',
-    'ausr.del_warn': 'This also deletes their site + {n} post(s).',
-    'ausr.del_confirm': 'Delete user {name}?',
-    'ausr.del_undo': 'This cannot be undone.',
-    'asit2.back': 'Admin',
-    'asit2.title': 'Sites',
-    'asit2.tagline': 'Manage all sites on this installation.',
-    'asit2.new_site': 'New site',
-    'asit2.empty': 'No sites yet.',
-    'asit2.empty_sub': 'Create one using the button above.',
-    'asit2.pill_primary': 'primary',
-    'asit2.pill_primary_title': 'Main/label site of this installation',
-    'asit2.pill_public': 'public',
-    'asit2.pill_public_title': 'Publicly visible',
-    'asit2.pill_private': 'private',
-    'asit2.pill_private_title': 'Not public',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Not indexed by search engines',
-    'asit2.by': 'by',
-    'asit2.t_posts': 'Number of posts',
-    'asit2.l_posts': 'posts',
-    'asit2.t_created': 'Created on',
-    'asit2.l_created': 'created',
-    'asit2.make_primary': 'Make primary',
-    'asit2.make_primary_title': 'Make primary/main site',
-    'asit2.make_primary_confirm': 'Set this site as the primary/main site?',
-    'asit2.edit': 'Edit',
-    'asit2.delete': 'Delete',
-    'asit2.delete_confirm': 'Delete site? Only works if there are no posts.',
-    'acom.back': 'Admin',
-    'acom.title': 'Comment moderation',
-    'acom.mode_for_site': 'Mode for this site:',
-    'acom.mode_trust_a': 'comments are auto-approved. Switch to',
-    'acom.mode_moderate_word': 'moderate',
-    'acom.site_settings': 'site settings',
-    'acom.mode_trust_b': 'to queue them.',
-    'acom.mode_moderate_hint': 'new comments require approval before showing up on posts.',
-    'acom.pending': 'Pending ({n})',
-    'acom.nothing_waiting': 'Nothing waiting.',
-    'acom.reply': 'reply',
-    'acom.on': 'on',
-    'acom.approve': 'Approve',
-    'acom.reject': 'Reject',
-    'acom.recent': 'Recent decisions',
-    'acom.nothing_yet': 'Nothing yet.',
-    'acir.title': 'Circles',
-    'acir.back_settings': 'Settings',
-    'acir.circles': 'Circles',
-    'acir.settings': 'Settings',
-    'acir.mode_off_1': 'The mode is not set to',
-    'acir.mode_off_2': '. Turn it on under',
-    'acir.mode_off_3': 'to show your circle feed at',
-    'acir.mode_off_4': '. You can already set up sources below.',
-    'acir.visibility_title': 'My visibility',
-    'acir.all_public': 'already-public',
-    'acir.visibility_help_1': 'Taking part in circles? This makes your',
-    'acir.visibility_help_2': 'posts fetchable by other Klonkt sites through a signed feed',
-    'acir.visibility_help_3': 'It is an opt-in choice, not a privacy lock: your posts stay public on your site anyway, even when this is off. To shield something, mark that post as non-public.',
-    'acir.show_in_circles': 'Show my site in other people\'s circles',
-    'acir.save': 'Save',
-    'acir.add_title': 'Add a Klonkt site',
-    'acir.add_help': 'Paste the base URL of another Klonkt site. Asymmetric: you show them, regardless of whether they show you.',
-    'acir.url': 'URL',
-    'acir.label': 'Label',
-    'acir.optional': 'optional',
-    'acir.name_auto': 'The name is taken automatically from the site — only the URL is needed.',
-    'acir.label_ph': 'e.g. Joost Klein',
-    'acir.add': 'Add',
-    'acir.in_circle': 'In my circle ({n})',
-    'acir.no_sources': 'No sources yet. Add one above.',
-    'acir.sync_all': 'Sync all now',
-    'acir.st_active': 'active',
-    'acir.posts': 'posts',
-    'acir.last': 'last',
-    'acir.st_mismatch': 'version mismatch',
-    'acir.mismatch_reason': 'This site runs a different Klonkt protocol version — an update is needed to federate.',
-    'acir.st_error': 'error',
-    'acir.st_paused': 'paused',
-    'acir.refresh': 'Refresh',
-    'acir.remove': 'Remove',
-    'acir.remove_confirm': 'Remove from your circle?',
-    'ashow.back_admin': 'Admin',
-    'ashow.title': 'Calendar',
-    'ashow.show_toggle': 'Show the calendar on the site',
-    'ashow.show_toggle_hint': '(Calendar button in the bar + the calendar page)',
-    'ashow.save': 'Save',
-    'ashow.off': 'off',
-    'ashow.off_notice_1': 'The calendar is currently',
-    'ashow.off_notice_2': '— visitors see no Calendar button and the calendar page is unreachable. Turn it on to show your events.',
-    'ashow.subscribers': 'subscriber(s) for event announcements.',
-    'ashow.smtp_warn': '⚠ SMTP not configured — events are saved, but notify emails can only be sent once you fill in SMTP.',
-    'ashow.f_date': 'Date',
-    'ashow.f_time': 'Time (optional)',
-    'ashow.f_city': 'City',
-    'ashow.f_country': 'Country (optional)',
-    'ashow.f_venue': 'Venue/hall (optional)',
-    'ashow.f_ticket': 'Ticket URL (optional)',
-    'ashow.f_notes': 'Note (optional)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Notify subscribers by email',
-    'ashow.smtp_required': '(SMTP required)',
-    'ashow.add_event': '+ Add event',
-    'ashow.del_confirm': 'Delete event??',
-    'ashow.empty': 'No events yet.',
-    'anews.title': 'Newsletter',
-    'anews.confirmed': 'confirmed',
-    'anews.pending': 'pending',
-    'anews.unsub': 'unsubscribed',
-    'anews.smtp_warn_1': '⚠ SMTP is not configured yet. Sign-ups are still collected, but sending is only possible once you fill in your SMTP details',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Sign-up link to share:',
-    'anews.subject': 'Subject',
-    'anews.subject_ph': 'New single out!',
-    'anews.body': 'Message',
-    'anews.body_ph': 'Write your update…',
-    'anews.send_confirm': 'Send newsletter to {n} confirmed subscriber(s)?',
-    'anews.send_btn': 'Send to {n} subscriber(s)',
-    'anews.sent_heading': 'Sent',
-    'anews.recipients': 'recipient(s)',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Recent changes',
-    'aupd.back_admin': 'Admin',
-    'aupd.version_heading': 'Version of this Klonkt',
-    'aupd.app_version': 'App version',
-    'aupd.current': 'Current',
-    'aupd.current_unknown': 'unknown (not yet updated via the update button)',
-    'aupd.latest': 'Latest',
-    'aupd.latest_failed': 'could not fetch the latest version',
-    'aupd.no_source': 'No update source reachable',
-    'aupd.uptodate': 'Up to date',
-    'aupd.update_available': 'Update available',
-    'aupd.behind_one': '{n} commit behind',
-    'aupd.behind_many': '{n} commits behind',
-    'aupd.run_confirm': 'The site will be brought to the latest version and restart briefly. Continue?',
-    'aupd.redeploy': 'Redeploy',
-    'aupd.update_now': 'Update now',
-    'aupd.help': 'Updating fetches the latest code and restarts this site briefly (~10s). Take your time — nothing is lost (your posts, settings and circle stay intact).',
-    'aupd.manual_hint': 'Update from GitHub by running this on your server:',
-    'aepk.title': 'Edit press kit',
-    'aepk.saved': 'Press kit saved',
-    'aepk.back_admin': 'Admin',
-    'aepk.view_epk': 'View press kit',
-    'aepk.text_heading': 'Text',
-    'aepk.text_help': 'The press kit (/pers) shows your site name + photo, this bio and contact, plus your most-played tracks and recent posts automatically. Leave the bio empty to use the site tagline; leave contact empty to show nothing (your login email is never shown automatically).',
-    'aepk.bio_label': 'Press bio',
-    'aepk.bio_ph': 'Short description of you/the project for press & bookers.',
-    'aepk.contact_label': 'Press contact',
-    'aepk.contact_ph': 'e.g. press@yourdomain.com or a booking link',
-    'aepk.tracks_label': 'Tracks on the press kit',
-    'aepk.tracks_hint': '(pick up to {n}; leave empty for the top {n} most-played automatically)',
-    'aepk.no_tracks': 'No tracks yet — add audio first under Admin → Audio.',
-    'aepk.untitled': '(untitled)',
-    'aepk.save': 'Save',
-    'ahelp.back': 'Admin',
-    'ahelp.title': 'Help guide',
-    'ahelp.intro': 'An explanation of every feature. Type below to search by topic or instruction.',
-    'ahelp.search_placeholder': 'Search… (e.g. \'agenda\', \'photo\', \'circle\', \'downloads\')',
-    'ahelp.search_aria': 'Search the help guide',
-    'ahelp.premium': 'premium',
-    'ahelp.empty': 'No topics found for your search.',
-    'ahelp.s_newpost_h': 'Writing a new post',
-    'ahelp.s_newpost_b': 'Admin → <strong>New post</strong>. At the top you pick the <strong>type</strong> (Post · Photo · Video · Audio) — that decides the inputs below. Give it a title and write your content. At the bottom you choose the status: <em>draft</em> (not visible) or <em>published</em>. Drafts appear at the top of your Admin overview so you can find them again.',
-    'ahelp.s_excerpt_h': 'Summary & Circle Preview',
-    'ahelp.s_excerpt_b': 'The <strong>Summary & Circle Preview</strong> field (the excerpt) is the short preview text shown below a post in listings, and the summary that other sites display when they pick up your post via a <strong>Circle</strong>. Leave it empty and the start of the post is used automatically.',
-    'ahelp.s_pin_h': 'Pinning posts / ordering',
-    'ahelp.s_pin_b': 'In the post editor you can <strong>pin</strong> a post with a rank (1 = top). Pinned posts appear first in the timeline/grid, in order of their rank. An empty rank or 0 = not pinned.',
-    'ahelp.s_schedule_h': 'Scheduling & friends only',
-    'ahelp.s_schedule_b': 'In the editor you can set a <strong>publish date</strong> in the future; the post then appears automatically at that moment. With <strong>Friends only</strong>, visitors who are not logged in see only a teaser + login invitation; logged-in friends see everything.',
-    'ahelp.s_images_h': 'Images in posts',
-    'ahelp.s_images_b': 'Images in the text and the cover image are always shown in <strong>full</strong> at full width (not cropped), at their natural height.',
-    'ahelp.s_audio_h': 'Adding audio & tracks',
-    'ahelp.s_audio_b': 'Admin → <strong>Audio</strong>. Upload a file or add a <em>link-only</em> track (no upload, just "open in" links). For each track you fill in the title, artist, cover and, optionally, album/position. In a post you show a track with the shortcode <code>[[track:id]]</code>, an album with <code>[[album:Name]]</code>, a playlist with <code>[[playlist:id]]</code>. <strong>Faster:</strong> in a post, pick type <em>Audio</em> at the top and drop the file straight in — it gets converted and added to the post.',
-    'ahelp.s_credit_h': 'Credit, licence & "open in"',
-    'ahelp.s_credit_b': 'For each track you can set an <strong>owner/credit</strong> (with a © button) and a <strong>licence</strong> — these are also written into the mp3 metadata. The <strong>open-in</strong> fields (Spotify / YouTube / SoundCloud) add buttons to open the track on those platforms.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Mark a track as <strong>downloadable</strong> in Admin → Audio (⬇ button). Visitors find them on the <strong>/downloads</strong> page and leave their email to receive the file (it goes onto your mailing list). Want downloads prominent in the feed? Create a regular post with the slug <code>downloads</code> and pin it.',
-    'ahelp.s_albums_h': 'Albums & playlists',
-    'ahelp.s_albums_b': 'Give tracks the same <strong>album</strong> + a <strong>position</strong> to form an album. You create playlists in Admin → <strong>Playlists</strong>. You show both in a post with <code>[[album:Name]]</code> or <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Agenda / events',
-    'ahelp.s_agenda_b': 'Admin → <strong>Agenda</strong>. Turn on <strong>"Show agenda on the site"</strong> at the top — then the Agenda button appears in the bar and the agenda page becomes reachable. Add events (date, town, venue, tickets). Visitors can sign up (separately from the newsletter) for a heads-up about a new event.',
-    'ahelp.s_presskit_h': 'Press kit',
-    'ahelp.s_presskit_b': 'A shareable press page at <strong>/pers</strong>. Edit it via the <strong>✎ Edit</strong> button on that page itself (only you see it). Set a short press bio + contact, and choose <strong>up to 5 tracks</strong> to be shown (or leave empty = automatically the top 5 most-played).',
-    'ahelp.s_circles_h': 'Circles (federation)',
-    'ahelp.s_circles_b': 'A <strong>Circle</strong> is your own curated feed. Open <strong>Fediverse → Following</strong>, follow accounts and <strong>feature them</strong> (✨). Featured accounts and posts you <strong>boost</strong> (🔁) show up in your <strong>/cirkel</strong> feed — a boosted post gets a Boost badge. Boosting a post from someone you do not follow adds it too. This is local: nothing is sent to the fediverse automatically — boosting to your own followers is always a deliberate per-post action.',
-    'ahelp.s_stats_h': 'Statistics',
-    'ahelp.s_stats_b': 'Admin → <strong>Statistics</strong>. Measured cookie-free. <strong>Visitor-days</strong> = unique visitors per day, added up (not a number of people). <strong>Views</strong> = home/feed and post loads. Admin visits and bots do not count. Good for trends; take absolute numbers with a grain of salt.',
-    'ahelp.s_newsletter_h': 'Newsletter',
-    'ahelp.s_newsletter_b': 'Admin → <strong>Newsletter</strong>: compose a message and send it to your confirmed subscribers. Visitors sign up via the footer or <strong>/nieuwsbrief</strong>. Sending requires that email (SMTP) is configured.',
-    'ahelp.s_linkbio_h': 'Link-in-bio',
-    'ahelp.s_linkbio_b': 'A Linktree-style page at <strong>/links</strong> with your profile links. You can see the clicks per link in Statistics.',
-    'ahelp.s_embed_h': 'Embeddable player',
-    'ahelp.s_embed_b': 'Admin → Audio shows a copyable <code>&lt;iframe&gt;</code> code (<strong>/embed</strong>) that lets you embed your player on another website.',
-    'ahelp.s_appearance_h': 'Appearance (theme, photo, accent)',
-    'ahelp.s_appearance_b': 'Admin → <strong>Appearance</strong>: set your site name, tagline, profile photo, accent colour and colour palette, and the default feed view (Timeline or Grid).',
-    'ahelp.s_tenancy_h': 'Solo / Circle mode',
-    'ahelp.s_tenancy_b': 'At the top of <strong>Admin → Settings</strong> you choose the mode. <em>Solo</em> = a standalone blog: no fediverse, no comments. <em>Circles</em> = your site joins the fediverse (ActivityPub): people can follow you and reply, and you get the Fediverse section + your Circle feed. Switching is safe — nothing gets deleted.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'In <strong>Circles</strong> mode your site joins the fediverse (Mastodon etc.). Open the <strong>Fediverse</strong> section via the globe in the menu bar (or the bell for notifications). Five tabs: <strong>News</strong> (posts from the accounts you follow — ⭐ like and 🔁 boost here, click again to undo), <strong>Following</strong> (follow accounts by @handle or profile URL, and feature ✨ them for your Circle), <strong>Replies</strong> (your sent replies + the drag-to-bookmarks-bar <em>interaction bookmarklet</em> to reply from any fediverse post), <strong>Notifications</strong> (new followers, likes, boosts and replies to your posts) and <strong>Blocking</strong> (block an account or a whole domain). Under each post, "From the fediverse" shows incoming replies, likes and boosts; as the owner you can reply, like or boost them right there. Visitors use the "Interact via the fediverse" button to respond from their own account. Click your profile photo for a profile summary; visitors also find a "Follow via the fediverse" button there. Your posts are delivered to your followers automatically.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'You unlock premium features (Statistics, Agenda, Downloads, Press kit, Newsletter, Link-in-bio, Embed) in Admin → Settings by connecting Patreon ($16 lifetime). Updates and the core app always stay free.',
-    'ahelp.s_password_h': 'Forgotten password / resetting',
-    'ahelp.s_password_b': 'Resetting is done with the <strong>command-line scripts</strong> on the server. Run <code>npm run reset-admin</code> from the project folder: with no argument it resets the god user and prints the new password. A specific user: <code>npm run reset-admin -- &lt;user|email&gt;</code>. Choose a password yourself (at least 8 characters): <code>npm run reset-admin -- &lt;user|email&gt; &lt;password&gt;</code>. Then log in at <strong>/auth/login</strong>. The email reset link at <strong>/auth/reset-request</strong> only works if email (SMTP) is configured; the command line always works.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Admin → <strong>Updates</strong> (god only) shows the current version and whether a newer one is available. With "Update now" you fetch the latest version.',
-    'pedit.title_new': 'New post',
-    'pedit.title_edit': 'Edit post',
-    'pedit.f_title': 'Title',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'auto from title if empty',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(comma-separated)',
-    'pedit.f_excerpt': 'Summary & Circle Preview',
-    'pedit.excerpt_hint': 'Also used as the summary in <strong>Circles</strong> (other sites that show your post). Leave empty = start of the post.',
-    'pedit.s_cover': 'Cover',
-    'pedit.f_cover_url': 'Cover URL',
-    'pedit.cover_url_placeholder': '/media/…  or https://…  (or upload with the button)',
-    'pedit.f_cover_alt': 'Alt text (description)',
-    'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
-    'pedit.f_language': 'Language',
-    'pedit.language_hint': 'for the fediverse language filter',
-    'pedit.cover_upload_btn': 'Upload new cover',
-    'pedit.s_content': 'Content',
-    'pedit.content_hint': 'Drag an image to insert · select text to format',
-    'pedit.tb_done': 'Done',
-    'pedit.tb_done_title': 'Done editing',
-    'pedit.tb_bold': 'Bold',
-    'pedit.tb_bold_title': 'Bold (Ctrl+B)',
-    'pedit.tb_italic': 'Italic',
-    'pedit.tb_italic_title': 'Italic (Ctrl+I)',
-    'pedit.tb_underline': 'Underline',
-    'pedit.tb_h2': 'Heading',
-    'pedit.tb_h3': 'Subheading',
-    'pedit.tb_p': 'Paragraph',
-    'pedit.tb_ul': 'List',
-    'pedit.tb_ol': 'Numbered list',
-    'pedit.tb_quote': 'Quote',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Ctrl+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Image',
-    'pedit.tb_image_title': 'Insert image',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Insert track',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Insert playlist',
-    'pedit.tb_embed': 'Embed media',
-    'pedit.tb_embed_title': 'Embed (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Clear formatting',
-    'pedit.tb_fullscreen': 'Full screen',
-    'pedit.editor_aria': 'Content',
-    'pedit.editor_placeholder': 'Start writing…',
-    'pedit.tap_to_edit': 'Tap to edit',
-    'pedit.tap_to_write': 'Tap to write…',
-    'pedit.chars': 'characters',
-    'pedit.s_publication': 'Publication',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Published',
-    'pedit.status_draft': 'Draft',
-    'pedit.status_archived': 'Archived',
-    'pedit.f_type': 'Type',
-    'pedit.type_post': 'Post',
-    'pedit.type_foto': 'Photo',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'What kind of post?',
-    'pedit.audio_up_drop': 'Drop audio here or click to choose',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — converted automatically and added straight to your post. Edit details later via the track.',
-    'pedit.audio_up_busy': 'Uploading…',
-    'pedit.audio_up_done': 'Added to your post',
-    'pedit.audio_up_fail': 'Failed',
-    'pedit.video_up_title': 'Add a video',
-    'pedit.video_up_ph': 'Paste a video URL (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Insert',
-    'pedit.foto_up_hint': 'Set your photo as the cover below, or add photos in the text with the image button in the toolbar.',
-    'pedit.pin_label': 'Pin to top',
-    'pedit.pin_up': 'Move up',
-    'pedit.pin_down': 'Move down',
-    'pedit.pin_top': 'at the top',
-    'pedit.pin_nth_suffix': 'th from top',
-    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'pedit.fedi_audio_label': 'Share audio openly on the fediverse (plays inline in apps; file downloadable)', 'pedit.fedi_audio_oneway': 'Note: opening is permanent', 'pedit.fedi_audio_locked': 'This audio has been shared openly on the fediverse. That is permanent — the file is already out there.', 'pedit.nsfw_cw_ph': 'Warning text (optional, default: Sensitive content)', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show', 'post.share': 'Share', 'post.share_copied': 'Link copied ✓', 'pedit.poll_label': 'Add a poll', 'pedit.poll_locked': 'Votes are in — the options can no longer change.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Add option', 'pedit.poll_remove': 'Remove option', 'pedit.poll_multiple': 'Allow multiple choices', 'pedit.poll_duration': 'Duration', 'pedit.poll_dur_5m': '5 minutes', 'pedit.poll_dur_30m': '30 minutes', 'pedit.poll_dur_1h': '1 hour', 'pedit.poll_dur_6h': '6 hours', 'pedit.poll_dur_12h': '12 hours', 'pedit.poll_dur_1d': '1 day', 'pedit.poll_dur_3d': '3 days', 'pedit.poll_dur_7d': '7 days',
-    'pedit.fan_only_label': 'Friends only',
-    'pedit.schedule_label': 'Schedule publication',
-    'pedit.schedule_hint': 'When off, your post goes live immediately. On = pick below when it appears.',
-    'pedit.publish_at_label': 'Date & time',
-    'pedit.scheduled_for': 'Scheduled for {d}',
-    'pedit.scheduled_prefix': 'Scheduled for',
-    'pedit.cancel': 'Cancel',
-    'pedit.publish': 'Publish',
-    'pedit.save': 'Save',
-    'pedit.js_link_prompt': 'Link URL (https://… or /path)',
-    'pedit.js_uploading': 'Uploading…',
-    'pedit.js_uploaded': 'Uploaded',
-    'pedit.js_inserted': 'Inserted',
-    'pedit.js_failed': 'Failed',
-    'pedit.js_embed_prompt': 'Paste a media URL to embed (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Enter a full URL (https://…).',
-    'pedit.js_no_tracks_found': 'No tracks found for',
-    'pedit.js_no_tracks_yet': 'No tracks yet. Upload via Admin then Audio.',
-    'pedit.js_tracks_loading': 'Loading tracks…',
-    'pedit.js_tracks_load_fail': 'Could not load tracks',
-    'pedit.js_playlist_editor_missing': 'Playlist editor not loaded',
-    'pedit.js_playlist_existing': 'Existing playlists:',
-    'pedit.js_playlist_choose': 'Choose a number to insert, empty = create new:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Insert track',
-    'pedit.tp_close': 'Close',
-    'pedit.tp_search_placeholder': 'Search by title or artist…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Edit image',
-    'imed.rotate_left': 'Rotate left', 'imed.rotate_right': 'Rotate right',
-    'imed.flip_h': 'Flip horizontally', 'imed.flip_v': 'Flip vertically',
-    'imed.zoom_in': 'Zoom in', 'imed.zoom_out': 'Zoom out', 'imed.reset': 'Reset',
-    'imed.cancel': 'Cancel', 'imed.apply': 'Apply',
-    'acct.back_home': 'Back to home',
-    'acct.title': 'Account',
-    'acct.subtitle': 'Profile and avatar.', 'acct.oauth_apps': 'Connected apps', 'acct.oauth_hint': 'Apps you granted access to your account via OAuth. Revoke anything you no longer trust or use.', 'acct.oauth_none': 'No apps connected yet.', 'acct.oauth_unknown_app': 'Unknown app', 'acct.oauth_last_used': 'last used', 'acct.oauth_never': 'never', 'acct.oauth_revoke': 'Revoke', 'acct.oauth_revoked': 'App access revoked.', 'acct.oauth_revoke_none': 'That access no longer existed.',
-    'acct.viewer_mode': 'Viewer mode',
-    'acct.viewer_note': 'This is a demo account. You can view everything, but change nothing — not even your photo or bio.',
-    'acct.profile': 'Profile',
-    'acct.avatar_change': 'Click to change your photo',
-    'acct.member_since': 'Member since', 'acct.photo_site_hint': 'Your profile photo is set in your <a href="/admin">site settings</a> — one photo, used everywhere.',
-    'acct.avatar_remove': 'Remove photo',
-    'acct.username': 'Username',
-    'acct.email': 'Email address',
-    'acct.email_ph': 'you@email.com',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'A short line about yourself',
-    'acct.bio_empty': 'No bio',
-    'acct.save': 'Save',
-    'acct.site': 'Site',
-    'acct.site_name': 'Site name',
-    'acct.site_name_hint': '— shown in your site\'s header',
-    'acct.tagline': 'Tagline',
-    'acct.tagline_hint': '— short one-liner (optional)',
-    'acct.site_save': 'Save site',
-    'acct.password_change': 'Change password',
-    'acct.password_current': 'Current password',
-    'acct.password_new': 'New password',
-    'acct.password_min': '(min 8 characters)',
-    'acct.password_confirm': 'Confirm new password',
-    'acct.login': 'Sign in',
-    'acct.login_google_only': 'This account has no password ({email}). Use "Forgot password" to set one.',
-    'news.this_artist': 'this artist',
-    'news.form_title': 'Stay in the loop',
-    'news.form_sub_before': 'Subscribe to the newsletter from ',
-    'news.form_sub_after': ' — new music, shows and updates, straight to your inbox. You can unsubscribe anytime with one click.',
-    'news.email_ph': 'you@email.com',
-    'news.subscribe': 'Subscribe',
-    'news.check_title': 'Almost done ✉',
-    'news.check_sub_before': 'We\'ve sent a confirmation email to ',
-    'news.check_sub_after': '. Click the link in that email to confirm your subscription.',
-    'news.your_address': 'your address',
-    'news.done_title': 'You\'re subscribed ✓',
-    'news.done_sub_before': 'Thanks — you\'re on the list of ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Subscription confirmed ✓',
-    'news.confirmed_sub_before': 'Great! You\'ll now receive the newsletter from ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Unsubscribed',
-    'news.unsubbed_sub': 'You\'ve been unsubscribed. You won\'t receive any more newsletters. Changed your mind? You can always subscribe again.',
-    'news.invalid_title': 'Invalid email address',
-    'news.invalid_sub': 'Please check your address and try again.',
-    'news.back': '← Back',
-    'news.smtperror_title': 'That didn\'t work',
-    'news.smtperror_sub': 'The confirmation email couldn\'t be sent. Please try again later.',
-    'news.badtoken_title': 'Link invalid or expired',
-    'news.badtoken_sub': 'This link no longer works. Feel free to subscribe again.',
-    'news.to_subscribe': 'To subscribe',
-    'news.error_title': 'Something went wrong',
-    'news.error_sub': 'Please try again later.',
-    'dl.ready_title': 'Thanks! ⬇',
-    'dl.ready_sub': 'Your download should start now. Nothing happening?',
-    'dl.manual': 'Start download manually',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Leave your email address and you\'ll get the file. You\'ll also be added to the newsletter list — you can unsubscribe anytime.',
-    'dl.email_ph': 'you@email.com',
-    'epk.kicker': 'Press kit',
-    'epk.edit': 'Edit',
-    'epk.contact_booking': 'Contact / booking',
-    'epk.view_site': 'View the site →',
-    'epk.press_photo': 'Press photo',
-    'epk.most_played': 'Most played',
-    'epk.recent': 'Recent',
-    'fgate.title': 'Friends only',
-    'fgate.sub': 'This post is for people who follow this site.',
-    'owa.title': 'Sign in to another site', 'owa.sub': 'This site is asking your server to confirm who you are. If you continue, it learns your address.', 'owa.as': 'You are signing in as', 'owa.choose': 'Which of your sites?', 'owa.go': 'Yes, sign me in', 'owa.cancel': 'No, go back', 'owa.fine': 'No password goes to that site. Only your address, and only if you say yes here.', 'fgate.owa_label': 'Sign in with your own fediverse address', 'fgate.owa_go': 'Continue', 'fgate.owa_hint': 'Your own server confirms who you are. No account and no password here — if you follow this site, you are in.', 'fgate.owa_failed': 'I could not reach that address. Is the spelling right?', 'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
-    'lbio.empty': 'No links set up yet.',
-    'lbio.back_to_site': 'back to the site',
-    'myst.overview': 'Overview',
-    'myst.title': 'My Klonkt Hub',
-    'myst.quick_links_aria': 'Admin shortcuts',
-    'myst.new_post': 'New post',
-    'myst.appearance': 'Appearance',
-    'myst.comments': 'Comments',
-    'myst.view_site': 'View my site',
-    'myst.account': 'Account',
-    'myst.posts': 'Posts',
-    'myst.published': 'Published',
-    'myst.draft_count_one': '{n} draft',
-    'myst.draft_count_many': '{n} drafts',
-    'myst.draft_badge': 'Draft',
-    'myst.untitled': '(untitled)',
-    'myst.edit': 'Edit',
-    'myst.view': 'View',
-    'arst.title': 'Reset password',
-    'arst.request_new': 'Request a new reset link',
-    'arst.back_login': 'Back to login',
-    'arst.set_for': 'Set a new password for {username}.',
-    'arst.new_pw': 'New password (min 8 characters)',
-    'arst.confirm_pw': 'Confirm new password',
-    'arst.submit': 'Set password',
-    'arrq.title': 'Reset password',
-    'arrq.sent': 'If an account exists for that email address, a reset link has been sent.',
-    'arrq.no_mailserver': '<strong>No mail server configured</strong> — reset link below.',
-    'arrq.no_mail_cli': 'No email set up? The administrator can also run <code>npm run reset-admin</code> on the server.',
-    'arrq.back_login': '← Back to login',
-    'arrq.tagline': 'Enter your email and we\'ll send you a reset link.',
-    'arrq.email': 'Email',
-    'arrq.submit': 'Send reset link',
-    'adir.home': 'Home',
-    'adir.logout': 'Log out',
-    'adir.login': 'Log in',
-    'adir.title': 'Members',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Search by name…',
-    'adir.search_aria': 'Search members',
-    'adir.search_btn': 'Search',
-    'adir.clear': 'Clear',
-    'adir.empty_q': 'No members found for "{q}".',
-    'adir.empty': 'No members yet.',
-    'adir.posts_one': '{n} post',
-    'adir.posts_many': '{n} posts',
-    'adir.pager_aria': 'Pagination',
-    'adir.prev': '← Previous',
-    'adir.page_info': 'Page {page} of {pages}',
-    'adir.next': 'Next →',
-    'pusr.post_one': 'post',
-    'pusr.post_many': 'posts',
-    'pusr.on_this_site': 'on this site',
-    'pusr.total': '{n} total',
-    'pusr.joined': 'joined {date}',
-    'pusr.send_dm': 'Send DM',
-    'pusr.empty': 'No posts on this site yet.',
-    'pusr.posts_heading': 'Posts',
-    'pusr.untitled': '(untitled)',
-    'phub.main_badge': 'Main page',
-    'phub.view_page': 'View the page',
-    'phub.members': 'Members',
-    'phub.post_one': 'post',
-    'phub.post_many': 'posts',
-    'phub.all_members': 'All {n} members',
-    'phub.latest_posts': 'Latest posts from our members.',
-    'cfeed.title': 'Circles',
-    'cfeed.sub': "What's happening at the other sites in my circle.",
-    'cfeed.count': '{n} sites in my circle',
-    'cfeed.empty': 'Nothing in my circle yet.',
-    'cfeed.close': 'Close',
-    'cfeed.grid_view': 'Grid view',
-    'cpost.back': 'Circle',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Read more at {source}',
-    'vblk.title': 'Viewer mode',
-    'vblk.text_before': 'This is a read-only account. You can view everything, but',
-    'vblk.text_strong': 'cannot change anything',
-    'vblk.text_after': '— saving, uploading, deleting and commenting are disabled.',
-    'vblk.back': 'Back',
-    'vblk.to_home': 'Go to the main page',
-    'phome.moved_lead': 'This account has moved. You can find me here now:',
-    'phome.moved_hint': 'Already following me? Your server most likely moves you along by itself. If not: follow the new address.',
-    'phome.empty_title': "It's quiet here for now.",
-    'phome.empty_sub': 'No posts yet. Exciting.',
-    'phome.write_first': 'Write your first post',
-    'phome.grid_view': 'Grid view',
-    'fav.title': 'Favorites',
-    'fav.sub': 'Posts you have liked. Tap the ♥ on a post to save it here.',
-    'fav.empty': 'You have no favorites yet. Open a post and tap the heart ♡.',
-    'chlog.back': 'Back',
-    'chlog.title': 'Changes',
-    'chlog.app_version': 'App version',
-    'chlog.fed_proto': 'Federation proto',
-    'chlog.manage_updates': 'Manage updates',
-    'e404.title': 'Page not found',
-    'e404.sub': 'This page no longer exists. The link may be outdated or mistyped.',
-    'e404.home': 'Go to the homepage',
-    'e404.archive': 'Archive',
-    'ptype.eyebrow': 'Type',
-    'ptype.count_one': '{n} post',
-    'ptype.count_many': '{n} posts',
-    'ptype.empty': 'No posts of this type yet.',
-    'ptype.untitled': '(untitled)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} post',
-    'ptag.count_many': '{n} posts',
-    'ptag.empty': 'No posts with this tag yet.',
-    'ptag.untitled': '(untitled)',
-    'parch.back': 'Back',
-    'parch.title': 'Archive',
-    'parch.count_one': '{n} post',
-    'parch.count_many': '{n} posts',
-    'parch.empty': 'No posts yet.',
-    'prin.tagline': 'Your direct messages on this site.',
-    'prin.empty': 'No conversations yet. Open someone\'s profile and click "Send DM" to start one.',
-    'prin.empty_conv': 'Empty conversation',
-    'prcv.back_aria': 'Back to inbox',
-    'prcv.inbox': 'Inbox',
-    'prcv.unknown': 'Unknown',
-    'prcv.view_profile': 'View profile',
-    'prcv.placeholder': 'Message…',
-    'prcv.send': 'Send',
-    'acct.lang_label': 'Language',
-    'acct.lang_hint': '— your personal interface language; travels with you across devices and sessions.',
-    'aset.default_lang': 'Default language for visitors',
-    'aset.default_lang_hint': 'What new visitors see before they pick a language themselves. A logged-in user with their own preference sees that instead.',
-    'aset.default_lang_auto': 'Automatic (browser language)',
-    'aset.timezone': 'Timezone',
-    'aset.timezone_hint': 'The timezone used to show and schedule dates and planned posts. Automatic = server default (UTC).',
-    'aset.timezone_auto': 'Automatic (UTC)',
-  },
-  de: {
-    'nav.back_to_site': '← Zurück zur Seite',
-    'nav.fediverse': 'Fediverse',
-    'nav.home': 'Start',
-    'nav.archive': 'Archiv',
-    'nav.search': 'Suche',
-    'nav.theme': 'Thema wechseln',
-    'nav.theme_label': 'Thema', 'nav.dark': 'Dunkel', 'nav.light': 'Hell',
-    'nav.install': 'App installieren',
-    'nav.login': 'Anmelden',
-    'nav.logout': 'Abmelden',
-    'nav.admin': 'Verwaltung',
-    'nav.account': 'Konto',
-    'nav.profile': 'Profil',
-    'nav.favorites': 'Favoriten',
-    'nav.new_post': 'Neuer Beitrag',
-    'nav.language': 'Sprache',
-    'nav.notifications': 'Benachrichtigungen',
-    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'notif.report_about': 'Zum Beitrag', 'notif.report_noreason': 'Kein Grund angegeben.', 'notif.mentioned': 'hat dich in einem Beitrag erwähnt', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
-    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefällt dein Beitrag',
-    'switch.agenda': 'Termine',
-    'switch.solo': 'Solo',
-    'switch.circle': 'Zirkel',
-    'switch.grid': 'Raster', 'switch.reader': 'Lesen', 'switch.timeline': 'Zeitleiste', 'read.to_top': 'Nach oben', 'read.pinned': 'Angeheftet', 'read.next': 'Weiter', 'read.prev': 'Vorheriger Beitrag', 'read.nav': 'Durch die Beitraege', 'read.hint': 'Oben oder unten tippen fuer einen Beitrag zurueck oder weiter', 'asite.reader_full_page': 'Lesen am Desktop: ein Bildschirm pro Beitrag (mobil immer)',
-    'asite.feed_alt': 'Zweite Ansicht', 'asite.feed_alt_reader': 'Lesen', 'asite.feed_alt_timeline': 'Zeitleiste', 'asite.feed_alt_auto': 'Lesen mobil, Zeitleiste am Desktop',
-    'switch.reader_solo_only': 'Lesen gibt es nur in Solo — im Zirkel stehen Beitr\u00e4ge anderer',
-    'postnav.newer': 'Neuer',
-    'postnav.older': 'Älter',
-    'postnav.newest': 'Neuester Beitrag',
-    'postnav.oldest': 'Ältester Beitrag',
-    'footer.subscribe_cta': 'Bleib auf dem Laufenden',
-    'footer.subscribe': 'Abonnieren',
-    'footer.install': 'App installieren',
-    'common.email_placeholder': 'du@email.de',
-    'common.back_to_admin': '← Verwaltung',
-    'agenda.title': 'Termine',
-    'agenda.empty': 'Zurzeit keine angekündigten Termine.',
-    'agenda.tickets': 'Tickets',
-    'agenda.notify_h': 'Keinen Termin verpassen',
-    'agenda.notify_sub': 'Hinterlasse deine E-Mail und wir melden uns bei neuen Terminen. Abmelden jederzeit möglich.',
-    'agenda.notify_btn': 'Halt mich auf dem Laufenden',
-    'agenda.msg_done': '✓ Du stehst auf der Liste — wir melden uns, sobald ein Termin angekündigt wird.',
-    'agenda.msg_check': '✉ Prüfe deine E-Mail, um die Anmeldung zu bestätigen.',
-    'downloads.title': 'Downloads',
-    'downloads.sub': 'Kostenlos herunterladen — hinterlasse deine E-Mail und du bekommst die Datei.',
-    'downloads.empty': 'Momentan keine Downloads verfügbar.',
-    'downloads.btn': '⬇ Herunterladen',
-    'admin.title': 'Verwaltung',
-    'adash.prem_active': 'Premium aktiv',
-    'adash.prem_unlinked': 'Premium nicht verbunden',
-    'adash.prem_layeroff': 'Premium-Ebene aus',
-    'admin.tagline_solo': 'Solo-Modus — deine Seite.',
-    'admin.tagline_cirkels': 'Cirkel-Modus: deine Seite, verbunden mit dem Fediverse.',
-    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
-    'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_folreq_t': 'Folgeanfrage', 'push.n_folreq_b': '{who} möchte dir folgen — du entscheidest', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.follow_out_line': 'möchte {who} folgen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_guard_folin_t': 'Folgeanfrage', 'push.n_guard_folin_b': '{who} möchte {ward} folgen', 'push.n_guard_folout_t': 'Dein Mündel möchte jemandem folgen', 'push.n_guard_folout_b': '{ward} fragt, ob es {who} folgen darf', 'guardian.panel_history': 'Verlauf ({n})', 'guardian.log_show': 'Verlauf zeigen', 'guardian.log_hide': 'Verlauf verbergen', 'guardian.ev_offer_rejected': 'Angebot abgelehnt', 'guardian.ev_offer_refused': 'Angebot verweigert', 'guardian.ev_committed': 'Vormundschaft bestätigt', 'guardian.ev_guardian_left': 'Guardian gegangen', 'guardian.ev_coguardian_left': 'Mit-Guardian gegangen', 'guardian.ev_gated_outcome': 'Tor entschieden', 'guardian.ev_lapse_opened': 'Freigabe vorgeschlagen', 'guardian.evr_not_a_teapot': 'der Kandidat ist selbst ein Mündel', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_former_ward': 'Nicht mehr dein Ward. Ihre anderen Guardians sind weiterhin fuer sie da.', 'guardian.warn_reversible': 'Was hier durchkommt, kommt nicht zurueck. Du kannst diese Einstellung spaeter wieder schliessen — was dein Kind gesehen hat, nicht.', 'guardian.warn_irreversible': 'Das laesst sich nicht rueckgaengig machen. Danach entscheidet sie selbst, und ihr koennt das nicht zuruecknehmen.', 'guardian.warn_unknown': 'Wir kennen diese Einstellung nicht, wissen also nicht, was durchkommt oder wie weit es reicht. Frag die Person, die es vorgeschlagen hat, bevor du zustimmst.', 'guardian.warn_decides': 'DEINE ANTWORT ENTSCHEIDET. Mit dir ist die Schwelle erreicht und es gilt sofort.', 'guardian.warn_not_last': 'Danach muss noch jemand antworten, bevor das gilt.', 'guardian.warn_tally_elsewhere': 'Wir sehen nicht, wie viele Guardians schon geantwortet haben — das zaehlt der Server des Kindes. Dein Ja kann den Ausschlag geben.', 'guardian.warn_go': 'Ja, das vorschlagen', 'guardian.warn_back': 'Nein, doch nicht', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_unavailable': 'noch nicht verfuegbar', 'guardian.gate_planned_note': 'Das gibt es auf diesem Server noch nicht.', 'guardian.gates_summary': '{n} Tore - {on} an, {wait} warten', 'guardian.gates_show': 'Tore zeigen', 'guardian.gates_hide': 'Tore ausblenden', 'guardian.gate_images': 'Bilder', 'guardian.gate_messages': 'Nachrichten', 'guardian.gate_asked': 'Dein Kind hat selbst darum gebeten.', 'guardian.gate_replies': 'Antworten in einem Gespraech', 'guardian.gate_compose': 'Selbst posten', 'guardian.gate_music': 'Musik', 'guardian.gate_quoteCards': 'Zitatkarten', 'guardian.gate_customEmoji': 'Eigene Emojis', 'guardian.gate_publicProfile': 'Oeffentlich sichtbar', 'guardian.gate_accountMove': 'Umziehen', 'guardian.gate_independence': 'Selbststaendig werden', 'guardian.gate_externalThreads': 'Antworten von Fremden', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_following': 'Anderen folgen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_default_off': 'aus (noch nichts entschieden)', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_always': 'Immer', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose_open': 'Vorschlagen: oeffnen', 'guardian.gate_propose_close': 'Vorschlagen: schliessen', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
-    'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
-    'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
-    'ppk.t': 'Erstelle deinen Passkey', 'ppk.h': 'Du bist Unterstützer, schön.', 'ppk.sub': 'Erstelle jetzt einen Passkey. Er wird dein Schlüssel für bezahlte Beiträge, ohne Konto und ohne Cookie. Wir speichern keinen Namen und keine E-Mail-Adresse.', 'ppk.make': 'Passkey erstellen', 'ppk.unsupported': 'Passkeys werden in diesem Browser nicht unterstützt.', 'ppk.follow': 'Folge der Abfrage deines Geräts…', 'ppk.done': 'Geschafft. Dein Passkey wurde erstellt.', 'ppk.failed': 'Erstellen fehlgeschlagen ({err}). Versuch es erneut.', 'ppk.cancelled': 'Abgebrochen.',
-    'pres.t': 'Entsperren', 'pres.notpatron_h': 'Noch kein Unterstützer', 'pres.notpatron_p': 'Du bist (noch) kein aktiver Unterstützer dieser Seite auf Patreon. Werde Unterstützer und versuch es danach erneut vom Beitrag aus.', 'pres.tier_h': 'Eine Stufe höher nötig', 'pres.tier_p': 'Dieser Beitrag verlangt ab €{need}. Deine Unterstützung ist derzeit €{have}. Erhöhe deine Unterstützung und versuch es erneut.', 'pres.expired_h': 'Anfrage abgelaufen', 'pres.expired_p': 'Dieser Entsperr-Link ist abgelaufen oder wurde schon benutzt. Geh zurück zum Beitrag und versuch es erneut.', 'pres.declined_h': 'Entsperren abgebrochen', 'pres.declined_p': 'Es wurde nichts verbunden. Du kannst es vom Beitrag aus erneut versuchen.', 'pres.join': 'Unterstützer werden auf Patreon', 'pres.back_post': 'Zurück zum Beitrag', 'pres.back_site': 'Zurück zur Seite',
-    'admin.tagline_hub': 'Hub-Modus — eine Firmenseite mit Nutzern, je ein eigener Klonkt Hub.',
-    'admin.b_sites': '🌐 Seiten', 'admin.b_users': '👥 Nutzer', 'admin.b_audio': '🎵 Audio',
-    'admin.b_media': '🎬 Medien', 'admin.t_media': 'Medien', 'admin.media_images': 'Bilder', 'admin.media_videos': 'Videos', 'admin.videos_count': 'Videos', 'admin.videos_empty': 'Noch keine Videos hochgeladen.', 'admin.videos_del_confirm': 'Dieses Video löschen?', 'admin.media_count': 'Bilder', 'admin.media_unused': 'ungenutzt', 'admin.media_cleanup': 'Ungenutzte löschen', 'admin.media_cleanup_confirm': 'Alle ungenutzten Bilder löschen?', 'admin.media_empty': 'Noch keine Bilder hochgeladen.', 'admin.media_copy': 'URL kopieren', 'admin.media_del_confirm': 'Dieses Bild löschen?',
-    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Kommentare', 'admin.b_seo': '🔎 SEO',
-    'admin.b_listeners': 'Hörer',
-    'alis.lis_title': 'Hörer',
-    'alis.lis_intro': 'Konten, die deiner BIBLIOTHEK folgen. Sie bekommen deine Musik, und bewusst nicht deine normalen Beiträge — wer ein Plattenregal abonniert, hat nicht um die Zeitung gebeten.',
-    'alis.lis_empty': 'Noch folgt niemand der Bibliothek.',
-    'alis.lis_since': 'Seit',
-    'alis.lis_last': 'Letzte Zustellung',
-    'alis.lis_never': 'noch nichts zugestellt',
-    'alis.lis_count': 'Hörer',
-    'alis.lis_error': 'Zustellung fehlgeschlagen',
-    'ple.titel': 'Titel *',
-    'ple.artiest': 'Künstler',
-    'ple.jaar': 'Jahr',
-    'ple.type': 'Typ',
-    'ple.k_album': 'Album (nummeriert)',
-    'ple.k_playlist': 'Playlist (Titelbilder)', 'ple.k_mixtape': 'Mixtape (Kassette: nur vor und zurück)',
-    'ple.uitgave': 'Veröffentlichungsdatum',
-    'ple.mb_release': 'MusicBrainz-Release-ID',
-    'ple.cover': 'Cover',
-    'ple.cover_kies': 'Bild wählen…',
-    'ple.cover_url': 'https://… oder hochladen',
-    'ple.tracks_in': 'Titel in der Playlist',
-    'ple.sleep_hint': '(⠿ ziehen zum Sortieren)',
-    'ple.beschikbaar': 'Verfügbare Titel',
-    'ple.zoek': 'Suchen…',
-    'ple.geen_res': 'Keine Ergebnisse.',
-    'ple.leeg_sel': 'Klicke rechts auf Titel, um sie hinzuzufügen.',
-    'ple.t_edit': 'Playlist bearbeiten',
-    'ple.t_new': 'Neue Playlist',
-    'ple.dialoog': 'Playlist-Editor',
-    'ple.sluiten': 'Schließen',
-    'ple.annuleren': 'Abbrechen',
-    'ple.opslaan': 'Speichern',
-    'ple.bezig_opslaan': 'Speichern…',
-    'ple.aanmaken': 'Anlegen',
-    'ple.versleep': 'Ziehen',
-    'ple.verwijder': 'Entfernen',
-    'ple.geen_audio': 'Titel hat keine Audiodatei',
-    'ple.e_geen_tracks': 'Keine Audiotitel vorhanden. Lade zuerst welche über Verwaltung → Audio hoch.',
-    'ple.e_tracks': 'Titel konnten nicht geladen werden',
-    'ple.e_opslaan': 'Speichern fehlgeschlagen: ',
-    'ple.e_mislukt': 'Fehlgeschlagen: ',
-    'ple.e_alleen_afb': 'Nur Bilder',
-    'ple.bezig': 'Wird hochgeladen…',
-    'ple.e_upload': 'Upload fehlgeschlagen',
-    'admin.b_settings': '⚙️ Einstellungen', "mig.title": "Migrieren", "mig.plan_new": "auf deinem neuen Klonkt", "mig.plan_from_old": "Du siehst die Instanz, die WEGGEHT. Schritt 1, 3 und 4 machst du auf deinem neuen Klonkt.", "mig.plan_title": "So l\u00e4uft ein Umzug", "mig.plan_hint": "Die Reihenfolge ist nicht beliebig. Schritt 2 muss vor Schritt 3, denn dein altes Klonkt gibt nichts an eine Adresse heraus, die es nicht als Nachfolger kennt. Mit einem Zip darf Schritt 2 dagegen zuletzt: eine Datei fragt niemanden.", "mig.plan_here": "hier", "mig.plan_old": "auf deinem alten Klonkt", "mig.plan_1": "Dein früheres Konto verknüpfen", "mig.plan_1_why": "Ohne das verweigert dein altes Klonkt den Umzug.", "mig.plan_2": "Den Umzug ank\u00fcndigen", "mig.plan_2_why": "Deine Follower ziehen mit. Danach wird das alte Konto gesperrt: kein Posten, Folgen oder Antworten mehr.", "mig.plan_3": "Beitr\u00e4ge und Musik holen", "mig.plan_3_why": "Direkt vom alten Klonkt, oder aus einem Zip, wenn es schon offline ist.", "mig.plan_4": "Deine Folgeliste zur\u00fcckspielen", "mig.plan_5": "Sp\u00e4ter: wenn du die alte Domain aufgibst", "mig.plan_5_why": "Lösche dort zuerst deine alten Beiträge. Dann verschwindet ein fremder Boost sauber, statt als kaputte Karte mit totem Link stehen zu bleiben. Entferne danach oben bei Schritt 1 dein früheres Konto: diese Adresse kontrollierst du dann nicht mehr. Dafür gibt es noch keinen Knopf.", "mig.follows_note": "Oder f\u00fcge den Inhalt deiner CSV oben ein. Die Datei gewinnt, wenn du beides ausf\u00fcllst.", "asite.moved_to_migrate": "Aliasse und Umzug stehen jetzt unter Migrieren, zusammen mit Export, Import und Holen.", "mig.alias_title": "Schritt 1: dein früheres Konto verknüpfen", "mig.alias_hint": "Sag, welches Konto früher deins war. Dein altes Klonkt prüft das, bevor es deine Follower umzieht, und der Hol-Knopf unten braucht es auch.", "mig.alias_label": "Deine früheren Konten, eines pro Zeile", "mig.alias_note": "So, wie du sie jemandem geben würdest: @du@mastodon.social. Höchstens fünf.", "mig.alias_btn": "Speichern", "mig.move_title": "Schritt 2: den Umzug ank\u00fcndigen", "mig.move_hint": "Das sagt allen Followern, dass dein Konto jetzt woanders wohnt. Sie ziehen mit um, und dieses Konto wird danach gesperrt: kein Posten, Folgen, Liken oder Antworten mehr.", "mig.move_label": "Deine neue Adresse", "mig.move_warn": "Das ist der einzige Knopf auf dieser Seite, den du nicht r\u00fcckg\u00e4ngig machen kannst. Dein altes Konto wird danach gesperrt. Nutzt du den Hol-Knopf, MUSS das zuerst: sonst gibt dein altes Klonkt nichts heraus.", "mig.move_btn": "Umzug ank\u00fcndigen", "mig.move_confirm": "Das k\u00fcndigt deinen Umzug allen Followern an und sperrt dieses Konto. Sicher?", "mig.move_done": "Dieses Konto ist bereits umgezogen nach", "mig.r_links_fixed": "Beitr\u00e4ge, deren Links jetzt hierher zeigen", "mig.r_posts_updated": "Beitr\u00e4ge repariert (Bilder lokal geholt)", "mig.r_tracks_updated": "Titel erg\u00e4nzt", "mig.r_tracks_missing": "{n} Titel sind NICHT angekommen, weil die Audiodatei fehlte. Sie wurden bewusst nicht angelegt: ein Titel, der in der Liste steht und nicht spielt, ist schlimmer als ein fehlender.", "mig.c_tracks": "Titel", "mig.c_playlists": "Playlists", "mig.audio_missing": "Achtung: Bei {n} Titel(n) ist die Audiodatei nicht auffindbar. Die kommen nicht mit.", "mig.audio_none": "Achtung: Diese Seite hat Musik, aber es kommt kein einziger Titel mit. Die Dateien liegen vermutlich woanders als die Datenbank denkt.", "mig.pull_title": "Von deinem alten Klonkt holen", "mig.pull_hint": "Bist du schon umgezogen, holt dieses Klonkt deine Beiträge direkt beim alten. Nichts abzutippen: dass du umgezogen bist, ist der Beweis.", "mig.pull_source": "Dein altes Konto", "mig.pull_source_hint": "Aus Schritt 1 übernommen. Stimmt es nicht, ändere es hier.", "mig.pull_btn": "Beitr\u00e4ge holen", "mig.pull_done": "Geholt", "mig.pull_failed": "Holen hat nicht geklappt", "mig.r_blocks": "Blockierungen \u00fcbernommen", "mig.state": "{n} Beitr\u00e4ge haben einen Verweis von ihrer alten Adresse.", "mig.state_done": "Die Liste ist vollst\u00e4ndig.", "mig.state_busy": "Die Liste ist noch nicht vollst\u00e4ndig.", "mig.e_no_source": "Es ist kein altes Konto bekannt. Trage die Adresse oben ein.", "mig.e_unreachable": "Das alte Klonkt ist nicht erreichbar.", "mig.e_not_moved_here": "Dieses Konto ist nicht hierher umgezogen. Kündige den Umzug zuerst auf deinem alten Klonkt an.", "mig.e_no_backreference": "Dieses Konto sagt nirgends, dass das alte Konto deins war. Trage es oben bei Schritt 1 ein, sonst kann dein altes Klonkt nicht erkennen, dass du es bist.", "mig.e_no_outbox": "Das alte Klonkt hat keine Beitragsliste.", "mig.e_partial": "Auf halbem Weg gestoppt. Was da ist, bleibt; versuche es noch einmal.", "mig.e_config": "Dieses Klonkt kennt seine eigene Adresse nicht.", "mig.e_crash": "Etwas Unerwartetes ist schiefgegangen.", "mig.e_points_at": "Dieses Konto verweist auf:", "mig.lead": "Nimm deine Beiträge, Musik und Fotos zu einem anderen Klonkt mit, oder hole sie hierher.", "mig.export_title": "Mitnehmen", "mig.export_hint": "Das erstellt ein Zip mit deinen Beitr\u00e4gen, den Antworten darauf und den zugeh\u00f6rigen Medien.", "mig.c_posts": "Beitr\u00e4ge", "mig.c_replies": "Antworten", "mig.c_media": "Mediendateien", "mig.c_following": "Folge ich", "mig.c_size": "gro\u00df", "mig.missing": "Achtung: Bei {n} Medienverweis(en) liegt die Datei nicht mehr auf der Platte. Die kommen nicht mit.", "mig.too_big": "Dieses Archiv ist zu gro\u00df f\u00fcr die Weboberfl\u00e4che. Nutze scripts/export-archive.mjs auf dem Server.", "mig.export_btn": "Archiv herunterladen (.zip)", "mig.export_none": "Noch nichts zu exportieren.", "mig.import_title": "Hierher holen", "mig.import_hint": "Wähle deine Datei. Du siehst zuerst, was passieren würde; es ändert sich noch nichts.", "mig.file_label": "Deine Archivdatei (.zip)", "mig.overwrite_label": "Beiträge ersetzen, die schon hier sind", "mig.overwrite_hint": "Normalerweise bleibt alles, was hier steht, unangetastet. Schaltest du das ein, wird ein Beitrag mit demselben Namen ersetzt, auch wenn es etwas anderes war. Das kommt nicht zurück.", "mig.check_btn": "Pr\u00fcfen", "mig.check_hint": "Das \u00e4ndert noch nichts. Du siehst zuerst, was passieren w\u00fcrde.", "mig.r_dry": "Was passieren w\u00fcrde", "mig.r_done": "Importiert", "mig.r_would": "k\u00e4men dazu", "mig.r_imported": "dazugekommen", "mig.r_skipped": "\u00fcbersprungen (schon vorhanden)", "mig.r_overwritten": "\u00fcberschrieben", "mig.r_media": "Mediendateien", "mig.r_media_missing": "Medien fehlten im Archiv", "mig.r_new_ids": "Dieses Archiv kommt von einer anderen Webadresse, deine Beiträge liegen hier also unter einer neuen Adresse. Andere Server wissen das noch nicht, also bleiben Antworten und Boosts auf die alte Adresse dort stehen.", "mig.r_confirm_hint": "Sieht gut aus? W\u00e4hle dieselbe Datei noch einmal, um es wirklich zu tun.", "mig.r_confirm_btn": "Jetzt wirklich importieren", "mig.follows_title": "Wem du folgst", "mig.follows_hint": "Deine Folgeliste ist auch im Archiv, und du kannst sie unter Connect separat herunterladen und zur\u00fcckspielen.", "mig.follows_btn": "Zu Connect", "admin.b_migrate": "\ud83d\udce6 Migrieren", 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
-    'admin.t_admin': 'Verwaltung', 'admin.t_settings': 'Einstellungen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Pressemappe bearbeiten', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Termine', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Neue Site', 'admin.t_editsite': 'Bearbeiten: {title}', 'admin.t_stats': 'Statistiken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Benutzer', 'admin.t_hub': 'Mein Klonkt Hub', 'admin.t_manual': 'Handbuch', 'aset.premium_gate': '{feature} ist eine Premium-Funktion — verbinde Patreon in Verwaltung → Einstellungen.',
-    'admin.b_makesite': '🎨 Seite erstellen', 'admin.b_circle': '🔗 Kreis', 'admin.b_stats': '📊 Statistik',
-    'admin.b_newsletter': '✉️ Newsletter', 'admin.b_perskit': '📰 Pressekit', 'admin.b_downloads': '⬇ Downloads',
-    'admin.b_linkbio': '🔗 Link-in-Bio', 'admin.b_agenda': '📅 Termine',
-    'admin.b_updates': '🔄 Updates', 'admin.b_help': '📖 Anleitung', 'admin.b_fediverse': 'Meine Antworten',
-    'admin.st_users': 'Nutzer', 'admin.st_sites': 'Seiten', 'admin.st_posts': 'Beiträge', 'admin.st_published': 'Veröffentlicht',
-    'admin.sec_posts': 'Beiträge', 'admin.sec_sites': 'Seiten', 'admin.sec_users': 'Nutzer',
-    'admin.draft': 'Entwurf', 'admin.edit': 'Bearbeiten', 'admin.view': 'Ansehen',
-    'admin.th_slug': 'Slug', 'admin.th_title': 'Titel', 'admin.th_owner': 'Eigentümer', 'admin.th_created': 'Erstellt',
-    'admin.th_username': 'Benutzername', 'admin.th_email': 'E-Mail', 'admin.th_role': 'Rolle', 'admin.th_joined': 'Mitglied seit',
-    'welcome.title': 'Willkommen bei Klonkt',
-    'welcome.tagline': 'Eine selbstgehostete Publishing-Plattform.',
-    'welcome.have_account': 'Schon ein Konto? Anmelden',
-    'welcome.note': 'Lege dein Administrator-Konto an, um zu starten — danach ist die Registrierung geschlossen.',
-    'welcome.nosite': 'Hallo {user}! Es ist noch keine Seite eingerichtet.',
-    'auth.admin_login_title': 'Administrator-Anmeldung',
-    'auth.username_or_email': 'Benutzername oder E-Mail',
-    'auth.password': 'Passwort',
-    'auth.forgot': 'Passwort vergessen?',
-    'auth.public_sub': 'Melde dich an, um zu kommentieren und Favoriten zu speichern.',
-    'auth.admin_box_q': 'Administrator?',
-    'auth.admin_box_sub': 'Mit Benutzername & Passwort anmelden',
-    'auth.create_admin': 'Administrator anlegen',
-    'auth.reg_intro': 'Ersteinrichtung — lege dein Administrator-Konto an. Das geht nur einmal.',
-    'setup.title': 'Richte dein Klonkt ein',
-    'setup.intro': 'Willkommen! Lass uns deine Seite einrichten — dauert nur eine Minute. Wähle zuerst deine Sprache.',
-    'setup.lang_label': 'Sprache',
-    'setup.f_sitename': 'Name deiner Seite',
-    'setup.sitename_ph': 'z. B. dein Künstlername',
-    'setup.submit': 'Meine Seite erstellen',
-    'setup.username_note': 'Das wird deine Adresse im Fediverse und kann später nicht geändert werden:',
-    'changelog.title': 'Änderungen', 'changelog.empty': 'Kein Änderungsprotokoll verfügbar.',
-    'auth.f_username': 'Benutzername (3-32 Zeichen, Buchstaben/Ziffern/_-)',
-    'auth.f_email': 'E-Mail',
-    'auth.f_password': 'Passwort (min. 8 Zeichen)',
-    'tab.home': 'Start', 'tab.search': 'Suche', 'tab.write': 'Schreiben', 'tab.profile': 'Profil',
-    'comments.heading_one': '{n} Kommentar', 'comments.heading_other': '{n} Kommentare',
-    'comments.empty': 'Noch keine Kommentare.',
-    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
-    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
-    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.report_open': 'Diesen Beitrag melden', 'fedi.report_where': 'Die Meldung geht an die Instanz und deren Moderator(en).', 'fedi.report_ph': 'Was ist das Problem? (optional)', 'fedi.report_send': 'Melden', 'fedi.reported_title': 'Gemeldet', 'fedi.reported_done': 'Deine Meldung wurde an den Server der Person gesendet. Deren Moderatoren prüfen sie.', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.mod_remove_confirm': 'Diese Antwort aus deinem Thread entfernen? Sie kommt nicht zurück, auch nicht über Thread-Auffüllung.', 'fedi.mod_report_confirm': 'Diese Antwort beim Server des Autors melden?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.moved_title': 'Dieses Konto ist umgezogen', 'tl.moved_lead': 'Neue Beiträge, Folgen, Likes und Antworten laufen jetzt über', 'tl.moved_hint': 'Lesen funktioniert hier weiterhin, und Antworten auf deine alten Beiträge kommen noch an. Zurück? Leere das Umzugsziel unter Aussehen.', 'tl.move_title': 'Deine Folgeliste mitnehmen', 'tl.move_hint': 'Ziehst du auf eine andere Adresse um? Deine Follower erfahren das automatisch, wem DU folgst aber nicht. Nimm diese Liste hier mit. Funktioniert auch von und zu Mastodon.', 'tl.move_export': 'Liste herunterladen (CSV)', 'tl.move_import_file': 'Wähle deine heruntergeladene CSV-Datei:', 'tl.move_import_lbl': 'Oder füge die Liste hier ein:', 'tl.move_import': 'Allen folgen', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.tab_followers': 'Follower', 'tl.followers': 'Follower', 'tl.followers_lead': 'Wer dir im Fediverse folgt, mit der letzten erfolgreichen Zustellung. Rot = nie zugestellt oder letzter Versuch fehlgeschlagen — nach einer Prüfung ein Kandidat zum Aufräumen.', 'tl.empty_followers': 'Noch keine Follower.', 'tl.last_delivery': 'Letzte Zustellung', 'tl.never_delivered': 'Nie zugestellt', 'tl.delivery_failed': 'letzter Versuch fehlgeschlagen', 'tl.remove_follower': 'Entfernen', 'tl.folreq_title': 'Folgeanfragen', 'tl.folreq_sub': 'Diese warten auf dein Ja oder Nein. Bis dahin sieht die anfragende Person nichts von deinen Beiträgen.', 'tl.folreq_accept': 'Annehmen', 'tl.folreq_deny': 'Ablehnen', 'tl.approve_toggle': 'Follower zuerst genehmigen', 'tl.approve_toggle_hint': 'An: Folgeanfragen warten hier auf dein Ja. Aus: alle dürfen sofort folgen.', 'tl.remove_confirm': 'Diesen Follower entfernen? Ein aktives Konto müsste dir erneut folgen.', 'tl.tab_connect': 'Connect', 'tl.connect': 'Connect', 'tl.dir_following': 'du folgst', 'tl.dir_follower': 'folgt dir', 'tl.dir_mutual': 'gegenseitig', 'tl.connect_empty': 'Noch keine Verbindungen. Folge oben jemandem, um zu starten.', 'tl.unreachable': 'Nicht erreichbar', 'tl.unreachable_lead': 'Diese Follower konnten wir nicht erreichen (nie zugestellt oder letzter Versuch fehlgeschlagen). Räume sie nach einer Prüfung auf.', 'msg.tab': 'Nachrichten', 'msg.title': 'Nachrichten', 'msg.filter_all': 'Alle', 'msg.filter_msgs': 'Nachrichten', 'msg.filter_conv': 'Gespräche', 'msg.filter_act': 'Aktivität', 'msg.filter_mod': 'Moderation', 'msg.filter_sent': 'Gesendet', 'msg.search_ph': 'Nachrichten durchsuchen…', 'msg.no_match': 'Nichts gefunden.', 'msg.poll_done': 'Deine Umfrage ist beendet', 'msg.poll_total': '{n} Teilnehmer', 'msg.you': 'Du', 'msg.sent_reply': 'hat über das Fediverse geantwortet', 'msg.and_more': 'und {n} andere', 'msg.liked_many': 'gefällt dein Beitrag', 'msg.boosted_many': 'teilten deinen Beitrag', 'msg.private': 'privat', 'msg.private_hint': 'Nur an dich gerichtet; erscheint nicht auf der öffentlichen Beitragsseite.', 'msg.new': 'Neu seit deinem letzten Besuch', 'msg.empty': 'Noch keine Nachrichten. Antworten, Erwähnungen und Aktivität erscheinen hier.', 'oauth.title': 'App autorisieren', 'oauth.wants_access': 'möchte sich mit deinem Klonkt-Konto verbinden.', 'oauth.post_as': 'Posten als', 'oauth.scope_read': 'Deine Beiträge, Antworten und Meldungen lesen', 'oauth.scope_write': 'In deinem Namen posten, antworten, liken und folgen', 'oauth.allow': 'Erlauben', 'oauth.deny': 'Ablehnen', 'oauth.foot': 'Du kannst den Zugriff später widerrufen. Autorisiere nur Apps, denen du vertraust.', 're.title': 'Antwort schreiben', 're.bold': 'Fett', 're.italic': 'Kursiv', 're.link': 'Link einfügen', 're.list': 'Aufzählung', 're.quote': 'Zitat', 're.lang': 'Sprache deiner Antwort', 're.attach': 'Medien hinzufügen (Bild, Audio, Video)', 're.attach_err': 'Upload fehlgeschlagen', 're.to': 'An:', 're.mention_del': 'Diese Person nicht mehr adressieren', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'feed.load_more': 'Mehr laden', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.open': 'offen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.', 'poll.voted_title': 'Stimme gesendet', 'poll.voted_done': 'Deine Stimme ist unterwegs zur Umfrage. Die Ergebnisse aktualisieren sich, sobald die Autorin oder der Autor das Update sendet.',
-    'comments.to_start': 'um das Gespräch zu starten.',
-    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
-    'comments.delete_confirm': 'Diesen Kommentar löschen?',
-    'comments.reply_to': 'Antwort an {name}…',
-    'comments.add_as': 'Kommentieren als', 'comments.placeholder': 'Teile deine Gedanken…',
-    'comments.post': 'Kommentar posten', 'comments.login_to_comment': 'Zum Kommentieren anmelden',
-    'comments.pending': 'Dein Kommentar wartet auf Freigabe. Er erscheint, sobald ein Administrator ihn genehmigt.',
-    'related.title': 'Ähnliche Beiträge',
-    'search.placeholder': 'Beiträge und Titel suchen…', 'search.button': 'Suchen',
-    'search.error': 'Diese Suche konnte nicht ausgeführt werden. Versuch einen einfacheren Begriff.',
-    'search.results_one': '{n} Ergebnis für „{q}“', 'search.results_other': '{n} Ergebnisse für „{q}“',
-    'search.section_tracks': 'Titel', 'search.section_posts': 'Beiträge',
-    'search.empty': 'Nichts gefunden.', 'search.in_post': 'im Beitrag →',
-    'search.section_events': 'Veranstaltungen', 'search.section_pages': 'Seiten',
-    'search.page_agenda': 'Termine', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Pressekit', 'search.page_archive': 'Archiv',
-    'search.suggest_all': 'Alle Ergebnisse →', 'search.suggest_empty': 'Keine Ergebnisse', 'search.suggest_typing': 'Zum Suchen tippen…',
-    'like.login_title': 'Zum Liken anmelden', 'like.fedi_title': 'Diesen Beitrag von deinem Fediverse-Konto liken',
-    // === Verwaltungs-Unterseiten ===
-    'aset.title': 'Einstellungen',
-    'aset.back_admin': 'Verwaltung',
-    'aset.mode': 'Modus',
-    'aset.mode_help': 'Legt fest, wie diese Installation funktioniert. Das Wechseln ist sicher: Es wird nichts gelöscht — Solo blendet lediglich die Multi-Site-Funktionen aus und zeigt deine primäre Site.',
-    'aset.solo': 'Solo',
-    'aset.solo_title': 'eine Site (deine).',
-    'aset.solo_desc': 'Kein Benutzerverzeichnis, kein Site-Wechsel.',
-    'aset.premium_badge': 'Premium',
-    'aset.circle': 'Kreise',
-    'aset.circle_title': 'Solo + Föderation.',
-    'aset.circle_desc': 'Eine eigene Site, die die öffentlichen Beiträge anderer Klonkt-Sites anzeigt. Asymmetrisch: Du bestimmst, wer in deinem Kreis ist.',
-    'aset.save': 'Speichern',
-    'aset.name': 'Name',
-    'aset.name_ph': 'z. B. Studio Noord',
-    'aset.tagline': 'Tagline',
-    'aset.tagline_ph': 'z. B. Unabhängiges Musiklabel',
-    'aset.intro': 'Intro',
-    'aset.intro_ph': 'Kurzer Introtext unter dem Titel.',
-    'aset.hero_image': 'Hero-Bild (URL)',
-    'aset.hero_image_hint': 'optional; Hintergrund des Heros',
-    'aset.hero_upload': '…oder lade ein Bild hoch',
-    'aset.hero_upload_hint': 'jpg/png/webp/gif, max. 5 MB; ersetzt die URL oben',
-    'aset.hero_overlay': 'Dunkles Overlay',
-    'aset.hero_overlay_hint': 'verdunkelt das Hero, damit der Text lesbar bleibt',
-    'aset.preview_overlay': 'Vorschau (mit Overlay):',
-    'aset.hero_preview_alt': 'Hero-Vorschau',
-    'aset.your_circle': 'Dein Kreis',
-    'aset.your_circle_help': 'Verwalte, welche anderen Klonkt-Sites du in deinem Kreis anzeigst und ob deine Site in den Kreisen anderer erscheinen darf. Asymmetrisch: Du bestimmst, wem du folgst.',
-    'aset.manage_circle': 'Deinen Kreis verwalten',
-    'aset.premium': 'Premium (Patreon)',
-    'aset.premium_help_1': 'Schalte die Premium-Module (Newsletter, Downloads, Statistiken, EPK, Link-in-Bio, Termine) mit deiner',
-    'aset.premium_lifetime': '$16-Lifetime',
-    'aset.premium_help_2': 'Patreon-Unterstützung frei. Die App und alle Updates bleiben kostenlos.',
-    'aset.premium_active': 'Premium aktiv.',
-    'aset.lifetime_support': 'Lifetime-Unterstützung:',
-    'aset.patreon_disconnect': 'Patreon trennen',
-    'aset.patreon_no_lifetime': 'Patreon verbunden, aber noch keine $16 Lifetime',
-    'aset.now': 'derzeit',
-    'aset.patreon_support_again': 'Unterstütze die Kampagne und verbinde erneut.',
-    'aset.patreon_reconnect': 'Erneut verbinden',
-    'aset.patreon_not_connected': 'Noch nicht verbunden.',
-    'aset.patreon_connect': 'Patreon verbinden',
-    'aset.status_set': 'Status: eingerichtet',
-    'aset.not_set_yet': 'Noch nicht eingerichtet.',
-    'aset.newsletter': 'Newsletter',
-    'aset.newsletter_help_1': 'Zeige ein',
-    'aset.newsletter_footer_field': 'Anmeldefeld in der Fußzeile',
-    'aset.newsletter_help_2': 'deiner Site, damit sich Besucher auf jeder Seite eintragen können. (Die vollständige Anmeldeseite bleibt unter',
-    'aset.newsletter_show_footer': 'Anmeldefeld in der Fußzeile anzeigen',
-    'aset.fediverse': 'Fediverse (ActivityPub)',
-    'aset.fediverse_help': 'Lass deine Seite am Fediverse teilnehmen: Leute auf Mastodon (oder einem anderen Klonkt) können dir folgen, liken und antworten — und diese Antworten erscheinen unter deinen Beiträgen. Schaltest du das aus, föderiert deine Seite nicht und hat keine Kommentare (ein ruhiger, eigenständiger Blog).',
-    'aset.fediverse_toggle': 'Fediverse an (Folgen, Liken, Kommentieren)', 'aset.mode': 'Modus', 'aset.mode_help': 'Wähle, wie deine Site funktioniert.', 'aset.mode_solo': 'Solo', 'aset.mode_solo_help': 'Ein eigenständiger Blog — kein Fediverse, keine Kommentare. Ruhig und in sich geschlossen.', 'aset.mode_cirkels': 'Kreise', 'aset.mode_18plus': 'Das Fediverse ist ein offenes Netzwerk, das auch Inhalte für Erwachsene (18+) enthalten kann — du musst alt genug sein, um teilzunehmen.', 'aset.mode_18plus_confirm': 'Kreise verbindet deine Seite mit dem Fediverse, einem offenen Netzwerk mit auch 18+-Inhalten. Bestätige, dass du alt genug bist, um dies zu aktivieren.', 'aset.mode_cirkels_help': 'Mach beim Fediverse mit (ActivityPub): Leute auf Mastodon oder einem anderen Klonkt können dir folgen, liken und antworten, und du kannst einem Kreis von Sites folgen.',
-    'aset.email_smtp': 'E-Mail (SMTP)',
-    'aset.smtp_help_1': 'Erforderlich, um den',
-    'aset.smtp_help_newsletter': 'Newsletter zu versenden',
-    'aset.smtp_help_2': ',',
-    'aset.smtp_help_notify': 'Show-Notify',
-    'aset.smtp_help_3': '-Mails zu senden und das Zurücksetzen des Passworts per Mail zu ermöglichen. Gib die Daten deines Mailanbieters ein (z. B. deine Hosting-Mail, ein Gmail-App-Passwort, Brevo, Mailgun…).',
-    'aset.via_env': 'über .env',
-    'aset.smtp_not_set': 'Noch nicht eingerichtet — Versenden funktioniert noch nicht.',
-    'aset.smtp_host': 'SMTP-Host',
-    'aset.smtp_port': 'Port',
-    'aset.smtp_port_hint': '587 (STARTTLS) oder 465 (SSL)',
-    'aset.smtp_user': 'Benutzername',
-    'aset.smtp_pass': 'Passwort',
-    'aset.smtp_pass_set_hint': 'eingerichtet; leer lassen = unverändert',
-    'aset.smtp_pass_ph_set': '•••••••• (eingerichtet)',
-    'aset.smtp_pass_ph': 'App-Passwort',
-    'aset.smtp_from': 'Absender',
-    'aset.smtp_from_hint': 'optional; Standard = Benutzername',
-    'aset.smtp_from_ph': 'Dein Name <du@deinanbieter.de>',
-    'aset.smtp_save': 'SMTP speichern',
-    'aset.clear': 'Löschen',
-    'aset.test_mail_to': 'Testmail senden an',
-    'aset.send_test_mail': 'Testmail senden',
-    'asite.back_admin': 'Verwaltung',
-    'asite.title_new': 'Neue Seite',
-    'asite.title_edit': 'Erscheinungsbild',
-    'asite.identity': 'Identität',
-    'asite.slug': 'Slug (URL)',
-    'asite.slug_fixed': '(fest)',
-    'asite.slug_placeholder': 'deinslug',
-    'asite.field_title': 'Titel',
-    'asite.field_title_hint': '— wird im Kopf und als Anzeigename gezeigt',
-    'asite.owner': 'Eigentümer',
-    'asite.owner_hint': '— wer dieses Klonkt selbst verwalten darf',
-    'asite.owner_god_suffix': ' (god)',
-    'asite.tagline': 'Tagline',
-    'asite.tagline_hint': '— kurzer Einzeiler',
-    'asite.bio': 'Bio / Beschreibung',
-    'asite.bio_hint': '— wird im Profilkopf gezeigt und für SEO verwendet',
-    'asite.profile_photo': 'Profilfoto',
-    'asite.photo_url_placeholder': '/media/avatars/foo.jpg oder https://…',
-    'asite.photo_upload': '📷 Hochladen',
-    'asite.photo_clear': 'Entfernen',
-    'asite.language': 'Sprache (ISO-Code)',
-    'asite.profile_enabled': 'Profilkopf unter der Navigation anzeigen',
-    'asite.appearance': 'Gestaltung',
-    'asite.accent_color': 'Akzentfarbe',
-    'asite.theme_default': 'Standardthema für neue Besucher',
-    'asite.theme_auto': 'Auto (Geräteeinstellung folgen)',
-    'asite.theme_light': 'Hell',
-    'asite.theme_dark': 'Dunkel',
-    'asite.palette': 'Palette',
-    'asite.behavior': 'Verhalten',
-    'asite.is_public': 'Öffentliche Seite (abwählen für einen geschlossenen Kreis)',
-    'asite.robots_index': 'Suchmaschinen dürfen indexieren (sitemap.xml ist ausgeblendet, wenn aus)',
-    'asite.require_login_comment': 'Anmeldung zum Kommentieren erforderlich',
-    'asite.enable_audio': 'Audioplayer + Embeds aktivieren', 'asite.approve_followers': 'Follower zuerst genehmigen (Folgeanfragen warten auf dein Ja auf der Connect-Seite)',
-    'asite.links': 'Social- / Streaming-Links',
-    'asite.links_hint': 'Werden als Marken-Icons im Profilkopf gezeigt. Füge so viele hinzu, wie du möchtest.',
-    'asite.aliases': 'Fediverse-Aliasse',
-    'asite.move': 'Umzug (Fediverse)',
-    'asite.move_hint': 'Kündige deinen Followern an, dass dieses Konto woanders weitergeht. Das neue Profil muss diese Adresse zuerst als Alias beanspruchen; Follower ziehen dann automatisch mit. Ein Konto mit Guardians kann noch nicht umziehen.',
-    'asite.move_confirm': 'Bist du sicher? Deine Follower erfahren, dass dieses Konto umgezogen ist.',
-    'asite.move_btn': 'Umzug ankündigen',
-    'asite.moved_to': 'Umgezogen nach',
-    'asite.aliases_hint': 'Einer pro Zeile: dein altes Konto als @name@server oder als Actor-URL. Nötig, um Follower eines alten Kontos hierher umzuziehen; der alte Server prüft, ob dieses Profil das alte beansprucht.',
-    'asite.link_add': '+ Link hinzufügen',
-    'asite.feed_view': 'Feed-Anzeige',
-    'asite.feed_default': 'Standardansicht für die Startseite',
-    'asite.feed_reader': 'Lesen (ganze Beiträge, einer pro Bildschirm)',
-    'asite.feed_grid': 'Raster (Karten)',
-    'asite.feed_switch': 'Umschalter Zeitstrahl ↔ Raster über dem Feed anzeigen',
-    'asite.show_search': 'Suchschaltfläche in der Navigation anzeigen',
-    'asite.show_archive': 'Archiv-Link in der Navigation anzeigen',
-    'asite.seo': 'SEO & Social',
-    'asite.seo_pointer': 'Titelvorlage, Canonical, Teilbild, Verifizierungs-Metas und mehr befinden sich jetzt auf einer eigenen Seite:',
-    'asite.seo_link': '🔎 SEO & Auffindbarkeit',
-    'asite.custom_legend': 'Eigenes CSS & HTML',
-    'asite.optional': '(optional)',
-    'asite.custom_css': 'Eigenes CSS (eingefügt als &lt;style&gt; im &lt;head&gt;)',
-    'asite.custom_head': 'Eigenes &lt;head&gt;-HTML (Analytics, zusätzliche Metas)',
-    'asite.custom_foot': 'Eigenes Footer-HTML',
-    'asite.submit_create': 'Seite erstellen',
-    'asite.submit_save': 'Änderungen speichern',
-    'aseo.back': 'Verwaltung',
-    'aseo.title': 'SEO & Auffindbarkeit',
-    'aseo.tagline_pre': 'Erweiterte SEO von',
-    'aseo.tagline_post': '— wie deine Seite in Suchmaschinen und beim Teilen in sozialen Medien erscheint.',
-    'aseo.index_legend': 'Indexierung',
-    'aseo.index_label': 'Suchmaschinen dürfen diese Seite indexieren',
-    'aseo.index_hint_pre': 'aus =',
-    'aseo.index_hint_post': '+ sitemap.xml verborgen',
-    'aseo.title_legend': 'Titel & Beschreibung',
-    'aseo.title_template': 'Titelvorlage',
-    'aseo.title_template_hint_pre': 'verwende',
-    'aseo.title_template_hint_and': 'und',
-    'aseo.default_desc': 'Standardbeschreibung',
-    'aseo.default_desc_hint': 'Meta-Description / og:description, wenn eine Seite keine hat',
-    'aseo.default_desc_ph': 'Kurze Beschreibung deiner Seite (max. ~160 Zeichen funktionieren am besten)',
-    'aseo.canonical': 'Kanonische Basis-URL',
-    'aseo.canonical_hint': 'die Produktions-HTTPS-URL, verhindert Strafen für doppelte Inhalte',
-    'aseo.author': 'Autor',
-    'aseo.author_hint': 'Meta-Author-Tag',
-    'aseo.author_ph': 'Dein Name',
-    'aseo.social_legend': 'Teilen in sozialen Medien',
-    'aseo.og_image': 'Standard-Teilbild (URL)',
-    'aseo.og_image_hint': 'og:image / Twitter-Card; ~1200×630px',
-    'aseo.og_theme': 'Teilen-Karte hell oder dunkel',
-    'aseo.og_theme_hint': 'das automatisch erzeugte Teilbild',
-    'aseo.og_theme_auto': 'Automatisch (folgt dem Site-Thema)',
-    'aseo.og_theme_light': 'Hell',
-    'aseo.og_theme_dark': 'Dunkel',
-    'aseo.og_locale': 'Sprach-Locale',
-    'aseo.og_locale_hint_pre': 'og:locale, z. B.',
-    'aseo.og_locale_hint_or': 'oder',
-    'aseo.twitter': 'Twitter-/X-Handle',
-    'aseo.twitter_hint': 'mit @',
-    'aseo.fb_app': 'Facebook App-ID',
-    'aseo.fb_app_hint': 'fb:app_id (optional)',
-    'aseo.publisher_legend': 'Herausgeber (JSON-LD / Rich Results)',
-    'aseo.type': 'Typ',
-    'aseo.type_person': 'Person',
-    'aseo.type_org': 'Organisation / Unternehmen',
-    'aseo.publisher_name': 'Name',
-    'aseo.publisher_name_hint': 'fällt auf den Seitentitel zurück',
-    'aseo.publisher_url': 'URL',
-    'aseo.publisher_logo': 'Logo (URL)',
-    'aseo.verify_legend': 'Suchmaschinen-Verifizierung',
-    'aseo.verify_google': 'Google-Site-Verifizierung',
-    'aseo.verify_bing': 'Bing',
-    'aseo.verify_bing_hint': 'msvalidate.01',
-    'aseo.verify_pinterest': 'Pinterest',
-    'aseo.verify_pinterest_hint': 'p:domain_verify',
-    'aseo.verify_yandex': 'Yandex',
-    'aseo.save': 'SEO speichern',
-    'aaud.title': 'Audio-Tracks',
-    'aaud.tagline_pre': 'MP3s auf Site-Ebene. Verwende',
-    'aaud.tagline_post': 'in einem Beitrag, um eine Wiedergabe-Schaltfläche einzufügen.',
-    'aaud.upload': 'Upload',
-    'aaud.artist': 'Künstler',
-    'aaud.album': 'Album',
-    'aaud.applied_all': '(auf alle Dateien angewendet)',
-    'aaud.optional': 'Optional',
-    'aaud.cover': 'Cover',
-    'aaud.cover_hint': '(optional, auf alle Dateien angewendet — jpg/png/webp/gif, max. 5 MB)',
-    'aaud.choose_cover': 'Cover wählen',
-    'aaud.no_file': 'Keine Datei gewählt',
-    'aaud.drag_here': 'Audio hierher ziehen',
-    'aaud.or_click': 'oder klicken, um Dateien zu wählen',
-    'aaud.start_upload': 'Upload starten',
-    'aaud.clear_list': 'Liste leeren',
-    'aaud.tracks': 'Tracks',
-    'aaud.add_link_track': 'Track ohne Audio',
-    'aaud.add_link_track_title': 'Ein Track ohne Audiodatei — nur Titel + Öffnen-in-Links',
-    'aaud.no_tracks': 'Noch keine Tracks. Lade oben einen hoch.',
-    'aaud.untitled': '(ohne Titel)',
-    'aaud.copy_click': 'Zum Kopieren klicken',
-    'aaud.play': 'Abspielen',
-    'aaud.pause': 'Pausieren',
-    'aaud.edit': 'Bearbeiten',
-    'aaud.delete': 'Löschen',
-    'aaud.delete_confirm': 'Track löschen?',
-    'aaud.dl_on': 'Download-für-E-Mail ist AN — zum Ausschalten klicken',
-    'aaud.dl_off': 'Download-für-E-Mail ist aus — zum Einschalten klicken',
-    'aaud.fedi_on': 'Im Fediverse geteilt (spielt überall inline, Datei herunterladbar) — zum Ausschalten klicken',
-    'aaud.fedi_off': 'Nicht im Fediverse geteilt (nur Web-Player, Datei verborgen) — zum Teilen klicken',
-    'aaud.embed_player': 'Einbettbarer Player',
-    'aseo.mb_legend': 'MusicBrainz-Verknüpfung',
-    'aseo.mb_linked': 'Verknüpft mit',
-    'aseo.mb_unlink': 'Trennen',
-    'aseo.mb_open': 'Bei MusicBrainz ansehen',
-    'aseo.mb_pick': 'Das bin ich',
-    'aseo.mb_none': 'Nichts gefunden. Noch nicht dabei? Du kannst dich auf musicbrainz.org eintragen — das geht nur dort, nicht aus Klonkt heraus.',
-    'aseo.mb_busy': 'Suche läuft…',
-    'aseo.mb_fail': 'MusicBrainz ist gerade nicht erreichbar.',
-    'aseo.mb_hint': 'Hier verknüpfst du deine MusicBrainz-Künstler-ID mit deiner Domain, mit Rückweg-Validierung über deine "social networking"-Profilseite.',
-    'aseo.mb_search_label': 'Nach deinem Namen suchen',
-    'aseo.mb_search_hint': 'dein Künstlername oder deine MusicBrainz-ID, falls du sie kennst',
-    'aseo.mb_placeholder': 'Ozzy Osbourne',
-    'aseo.mb_search': 'Nachschlagen',
-    'aseo.mb_verified': 'Gegenseitig: die MusicBrainz-Seite verweist zurück auf diese Domain.',
-    'aseo.mb_unverified': 'Noch einseitig. Trage diese Domain auf deiner MusicBrainz-Seite unter "social networking" ein, dann ist die Verknüpfung von beiden Seiten bestätigt.',
-    'aseo.mb_checking': 'Rückweg wird geprüft…',
-    'aaud.embed_hint': 'Füge diesen Code auf deiner eigenen Website/deinem Blog ein, um deine Musik mit diesem Player einzubetten:',
-    'aaud.preview_player': 'Player-Vorschau öffnen',
-    'aaud.st_queued': 'Warten',
-    'aaud.st_uploading': 'Wird hochgeladen…',
-    'aaud.st_transcoding': 'Wird konvertiert…',
-    'aaud.st_done': 'Fertig',
-    'aaud.st_error': 'Fehler',
-    'aaud.err_unexpected': 'Unerwartete Serverantwort',
-    'aaud.failed': 'Fehlgeschlagen',
-    'aaud.copied': 'kopiert',
-    'aaud.new_track': 'Neuer Track',
-    'aaud.create_failed': 'Track konnte nicht erstellt werden',
-    'aaud.editor_not_loaded': 'Track-Editor nicht geladen',
-    'aaud.change_failed': 'Änderung fehlgeschlagen',
-    'astat.title': 'Statistiken',
-    'astat.intro': 'Cookiefrei gemessen — keine Tracking-Cookies, kein Zustimmungsbanner. Besucher werden pro Tag über einen täglich rotierenden, anonymen Hash gezählt (IP/Browser werden nicht gespeichert). Deine eigenen Admin-Besuche und bekannte Bots/Crawler werden nicht mitgezählt.',
-    'astat.your_ip': 'Deine IP', 'astat.ip_counted': 'wird mitgezählt.', 'astat.ip_not_counted': 'wird NICHT mitgezählt.', 'astat.ip_exclude': 'Meine Besuche nicht zählen', 'astat.ip_count': 'Meine Besuche zählen',
-    'astat.visitor_days': 'Besuchertage ({n}T)',
-    'astat.pageviews_days': 'Aufrufe ({n}T)',
-    'astat.plays_total': 'Wiedergaben (gesamt)',
-    'astat.postviews_total': 'Beitragsaufrufe (gesamt)',
-    'astat.alltime_pre': 'Gesamt:',
-    'astat.alltime_mid': 'Aufrufe',
-    'astat.alltime_post': 'Besuchertage.',
-    'astat.help_summary': 'Was bedeuten diese Zahlen?',
-    'astat.help_vd_term': 'Besuchertage',
-    'astat.help_vd_a': 'die Anzahl eindeutiger Besucher',
-    'astat.help_vd_em': 'pro Tag, zusammengezählt',
-    'astat.help_vd_b': '. Eine Person, die an 5 Tagen vorbeischaut = 5 Besuchertage. Ohne Cookies kann nicht über Tage hinweg gezählt werden, daher ist dies keine Anzahl eindeutiger Personen — die tatsächliche Personenzahl liegt (oft deutlich) niedriger.',
-    'astat.help_pv_term': 'Aufrufe',
-    'astat.help_pv': 'wie oft die Startseite/der Feed oder ein Beitrag geladen wurde (auch beim Klicken innerhalb der Site). Andere Seiten (Termine, Downloads, Links) werden hier nicht mitgezählt.',
-    'astat.help_plays_term': 'Wiedergaben',
-    'astat.help_plays': 'Gesamtzahl, wie oft ein Track gestartet wurde.',
-    'astat.help_postviews_term': 'Beitragsaufrufe',
-    'astat.help_postviews': 'gesamt über alle Beiträge zusammen.',
-    'astat.help_footer': 'Admin-Besuche und bekannte Bots/Crawler werden übersprungen. Die rohe IP wird niemals gespeichert. Gut für Trends; nimm absolute Zahlen mit Vorsicht.',
-    'astat.period': 'Zeitraum:',
-    'astat.last_n_days': 'Letzte {n} Tage',
-    'astat.lg_visitor_days': 'Besuchertage',
-    'astat.lg_pageviews': 'Aufrufe',
-    'astat.bar_title': '{day} — {pv} Aufrufe, {vd} Besuchertage',
-    'astat.top_posts': 'Beliebteste Beiträge',
-    'astat.no_views': 'Noch keine Aufrufe.',
-    'astat.most_played': 'Meistgehört',
-    'astat.no_plays': 'Noch keine Wiedergaben.',
-    'astat.sources': 'Quellen (woher Besucher kommen)',
-    'astat.linkbio_clicks': 'Link-in-Bio-Klicks',
-    'apl.title': 'Playlists',
-    'apl.tagline_pre': 'Kanonische Playlists. Bearbeite eine Playlist hier und die Änderungen wirken sich auf alle Beiträge aus, die sie verwenden, über',
-    'apl.tagline_post': '.',
-    'apl.new_playlist': 'Neue Playlist',
-    'apl.none': 'Noch keine Playlists.',
-    'apl.none_sub': 'Erstelle eine über die Schaltfläche oben oder über die 📃-Schaltfläche im Beitrags-Editor.',
-    'apl.pill_playlist': 'Playlist',
-    'apl.pill_album': 'Album', 'apl.pill_mixtape': 'Mixtape',
-    'apl.track': 'Track',
-    'apl.tracks': 'Tracks',
-    'apl.copy_click': 'Zum Kopieren klicken',
-    'apl.edit': 'Bearbeiten',
-    'apl.delete': 'Löschen',
-    'apl.copied': 'kopiert',
-    'apl.delete_confirm': 'Playlist "{title}" löschen? Beiträge, die diese Playlist einbetten, zeigen ab jetzt einen Platzhalter.',
-    'apl.delete_failed': 'Löschen fehlgeschlagen',
-    'ausr.back': 'Verwaltung',
-    'ausr.title': 'Benutzer',
-    'ausr.tagline_a': 'Benutzer, Rollen und Löschungen verwalten. Rolle',
-    'ausr.tagline_b': '= alles ansehen (inkl. Verwaltung), nichts ändern — praktisch für Demos.',
-    'ausr.empty': 'Keine Benutzer.',
-    'ausr.you': 'du',
-    'ausr.t_sites': 'Seiten',
-    'ausr.t_posts': 'Beiträge',
-    'ausr.t_joined': 'Registriert am',
-    'ausr.l_sites': 'Seiten',
-    'ausr.l_posts': 'Beiträge',
-    'ausr.l_joined': 'dabei seit',
-    'ausr.new_klonkt': 'Neues Klonkt für diesen Benutzer',
-    'ausr.new_klonkt_for': 'Neues Klonkt für {name}',
-    'ausr.role_label': 'Rolle',
-    'ausr.role_kijker': 'Betrachter',
-    'ausr.role_member': 'Mitglied',
-    'ausr.role_admin': 'Admin',
-    'ausr.role_god': 'God',
-    'ausr.delete': 'Löschen',
-    'ausr.del_warn': 'Dies löscht auch ihre Seite + {n} Beitrag/Beiträge.',
-    'ausr.del_confirm': 'Benutzer {name} löschen?',
-    'ausr.del_undo': 'Dies kann nicht rückgängig gemacht werden.',
-    'asit2.back': 'Verwaltung',
-    'asit2.title': 'Seiten',
-    'asit2.tagline': 'Alle Seiten dieser Installation verwalten.',
-    'asit2.new_site': 'Neue Seite',
-    'asit2.empty': 'Noch keine Seiten.',
-    'asit2.empty_sub': 'Erstelle eine über die Schaltfläche oben.',
-    'asit2.pill_primary': 'primär',
-    'asit2.pill_primary_title': 'Haupt-/Label-Seite dieser Installation',
-    'asit2.pill_public': 'öffentlich',
-    'asit2.pill_public_title': 'Öffentlich sichtbar',
-    'asit2.pill_private': 'privat',
-    'asit2.pill_private_title': 'Nicht öffentlich',
-    'asit2.pill_noindex': 'noindex',
-    'asit2.pill_noindex_title': 'Nicht von Suchmaschinen indexiert',
-    'asit2.by': 'von',
-    'asit2.t_posts': 'Anzahl Beiträge',
-    'asit2.l_posts': 'Beiträge',
-    'asit2.t_created': 'Erstellt am',
-    'asit2.l_created': 'erstellt',
-    'asit2.make_primary': 'Als primär festlegen',
-    'asit2.make_primary_title': 'Als primäre/Haupt-Seite festlegen',
-    'asit2.make_primary_confirm': 'Diese Seite als primäre/Haupt-Seite festlegen?',
-    'asit2.edit': 'Bearbeiten',
-    'asit2.delete': 'Löschen',
-    'asit2.delete_confirm': 'Seite löschen? Funktioniert nur, wenn keine Beiträge vorhanden sind.',
-    'acom.back': 'Verwaltung',
-    'acom.title': 'Kommentar-Moderation',
-    'acom.mode_for_site': 'Modus für diese Seite:',
-    'acom.mode_trust_a': 'Kommentare werden automatisch freigegeben. Wechsle zu',
-    'acom.mode_moderate_word': 'Moderieren',
-    'acom.site_settings': 'Seiteneinstellungen',
-    'acom.mode_trust_b': 'um sie in die Warteschlange zu stellen.',
-    'acom.mode_moderate_hint': 'Neue Kommentare müssen freigegeben werden, bevor sie bei Beiträgen erscheinen.',
-    'acom.pending': 'Ausstehend ({n})',
-    'acom.nothing_waiting': 'Nichts in der Warteschlange.',
-    'acom.reply': 'Antwort',
-    'acom.on': 'zu',
-    'acom.approve': 'Freigeben',
-    'acom.reject': 'Ablehnen',
-    'acom.recent': 'Letzte Entscheidungen',
-    'acom.nothing_yet': 'Noch nichts.',
-    'acir.title': 'Zirkel',
-    'acir.back_settings': 'Einstellungen',
-    'acir.circles': 'Zirkel',
-    'acir.settings': 'Einstellungen',
-    'acir.mode_off_1': 'Der Modus steht nicht auf',
-    'acir.mode_off_2': '. Schalte ihn unter',
-    'acir.mode_off_3': 'ein, um deinen Zirkel-Feed anzuzeigen auf',
-    'acir.mode_off_4': '. Du kannst unten aber schon Quellen vorbereiten.',
-    'acir.visibility_title': 'Meine Sichtbarkeit',
-    'acir.all_public': 'bereits öffentlichen',
-    'acir.visibility_help_1': 'Machst du bei Zirkeln mit? Damit werden deine',
-    'acir.visibility_help_2': 'Beiträge für andere Klonkt-Seiten über einen signierten Feed abrufbar',
-    'acir.visibility_help_3': 'Es ist eine Teilnahme-Entscheidung, kein Datenschutz-Schloss: deine Beiträge bleiben ohnehin öffentlich auf deiner Seite, auch wenn dies aus ist. Möchtest du etwas abschirmen, mach den Beitrag nicht-öffentlich.',
-    'acir.show_in_circles': 'Meine Seite in den Zirkeln anderer anzeigen',
-    'acir.save': 'Speichern',
-    'acir.add_title': 'Klonkt-Seite hinzufügen',
-    'acir.add_help': 'Füge die Basis-URL einer anderen Klonkt-Seite ein. Asymmetrisch: du zeigst sie, unabhängig davon, ob sie dich zeigen.',
-    'acir.url': 'URL',
-    'acir.label': 'Bezeichnung',
-    'acir.optional': 'optional',
-    'acir.name_auto': 'Der Name wird automatisch von der Seite übernommen — nur die URL ist nötig.',
-    'acir.label_ph': 'z. B. Joost Klein',
-    'acir.add': 'Hinzufügen',
-    'acir.in_circle': 'In meinem Zirkel ({n})',
-    'acir.no_sources': 'Noch keine Quellen. Füge oben eine hinzu.',
-    'acir.sync_all': 'Jetzt alle synchronisieren',
-    'acir.st_active': 'aktiv',
-    'acir.posts': 'Beiträge',
-    'acir.last': 'zuletzt',
-    'acir.st_mismatch': 'Versions-Unterschied',
-    'acir.mismatch_reason': 'Diese Seite läuft mit einer anderen Klonkt-Protokollversion — ein Update ist nötig, um zu föderieren.',
-    'acir.st_error': 'Fehler',
-    'acir.st_paused': 'pausiert',
-    'acir.refresh': 'Aktualisieren',
-    'acir.remove': 'Entfernen',
-    'acir.remove_confirm': 'Aus deinem Zirkel entfernen?',
-    'ashow.back_admin': 'Verwaltung',
-    'ashow.title': 'Termine',
-    'ashow.show_toggle': 'Termine auf der Seite anzeigen',
-    'ashow.show_toggle_hint': '(Termine-Button in der Leiste + die Termin-Seite)',
-    'ashow.save': 'Speichern',
-    'ashow.off': 'aus',
-    'ashow.off_notice_1': 'Die Termine stehen gerade auf',
-    'ashow.off_notice_2': '— Besucher sehen keinen Termine-Button und die Termin-Seite ist nicht erreichbar. Schalte sie ein, um deine Veranstaltungen anzuzeigen.',
-    'ashow.subscribers': 'Abonnent(en) für Veranstaltungs-Ankündigungen.',
-    'ashow.smtp_warn': '⚠ SMTP nicht eingerichtet — Veranstaltungen werden gespeichert, aber Benachrichtigungs-Mails können erst versendet werden, sobald du SMTP einträgst.',
-    'ashow.f_date': 'Datum',
-    'ashow.f_time': 'Uhrzeit (optional)',
-    'ashow.f_city': 'Ort',
-    'ashow.f_country': 'Land (optional)',
-    'ashow.f_venue': 'Location/Saal (optional)',
-    'ashow.f_ticket': 'Ticket-URL (optional)',
-    'ashow.f_notes': 'Notiz (optional)',
-    'ashow.f_notes_ph': 'Support: ...',
-    'ashow.notify_label': 'Abonnenten per E-Mail benachrichtigen',
-    'ashow.smtp_required': '(SMTP erforderlich)',
-    'ashow.add_event': '+ Veranstaltung hinzufügen',
-    'ashow.del_confirm': 'Veranstaltung löschen??',
-    'ashow.empty': 'Noch keine Veranstaltungen.',
-    'anews.title': 'Newsletter',
-    'anews.confirmed': 'bestätigt',
-    'anews.pending': 'ausstehend',
-    'anews.unsub': 'abgemeldet',
-    'anews.smtp_warn_1': '⚠ SMTP ist noch nicht eingerichtet. Anmeldungen werden zwar gesammelt, aber der Versand ist erst möglich, sobald du deine SMTP-Daten einträgst',
-    'anews.smtp_warn_2': 'in',
-    'anews.share': 'Anmeldelink zum Teilen:',
-    'anews.subject': 'Betreff',
-    'anews.subject_ph': 'Neue Single draußen!',
-    'anews.body': 'Nachricht',
-    'anews.body_ph': 'Schreibe dein Update…',
-    'anews.send_confirm': 'Newsletter an {n} bestätigte(n) Abonnent(en) senden?',
-    'anews.send_btn': 'An {n} Abonnent(en) senden',
-    'anews.sent_heading': 'Versendet',
-    'anews.recipients': 'Empfänger',
-    'aupd.title': 'Updates',
-    'aupd.changes_heading': 'Letzte Änderungen',
-    'aupd.back_admin': 'Verwaltung',
-    'aupd.version_heading': 'Version dieses Klonkt',
-    'aupd.app_version': 'App-Version',
-    'aupd.current': 'Aktuell',
-    'aupd.current_unknown': 'unbekannt (noch nicht über den Update-Button aktualisiert)',
-    'aupd.latest': 'Neueste',
-    'aupd.latest_failed': 'die neueste Version konnte nicht abgerufen werden',
-    'aupd.no_source': 'Keine Update-Quelle erreichbar',
-    'aupd.uptodate': 'Aktuell',
-    'aupd.update_available': 'Update verfügbar',
-    'aupd.behind_one': '{n} Commit zurück',
-    'aupd.behind_many': '{n} Commits zurück',
-    'aupd.run_confirm': 'Die Seite wird auf die neueste Version gebracht und kurz neu gestartet. Fortfahren?',
-    'aupd.redeploy': 'Erneut ausrollen',
-    'aupd.update_now': 'Jetzt aktualisieren',
-    'aupd.help': 'Das Aktualisieren holt den neuesten Code und startet diese Seite kurz neu (~10s). Lass dir Zeit — es geht nichts verloren (deine Beiträge, Einstellungen und dein Zirkel bleiben erhalten).',
-    'aupd.manual_hint': 'Aktualisiere von GitHub, indem du dies auf deinem Server ausführst:',
-    'aepk.title': 'Pressekit bearbeiten',
-    'aepk.saved': 'Pressekit gespeichert',
-    'aepk.back_admin': 'Verwaltung',
-    'aepk.view_epk': 'Pressekit ansehen',
-    'aepk.text_heading': 'Text',
-    'aepk.text_help': 'Das Pressekit (/pers) zeigt deinen Seitennamen + Foto, diese Bio und Kontakt sowie automatisch deine meistgehörten Titel und neuesten Beiträge. Lass die Bio leer, um den Seiten-Slogan zu verwenden; lass Kontakt leer, um nichts anzuzeigen (deine Login-E-Mail wird nie automatisch angezeigt).',
-    'aepk.bio_label': 'Presse-Bio',
-    'aepk.bio_ph': 'Kurze Beschreibung von dir/dem Projekt für Presse & Booker.',
-    'aepk.contact_label': 'Presse-Kontakt',
-    'aepk.contact_ph': 'z. B. presse@deinedomain.de oder ein Booking-Link',
-    'aepk.tracks_label': 'Titel im Pressekit',
-    'aepk.tracks_hint': '(wähle max. {n}; leer lassen für automatisch die Top {n} meistgehört)',
-    'aepk.no_tracks': 'Noch keine Titel — füge zuerst Audio unter Verwaltung → Audio hinzu.',
-    'aepk.untitled': '(ohne Titel)',
-    'aepk.save': 'Speichern',
-    'ahelp.back': 'Verwaltung',
-    'ahelp.title': 'Anleitung',
-    'ahelp.intro': 'Erklärung aller Funktionen. Tippe unten, um nach einem Thema oder einer Anleitung zu suchen.',
-    'ahelp.search_placeholder': 'Suchen… (z. B. \'Termine\', \'Foto\', \'Zirkel\', \'Downloads\')',
-    'ahelp.search_aria': 'In der Anleitung suchen',
-    'ahelp.premium': 'Premium',
-    'ahelp.empty': 'Keine Themen für deine Suche gefunden.',
-    'ahelp.s_newpost_h': 'Einen neuen Beitrag schreiben',
-    'ahelp.s_newpost_b': 'Verwaltung → <strong>Neuer Beitrag</strong>. Oben wählst du den <strong>Typ</strong> (Beitrag · Foto · Video · Audio) — der bestimmt die Eingaben darunter. Gib einen Titel ein und schreibe deinen Inhalt. Unten wählst du den Status: <em>Entwurf</em> (nicht sichtbar) oder <em>veröffentlicht</em>. Entwürfe stehen oben in deiner Verwaltungsübersicht, damit du sie wiederfindest.',
-    'ahelp.s_excerpt_h': 'Zusammenfassung & Zirkel-Vorschau',
-    'ahelp.s_excerpt_b': 'Das Feld <strong>Zusammenfassung & Zirkel-Vorschau</strong> (der Auszug) ist der kurze Vorschautext unter einem Beitrag in Listen sowie die Zusammenfassung, die andere Seiten anzeigen, wenn sie deinen Beitrag über einen <strong>Zirkel</strong> übernehmen. Lässt du es leer, wird automatisch der Anfang des Beitrags verwendet.',
-    'ahelp.s_pin_h': 'Beitrag anheften / Reihenfolge',
-    'ahelp.s_pin_b': 'Im Beitragseditor kannst du einen Beitrag mit einem Rang <strong>anheften</strong> (1 = ganz oben). Angeheftete Beiträge stehen in der Zeitleiste/im Raster ganz vorne, sortiert nach ihrem Rang. Rang leer oder 0 = nicht angeheftet.',
-    'ahelp.s_schedule_h': 'Veröffentlichung planen & nur für Freunde',
-    'ahelp.s_schedule_b': 'Im Editor kannst du ein <strong>Veröffentlichungsdatum</strong> in der Zukunft festlegen; der Beitrag erscheint dann automatisch zu diesem Zeitpunkt. Mit <strong>Nur für Freunde</strong> sehen nicht angemeldete Besucher nur einen Teaser + Login-Einladung; angemeldete Freunde sehen alles.',
-    'ahelp.s_images_h': 'Bilder in Beiträgen',
-    'ahelp.s_images_b': 'Bilder im Text und das Titelbild werden immer <strong>vollständig</strong> in voller Breite angezeigt (nicht zugeschnitten), in ihrer natürlichen Höhe.',
-    'ahelp.s_audio_h': 'Audio & Titel hinzufügen',
-    'ahelp.s_audio_b': 'Verwaltung → <strong>Audio</strong>. Lade eine Datei hoch oder füge einen <em>Nur-Link</em>-Titel hinzu (ohne Upload, nur „Öffnen in“-Links). Pro Titel gibst du Titel, Künstler, Cover und optional Album/Position an. In einem Beitrag zeigst du einen Titel mit dem Shortcode <code>[[track:id]]</code>, ein Album mit <code>[[album:Name]]</code>, eine Playlist mit <code>[[playlist:id]]</code>. <strong>Schneller:</strong> Wähle in einem Beitrag oben den Typ <em>Audio</em> und zieh die Datei direkt hinein — sie wird umgewandelt und sofort in den Beitrag eingefügt.',
-    'ahelp.s_credit_h': 'Credit, Lizenz & „Öffnen in“',
-    'ahelp.s_credit_b': 'Pro Titel kannst du einen <strong>Eigentümer/Credit</strong> (mit ©-Schaltfläche) und eine <strong>Lizenz</strong> festlegen — diese werden auch in die mp3-Metadaten geschrieben. Mit den <strong>Öffnen-in</strong>-Feldern (Spotify / YouTube / SoundCloud) erscheinen Schaltflächen, um den Titel auf diesen Plattformen zu öffnen.',
-    'ahelp.s_downloads_h': 'Downloads',
-    'ahelp.s_downloads_b': 'Markiere einen Titel in Verwaltung → Audio als <strong>herunterladbar</strong> (⬇-Schaltfläche). Besucher finden sie auf der Seite <strong>/downloads</strong> und hinterlassen ihre E-Mail-Adresse, um die Datei zu erhalten (sie kommt auf deine Mailingliste). Möchtest du Downloads prominent im Feed? Erstelle einen normalen Beitrag mit dem Slug <code>downloads</code> und hefte ihn an.',
-    'ahelp.s_albums_h': 'Alben & Playlists',
-    'ahelp.s_albums_b': 'Gib Titeln dasselbe <strong>Album</strong> + eine <strong>Position</strong>, um ein Album zu bilden. Playlists erstellst du in Verwaltung → <strong>Playlists</strong>. Beide zeigst du in einem Beitrag mit <code>[[album:Name]]</code> oder <code>[[playlist:id]]</code>.',
-    'ahelp.s_agenda_h': 'Termine / Veranstaltungen',
-    'ahelp.s_agenda_b': 'Verwaltung → <strong>Termine</strong>. Aktiviere oben <strong>„Termine auf der Seite anzeigen“</strong> — dann erscheint die Termine-Schaltfläche in der Leiste und die Terminseite ist erreichbar. Füge Veranstaltungen hinzu (Datum, Ort, Veranstaltungsort, Tickets). Besucher können sich (unabhängig vom Newsletter) für eine Benachrichtigung bei einer neuen Veranstaltung anmelden.',
-    'ahelp.s_presskit_h': 'Pressekit',
-    'ahelp.s_presskit_b': 'Eine teilbare Presseseite unter <strong>/pers</strong>. Bearbeite sie über die Schaltfläche <strong>✎ Bearbeiten</strong> auf der Seite selbst (nur du siehst sie). Lege eine kurze Presse-Bio + Kontakt fest und wähle <strong>bis zu 5 Titel</strong>, die angezeigt werden (oder lass es leer = automatisch die 5 meistgehörten).',
-    'ahelp.s_circles_h': 'Zirkel (Föderation)',
-    'ahelp.s_circles_b': 'Ein <strong>Zirkel</strong> ist dein eigener kuratierter Feed. Öffne <strong>Fediverse → Folge ich</strong>, folge Konten und <strong>hebe sie hervor</strong> (✨). Hervorgehobene Konten und Beiträge, die du <strong>teilst</strong> (🔁), erscheinen in deinem <strong>/cirkel</strong>-Feed — ein geteilter Beitrag bekommt ein Boost-Abzeichen. Teilst du einen Beitrag von jemandem, dem du nicht folgst, kommt er ebenfalls hinein. Das ist lokal: nichts wird automatisch ins Fediverse gesendet — das Teilen an deine eigenen Follower ist immer eine bewusste Aktion pro Beitrag.',
-    'ahelp.s_stats_h': 'Statistiken',
-    'ahelp.s_stats_b': 'Verwaltung → <strong>Statistiken</strong>. Cookiefrei gemessen. <strong>Besuchertage</strong> = eindeutige Besucher pro Tag, aufsummiert (keine Personenzahl). <strong>Aufrufe</strong> = Start-/Feed- und Beitragsaufrufe. Verwaltungsbesuche und Bots zählen nicht mit. Gut für Trends; absolute Zahlen mit Vorsicht genießen.',
-    'ahelp.s_newsletter_h': 'Newsletter',
-    'ahelp.s_newsletter_b': 'Verwaltung → <strong>Newsletter</strong>: Verfasse eine Nachricht und sende sie an deine bestätigten Abonnenten. Besucher melden sich über die Fußzeile oder <strong>/nieuwsbrief</strong> an. Zum Versenden muss E-Mail (SMTP) eingerichtet sein.',
-    'ahelp.s_linkbio_h': 'Link-in-Bio',
-    'ahelp.s_linkbio_b': 'Eine Linktree-ähnliche Seite unter <strong>/links</strong> mit deinen Profil-Links. Die Klicks pro Link siehst du in den Statistiken.',
-    'ahelp.s_embed_h': 'Einbettbarer Player',
-    'ahelp.s_embed_b': 'Verwaltung → Audio zeigt einen kopierbaren <code>&lt;iframe&gt;</code>-Code (<strong>/embed</strong>), mit dem du deinen Player auf einer anderen Website einbetten kannst.',
-    'ahelp.s_appearance_h': 'Erscheinungsbild (Theme, Foto, Akzent)',
-    'ahelp.s_appearance_b': 'Verwaltung → <strong>Erscheinungsbild</strong>: Lege deinen Seitennamen, Slogan, dein Profilfoto, deine Akzentfarbe und Farbpalette fest sowie die Standard-Feed-Ansicht (Zeitleiste oder Raster).',
-    'ahelp.s_tenancy_h': 'Solo- / Zirkel-Modus',
-    'ahelp.s_tenancy_b': 'Oben in <strong>Verwaltung → Einstellungen</strong> wählst du den Modus. <em>Solo</em> = ein eigenständiger Blog: kein Fediverse, keine Kommentare. <em>Zirkel</em> = deine Seite macht beim Fediverse (ActivityPub) mit: Leute können dir folgen und antworten, und du bekommst den Fediverse-Bereich + deinen Zirkel-Feed. Das Wechseln ist sicher — es wird nichts gelöscht.',
-    'ahelp.s_fedi_h': 'Fediverse (ActivityPub)',
-    'ahelp.s_fedi_b': 'Im Modus <strong>Zirkel</strong> macht deine Seite beim Fediverse (Mastodon usw.) mit. Öffne den <strong>Fediverse</strong>-Bereich über die Weltkugel in der Menüleiste (oder die Glocke für Benachrichtigungen). Fünf Tabs: <strong>News</strong> (Beiträge der Konten, denen du folgst — hier ⭐ liken und 🔁 teilen, nochmal klicken = rückgängig), <strong>Folge ich</strong> (Konten per @handle oder Profil-URL folgen und ✨ für deinen Zirkel hervorheben), <strong>Antworten</strong> (deine gesendeten Antworten + das <em>Interaktions-Bookmarklet</em> zum Ziehen in die Lesezeichenleiste, um von jedem Fediverse-Beitrag zu antworten), <strong>Benachrichtigungen</strong> (neue Follower, Likes, Boosts und Antworten auf deine Beiträge) und <strong>Blockieren</strong> (ein Konto oder eine ganze Domain blockieren). Unter jedem Beitrag zeigt "Aus dem Fediverse" eingehende Antworten, Likes und Boosts; als Eigentümer kannst du dort direkt antworten, liken oder teilen. Besucher nutzen den Button "Interact via the fediverse", um von ihrem eigenen Konto zu reagieren. Klicke auf dein Profilfoto für eine Profil-Zusammenfassung; Besucher finden dort auch einen "Über das Fediverse folgen"-Button. Deine Beiträge werden automatisch an deine Follower zugestellt.',
-    'ahelp.s_premium_h': 'Premium / Patreon',
-    'ahelp.s_premium_b': 'Premium-Funktionen (Statistiken, Termine, Downloads, Pressekit, Newsletter, Link-in-Bio, Embed) schaltest du in Verwaltung → Einstellungen frei, indem du Patreon verknüpfst ($16 lebenslang). Updates und die Kern-App bleiben immer kostenlos.',
-    'ahelp.s_password_h': 'Passwort vergessen / zurücksetzen',
-    'ahelp.s_password_b': 'Das Zurücksetzen läuft über die <strong>Kommandozeilen-Skripte</strong> auf dem Server. Führe im Projektordner <code>npm run reset-admin</code> aus: ohne Argument setzt das den God-User zurück und gibt das neue Passwort aus. Ein bestimmter Benutzer: <code>npm run reset-admin -- &lt;Benutzer|E-Mail&gt;</code>. Passwort selbst wählen (mindestens 8 Zeichen): <code>npm run reset-admin -- &lt;Benutzer|E-Mail&gt; &lt;Passwort&gt;</code>. Melde dich danach unter <strong>/auth/login</strong> an. Der Reset-Link per Mail unter <strong>/auth/reset-request</strong> funktioniert nur, wenn E-Mail (SMTP) eingerichtet ist; die Kommandozeile funktioniert immer.',
-    'ahelp.s_updates_h': 'Updates',
-    'ahelp.s_updates_b': 'Verwaltung → <strong>Updates</strong> (nur God) zeigt die aktuelle Version und ob eine neuere verfügbar ist. Mit „Jetzt aktualisieren“ holst du dir die neueste Version.',
-    'pedit.title_new': 'Neuer Beitrag',
-    'pedit.title_edit': 'Beitrag bearbeiten',
-    'pedit.f_title': 'Titel',
-    'pedit.f_slug': 'Slug (URL)',
-    'pedit.slug_placeholder': 'automatisch aus Titel, wenn leer',
-    'pedit.f_tags': 'Tags',
-    'pedit.tags_hint': '(kommagetrennt)',
-    'pedit.f_excerpt': 'Zusammenfassung & Zirkel-Vorschau',
-    'pedit.excerpt_hint': 'Wird auch als Zusammenfassung in <strong>Zirkeln</strong> verwendet (andere Seiten, die deinen Beitrag zeigen). Leer lassen = Anfang des Beitrags.',
-    'pedit.s_cover': 'Titelbild',
-    'pedit.f_cover_url': 'Titelbild-URL',
-    'pedit.cover_url_placeholder': '/media/…  oder https://…  (oder per Schaltfläche hochladen)',
-    'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
-    'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
-    'pedit.f_language': 'Sprache',
-    'pedit.language_hint': 'für den Fediverse-Sprachfilter',
-    'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
-    'pedit.s_content': 'Inhalt',
-    'pedit.content_hint': 'Bild ziehen zum Einfügen · Text markieren zum Formatieren',
-    'pedit.tb_done': 'Fertig',
-    'pedit.tb_done_title': 'Bearbeitung beenden',
-    'pedit.tb_bold': 'Fett',
-    'pedit.tb_bold_title': 'Fett (Strg+B)',
-    'pedit.tb_italic': 'Kursiv',
-    'pedit.tb_italic_title': 'Kursiv (Strg+I)',
-    'pedit.tb_underline': 'Unterstrichen',
-    'pedit.tb_h2': 'Überschrift',
-    'pedit.tb_h3': 'Zwischenüberschrift',
-    'pedit.tb_p': 'Absatz',
-    'pedit.tb_ul': 'Liste',
-    'pedit.tb_ol': 'Nummerierte Liste',
-    'pedit.tb_quote': 'Zitat',
-    'pedit.tb_link': 'Link',
-    'pedit.tb_link_title': 'Link (Strg+K)',
-    'pedit.tb_code': 'Code',
-    'pedit.tb_code_title': 'Code (inline)',
-    'pedit.tb_image': 'Bild',
-    'pedit.tb_image_title': 'Bild einfügen',
-    'pedit.tb_track': 'Track',
-    'pedit.tb_track_title': 'Track einfügen',
-    'pedit.tb_playlist': 'Playlist',
-    'pedit.tb_playlist_title': 'Playlist einfügen',
-    'pedit.tb_embed': 'Medien einbetten',
-    'pedit.tb_embed_title': 'Einbetten (YouTube, Spotify, SoundCloud, Vimeo…)',
-    'pedit.tb_clear': 'Formatierung entfernen',
-    'pedit.tb_fullscreen': 'Vollbild',
-    'pedit.editor_aria': 'Inhalt',
-    'pedit.editor_placeholder': 'Schreib los…',
-    'pedit.tap_to_edit': 'Zum Bearbeiten tippen',
-    'pedit.tap_to_write': 'Zum Schreiben tippen…',
-    'pedit.chars': 'Zeichen',
-    'pedit.s_publication': 'Veröffentlichung',
-    'pedit.f_status': 'Status',
-    'pedit.status_published': 'Veröffentlicht',
-    'pedit.status_draft': 'Entwurf',
-    'pedit.status_archived': 'Archiviert',
-    'pedit.f_type': 'Typ',
-    'pedit.type_post': 'Beitrag',
-    'pedit.type_foto': 'Foto',
-    'pedit.type_video': 'Video',
-    'pedit.type_audio': 'Audio',
-    'pedit.type_album': 'Album',
-    'pedit.type_playlist': 'Playlist', 'pedit.type_mixtape': 'Mixtape',
-    'pedit.s_type': 'Was für ein Beitrag?',
-    'pedit.audio_up_drop': 'Audio hierher ziehen oder klicken',
-    'pedit.audio_up_hint': 'mp3, m4a, ogg, flac, wav — wird automatisch umgewandelt und direkt in den Beitrag eingefügt. Details später über den Track ändern.',
-    'pedit.audio_up_busy': 'Wird hochgeladen…',
-    'pedit.audio_up_done': 'Zum Beitrag hinzugefügt',
-    'pedit.audio_up_fail': 'Fehlgeschlagen',
-    'pedit.video_up_title': 'Video hinzufügen',
-    'pedit.video_up_ph': 'Video-URL einfügen (YouTube, Vimeo…)',
-    'pedit.video_up_btn': 'Einfügen',
-    'pedit.foto_up_hint': 'Lege dein Foto unten als Titelbild fest oder füge Fotos im Text über die Bild-Schaltfläche in der Symbolleiste ein.',
-    'pedit.pin_label': 'Oben anheften',
-    'pedit.pin_up': 'Nach oben',
-    'pedit.pin_down': 'Nach unten',
-    'pedit.pin_top': 'ganz oben',
-    'pedit.pin_nth_suffix': '. von oben',
-    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'pedit.fedi_audio_label': 'Audio offen im Fediverse teilen (spielt inline in Apps; Datei herunterladbar)', 'pedit.fedi_audio_oneway': 'Achtung: Öffnen ist dauerhaft', 'pedit.fedi_audio_locked': 'Dieses Audio wurde offen im Fediverse geteilt. Das ist dauerhaft — die Datei ist bereits verbreitet.', 'pedit.nsfw_cw_ph': 'Warntext (optional, Standard: Sensibler Inhalt)', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen', 'post.share': 'Teilen', 'post.share_copied': 'Link kopiert ✓', 'pedit.poll_label': 'Umfrage hinzufügen', 'pedit.poll_locked': 'Es wurde bereits abgestimmt — die Optionen lassen sich nicht mehr ändern.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Option hinzufügen', 'pedit.poll_remove': 'Option entfernen', 'pedit.poll_multiple': 'Mehrfachauswahl erlauben', 'pedit.poll_duration': 'Laufzeit', 'pedit.poll_dur_5m': '5 Minuten', 'pedit.poll_dur_30m': '30 Minuten', 'pedit.poll_dur_1h': '1 Stunde', 'pedit.poll_dur_6h': '6 Stunden', 'pedit.poll_dur_12h': '12 Stunden', 'pedit.poll_dur_1d': '1 Tag', 'pedit.poll_dur_3d': '3 Tage', 'pedit.poll_dur_7d': '7 Tage',
-    'pedit.fan_only_label': 'Nur für Freunde',
-    'pedit.schedule_label': 'Veröffentlichung planen',
-    'pedit.schedule_hint': 'Wenn aus, geht dein Beitrag sofort live. An = wähle unten, wann er erscheint.',
-    'pedit.publish_at_label': 'Datum & Uhrzeit',
-    'pedit.scheduled_for': 'Geplant für {d}',
-    'pedit.scheduled_prefix': 'Geplant für',
-    'pedit.cancel': 'Abbrechen',
-    'pedit.publish': 'Veröffentlichen',
-    'pedit.save': 'Speichern',
-    'pedit.js_link_prompt': 'Link-URL (https://… oder /pfad)',
-    'pedit.js_uploading': 'Wird hochgeladen…',
-    'pedit.js_uploaded': 'Hochgeladen',
-    'pedit.js_inserted': 'Eingefügt',
-    'pedit.js_failed': 'Fehlgeschlagen',
-    'pedit.js_embed_prompt': 'Füge eine Medien-URL zum Einbetten ein (YouTube, Spotify, SoundCloud, Vimeo, Apple Music, Bandcamp):',
-    'pedit.js_embed_invalid': 'Gib eine vollständige URL ein (https://…).',
-    'pedit.js_no_tracks_found': 'Keine Tracks gefunden für',
-    'pedit.js_no_tracks_yet': 'Noch keine Tracks. Lade welche über Verwaltung dann Audio hoch.',
-    'pedit.js_tracks_loading': 'Tracks werden geladen…',
-    'pedit.js_tracks_load_fail': 'Tracks konnten nicht geladen werden',
-    'pedit.js_playlist_editor_missing': 'Playlist-Editor nicht geladen',
-    'pedit.js_playlist_existing': 'Vorhandene Playlists:',
-    'pedit.js_playlist_choose': 'Wähle eine Nummer zum Einfügen, leer = neue erstellen:',
-    'pedit.chip_track': 'Track',
-    'pedit.chip_album': 'Album:',
-    'pedit.chip_playlist': 'Playlist:',
-    'pedit.tp_title': 'Track einfügen',
-    'pedit.tp_close': 'Schließen',
-    'pedit.tp_search_placeholder': 'Nach Titel oder Künstler suchen…',
-    'pedit.tp_list_aria': 'Tracks',
-    'imed.title': 'Bild bearbeiten',
-    'imed.rotate_left': 'Nach links drehen', 'imed.rotate_right': 'Nach rechts drehen',
-    'imed.flip_h': 'Horizontal spiegeln', 'imed.flip_v': 'Vertikal spiegeln',
-    'imed.zoom_in': 'Vergrößern', 'imed.zoom_out': 'Verkleinern', 'imed.reset': 'Zurücksetzen',
-    'imed.cancel': 'Abbrechen', 'imed.apply': 'Übernehmen',
-    'acct.back_home': 'Zurück zur Startseite',
-    'acct.title': 'Konto',
-    'acct.subtitle': 'Profil und Avatar.', 'acct.oauth_apps': 'Verbundene Apps', 'acct.oauth_hint': 'Apps, denen du \u00fcber OAuth Zugriff auf dein Konto gegeben hast. Widerrufe, was du nicht mehr vertraust oder nutzt.', 'acct.oauth_none': 'Noch keine Apps verbunden.', 'acct.oauth_unknown_app': 'Unbekannte App', 'acct.oauth_last_used': 'zuletzt genutzt', 'acct.oauth_never': 'nie', 'acct.oauth_revoke': 'Widerrufen', 'acct.oauth_revoked': 'App-Zugriff widerrufen.', 'acct.oauth_revoke_none': 'Dieser Zugriff bestand nicht mehr.',
-    'acct.viewer_mode': 'Betrachter-Modus',
-    'acct.viewer_note': 'Dies ist ein Demo-Konto. Du kannst alles ansehen, aber nichts ändern — auch kein Foto oder keine Bio.',
-    'acct.profile': 'Profil',
-    'acct.avatar_change': 'Zum Ändern deines Fotos klicken',
-    'acct.member_since': 'Mitglied seit', 'acct.photo_site_hint': 'Dein Profilbild legst du in den <a href="/admin">Site-Einstellungen</a> fest — ein Bild für überall.',
-    'acct.avatar_remove': 'Foto entfernen',
-    'acct.username': 'Benutzername',
-    'acct.email': 'E-Mail-Adresse',
-    'acct.email_ph': 'du@email.de',
-    'acct.bio': 'Bio',
-    'acct.bio_ph': 'Eine kurze Zeile über dich',
-    'acct.bio_empty': 'Keine Bio',
-    'acct.save': 'Speichern',
-    'acct.site': 'Seite',
-    'acct.site_name': 'Seitenname',
-    'acct.site_name_hint': '— wird in der Kopfzeile deiner Seite angezeigt',
-    'acct.tagline': 'Slogan',
-    'acct.tagline_hint': '— kurzer Einzeiler (optional)',
-    'acct.site_save': 'Seite speichern',
-    'acct.password_change': 'Passwort ändern',
-    'acct.password_current': 'Aktuelles Passwort',
-    'acct.password_new': 'Neues Passwort',
-    'acct.password_min': '(mind. 8 Zeichen)',
-    'acct.password_confirm': 'Neues Passwort bestätigen',
-    'acct.login': 'Anmelden',
-    'acct.login_google_only': 'Dieses Konto hat kein Passwort ({email}). Nutze "Passwort vergessen", um eines festzulegen.',
-    'news.this_artist': 'diese:r Künstler:in',
-    'news.form_title': 'Bleib auf dem Laufenden',
-    'news.form_sub_before': 'Abonniere den Newsletter von ',
-    'news.form_sub_after': ' — neue Musik, Konzerte und Updates, direkt in dein Postfach. Abmelden ist jederzeit mit einem Klick möglich.',
-    'news.email_ph': 'du@email.de',
-    'news.subscribe': 'Abonnieren',
-    'news.check_title': 'Fast geschafft ✉',
-    'news.check_sub_before': 'Wir haben eine Bestätigungs-E-Mail gesendet an ',
-    'news.check_sub_after': '. Klicke auf den Link in dieser E-Mail, um dein Abo zu bestätigen.',
-    'news.your_address': 'deine Adresse',
-    'news.done_title': 'Du bist angemeldet ✓',
-    'news.done_sub_before': 'Danke — du stehst auf der Liste von ',
-    'news.done_sub_after': '.',
-    'news.confirmed_title': 'Abo bestätigt ✓',
-    'news.confirmed_sub_before': 'Super! Du erhältst ab jetzt den Newsletter von ',
-    'news.confirmed_sub_after': '.',
-    'news.unsubbed_title': 'Abgemeldet',
-    'news.unsubbed_sub': 'Du wurdest abgemeldet. Du erhältst keine Newsletter mehr. Anders überlegt? Du kannst dich jederzeit wieder anmelden.',
-    'news.invalid_title': 'Ungültige E-Mail-Adresse',
-    'news.invalid_sub': 'Bitte überprüfe deine Adresse und versuche es erneut.',
-    'news.back': '← Zurück',
-    'news.smtperror_title': 'Hat gerade nicht geklappt',
-    'news.smtperror_sub': 'Die Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte versuche es später noch einmal.',
-    'news.badtoken_title': 'Link ungültig oder abgelaufen',
-    'news.badtoken_sub': 'Dieser Link funktioniert nicht mehr. Melde dich bei Bedarf erneut an.',
-    'news.to_subscribe': 'Zur Anmeldung',
-    'news.error_title': 'Etwas ist schiefgelaufen',
-    'news.error_sub': 'Bitte versuche es später noch einmal.',
-    'dl.ready_title': 'Danke! ⬇',
-    'dl.ready_sub': 'Dein Download sollte jetzt starten. Passiert nichts?',
-    'dl.manual': 'Download manuell starten',
-    'dl.download_btn': 'Download',
-    'dl.capture_sub': 'Hinterlasse deine E-Mail-Adresse und du erhältst die Datei. Du kommst damit auch auf die Newsletter-Liste — abmelden ist jederzeit möglich.',
-    'dl.email_ph': 'du@email.de',
-    'epk.kicker': 'Pressekit',
-    'epk.edit': 'Bearbeiten',
-    'epk.contact_booking': 'Kontakt / Buchung',
-    'epk.view_site': 'Zur Seite →',
-    'epk.press_photo': 'Pressefoto',
-    'epk.most_played': 'Meistgehört',
-    'epk.recent': 'Aktuell',
-    'fgate.title': 'Nur für Freunde',
-    'fgate.sub': 'Dieser Beitrag ist für alle, die dieser Seite folgen.',
-    'owa.title': 'Bei einer anderen Seite anmelden', 'owa.sub': 'Diese Seite bittet deinen Server zu bestätigen, wer du bist. Wenn du fortfährst, kennt sie deine Adresse.', 'owa.as': 'Du meldest dich an als', 'owa.choose': 'Mit welcher deiner Seiten?', 'owa.go': 'Ja, anmelden', 'owa.cancel': 'Nein, zurück', 'owa.fine': 'Es geht kein Passwort an diese Seite. Nur deine Adresse, und nur wenn du hier ja sagst.', 'fgate.owa_label': 'Mit deiner eigenen Fediverse-Adresse anmelden', 'fgate.owa_go': 'Weiter', 'fgate.owa_hint': 'Dein eigener Server bestätigt, wer du bist. Kein Konto und kein Passwort hier — wenn du dieser Seite folgst, bist du drin.', 'fgate.owa_failed': 'Diese Adresse war nicht erreichbar. Stimmt die Schreibweise?', 'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
-    'lbio.empty': 'Noch keine Links eingerichtet.',
-    'lbio.back_to_site': 'zurück zur Seite',
-    'myst.overview': 'Übersicht',
-    'myst.title': 'Mein Klonkt Hub',
-    'myst.quick_links_aria': 'Verwaltungs-Schnellzugriffe',
-    'myst.new_post': 'Neuer Beitrag',
-    'myst.appearance': 'Erscheinungsbild',
-    'myst.comments': 'Kommentare',
-    'myst.view_site': 'Meine Seite ansehen',
-    'myst.account': 'Konto',
-    'myst.posts': 'Beiträge',
-    'myst.published': 'Veröffentlicht',
-    'myst.draft_count_one': '{n} Entwurf',
-    'myst.draft_count_many': '{n} Entwürfe',
-    'myst.draft_badge': 'Entwurf',
-    'myst.untitled': '(ohne Titel)',
-    'myst.edit': 'Bearbeiten',
-    'myst.view': 'Ansehen',
-    'arst.title': 'Passwort zurücksetzen',
-    'arst.request_new': 'Neuen Reset-Link anfordern',
-    'arst.back_login': 'Zurück zur Anmeldung',
-    'arst.set_for': 'Lege ein neues Passwort für {username} fest.',
-    'arst.new_pw': 'Neues Passwort (mind. 8 Zeichen)',
-    'arst.confirm_pw': 'Neues Passwort bestätigen',
-    'arst.submit': 'Passwort festlegen',
-    'arrq.title': 'Passwort zurücksetzen',
-    'arrq.sent': 'Falls für diese E-Mail-Adresse ein Konto besteht, wurde ein Reset-Link gesendet.',
-    'arrq.no_mailserver': '<strong>Kein Mailserver eingerichtet</strong> — Reset-Link unten.',
-    'arrq.no_mail_cli': 'Keine E-Mail eingerichtet? Der Administrator kann auch <code>npm run reset-admin</code> auf dem Server ausführen.',
-    'arrq.back_login': '← Zurück zur Anmeldung',
-    'arrq.tagline': 'Gib deine E-Mail-Adresse ein; wir senden dir einen Reset-Link.',
-    'arrq.email': 'E-Mail',
-    'arrq.submit': 'Reset-Link senden',
-    'adir.home': 'Startseite',
-    'adir.logout': 'Abmelden',
-    'adir.login': 'Anmelden',
-    'adir.title': 'Mitglieder',
-    'adir.count_one': '{n} Klonkt',
-    'adir.count_many': "{n} Klonkt's",
-    'adir.search_ph': 'Nach Namen suchen…',
-    'adir.search_aria': 'Mitglieder suchen',
-    'adir.search_btn': 'Suchen',
-    'adir.clear': 'Löschen',
-    'adir.empty_q': 'Keine Mitglieder gefunden für „{q}".',
-    'adir.empty': 'Noch keine Mitglieder.',
-    'adir.posts_one': '{n} Beitrag',
-    'adir.posts_many': '{n} Beiträge',
-    'adir.pager_aria': 'Seitennummerierung',
-    'adir.prev': '← Zurück',
-    'adir.page_info': 'Seite {page} von {pages}',
-    'adir.next': 'Weiter →',
-    'pusr.post_one': 'Beitrag',
-    'pusr.post_many': 'Beiträge',
-    'pusr.on_this_site': 'auf dieser Seite',
-    'pusr.total': '{n} insgesamt',
-    'pusr.joined': 'dabei seit {date}',
-    'pusr.send_dm': 'DM senden',
-    'pusr.empty': 'Noch keine Beiträge auf dieser Seite.',
-    'pusr.posts_heading': 'Beiträge',
-    'pusr.untitled': '(ohne Titel)',
-    'phub.main_badge': 'Hauptseite',
-    'phub.view_page': 'Seite ansehen',
-    'phub.members': 'Mitglieder',
-    'phub.post_one': 'Beitrag',
-    'phub.post_many': 'Beiträge',
-    'phub.all_members': 'Alle {n} Mitglieder',
-    'phub.latest_posts': 'Neueste Beiträge unserer Mitglieder.',
-    'cfeed.title': 'Zirkel',
-    'cfeed.sub': 'Was bei den anderen Seiten in meinem Zirkel passiert.',
-    'cfeed.count': '{n} Seiten in meinem Zirkel',
-    'cfeed.empty': 'Noch nichts in meinem Zirkel.',
-    'cfeed.close': 'Schließen',
-    'cfeed.grid_view': 'Rasteransicht',
-    'cpost.back': 'Zirkel',
-    'cpost.via': 'via',
-    'cpost.read_more': 'Weiterlesen bei {source}',
-    'vblk.title': 'Betrachter-Modus',
-    'vblk.text_before': 'Dies ist ein schreibgeschütztes Konto. Du kannst alles ansehen, aber',
-    'vblk.text_strong': 'nichts ändern',
-    'vblk.text_after': '— Speichern, Hochladen, Löschen und Kommentieren sind deaktiviert.',
-    'vblk.back': 'Zurück',
-    'vblk.to_home': 'Zur Hauptseite',
-    'phome.moved_lead': 'Dieses Konto ist umgezogen. Du findest mich jetzt hier:',
-    'phome.moved_hint': 'Folgst du mir schon? Dann zieht dein Server dich wahrscheinlich automatisch mit. Falls nicht: folge der neuen Adresse.',
-    'phome.empty_title': 'Hier ist es noch still.',
-    'phome.empty_sub': 'Noch keine Beiträge. Spannend.',
-    'phome.write_first': 'Schreibe deinen ersten Beitrag',
-    'phome.grid_view': 'Rasteransicht',
-    'fav.title': 'Favoriten',
-    'fav.sub': 'Beiträge, die dir gefallen haben. Tippe auf das ♥ eines Beitrags, um ihn hier zu speichern.',
-    'fav.empty': 'Du hast noch keine Favoriten. Öffne einen Beitrag und tippe auf das Herz ♡.',
-    'chlog.back': 'Zurück',
-    'chlog.title': 'Änderungen',
-    'chlog.app_version': 'App-Version',
-    'chlog.fed_proto': 'Föderations-Proto',
-    'chlog.manage_updates': 'Updates verwalten',
-    'e404.title': 'Seite nicht gefunden',
-    'e404.sub': 'Diese Seite existiert nicht (mehr). Vielleicht ist der Link veraltet oder falsch eingegeben.',
-    'e404.home': 'Zur Startseite',
-    'e404.archive': 'Archiv',
-    'ptype.eyebrow': 'Typ',
-    'ptype.count_one': '{n} Beitrag',
-    'ptype.count_many': '{n} Beiträge',
-    'ptype.empty': 'Noch keine Beiträge dieses Typs.',
-    'ptype.untitled': '(ohne Titel)',
-    'ptag.eyebrow': 'Tag',
-    'ptag.count_one': '{n} Beitrag',
-    'ptag.count_many': '{n} Beiträge',
-    'ptag.empty': 'Noch keine Beiträge mit diesem Tag.',
-    'ptag.untitled': '(ohne Titel)',
-    'parch.back': 'Zurück',
-    'parch.title': 'Archiv',
-    'parch.count_one': '{n} Beitrag',
-    'parch.count_many': '{n} Beiträge',
-    'parch.empty': 'Noch keine Beiträge.',
-    'prin.tagline': 'Deine Direktnachrichten auf dieser Seite.',
-    'prin.empty': 'Noch keine Unterhaltungen. Öffne das Profil einer Person und klicke auf „DM senden“, um eine zu beginnen.',
-    'prin.empty_conv': 'Leere Unterhaltung',
-    'prcv.back_aria': 'Zurück zum Posteingang',
-    'prcv.inbox': 'Posteingang',
-    'prcv.unknown': 'Unbekannt',
-    'prcv.view_profile': 'Profil ansehen',
-    'prcv.placeholder': 'Nachricht…',
-    'prcv.send': 'Senden',
-    'acct.lang_label': 'Sprache',
-    'acct.lang_hint': '— deine persönliche Interface-Sprache; reist mit dir über Geräte und Sitzungen.',
-    'aset.default_lang': 'Standardsprache für Besucher',
-    'aset.default_lang_hint': 'Was neue Besucher sehen, bevor sie selbst eine Sprache wählen. Ein angemeldeter Benutzer mit eigener Voreinstellung sieht diese.',
-    'aset.default_lang_auto': 'Automatisch (Browsersprache)',
-    'aset.timezone': 'Zeitzone',
-    'aset.timezone_hint': 'Die Zeitzone, in der Datumsangaben und geplante Veröffentlichungen angezeigt und gespeichert werden. Automatisch = Server-Standard (UTC).',
-    'aset.timezone_auto': 'Automatisch (UTC)',
-  },
-};
-
-export function t(lang, key, vars) {
-  const l = SUPPORTED.includes(lang) ? lang : 'nl';
-  let s = (DICT[l] && DICT[l][key]);
-  if (s === undefined) s = (DICT.nl[key] !== undefined ? DICT.nl[key] : key);
-  if (vars) for (const k in vars) s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
-  return s;
-}
-
-// Bepaal de taal voor dit request: expliciete sessie-keuze → instance-standaard
-// (env KLONKT_DEFAULT_LANG, bv. 'de' voor een Duitse site) → browser-taal → nl.
-export function resolveLang(req, opts = {}) {
-  const s = req && req.session && req.session.lang;
-  if (s && SUPPORTED.includes(s)) return s;             // bezoeker koos zelf (deze sessie)
-  const u = (opts.userLang || '').toLowerCase();
-  if (SUPPORTED.includes(u)) return u;                  // ingelogde gebruiker: eigen voorkeur
-  const d = (opts.defaultLang || '').toLowerCase();
-  if (SUPPORTED.includes(d)) return d;                  // admin-ingestelde standaard (Beheer/DB)
-  const envDefault = (process.env.KLONKT_DEFAULT_LANG || '').toLowerCase();
-  if (SUPPORTED.includes(envDefault)) return envDefault; // per-instance standaard (env)
-  const al = ((req && req.headers && req.headers['accept-language']) || '').toLowerCase();
-  const first = al.split(',')[0].trim().slice(0, 2);
-  if (SUPPORTED.includes(first)) return first;          // browser-voorkeur
-  return 'nl';
-}
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,883 +1,0 @@
-/**
- * De muziekkant van ActivityPub: een track als AS2-object, de collecties
- * eromheen, en welke post hem uitbrengt.
- *
- * Waarom een eigen map (shaer-drc): ActivityPubService was 6400 regels over
- * tweeentwintig onderwerpen. Dit is het eerste onderwerp dat er als geheel uit
- * kan, en het is meteen het onderwerp dat gaat GROEIEN -- de typering uit
- * shaer-cyg (playlist versus album, afgeleid uit wat er in de post staat) landt
- * hier straks.
- *
- * De regel die guardianship al aanhoudt geldt hier ook: deze map importeert
- * alleen db en ap-core, en NOOIT terug uit ActivityPubService.
- */
-
-import db, { isoSql } from '../../config/database.js';
-import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
-import { afleidenUitInsluitingen, ingeslotenPlaylists, SOORTEN } from '../../assets/js/shared/post-music-type.js';
-// De luisteraars horen bij de muziekkant; hier doorgegeven zodat
-// ActivityPubService niet in een submap hoeft te grijpen.
-export * as luisteraars from './luisteraars.js';
-
-// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
-export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
-     t.position, t.license,
-     m.filename, m.storage_path, m.mime_type, m.size`;
-
-/**
- * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
- * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
- * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
- */
-export function playlistOpenTracks(playlistId, { alles = false } = {}) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM playlist_tracks pt
-     JOIN audio_tracks t ON t.id = pt.track_id
-     JOIN media m ON m.id = t.media_id
-     WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
-     ORDER BY pt.position`
-  ).all(playlistId);
-}
-
-/**
- * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
- *
- * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
- * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
- * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
- */
-/**
- * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
- * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
- * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
- * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
- * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
- * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
- * moved_to er kwam te staan.
- */
-export function siteOpenTracks(siteId, { alles = false } = {}) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
-     ORDER BY t.position, t.created_at, t.id`
-  ).all(siteId);
-}
-
-export function openTrack(siteId, trackId) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
-  ).get(siteId, trackId);
-}
-
-/**
- * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
- *
- * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
- * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
- * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
- * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
- * precies zo, per stuk, in Create en Delete.
- *
- * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
- * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
- * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
- * track wordt getoond binnen een post, en een post over vijf nummers is niet de
- * pagina van dit ene nummer. Liever geen link dan een link die iets anders
- * belooft.
- */
-/**
- * Bij welke post hoort een track? (shaer-0nh)
- *
- * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
- * een van drie insluitingen in posts.content. Die relatie stond alleen in die
- * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
- * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
- *
- * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
- * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
- * scans. Nu is het er een, en de map gaat mee als optie.
- *
- * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
- * is specifieker dan via een playlist, en die weer specifieker dan via een
- * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
- * laatst heeft uitgebracht.
- */
-export function trackHostPosts(siteId) {
-  const rijen = db.prepare(`
-    SELECT tid, post_id, post_slug, rang, wanneer FROM (
-      SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
-             COALESCE(p.published_at, p.created_at) AS wanneer
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[track:' || t.id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
-        FROM playlist_tracks pt
-        JOIN audio_tracks t ON t.id = pt.track_id
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[album:' || t.album || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
-    ) ORDER BY rang, wanneer DESC
-  `).all(siteId, siteId, siteId);
-  const uit = new Map();
-  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
-  return uit;
-}
-
-/**
- * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
- *
- * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
- * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
- * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
- * identiteit uit een string zijn, en dat is de fout die we bij albums juist
- * vermijden.
- *
- * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
- * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
- */
-function artistCredit(base, site, creditTekst, wanneer) {
-  const artiest = {
-    type: 'Artist',
-    id: actorId(base, site.slug),
-    name: site.title || site.slug,
-    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
-  };
-  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
-  return [{
-    type: 'ArtistCredit',
-    id: `${actorId(base, site.slug)}#artist-credit`,
-    published: artiest.published,
-    artist: artiest,
-    ...(creditTekst ? { credit: creditTekst } : {}),
-  }];
-}
-
-/**
- * De identiteit van een track op de draad.
- *
- * Staat apart omdat hij op TWEE momenten nodig is die ver uit elkaar liggen:
- * bij het bouwen van het Audio-object, en bij het verwijderen ervan, wanneer de
- * rij al weg is en er dus niets meer te bouwen valt. Toen dit nog inline stond,
- * kende alleen de bouwkant de vorm en ging er bij verwijderen geen Delete uit --
- * elke server die de track had geindexeerd hield hem voor altijd (21-8).
- */
-export function trackUri(base, site, id) {
-  return `${actorId(base, site.slug)}/tracks/${encodeURIComponent(id)}`;
-}
-
-export function buildTrackAudio(base, site, r, opts = {}) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const fn = r.filename || (r.storage_path || '').split('/').pop();
-  // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
-  // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
-  // Zo doet Funkwhale het ook.
-  // De post waar dit nummer in staat. Meegegeven door de collectie (een
-  // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
-  // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
-  const post = opts.hostPosts !== undefined
-    ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
-    : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
-
-  const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
-  if (Number(r.size)) bestand.size = Number(r.size);
-  // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
-  // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
-  // de getallen hebben -- liever geen veld dan een verzonnen getal.
-  if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
-
-  const a = {
-    ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
-    id: trackUri(base, site, r.id),
-    type: 'Audio',
-    name: r.title || 'Audio',
-    attributedTo: actorId(base, site.slug),
-    // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
-    // track moet zelf kunnen zeggen dat hij openbaar is.
-    to: [PUBLIC],
-    // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
-    // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
-    // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
-    // horen, en dat is wat zo'n link betekent.
-    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
-  };
-  // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
-  // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
-  // daar een naam zonder geluid.
-  a.library = libraryId(base, site);
-  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
-  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
-  // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
-  // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
-  // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
-  // van te maken.
-  if (post) a.context = noteId(base, post.id);
-  // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
-  // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
-  // ervan als eerste wat hem opviel aan onze objecten.
-  //
-  // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
-  // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
-  // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
-  // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
-  // een geldige IRI en wijst naar hetzelfde document.
-  //
-  // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
-  // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
-  // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
-  //
-  // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
-  // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
-  // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
-  // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
-  // precies waar hun model de credittekst verwacht.
-  //
-  // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
-  // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
-  // toch al gebeurde.
-  //
-  // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
-  // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
-  // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
-  // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
-  // (shaer-756s).
-  const wanneer = r.created_at ? new Date(r.created_at).toISOString()
-    : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
-
-  a.track = {
-    type: 'Track',
-    id: `${a.id}#track`,
-    name: a.name,
-    published: wanneer,
-    ...(Number(r.position) ? { position: Number(r.position) } : {}),
-    artist_credit: artistCredit(base, site, r.artist, wanneer),
-  };
-  // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
-  // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
-  const uitgave = opts.albums !== undefined
-    ? (opts.albums && opts.albums.get(r.id)) || null
-    : ((site.id && trackAlbums(site.id).get(r.id)) || null);
-  if (uitgave) {
-    a.track.album = buildAlbumObject(base, site, uitgave);
-    // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
-    // allebei, en het scheelt een lezer het uitpakken van de track.
-    a.album = a.track.album.id;
-  }
-  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
-  if (r.created_at) a.published = new Date(r.created_at).toISOString();
-  if (Number(r.position)) a.position = Number(r.position);
-  const lic = licentieUri(r.license);
-  if (lic) a.license = lic;
-  const art = abs(r.cover_url || opts.coverFallback || null);
-  // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
-  // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
-  if (art) {
-    const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
-    a.icon = plaat;
-    a.image = plaat;
-  }
-  return a;
-}
-
-/**
- * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
- * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
- * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
- * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
- * een lezer gelooft hem.
- */
-const LICENTIES = {
-  'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
-  'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
-  'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
-  'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
-  'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
-  'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
-};
-export function licentieUri(waarde) {
-  const s = String(waarde || '').trim();
-  if (!s) return null;
-  if (/^https?:\/\//i.test(s)) return safeUrl(s);   // iemand vulde al een URI in
-  return LICENTIES[s.toLowerCase()] || null;        // "Alle rechten voorbehouden" heeft er geen
-}
-
-/** Het AS2-id van de bibliotheek van een site. */
-export function libraryId(base, site) {
-  return `${actorId(base, site.slug)}/library`;
-}
-
-/**
- * De site als Funkwhale-LIBRARY (skelet).
- *
- * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
- * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
- * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
- * echte telling en een echt id.
- *
- * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
- * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
- * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
- * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
- * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
- * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
- * geprobeerd.
- *
- * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
- * type, id, name, followers, totalItems, first, last, plus attributedTo en
- * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
- * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
- * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
- */
-export function buildLibrary(base, site, rows, { page = false } = {}) {
-  const id = libraryId(base, site);
-  const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
-  return pagedCollection(id, items, {
-    page,
-    // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
-    // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
-    // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
-    // `CollectionPage` met `items`.
-    ongeordend: true,
-    extra: {
-      type: 'Library',
-      name: site.title || site.slug,
-      attributedTo: actorId(base, site.slug),
-      // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
-      // `audience` optioneel, maar zijn create() doet er meteen
-      // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
-      // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
-      // teruggaf toen Robin onze library-URI daar opzocht.
-      //
-      // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
-      // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
-      // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
-      audience: 'https://www.w3.org/ns/activitystreams#Public',
-      // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
-      // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
-      followers: `${id}/followers`,
-      ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
-    },
-  });
-}
-
-/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
-export function buildTrackCollection(base, site, rows, { page = false } = {}) {
-  // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
-  const posts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
-  return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
-}
-
-// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
-// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
-// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
-// client die hem kent haalt de collectie op. Opgelost uit post.content en
-// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
-// zonder site-check zou een post van site A naar de collectie van site B
-// kunnen wijzen.
-export function playlistLinkTags(base, site, content, post = null) {
-  const out = [];
-  try {
-    // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
-    // krijgt ook geen link. Dit stond hier met een eigen patroon dat
-    // underscores accepteerde die nergens anders meetellen.
-    for (const id of ingeslotenPlaylists(content)) {
-      const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
-      if (!pl) continue;
-      out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
-    }
-    // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
-    // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
-    // link zou die collectie bestaan maar door niemand te vinden zijn.
-    if (post && post.id) {
-      const eenheid = postMusicType(content, site.id);
-      if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
-        out.push({
-          type: 'Link',
-          href: postTracksId(base, site, post.id),
-          mediaType: 'application/activity+json',
-          name: post.title || 'Tracks',
-        });
-      }
-    }
-  } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
-  return out;
-}
-
-// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
-// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
-// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
-// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
-// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
-// poort staat.
-export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
-  const rows = db.prepare(
-    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
-  ).all(site.id);
-  const colId = `${actorId(base, site.slug)}/playlists`;
-  const items = rows.map((p) => {
-    const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
-    if (!enriched) return uri;
-    const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
-    delete stub['@context'];       // genest object draagt de context van zijn omhulsel
-    delete stub.orderedItems;      // stub: wie de tracks wil, haalt de collectie op
-    return stub;
-  });
-  return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
-}
-
-/**
- * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
- *
- * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
- * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
- * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
- * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
- * mooiste keuze is.
- *
- * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
- * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
- */
-export function trackAlbums(siteId) {
-  const rijen = db.prepare(`
-    SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
-           p.release_date, p.mb_release_id, p.created_at
-      FROM playlist_tracks pt
-      JOIN playlists p ON p.id = pt.playlist_id
-     WHERE p.site_id = ? AND p.kind = 'album'
-     ORDER BY p.created_at, p.id
-  `).all(siteId);
-  const uit = new Map();
-  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
-  // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
-  // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
-  // post) en een site heeft veel meer nummers dan platen.
-  //
-  // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
-  // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
-  // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
-  // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
-  // meting rolde.
-  const perAlbum = new Map();
-  for (const r of uit.values()) {
-    if (perAlbum.has(r.id)) continue;
-    perAlbum.set(r.id, uitgavePost(siteId, r.id));
-  }
-  for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
-  return uit;
-}
-
-/**
- * Een uitgave als `fw:Album`.
- *
- * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
- * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
- * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
- * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
- * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
- *
- * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
- * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
- * een heeft.
- */
-/**
- * Het bandje: EEN object, samengesteld uit de nummers van een playlist.
- *
- * Waarom het een eigen soort is en geen album met een ander jasje. Een album is
- * een uitgave: het heeft een uitgavedatum, een release-id, en de nummers
- * bestaan er los van. Een mixtape is het omgekeerde -- de volgorde IS het werk,
- * en de nummers zijn er onderdelen van. Vandaar `orderedItems` op het object
- * zelf in plaats van een collectie ernaast, en vandaar geen `released` en geen
- * `musicbrainzId`: die zouden beweren dat dit een uitgave is.
- *
- * GEEN EIGEN `url`, en dat is een keuze van Robin (21-8) met een prijs die het
- * waard is om hier op te schrijven. Het bandje is een logische omhulling, geen
- * gerenderd bestand: er wordt niets samengevoegd. Een ontvanger die `Mixtape`
- * niet kent heeft dus geen stream om te spelen. Dat is bewust -- de nummers
- * staan er stuk voor stuk in, met hun eigen id en hun eigen url, dus er gaat
- * niets verloren; het kost alleen een consument die het type wel begrijpt.
- *
- * `type` is een STRING en geen array. Dat is geen slordigheid maar een geleerde
- * les: er stond bij de playlist-collectie ooit ['OrderedCollection', 'Album'],
- * geldig AS2 en werkelijk allebei, en een lezer die `type` als tekst uitpakt
- * (Shaer doet dat) verloor daarmee stil het hele object.
- */
-export function buildMixtapeObject(base, site, pl, rows) {
-  if (!pl) return null;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
-  const wanneer = postDatum ? postDatum.toISOString()
-    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
-  // Dezelfde lening als bij het album: de post die het bandje uitbrengt geeft
-  // zijn titel, en de eigen titel blijft als alsoKnownAs staan.
-  const titel = (pl._post && pl._post.title) || pl.title;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: pl.cover_url || null }));
-  const tape = {
-    type: 'Mixtape',
-    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
-    name: titel,
-    ...(wanneer ? { published: wanneer } : {}),
-    attributedTo: actorId(base, site.slug),
-    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
-    // De kant die het bandje maakt: eerst dit nummer, dan dat. Vooruit en
-    // achteruit is de speler; de volgorde is het object.
-    totalItems: items.length,
-    orderedItems: items,
-  };
-  if (titel !== pl.title) tape.alsoKnownAs = pl.title;
-  // De speelduur van het geheel, want dat is wat een bandje heeft: een lengte.
-  // Alleen als we van ELK nummer de duur kennen -- een som met gaten erin is
-  // een verzonnen getal, en die zetten we hier niet neer (zelfde regel als bij
-  // de bitrate van een track).
-  const duren = (rows || []).map((r) => Number(r.duration) || 0);
-  if (duren.length && duren.every((d) => d > 0)) {
-    tape.duration = `PT${Math.round(duren.reduce((a, b) => a + b, 0))}S`;
-  }
-  const hoes = abs(pl.cover_url || null);
-  if (hoes) tape.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
-  return tape;
-}
-
-export function buildAlbumObject(base, site, pl) {
-  if (!pl) return null;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
-  // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
-  // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
-  // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
-  // openbaar werd, en dat is de post.
-  //
-  // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
-  // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
-  // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
-  // title, artist, year, cover_url en kind selecteerde en de rest niet.
-  //
-  // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
-  // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
-  // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
-  // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
-  // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
-  // uitzondering: bij een heruitgave vul je hem in en die wint.
-  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
-  const wanneer = postDatum ? postDatum.toISOString()
-    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
-  // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
-  // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
-  const titel = (pl._post && pl._post.title) || pl.title;
-  const album = {
-    type: 'Album',
-    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
-    name: titel,
-    ...(wanneer ? { published: wanneer } : {}),
-    attributedTo: actorId(base, site.slug),
-    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
-  };
-  if (titel !== pl.title) album.alsoKnownAs = pl.title;
-  // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
-  // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
-  // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
-  // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
-  if (pl.release_date) album.released = pl.release_date;
-  else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
-  if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
-  const hoes = abs(pl.cover_url || null);
-  if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
-  return album;
-}
-
-export function buildPlaylistCollection(base, site, playlist, rows) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
-  // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
-  // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
-  // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
-  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
-  const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const albums = site.id ? trackAlbums(site.id) : null;
-  // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
-  // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
-  // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
-  // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
-  const post = site.id ? uitgavePost(site.id, playlist.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
-  const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
-    extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
-  });
-  // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
-  const parts = [];
-  if (playlist.artist) parts.push(playlist.artist);
-  if (playlist.year) parts.push(String(playlist.year));
-  if (parts.length) out.summary = parts.join(' · ');
-  const cover = abs(playlist.cover_url || null);
-  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
-
-  // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
-  // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
-  // hoort hier hetzelfde te staan als in het ingesloten object.
-  //
-  // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
-  // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
-  // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
-  // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
-  // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
-  // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
-  // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
-  // staat toch al ingesloten op de track.
-  const soort = SOORTEN.includes(playlist.kind) ? playlist.kind : 'album';
-  if (soort === 'album') {
-    const album = buildAlbumObject(base, site, { ...playlist, _post: post });
-    for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
-      if (album[veld] !== undefined) out[veld] = album[veld];
-    }
-  }
-  // Een mixtape draagt hier zijn eigen velden, om dezelfde reden als het album:
-  // dit adres is waar een lezer terechtkomt die het bandje wil ophalen, en dan
-  // hoort er hetzelfde te staan als in het ingesloten object. `type` blijft ook
-  // hier OrderedCollection -- zie de uitleg hierboven over Shaer.
-  if (soort === 'mixtape') {
-    const tape = buildMixtapeObject(base, site, { ...playlist, _post: post }, rows);
-    for (const veld of ['published', 'artist_credit', 'image', 'duration', 'alsoKnownAs']) {
-      if (tape[veld] !== undefined) out[veld] = tape[veld];
-    }
-  }
-  return leenVanPost(base, site, out, post);
-}
-
-// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
-
-/** Het AS2-id van de collectie losse tracks van een post. */
-function postTracksId(base, site, postId) {
-  return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
-}
-
-/**
- * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
- *
- * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
- * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
- * post niet meer de drager van een identiteit en houdt de playlist de zijne --
- * dezelfde regel als in de afleiding, hier alleen toegepast.
- *
- * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
- * uitgebracht.
- */
-export function uitgavePost(siteId, playlistId) {
-  if (!siteId || !playlistId) return null;
-  try {
-    const rijen = db.prepare(`
-      SELECT id, slug, title, excerpt, content, cover_image_url, tags,
-             ${isoSql('COALESCE(published_at, created_at)')} AS uit_wanneer
-      FROM posts
-      WHERE site_id = ? AND status = 'published'
-        AND content LIKE '%[[playlist:' || ? || ']]%'
-      ORDER BY ${isoSql('COALESCE(published_at, created_at)')} DESC
-    `).all(siteId, playlistId);
-    for (const p of rijen) {
-      const r = postMusicType(p.content, siteId);
-      if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
-    }
-  } catch { /* geen lening is geen fout */ }
-  return null;
-}
-
-/**
- * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
- *
- * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
- * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
- * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
- * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
- * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
- * hij nu krijgt.
- *
- * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
- * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
- */
-function leenVanPost(base, site, obj, post) {
-  if (!post) return obj;
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-
-  if (post.title) {
-    if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
-    obj.name = post.title;
-  }
-  // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
-  // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
-  // staan -- dat is een samenvatting, en de posttekst is dat niet.
-  const tekst = tekstVanPost(post);
-  if (tekst) obj.content = tekst;
-
-  const cover = abs(post.cover_image_url || null);
-  if (cover) {
-    obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
-    if (!obj.icon) obj.icon = obj.image;      // geen eigen hoes? dan die van de post
-  }
-
-  // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
-  // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
-  // missen en de rest anders spellen dan dezelfde post elders doet.
-  const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
-  if (tags.length) obj.tag = tags;
-
-  // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
-  // losse track: url wijst een mens naar de post, context zegt waar dit object
-  // thuishoort.
-  obj.url = `${base}/${post.slug}`;
-  obj.context = noteId(base, post.id);
-  return obj;
-}
-
-/**
- * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
- * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
- * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
- * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
- */
-function tekstVanPost(post) {
-  const excerpt = String(post.excerpt || '').trim();
-  if (excerpt) return excerpt;
-  const kaal = String(post.content || '')
-    .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
-    .replace(/<[^>]+>/g, ' ')
-    .replace(/&nbsp;/gi, ' ')
-    .replace(/&[a-z#0-9]+;/gi, ' ')
-    // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
-    // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
-    // #devs" op als omschrijving van een post die verder geen tekst heeft.
-    .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
-    .replace(/\s+/g, ' ')
-    .trim();
-  return kaal || null;
-}
-
-/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
-function losseTracksVanPost(siteId, ids) {
-  if (!siteId || !ids?.length) return [];
-  const gaten = ids.map(() => '?').join(',');
-  const rijen = db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
-  ).all(siteId, ...ids);
-  // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
-  // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
-  const opId = new Map(rijen.map((r) => [r.id, r]));
-  return ids.map((id) => opId.get(id)).filter(Boolean);
-}
-
-/**
- * De losse tracks van een post als EEN uitgave (shaer-38y).
- *
- * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
- * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
- * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
- * heeft ze wel en de losse tracks niet.
- *
- * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
- * post die geen enkele muzikale eenheid IS.
- */
-export function buildPostTrackCollection(base, site, post) {
-  if (!post || !post.id) return null;
-  const eenheid = postMusicType(post.content, site.id);
-  if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
-
-  const rows = losseTracksVanPost(site.id, eenheid.tracks);
-  if (!rows.length) return null;
-
-  const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
-  const out = pagedCollection(postTracksId(base, site, post.id),
-    rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
-    { extra: { attributedTo: actorId(base, site.slug) } });
-  return leenVanPost(base, site, out, post);
-}
-
-/**
- * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
- *
- * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
- * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
- * ophaalt niet denkt dat er iets nieuws is.
- */
-export function buildTrackCreate(base, site, r, opts = {}) {
-  const audio = buildTrackAudio(base, site, r, opts);
-  const me = actorId(base, site.slug);
-  return {
-    '@context': AP_CONTEXT,
-    id: `${audio.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: audio.published,
-    to: [PUBLIC],
-    cc: [`${me}/followers`],
-    object: audio,
-  };
-}
-
-/**
- * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
- * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
- * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
- * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
- */
-export function channelCategory(site) {
-  try {
-    // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
-    // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
-    // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
-    // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
-    // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
-    // muziekkanaal.
-    return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
-  } catch { return null; }
-}
-
-// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
-
-/**
- * Het type van een post afleiden uit de muziek die erin staat.
- *
- * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
- * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
- * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
- * stuk bij dat de server kan en de browser niet: de gekozen soort van een
- * playlist opzoeken.
- *
- * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
- * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
- * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
- * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
- *
- * @param {string} content   de HTML/tekst van de post
- * @param {string} siteId    nodig om playlists.kind te kunnen opzoeken
- */
-export function postMusicType(content, siteId) {
-  return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
-}
-
-/**
- * De gekozen soort van een playlist: 'album' | 'playlist' | 'mixtape', of null
- * als hij niet (op deze site) bestaat. Zelfde lijst als PlaylistService, via
- * de gedeelde pure module -- alles wat er niet in staat is een album.
- */
-function playlistKind(id, siteId) {
-  if (!siteId) return null;
-  try {
-    const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
-    if (!r) return null;
-    return SOORTEN.includes(r.kind) ? r.kind : 'album';
-  } catch { return null; }
-}
Index: src/services/music/luisteraars.js
===================================================================
--- src/services/music/luisteraars.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,73 +1,0 @@
-/**
- * Luisteraars: wie de BIBLIOTHEEK volgt (shaer-0nh).
- *
- * Een aparte soort volger. Ze hangen aan `/ap/users/<slug>/library` en niet aan
- * de actor, en dat verschil is de hele bedoeling: een luisteraar krijgt de
- * muziek en NIET de gewone posts. Iemand die zich abonneert op een
- * platenkast heeft niet gevraagd om de Krant.
- *
- * WAAROM EEN EIGEN TABEL EN GEEN VLAG op ap_followers: zolang ze ergens anders
- * staan kan een bezorging ze niet per ongeluk meenemen. Een vlag die iemand
- * vergeet te filteren doet dat wel, en dan is de fout stil -- de posts komen
- * gewoon aan bij mensen die er niet om vroegen, en niemand ziet het aan onze
- * kant. Dit is dezelfde afweging als bij de wachtrijen: de vorm moet de fout
- * onmogelijk maken, niet alleen onwaarschijnlijk.
- */
-import db from '../../config/database.js';
-
-const stmt = (sql) => db.prepare(sql);
-
-/** Erbij, of bijwerken als hij er al was. Volgen is idempotent. */
-export function voegToe(slug, { actorUri, inbox, sharedInbox, name, handle, icon }) {
-  if (!slug || !actorUri) return false;
-  try {
-    stmt(`INSERT INTO ap_library_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon)
-          VALUES (?,?,?,?,?,?,?)
-          ON CONFLICT (slug, actor_uri) DO UPDATE SET
-            inbox = excluded.inbox, shared_inbox = excluded.shared_inbox,
-            name = excluded.name, handle = excluded.handle, icon = excluded.icon`)
-      .run(slug, actorUri, inbox || null, sharedInbox || null, name || null, handle || null, icon || null);
-    return true;
-  } catch { return false; }
-}
-
-/** Weg. Een Undo(Follow) hoort meteen te werken, niet pas na een opruiming. */
-export function verwijder(slug, actorUri) {
-  try { return stmt('DELETE FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').run(slug, actorUri).changes > 0; }
-  catch { return false; }
-}
-
-/** Voor het beheerscherm. */
-export function lijst(slug) {
-  try {
-    return stmt(`SELECT actor_uri, inbox, shared_inbox, name, handle, icon, created_at, last_delivery_at, last_error_at
-                 FROM ap_library_followers WHERE slug = ? ORDER BY created_at DESC`).all(slug);
-  } catch { return []; }
-}
-
-export function telling(slug) {
-  try { return stmt('SELECT COUNT(*) n FROM ap_library_followers WHERE slug = ?').get(slug).n; }
-  catch { return 0; }
-}
-
-/** Volgt deze actor onze bibliotheek al? */
-export function isLuisteraar(slug, actorUri) {
-  try { return !!stmt('SELECT 1 FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').get(slug, actorUri); }
-  catch { return false; }
-}
-
-/**
- * De inboxen om muziek naartoe te sturen, ontdubbeld op gedeelde inbox.
- * Nog niemand gebruikt dit -- de bezorging is de volgende stap -- maar het hoort
- * bij de opslag en niet bij de aanroeper.
- */
-export function inboxen(slug) {
-  const uit = new Map();
-  for (const r of lijst(slug)) {
-    const adres = r.shared_inbox || r.inbox;
-    if (adres && !uit.has(adres)) uit.set(adres, r.actor_uri);
-  }
-  return [...uit.keys()];
-}
-
-export default { voegToe, verwijder, lijst, telling, isLuisteraar, inboxen };
Index: src/views/pages/404.ejs
===================================================================
--- src/views/pages/404.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,22 +1,0 @@
-<section class="nf">
-  <div class="nf-code">404</div>
-  <h1 class="nf-title"><%= t('e404.title') %></h1>
-  <p class="nf-sub"><%= t('e404.sub') %></p>
-  <div class="nf-actions">
-    <a class="nf-btn nf-btn-primary" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/">← <%= t('e404.home') %></a>
-    <% if (typeof site !== 'undefined' && site && site.show_archive_link !== 0) { %>
-      <a class="nf-btn" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/archive"><%= t('e404.archive') %></a>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .nf { max-width: 540px; margin: 0 auto; padding: 12vh 20px 64px; text-align: center; }
-  .nf-code { font-size: clamp(72px, 22vw, 140px); font-weight: 800; line-height: .9; color: var(--accent, #6b8f71); opacity: .9; letter-spacing: -.03em; }
-  .nf-title { font-size: clamp(22px, 6vw, 32px); margin: .4rem 0 .6rem; }
-  .nf-sub { opacity: .8; line-height: 1.6; margin: 0 auto 1.6rem; max-width: 36ch; }
-  .nf-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
-  .nf-btn { display: inline-block; padding: 12px 20px; border-radius: 999px; text-decoration: none; font-weight: 600; font-size: 15px; border: 1px solid rgba(128,128,128,.35); color: inherit; }
-  .nf-btn-primary { background: var(--accent, #6b8f71); border-color: var(--accent, #6b8f71); color: #fff; }
-  @media (max-width: 420px) { .nf-actions { flex-direction: column; } .nf-btn { width: 100%; } }
-</style>
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/account.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -2,8 +2,8 @@
 
   <header class="ax-header">
-    <a href="/" class="ax-back" aria-label="<%= t('acct.back_home') %>">←</a>
+    <a href="/" class="ax-back" aria-label="Terug naar home">←</a>
     <div>
-      <h1><%= t('acct.title') %></h1>
-      <p class="ax-tagline"><%= t('acct.subtitle') %></p>
+      <h1>Account</h1>
+      <p class="ax-tagline">Profiel en wachtwoord.</p>
     </div>
   </header>
@@ -12,165 +12,80 @@
   <% if (error)   { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
 
-  <% if (isViewer) { %>
-    <div class="ax-viewer-note" role="note">
-      <span class="ax-viewer-ico" aria-hidden="true">👁️</span>
-      <div>
-        <strong><%= t('acct.viewer_mode') %></strong>
-        <span><%= t('acct.viewer_note') %></span>
-      </div>
-    </div>
-  <% } %>
-
   <%# ── PROFILE ────────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.profile') %></div>
+    <div class="ax-card-title">Profiel</div>
 
     <div class="ax-profile-id">
-      <span class="ax-profile-avatar ax-profile-avatar-static">
-        <% var _av = (typeof siteAvatar !== 'undefined' ? siteAvatar : null); %>
-        <% if (_av) { %>
-          <img src="<%= avatar(_av, 128) %>" alt="">
+      <div class="ax-profile-avatar">
+        <% if (account.avatar_url) { %>
+          <img src="<%= account.avatar_url %>" alt="">
         <% } else { %>
           <span><%= account.username.charAt(0).toUpperCase() %></span>
         <% } %>
-      </span>
+      </div>
       <div class="ax-profile-meta">
         <div class="ax-profile-name"><%= account.username %></div>
         <div class="ax-profile-role"><%= account.role %> · <%= account.email %></div>
         <% if (account.created_at) { %>
-          <div class="ax-profile-joined"><%= t('acct.member_since') %> <%= formatDate(account.created_at) %></div>
+          <div class="ax-profile-joined">Lid sinds <%= formatDate(account.created_at) %></div>
         <% } %>
-        <% if (canMutate) { %>
-          <div class="ax-profile-joined"><%- t('acct.photo_site_hint') %></div>
-        <% } %>
       </div>
     </div>
 
-    <% if (canMutate) { %>
-      <form action="/account/profile" method="post" class="ax-form ax-form-bio">
-        <label class="ax-field">
-          <span><%= t('acct.username') %></span>
-          <input type="text" name="username" value="<%= account.username %>" minlength="2" maxlength="30" pattern="[A-Za-z0-9_\-]{2,30}" autocomplete="username">
-        </label>
-        <label class="ax-field">
-          <span><%= t('acct.email') %></span>
-          <input type="email" name="email" value="<%= account.email || '' %>" maxlength="254" autocomplete="email" placeholder="<%= t('acct.email_ph') %>">
-        </label>
-        <label class="ax-field">
-          <span><%= t('acct.bio') %></span>
-          <textarea name="bio" rows="3" maxlength="500" placeholder="<%= t('acct.bio_ph') %>"><%= account.bio || '' %></textarea>
-        </label>
-        <div class="ax-form-actions">
-          <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
-        </div>
-      </form>
-    <% } else { %>
-      <div class="ax-form ax-form-bio">
-        <label class="ax-field">
-          <span><%= t('acct.bio') %></span>
-          <textarea rows="3" disabled placeholder="<%= t('acct.bio_empty') %>"><%= account.bio || '' %></textarea>
-        </label>
-      </div>
-    <% } %>
-  </section>
-
-  <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %>
-  <% if (canMutate) { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.lang_label') %></div>
-    <form action="/account/lang" method="post" class="ax-form">
-      <label class="ax-field">
-        <span><%= t('acct.lang_label') %> <small><%= t('acct.lang_hint') %></small></span>
-        <select name="lang">
-          <% langs.forEach(function(l){ var sel = (account && account.lang) ? (account.lang === l.code) : l.active; %>
-            <option value="<%= l.code %>"<%= sel ? ' selected' : '' %>><%= l.name %></option>
-          <% }); %>
-        </select>
+    <form action="/account/avatar" method="post" enctype="multipart/form-data" class="ax-form">
+      <label class="ax-field ax-file">
+        <span>Avatar uploaden <small>(jpg/png/webp/gif, max 5 MB)</small></span>
+        <span class="ax-file-control">
+          <span class="ax-btn ax-btn-secondary ax-file-btn">📷 Kies bestand</span>
+          <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
+          <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" required>
+        </span>
       </label>
       <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
+        <button type="submit" class="ax-btn ax-btn-primary">⬆ Uploaden</button>
+        <% if (account.avatar_url) { %>
+          <%# Separate <form> block kept inline since it submits to a different endpoint. %>
+          <button type="button" class="ax-btn" onclick="document.getElementById('avatar-remove-form').submit()">Avatar verwijderen</button>
+        <% } %>
+      </div>
+    </form>
+    <% if (account.avatar_url) { %>
+      <form action="/account/avatar/remove" method="post" id="avatar-remove-form" hidden></form>
+    <% } %>
+
+    <form action="/account/profile" method="post" class="ax-form ax-form-bio">
+      <label class="ax-field">
+        <span>Bio</span>
+        <textarea name="bio" rows="3" maxlength="500" placeholder="Een korte regel over jezelf"><%= account.bio || '' %></textarea>
+      </label>
+      <div class="ax-form-actions">
+        <button type="submit" class="ax-btn ax-btn-primary">💾 Bio opslaan</button>
       </div>
     </form>
   </section>
-  <% } %>
-
-  <%# ── SITE (owner may update their site name) ─────────── %>
-  <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
+
+  <%# ── PASSWORD ──────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.site') %></div>
-    <form action="/account/site" method="post" class="ax-form">
-      <label class="ax-field">
-        <span><%= t('acct.site_name') %> <small><%= t('acct.site_name_hint') %></small></span>
-        <input type="text" name="site_title" value="<%= editableSite.title || '' %>" maxlength="200" required>
-      </label>
-      <label class="ax-field">
-        <span><%= t('acct.tagline') %> <small><%= t('acct.tagline_hint') %></small></span>
-        <input type="text" name="site_tagline" value="<%= editableSite.tagline || '' %>" maxlength="200">
-      </label>
-      <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.site_save') %></button>
-      </div>
-    </form>
-  </section>
-  <% } %>
-
-  <%# ── PASSWORD ──────────────────────────────────────────── %>
-  <%# In viewer mode there is no password section (nothing to change). %>
-  <%# ── CONNECTED APPS (OAuth C2S) ─────────────────────────── %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.oauth_apps') %></div>
-    <p class="ax-tagline" style="margin:0 0 .6rem"><%= t('acct.oauth_hint') %></p>
-    <% if (!authorizations || !authorizations.length) { %>
-      <p class="ax-oauth-empty"><%= t('acct.oauth_none') %></p>
-    <% } else { %>
-      <ul class="ax-oauth-list">
-        <% authorizations.forEach(function(a){ %>
-          <li class="ax-oauth-item">
-            <div class="ax-oauth-info">
-              <strong><%= a.client_name || t('acct.oauth_unknown_app') %></strong>
-              <span class="ax-oauth-meta">@<%= a.site_slug %> · <%= a.scope || 'c2s' %> · <%= t('acct.oauth_last_used') %> <%= a.last_used_at ? formatDate(a.last_used_at) : t('acct.oauth_never') %></span>
-            </div>
-            <form action="/account/oauth/revoke" method="post">
-              <input type="hidden" name="token_hash" value="<%= a.token_hash %>">
-              <button type="submit" class="ax-btn ax-btn-danger"><%= t('acct.oauth_revoke') %></button>
-            </form>
-          </li>
-        <% }); %>
-      </ul>
-    <% } %>
-  </section>
-
-  <% if (canMutate) { %>
-  <% if (hasPassword) { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.password_change') %></div>
+    <div class="ax-card-title">Wachtwoord wijzigen</div>
     <form action="/account/password" method="post" class="ax-form">
       <label class="ax-field">
-        <span><%= t('acct.password_current') %></span>
+        <span>Huidig wachtwoord</span>
         <input type="password" name="current" required autocomplete="current-password">
       </label>
       <div class="ax-row ax-row-2">
         <label class="ax-field">
-          <span><%= t('acct.password_new') %> <small><%= t('acct.password_min') %></small></span>
+          <span>Nieuw wachtwoord <small>(min 8 tekens)</small></span>
           <input type="password" name="new_password" required minlength="8" autocomplete="new-password">
         </label>
         <label class="ax-field">
-          <span><%= t('acct.password_confirm') %></span>
+          <span>Bevestig nieuw wachtwoord</span>
           <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
         </label>
       </div>
       <div class="ax-form-actions">
-        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.password_change') %></button>
+        <button type="submit" class="ax-btn ax-btn-primary">🔒 Wachtwoord wijzigen</button>
       </div>
     </form>
   </section>
-
-  <% } else { %>
-  <section class="ax-card">
-    <div class="ax-card-title"><%= t('acct.login') %></div>
-    <p class="ax-tagline" style="margin:0"><%= t('acct.login_google_only', { email: account.email }) %></p>
-  </section>
-  <% } %>
-  <% } %>
 
 </div>
@@ -193,19 +108,4 @@
 .ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
 .ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
-
-/* ─── Kijker-modus notice ──────────────────────────────────────── */
-.ax-viewer-note {
-  display: flex; align-items: flex-start; gap: 0.75rem;
-  padding: 0.85rem 1rem; margin-bottom: 1rem;
-  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
-  border-left: 3px solid var(--accent);
-  border-radius: 10px;
-  background: color-mix(in srgb, var(--accent) 8%, var(--paper));
-}
-.ax-viewer-ico { font-size: 1.1rem; line-height: 1.4; }
-.ax-viewer-note strong { display: block; font-size: 0.92rem; }
-.ax-viewer-note span { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
-.ax-profile-avatar-static { flex-shrink: 0; }
-.ax-form-bio textarea:disabled { opacity: 0.7; cursor: default; }
 
 /* ─── Cards ────────────────────────────────────────────────────── */
@@ -299,6 +199,6 @@
 .ax-btn:hover { border-color: var(--accent); }
 .ax-btn-secondary { background: var(--paper-2); }
-.ax-btn-primary, a.ax-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
-.ax-btn-primary:hover, a.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); color: #fff; }
+.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
+.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
 
 /* ─── Profile ID row ──────────────────────────────────────────── */
@@ -309,11 +209,6 @@
   margin-bottom: 1rem;
 }
-.ax-avatar-form { margin: 0; flex-shrink: 0; }
-.ax-avatar-wrap {
-  position: relative; display: block;
-  width: 84px; height: 84px; cursor: pointer;
-}
 .ax-profile-avatar {
-  width: 84px; height: 84px;
+  width: 64px; height: 64px;
   border-radius: 50%;
   overflow: hidden;
@@ -322,26 +217,8 @@
   display: inline-flex; align-items: center; justify-content: center;
   font-family: var(--font-display, serif);
-  font-size: 1.7rem; font-weight: 600;
-  border: 1px solid var(--rule);
-  transition: border-color 120ms;
-}
-.ax-avatar-wrap:hover .ax-profile-avatar { border-color: var(--accent); }
+  font-size: 1.6rem; font-weight: 600;
+  flex-shrink: 0;
+}
 .ax-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
-.ax-avatar-edit {
-  position: absolute; right: -2px; bottom: -2px;
-  width: 28px; height: 28px; border-radius: 50%;
-  background: var(--accent); color: #fff;
-  display: flex; align-items: center; justify-content: center;
-  border: 3px solid var(--paper);
-  transition: transform 120ms;
-}
-.ax-avatar-wrap:hover .ax-avatar-edit { transform: scale(1.08); }
-.ax-avatar-remove { margin-top: 0.45rem; }
-.ax-linkbtn {
-  background: none; border: 0; padding: 0; cursor: pointer;
-  color: var(--ink-muted, var(--ink-soft)); font-size: 0.8rem; text-decoration: underline;
-  font-family: var(--font-ui, system-ui), sans-serif;
-}
-.ax-linkbtn:hover { color: #c33; }
 .ax-profile-meta { min-width: 0; }
 .ax-profile-name { font-weight: 600; font-size: 1.1rem; }
@@ -355,13 +232,3 @@
   .ax-profile-id { flex-direction: column; align-items: flex-start; gap: 0.75rem; text-align: left; }
 }
-
-.ax-oauth-empty { margin: 0; color: var(--ink-soft, #888); }
-.ax-oauth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6rem; }
-.ax-oauth-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .7rem .85rem; border-radius: 12px; background: var(--paper-2, rgba(0,0,0,.04)); }
-.ax-oauth-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
-.ax-oauth-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ax-oauth-meta { font-size: .8rem; color: var(--ink-soft, #888); }
-@media (max-width: 480px) {
-  .ax-oauth-item { flex-direction: column; align-items: stretch; }
-}
 </style>
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-audio.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,9 @@
-<%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze
-    pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus
-    later te komen, anders wint de partial ervan. %>
-<%- include('../partials/admin-styles') %>
 <div class="container ax-page">
 
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'audio', audioOn: true }) %>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('aaud.title') %></h1>
-      <p class="ax-tagline"><%= t('aaud.tagline_pre') %> <code>[[track:&lt;id&gt;]]</code> <%= t('aaud.tagline_post') %></p>
+      <h1>Audio tracks</h1>
+      <p class="ax-tagline">Site-level MP3s. Gebruik <code>[[track:&lt;id&gt;]]</code> in een post om een play-knop in te voegen.</p>
     </div>
   </header>
@@ -19,5 +14,5 @@
   <%# ── UPLOAD ──────────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title"><%= t('aaud.upload') %></div>
+    <div class="ax-card-title">Upload</div>
 
     <%# Bulk drag-drop zone. Multiple files at once, transcoded sequentially.
@@ -30,17 +25,17 @@
       <div class="ax-row ax-row-2">
         <label class="ax-field">
-          <span><%= t('aaud.artist') %> <small><%= t('aaud.applied_all') %></small></span>
-          <input type="text" id="batch-artist" placeholder="<%= t('aaud.optional') %>">
+          <span>Artiest <small>(toegepast op alle bestanden)</small></span>
+          <input type="text" id="batch-artist" placeholder="Optioneel">
         </label>
         <label class="ax-field">
-          <span><%= t('aaud.album') %> <small><%= t('aaud.applied_all') %></small></span>
-          <input type="text" id="batch-album" placeholder="<%= t('aaud.optional') %>">
+          <span>Album <small>(toegepast op alle bestanden)</small></span>
+          <input type="text" id="batch-album" placeholder="Optioneel">
         </label>
       </div>
       <label class="ax-field ax-file">
-        <span><%= t('aaud.cover') %> <small><%= t('aaud.cover_hint') %></small></span>
+        <span>Cover <small>(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)</small></span>
         <span class="ax-file-control">
-          <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 <%= t('aaud.choose_cover') %></span>
-          <span class="ax-file-name" data-empty><%= t('aaud.no_file') %></span>
+          <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 Kies cover</span>
+          <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
           <input type="file" id="batch-cover" accept="image/jpeg,image/png,image/webp,image/gif">
         </span>
@@ -50,6 +45,6 @@
       <label class="ax-dropzone" id="audio-dropzone" tabindex="0">
         <span class="ax-dropzone-icon">🎵</span>
-        <strong class="ax-dropzone-title"><%= t('aaud.drag_here') %></strong>
-        <span class="ax-dropzone-sub"><%= t('aaud.or_click') %><br><small>mp3, m4a, ogg, opus, flac · max <%= maxBytesMb %> MB · wav max <%= maxWavMb %> MB</small></span>
+        <strong class="ax-dropzone-title">Sleep audio hierheen</strong>
+        <span class="ax-dropzone-sub">of klik om bestanden te kiezen<br><small>mp3, m4a, ogg, opus, flac, wav · max <%= maxBytesMb %> MB per bestand</small></span>
         <input type="file" id="audio-files" accept="audio/*" multiple>
       </label>
@@ -59,6 +54,6 @@
 
       <div class="ax-form-actions" id="upload-actions" hidden>
-        <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ <%= t('aaud.start_upload') %></button>
-        <button type="button" class="ax-btn" id="clear-queue-btn"><%= t('aaud.clear_list') %></button>
+        <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ Start upload</button>
+        <button type="button" class="ax-btn" id="clear-queue-btn">Wis lijst</button>
       </div>
     </div>
@@ -67,10 +62,6 @@
   <%# ── TRACK LIST ─────────────────────────────────────────── %>
   <section class="ax-card">
-    <div class="ax-card-title" style="display:flex;align-items:center;gap:.5rem">
-      <%= t('aaud.tracks') %> <span class="ax-count"><%= tracks.length %></span>
-      <button type="button" class="ax-btn ax-btn-secondary" id="add-link-track-btn"
-              style="margin-left:auto;font-size:.85rem" title="<%= t('aaud.add_link_track_title') %>">
-        + <%= t('aaud.add_link_track') %>
-      </button>
+    <div class="ax-card-title">
+      Tracks <span class="ax-count"><%= tracks.length %></span>
     </div>
 
@@ -78,18 +69,7 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">♫</div>
-        <p><%= t('aaud.no_tracks') %></p>
+        <p>Nog geen tracks. Upload er een hierboven.</p>
       </div>
     <% } else { %>
-      <%
-        // Pre-fetch loop labels — `t` is shadowed by the loop var below.
-        var _aaudUntitled = t('aaud.untitled');
-        var _aaudPlay     = t('aaud.play');
-        var _aaudEdit     = t('aaud.edit');
-        var _aaudDlOn     = t('aaud.dl_on');
-        var _aaudDlOff    = t('aaud.dl_off');
-        var _aaudDelete   = t('aaud.delete');
-        var _aaudCopy     = t('aaud.copy_click');
-        var _aaudDelConfirm = t('aaud.delete_confirm');
-      %>
       <ul class="ax-list">
         <% tracks.forEach(function(t) { %>
@@ -102,5 +82,5 @@
 
             <div class="ax-track-meta">
-              <div class="ax-track-title" data-cell="title"><%= t.title || _aaudUntitled %></div>
+              <div class="ax-track-title" data-cell="title"><%= t.title || '(zonder titel)' %></div>
               <div class="ax-track-sub">
                 <span data-cell="artist"><%= t.artist || '—' %></span>
@@ -108,5 +88,5 @@
                 <span class="ax-track-sep">·</span><span><%= t.size ? Math.round(t.size/1024) + ' KB' : '—' %></span>
               </div>
-              <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="<%= _aaudCopy %>">[[track:<%= t.id %>]]</code>
+              <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="Klik om te kopiëren">[[track:<%= t.id %>]]</code>
             </div>
 
@@ -116,16 +96,11 @@
                         data-stream-url="<%= t.stream_url %>"
                         data-track-id="<%= t.id %>"
-                        aria-label="<%= _aaudPlay %>" title="<%= _aaudPlay %>">
+                        aria-label="Afspelen" title="Afspelen">
                   <span class="ax-track-play-icon" aria-hidden="true">▶</span>
                 </button>
               <% } %>
-              <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="<%= _aaudEdit %>" title="<%= _aaudEdit %>">✎</button>
-              <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-                <button type="button" class="ax-icon-btn" data-track-dl data-id="<%= t.id %>" data-on="<%= t.downloadable ? '1' : '0' %>"
-                        aria-label="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>" title="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>"
-                        style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
-              <% } %>
-              <form action="/admin/audio/<%= t.id %>/delete" method="post" data-confirm="<%= _aaudDelConfirm %>" class="ax-track-delete">
-                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _aaudDelete %>" title="<%= _aaudDelete %>">🗑</button>
+              <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
+              <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('Track verwijderen?')" class="ax-track-delete">
+                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
               </form>
             </div>
@@ -136,13 +111,4 @@
   </section>
 
-  <% if ((typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof embedUrl !== 'undefined') { %>
-    <section class="ax-section" style="margin-top:1.5rem">
-      <h2 style="margin:0 0 .4rem">🔊 <%= t('aaud.embed_player') %></h2>
-      <p style="opacity:.75;font-size:.9rem;margin:0 0 .6rem"><%= t('aaud.embed_hint') %></p>
-      <textarea readonly rows="3" data-selectall style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;">&lt;iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"&gt;&lt;/iframe&gt;</textarea>
-      <p style="margin:.6rem 0 0"><a class="btn" href="<%= embedUrl %>" target="_blank">▶ <%= t('aaud.preview_player') %></a></p>
-    </section>
-  <% } %>
-
 </div>
 
@@ -153,4 +119,57 @@
   margin: 1.5rem auto 4rem;
   padding: 0 1rem;
+}
+.ax-header {
+  display: flex; align-items: flex-start; gap: 0.75rem;
+  margin-bottom: 1.5rem;
+}
+.ax-header h1 {
+  font-family: var(--font-display, serif);
+  font-size: 1.75rem;
+  margin: 0 0 0.25rem;
+}
+.ax-tagline {
+  color: var(--ink-muted, var(--ink-soft));
+  margin: 0;
+  font-size: 0.9rem;
+  line-height: 1.5;
+}
+.ax-tagline code {
+  background: var(--paper-2);
+  padding: 0.1em 0.4em;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+.ax-back {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  background: var(--paper);
+  color: var(--ink);
+  text-decoration: none;
+  font-size: 1.1rem;
+  flex-shrink: 0;
+  transition: border-color 120ms;
+}
+.ax-back:hover { border-color: var(--accent); }
+
+/* ─── Flash messages ───────────────────────────────────────────── */
+.ax-flash {
+  padding: 0.75rem 1rem;
+  border-radius: 8px;
+  margin-bottom: 1rem;
+  font-size: 0.9rem;
+}
+.ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
+.ax-flash-err { background: rgba(200,60,60,.15); color: #c33;    border: 1px solid rgba(200,60,60,.3); }
+
+/* ─── Card sections ────────────────────────────────────────────── */
+.ax-card {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  padding: 1.25rem;
+  margin-bottom: 1rem;
 }
 .ax-card-title {
@@ -254,4 +273,43 @@
 .ax-form-actions[hidden] { display: none; }
 
+/* ─── Buttons ──────────────────────────────────────────────────── */
+.ax-btn {
+  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
+  padding: 0.55rem 1rem;
+  border: 1px solid var(--rule);
+  background: var(--paper-2);
+  color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.9rem; font-weight: 500;
+  text-decoration: none;
+  border-radius: 6px;
+  cursor: pointer;
+  min-height: 40px;
+  transition: background 120ms, border-color 120ms;
+  -webkit-tap-highlight-color: transparent;
+}
+.ax-btn:hover { border-color: var(--accent); }
+.ax-btn-secondary { background: var(--paper-2); }
+.ax-btn-primary {
+  background: var(--accent); color: white;
+  border-color: var(--accent);
+}
+.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
+
+/* Tiny icon-only button for row actions */
+.ax-icon-btn {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule);
+  background: var(--paper-2);
+  color: var(--ink);
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 1rem;
+  transition: background 120ms, border-color 120ms, color 120ms;
+}
+.ax-icon-btn:hover { border-color: var(--accent); }
+.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
+
 .ax-track-play.is-playing {
   border-color: var(--accent);
@@ -260,9 +318,108 @@
 }
 .ax-track-play-icon { font-size: 0.85rem; line-height: 1; }
+
+/* ─── Empty state ──────────────────────────────────────────────── */
+.ax-empty {
+  text-align: center;
+  padding: 2rem 1rem;
+  color: var(--ink-muted, var(--ink-soft));
+}
+.ax-empty-icon {
+  font-size: 2.5rem;
+  font-family: var(--font-display, serif);
+  color: var(--ink-soft);
+  opacity: 0.4;
+  margin-bottom: 0.5rem;
+}
+.ax-empty p { margin: 0; }
+
+/* ─── Track list (card per row) ────────────────────────────────── */
+.ax-list {
+  list-style: none; padding: 0; margin: 0;
+  display: flex; flex-direction: column;
+  gap: 0.6rem;
+}
+.ax-track {
+  display: grid;
+  grid-template-columns: 56px 1fr auto;
+  gap: 0.85rem;
+  align-items: center;
+  padding: 0.75rem;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 10px;
+  transition: border-color 120ms;
+}
+.ax-track:hover { border-color: var(--accent); }
+.ax-track-cover {
+  width: 56px; height: 56px;
+  border-radius: 6px;
+  object-fit: cover;
+  background: var(--paper);
+  display: flex; align-items: center; justify-content: center;
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem;
+  color: var(--accent);
+  flex-shrink: 0;
+}
 .ax-track-cover-empty { /* span variant */ }
+.ax-track-meta {
+  min-width: 0;  /* allow text-overflow on long titles */
+  display: flex; flex-direction: column; gap: 0.2rem;
+}
+.ax-track-title {
+  font-weight: 600;
+  font-size: 0.95rem;
+  color: var(--ink);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.ax-track-sub {
+  font-size: 0.8rem;
+  color: var(--ink-muted, var(--ink-soft));
+  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
+}
+.ax-track-sep { opacity: 0.5; }
+.ax-embed {
+  display: inline-block;
+  background: var(--paper);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  font-family: var(--font-mono, ui-monospace, monospace);
+  font-size: 0.75rem;
+  color: var(--ink-soft);
+  cursor: pointer;
+  user-select: all;
+  margin-top: 0.15rem;
+  word-break: break-all;
+  align-self: flex-start;
+  transition: background 120ms;
+}
+.ax-embed:hover { background: var(--rule); }
+.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
+
+.ax-track-actions {
+  display: flex; gap: 0.4rem;
+  flex-shrink: 0;
+}
 .ax-track-delete { display: inline; }
 
 /* ─── Mobile tweaks ───────────────────────────────────────────── */
 @media (max-width: 480px) {
+  .ax-track {
+    grid-template-columns: 48px 1fr;
+    grid-template-areas: "cover meta" "actions actions";
+    gap: 0.6rem;
+  }
+  .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }
+  .ax-track-meta  { grid-area: meta; }
+  .ax-track-actions {
+    grid-area: actions;
+    justify-content: flex-end;
+    border-top: 1px solid var(--rule);
+    padding-top: 0.5rem;
+    margin-top: 0.25rem;
+  }
 }
 
@@ -386,4 +543,382 @@
 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 
-<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
+<script>
+(function() {
+
+  // ── Live filename display for custom file inputs ──────────────
+  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
+    const nameEl = input.parentElement.querySelector('.ax-file-name');
+    if (!nameEl) return;
+    input.addEventListener('change', () => {
+      if (input.files && input.files[0]) {
+        nameEl.textContent = input.files[0].name;
+        nameEl.removeAttribute('data-empty');
+      } else {
+        nameEl.textContent = 'Geen bestand gekozen';
+        nameEl.setAttribute('data-empty', '');
+      }
+    });
+  });
+
+  // ── Inline track preview (routed through the global mini-player) ──
+  // Each .ax-track-play button is a thin wrapper around the global
+  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
+  // .is-playing) is synced from the player's own audio element so it
+  // stays accurate even when the user uses the mini-player's controls.
+  (function setupTrackPreview() {
+    const buttons = document.querySelectorAll('.ax-track-play');
+    if (!buttons.length) return;
+
+    function setIcon(btn, playing) {
+      const icon = btn.querySelector('.ax-track-play-icon');
+      if (icon) icon.textContent = playing ? '⏸' : '▶';
+      btn.classList.toggle('is-playing', playing);
+      btn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
+    }
+
+    // Resync ALL preview buttons against the current audio element state.
+    // Called on every play/pause/ended event so admins always see the right
+    // icon — including the case where they hit pause on the mini-player
+    // bar instead of the row's button.
+    function resyncAll() {
+      const audio = document.getElementById('audio-element');
+      const playing = audio && !audio.paused && !audio.ended;
+      const currentSrc = audio ? audio.src : '';
+      buttons.forEach(b => {
+        const isThisOne = playing && currentSrc.endsWith(b.dataset.streamUrl);
+        setIcon(b, isThisOne);
+      });
+    }
+
+    buttons.forEach(btn => {
+      btn.addEventListener('click', e => {
+        e.preventDefault();
+        const player = window.pcmsAudioPlayer;
+        if (!player) {
+          console.warn('[admin-audio] miniplayer not available');
+          return;
+        }
+        const url = btn.dataset.streamUrl;
+        if (!url) return;
+
+        // Build track metadata from the row's DOM so the mini-player shows
+        // useful info (title/artist/album/cover) without an extra API call.
+        const row = btn.closest('.ax-track');
+        const titleEl  = row && row.querySelector('[data-cell="title"]');
+        const artistEl = row && row.querySelector('[data-cell="artist"]');
+        const albumEl  = row && row.querySelector('[data-cell="album"]');
+        const coverImg = row && row.querySelector('img[data-cover-thumb]');
+        const track = {
+          url,
+          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
+          artist: artistEl ? artistEl.textContent.trim() : '',
+          album:  albumEl  ? albumEl.textContent.trim()  : '',
+          cover:  coverImg ? coverImg.src                : '',
+        };
+
+        // If this exact track is already playing, toggle pause/play instead
+        // of restarting from zero.
+        const audio = document.getElementById('audio-element');
+        if (audio && audio.src.endsWith(url)) {
+          if (audio.paused) player.play();
+          else              player.pause();
+          return;
+        }
+
+        player.setQueue([track], 0);
+      });
+    });
+
+    // Hook the global audio element's events to keep the row buttons synced.
+    // We attach lazily after the player has built its DOM. The script in
+    // shell.ejs runs at page-load so #audio-element exists by the time
+    // this IIFE fires (script tag is below the body content).
+    const audio = document.getElementById('audio-element');
+    if (audio) {
+      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
+        audio.addEventListener(ev, resyncAll);
+      });
+      // Initial state on page load (e.g. user navigated back while a track
+      // was already playing — buttons should reflect that).
+      resyncAll();
+    }
+  })();
+
+
+  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
+  // Files dropped or picked are queued (not uploaded immediately) so the
+  // user can review the list, set shared metadata, then hit "Start upload".
+  // The loop POSTs one file at a time to /admin/audio/upload with
+  // Accept: application/json so the server returns structured per-file
+  // results instead of redirecting.
+  const dropzone   = document.getElementById('audio-dropzone');
+  const fileInput  = document.getElementById('audio-files');
+  const queueEl    = document.getElementById('upload-queue');
+  const actionsEl  = document.getElementById('upload-actions');
+  const startBtn   = document.getElementById('start-upload-btn');
+  const clearBtn   = document.getElementById('clear-queue-btn');
+  const artistInput= document.getElementById('batch-artist');
+  const albumInput = document.getElementById('batch-album');
+  const coverInput = document.getElementById('batch-cover');
+
+  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
+  const queue = [];
+
+  function fmtBytes(n) {
+    if (n < 1024) return n + ' B';
+    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
+    return (n / 1024 / 1024).toFixed(1) + ' MB';
+  }
+
+  function setItemStatus(item, status, label) {
+    const labels = {
+      queued:      'Wachten',
+      uploading:   'Uploaden…',
+      transcoding: 'Converteren…',
+      done:        '✓ Klaar',
+      error:       '✗ Fout',
+    };
+    item.status = status;
+    const badge = item.el.querySelector('.ax-queue-status');
+    badge.className = 'ax-queue-status ax-queue-status--' + status;
+    badge.textContent = label || labels[status] || status;
+    // Restyle the row
+    item.el.classList.remove(
+      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
+    );
+    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
+    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
+    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
+  }
+
+  function addFiles(files) {
+    let added = 0;
+    for (const file of files) {
+      if (!file.type.startsWith('audio/') &&
+          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
+        // Silently skip non-audio drops; keeps the UX uncluttered.
+        continue;
+      }
+      const li = document.createElement('li');
+      li.className = 'ax-queue-item';
+      li.innerHTML =
+        '<div class="ax-queue-name"></div>' +
+        '<span class="ax-queue-status ax-queue-status--queued">Wachten</span>';
+      // Use textContent to avoid HTML-injection if a filename contains markup.
+      li.querySelector('.ax-queue-name').textContent = file.name;
+      // Append size hint inline
+      const size = document.createElement('span');
+      size.className = 'ax-queue-size';
+      size.textContent = fmtBytes(file.size);
+      li.querySelector('.ax-queue-name').appendChild(size);
+      queueEl.appendChild(li);
+      queue.push({ file, el: li, status: 'queued' });
+      added++;
+    }
+    if (added) {
+      queueEl.hidden = false;
+      actionsEl.hidden = false;
+    }
+  }
+
+  // ── Drop-zone events ─────────────────────────────────────────
+  // Page-level guard: a dropped file outside the zone would otherwise
+  // make the browser navigate to it (e.g. opening the audio inline), which
+  // discards typed metadata. We swallow drops anywhere unless the dropzone
+  // explicitly handles them.
+  ['dragover', 'drop'].forEach(ev => {
+    window.addEventListener(ev, e => {
+      // Allow drops INSIDE the dropzone — its own listener handles those.
+      if (dropzone.contains(e.target)) return;
+      e.preventDefault();
+    });
+  });
+
+  ['dragenter', 'dragover'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.add('is-dragover');
+    });
+  });
+  ['dragleave', 'drop'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.remove('is-dragover');
+    });
+  });
+  dropzone.addEventListener('drop', e => {
+    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
+  });
+  fileInput.addEventListener('change', () => {
+    addFiles(fileInput.files);
+    // Reset so the same file can be picked again later if user wants
+    fileInput.value = '';
+  });
+
+  // ── Clear queue button ───────────────────────────────────────
+  clearBtn.addEventListener('click', () => {
+    // Only remove items that aren't currently uploading (anything queued
+    // or already finished). An in-progress upload finishes, then its row
+    // would also disappear once we re-render — but we keep it simple and
+    // just refuse to clear during an active run.
+    if (startBtn.disabled) return;
+    queue.length = 0;
+    queueEl.innerHTML = '';
+    queueEl.hidden = true;
+    actionsEl.hidden = true;
+  });
+
+  // ── Sequential upload loop ───────────────────────────────────
+  startBtn.addEventListener('click', async () => {
+    if (startBtn.disabled) return;
+    startBtn.disabled = true;
+    clearBtn.disabled = true;
+    dropzone.style.pointerEvents = 'none';
+    dropzone.style.opacity = '0.5';
+
+    const sharedArtist = artistInput.value.trim();
+    const sharedAlbum  = albumInput.value.trim();
+    const sharedCover  = coverInput.files && coverInput.files[0];
+
+    // Process queued items one at a time. We iterate via index so that
+    // if more files get dropped during the run they ALSO get processed
+    // (queue.push above mutates the same array we're iterating).
+    for (let i = 0; i < queue.length; i++) {
+      const item = queue[i];
+      if (item.status !== 'queued') continue;
+      try {
+        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
+      } catch (err) {
+        console.error('upload failed for', item.file.name, err);
+        setItemStatus(item, 'error', '✗ ' + (err.message || 'Mislukt'));
+      }
+    }
+
+    startBtn.disabled = false;
+    clearBtn.disabled = false;
+    dropzone.style.pointerEvents = '';
+    dropzone.style.opacity = '';
+
+    // Reload the page so the new tracks appear in the list below.
+    // Could also fetch them and inject, but a full reload is simpler and
+    // ensures position indexes / album-grouping are correct.
+    const anyDone = queue.some(q => q.status === 'done');
+    if (anyDone) {
+      setTimeout(() => location.reload(), 700);
+    }
+  });
+
+  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
+    setItemStatus(item, 'uploading');
+
+    const fd = new FormData();
+    fd.append('audio', item.file);
+    if (sharedArtist) fd.append('artist', sharedArtist);
+    if (sharedAlbum)  fd.append('album',  sharedAlbum);
+    if (sharedCover)  fd.append('cover',  sharedCover);
+    // Title is intentionally omitted — server uses filename fallback.
+
+    // We can't reliably distinguish "still uploading bytes" from
+    // "uploading done, ffmpeg running" without progress events, but the
+    // status flips to "Converteren…" once the request is past upload phase.
+    // We approximate this by waiting until the response arrives — by then
+    // both phases are complete on the server side. For a smoother feel we
+    // briefly show "transcoding" near the end of the request lifecycle.
+    const transcodeHint = setTimeout(() => {
+      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
+    }, 1500);
+
+    try {
+      const res = await fetch('/admin/audio/upload', {
+        method: 'POST',
+        headers: { 'Accept': 'application/json' },
+        body: fd,
+        credentials: 'same-origin',
+      });
+      clearTimeout(transcodeHint);
+
+      // Server responds with JSON for our Accept header. If it didn't
+      // (e.g. session expired and got an HTML login page), surface that.
+      let data;
+      try { data = await res.json(); }
+      catch (_) { throw new Error('Onverwacht serverantwoord (' + res.status + ')'); }
+
+      if (!res.ok || !data.ok) {
+        throw new Error(data.error || ('HTTP ' + res.status));
+      }
+
+      setItemStatus(item, 'done', '✓ ' + (data.title || 'Klaar'));
+    } catch (err) {
+      clearTimeout(transcodeHint);
+      throw err;
+    }
+  }
+
+  // ── Click-to-copy embed codes ─────────────────────────────────
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      const text = el.dataset.copy;
+      try {
+        await navigator.clipboard.writeText(text);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ gekopieerd';
+        setTimeout(() => {
+          el.classList.remove('is-copied');
+          el.textContent = original;
+        }, 1200);
+      } catch (_) { /* fall through — selection still works */ }
+    });
+  });
+
+  // ── Wire all "Edit" buttons to the track-editor modal ─────────
+  // After save we patch the row in-place rather than reloading,
+  // so the user keeps their scroll position on long lists.
+  document.querySelectorAll('[data-track-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openTrackEditor !== 'function') {
+        alert('Track editor niet geladen');
+        return;
+      }
+      const id = btn.dataset.id;
+      window.openTrackEditor({
+        id,
+        onSaved: (track) => {
+          const row = document.querySelector('li[data-track-id="' + id + '"]');
+          if (!row) return;
+          // Update visible cells
+          const titleEl  = row.querySelector('[data-cell="title"]');
+          const artistEl = row.querySelector('[data-cell="artist"]');
+          const albumEl  = row.querySelector('[data-cell="album"]');
+          if (titleEl)  titleEl.textContent  = track.title  || '(zonder titel)';
+          if (artistEl) artistEl.textContent = track.artist || '—';
+          if (albumEl)  {
+            albumEl.textContent = track.album || '';
+            if (track.album) albumEl.removeAttribute('hidden');
+            else albumEl.setAttribute('hidden', '');
+          }
+          // Update cover thumb (replace element if type changed)
+          const oldThumb = row.querySelector('[data-cover-thumb]');
+          if (oldThumb) {
+            const parent = oldThumb.parentElement;
+            if (track.cover_url) {
+              const img = document.createElement('img');
+              img.className = 'ax-track-cover';
+              img.src = track.cover_url;
+              img.alt = '';
+              img.dataset.coverThumb = '';
+              parent.replaceChild(img, oldThumb);
+            } else {
+              const sp = document.createElement('span');
+              sp.className = 'ax-track-cover ax-track-cover-empty';
+              sp.textContent = '♫';
+              sp.dataset.coverThumb = '';
+              parent.replaceChild(sp, oldThumb);
+            }
+          }
+        },
+      });
+    });
+  });
+})();
+</script>
Index: src/views/pages/admin-comments.ejs
===================================================================
--- src/views/pages/admin-comments.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/admin-comments.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,107 @@
+<div class="container admin-comments-page">
+  <p><a href="/admin" class="btn">&larr; Admin</a></p>
+  <h1>Comment moderation</h1>
+
+  <p class="muted">
+    Mode for this site: <strong><%= moderationMode %></strong>
+    <% if (moderationMode === 'trust') { %>
+      &mdash; comments are auto-approved. Switch to <em>moderate</em> in
+      <a href="/admin/sites/<%= site.slug %>/edit">site settings</a> to queue them.
+    <% } else { %>
+      &mdash; new comments require approval before showing up on posts.
+    <% } %>
+  </p>
+
+  <% if (success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
+  <% if (error)   { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
+
+  <h2>Pending (<%= pending.length %>)</h2>
+  <% if (!pending.length) { %>
+    <p class="muted">Nothing waiting.</p>
+  <% } else { %>
+    <ol class="moderation-list">
+      <% pending.forEach(function(c) { %>
+        <li class="moderation-item">
+          <div class="moderation-meta">
+            <strong><%= c.author_username %></strong>
+            <% if (c.parent_comment_id) { %><span class="muted">(reply)</span><% } %>
+            on <a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a>
+            <span class="muted">&middot; <%= formatDateTime(c.created_at) %></span>
+          </div>
+          <blockquote class="moderation-content"><%= c.content %></blockquote>
+          <div class="moderation-actions">
+            <form method="post" action="/admin/comments/<%= c.id %>/approve" style="display:inline">
+              <button type="submit" class="btn btn-primary">Approve</button>
+            </form>
+            <form method="post" action="/admin/comments/<%= c.id %>/reject" style="display:inline">
+              <button type="submit" class="btn btn-danger">Reject</button>
+            </form>
+          </div>
+        </li>
+      <% }); %>
+    </ol>
+  <% } %>
+
+  <h2>Recent decisions</h2>
+  <% if (!recent.length) { %>
+    <p class="muted">Nothing yet.</p>
+  <% } else { %>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Author</th><th>Post</th><th>Status</th><th>Date</th></tr>
+      </thead>
+      <tbody>
+        <% recent.forEach(function(c) { %>
+          <tr class="status-<%= c.status %>">
+            <td><%= c.author_username %></td>
+            <td><a href="/<%= c.post_slug %>"><%= c.post_title || c.post_slug %></a></td>
+            <td>
+              <span class="status-pill status-pill--<%= c.status %>"><%= c.status %></span>
+            </td>
+            <td><%= formatDateTime(c.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+</div>
+
+<style>
+.admin-comments-page { max-width: 900px; margin: 3rem auto; padding: 0 1rem; }
+.admin-comments-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 1rem; }
+.admin-comments-page h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 1.5rem 0 0.75rem; }
+.muted { color: var(--ink-muted, var(--ink-soft)); }
+.muted a { color: var(--accent); }
+
+.moderation-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
+.moderation-item {
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  padding: 1rem;
+}
+.moderation-meta { font-size: 0.85rem; margin-bottom: 0.5rem; color: var(--ink-soft); }
+.moderation-meta a { color: var(--accent); }
+.moderation-content {
+  margin: 0 0 0.75rem;
+  padding: 0.5rem 0.75rem;
+  border-left: 3px solid var(--accent);
+  background: var(--paper);
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  color: var(--ink);
+}
+.moderation-actions { display: flex; gap: 0.5rem; }
+
+.status-pill {
+  display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px;
+  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
+}
+.status-pill--approved { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; }
+.status-pill--rejected { background: rgba(200, 60, 60, 0.15); color: #c33; }
+.status-pill--pending  { background: var(--paper); color: var(--ink-muted, var(--ink-soft)); }
+
+.audio-flash { padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; font-size: 0.9rem; }
+.audio-flash--ok { background: rgba(40, 160, 90, 0.15); color: #2a9d5e; border: 1px solid rgba(40, 160, 90, 0.3); }
+.audio-flash--err { background: rgba(200, 60, 60, 0.15); color: #c33; border: 1px solid rgba(200, 60, 60, 0.3); }
+</style>
Index: src/views/pages/admin-epk.ejs
===================================================================
--- src/views/pages/admin-epk.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,92 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aepk.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aepk.back_admin') %></a> <a href="/pers" class="btn" target="_blank" rel="noopener">📰 <%= t('aepk.view_epk') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-
-  <section class="set-card">
-    <h2><%= t('aepk.text_heading') %></h2>
-    <p class="set-help">
-      <%= t('aepk.text_help') %>
-    </p>
-
-    <form method="post" action="/admin/epk" class="set-form">
-      <label class="set-field">
-        <span><%= t('aepk.bio_label') %></span>
-        <textarea name="epk_bio" rows="5" maxlength="1000"
-                  placeholder="<%= t('aepk.bio_ph') %>"><%= epkBio %></textarea>
-      </label>
-      <label class="set-field">
-        <span><%= t('aepk.contact_label') %></span>
-        <input type="text" name="epk_contact" maxlength="300"
-               placeholder="<%= t('aepk.contact_ph') %>"
-               value="<%= epkContact %>">
-      </label>
-
-      <div class="set-field">
-        <span><%= t('aepk.tracks_label') %> <small><%= t('aepk.tracks_hint', { n: maxEpkTracks }) %></small></span>
-        <% if (!allTracks.length) { %>
-          <p class="set-help" style="margin:0"><%= t('aepk.no_tracks') %></p>
-        <% } else { %>
-          <% var _untitled = t('aepk.untitled'); %>
-          <div class="epk-track-pick" data-max="<%= maxEpkTracks %>">
-            <% allTracks.forEach(function(t){ %>
-              <label class="epk-pick-item">
-                <input type="checkbox" name="epk_tracks" value="<%= t.id %>" <%= chosenTracks.indexOf(t.id) >= 0 ? 'checked' : '' %>>
-                <span class="epk-box" aria-hidden="true">✓</span>
-                <span class="epk-pick-title"><%= t.title || _untitled %></span>
-                <% if (t.artist) { %><span class="epk-pick-artist"><%= t.artist %></span><% } %>
-              </label>
-            <% }); %>
-          </div>
-        <% } %>
-      </div>
-
-      <button type="submit" class="btn btn-primary"><%= t('aepk.save') %></button>
-    </form>
-  </section>
-</div>
-
-<style>
-  .admin-page .set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; margin-top: 1rem; }
-  .admin-page .set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
-  .admin-page .set-help { font-size: 0.85rem; color: var(--ink-soft, var(--ink-muted)); line-height: 1.5; margin: 0 0 1rem; max-width: 60ch; }
-  .admin-page .set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 520px; }
-  .admin-page .set-field { display: flex; flex-direction: column; gap: 0.3rem; }
-  .admin-page .set-field > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-  .admin-page .set-field input,
-  .admin-page .set-field textarea {
-    width: 100%; box-sizing: border-box;
-    padding: 0.6rem 0.75rem;
-    border: 1px solid var(--rule); border-radius: 6px;
-    background: var(--paper); color: var(--ink);
-    font-family: var(--font-ui, system-ui), sans-serif; font-size: 0.95rem;
-    -webkit-appearance: none; appearance: none;
-  }
-  .admin-page .set-field textarea { resize: vertical; line-height: 1.5; }
-  .admin-page .set-field input:focus,
-  .admin-page .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-  .admin-page .set-form .btn { align-self: flex-start; }
-  .admin-page .epk-track-pick { display: flex; flex-direction: column; gap: 2px; max-height: 300px; overflow-y: auto; border: 1px solid var(--rule); border-radius: 8px; padding: 4px; }
-  .admin-page .epk-pick-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer; border: 1px solid transparent; }
-  .admin-page .epk-pick-item:hover { background: var(--paper); }
-  /* Native checkbox verbergen; we tekenen een eigen, duidelijk vakje (.epk-box). */
-  .admin-page .epk-pick-item input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
-  .admin-page .epk-box {
-    flex: 0 0 auto; width: 20px; height: 20px;
-    border: 2px solid var(--rule); border-radius: 5px;
-    display: grid; place-items: center;
-    color: transparent; font-size: 13px; font-weight: 900; line-height: 1;
-  }
-  .admin-page .epk-pick-item:has(input:checked) .epk-box { background: var(--accent); border-color: var(--accent); color: #fff; }
-  /* Aangevinkte rij ook getint. */
-  .admin-page .epk-pick-item:has(input:checked) {
-    background: color-mix(in srgb, var(--accent) 14%, transparent);
-    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
-  }
-  .admin-page .epk-pick-item:has(input:disabled) { opacity: .45; cursor: not-allowed; }
-  .admin-page .epk-pick-title { font-weight: 600; }
-  .admin-page .epk-pick-artist { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
-  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
Index: src/views/pages/admin-help.ejs
===================================================================
--- src/views/pages/admin-help.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,152 +1,0 @@
-<div class="container admin-page hb">
-  <p><a href="/admin" class="btn">&larr; <%= t('ahelp.back') %></a></p>
-  <h1><%= t('ahelp.title') %></h1>
-  <p class="hb-intro"><%= t('ahelp.intro') %></p>
-
-  <div class="hb-search">
-    <input type="search" id="hb-q" placeholder="<%= t('ahelp.search_placeholder') %>" autocomplete="off" aria-label="<%= t('ahelp.search_aria') %>">
-  </div>
-  <p class="hb-count" id="hb-count" aria-live="polite"></p>
-
-  <div class="hb-list">
-
-    <section class="hb-item">
-      <h2>✍️ <%= t('ahelp.s_newpost_h') %></h2>
-      <p><%- t('ahelp.s_newpost_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📝 <%= t('ahelp.s_excerpt_h') %></h2>
-      <p><%- t('ahelp.s_excerpt_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📌 <%= t('ahelp.s_pin_h') %></h2>
-      <p><%- t('ahelp.s_pin_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🗓️ <%= t('ahelp.s_schedule_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_schedule_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🖼️ <%= t('ahelp.s_images_h') %></h2>
-      <p><%- t('ahelp.s_images_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🎵 <%= t('ahelp.s_audio_h') %></h2>
-      <p><%- t('ahelp.s_audio_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>© <%= t('ahelp.s_credit_h') %></h2>
-      <p><%- t('ahelp.s_credit_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>⬇ <%= t('ahelp.s_downloads_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_downloads_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📃 <%= t('ahelp.s_albums_h') %></h2>
-      <p><%- t('ahelp.s_albums_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📅 <%= t('ahelp.s_agenda_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_agenda_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📰 <%= t('ahelp.s_presskit_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_presskit_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔗 <%= t('ahelp.s_circles_h') %></h2>
-      <p><%- t('ahelp.s_circles_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🌐 <%= t('ahelp.s_fedi_h') %></h2>
-      <p><%- t('ahelp.s_fedi_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>📊 <%= t('ahelp.s_stats_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_stats_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>✉️ <%= t('ahelp.s_newsletter_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_newsletter_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔗 <%= t('ahelp.s_linkbio_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_linkbio_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>▶️ <%= t('ahelp.s_embed_h') %> <small>(<%= t('ahelp.premium') %>)</small></h2>
-      <p><%- t('ahelp.s_embed_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🎨 <%= t('ahelp.s_appearance_h') %></h2>
-      <p><%- t('ahelp.s_appearance_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>👥 <%= t('ahelp.s_tenancy_h') %></h2>
-      <p><%- t('ahelp.s_tenancy_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>⭐ <%= t('ahelp.s_premium_h') %></h2>
-      <p><%- t('ahelp.s_premium_b') %></p>
-    </section>
-
-
-    <section class="hb-item">
-      <h2>🔑 <%= t('ahelp.s_password_h') %></h2>
-      <p><%- t('ahelp.s_password_b') %></p>
-    </section>
-
-    <section class="hb-item">
-      <h2>🔄 <%= t('ahelp.s_updates_h') %></h2>
-      <p><%- t('ahelp.s_updates_b') %></p>
-    </section>
-
-  </div>
-
-  <p class="hb-empty" id="hb-empty" hidden><%= t('ahelp.empty') %></p>
-</div>
-
-<style>
-  .hb { max-width: 760px; }
-  .hb h1 { margin-bottom: .25rem; }
-  .hb-intro { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1rem; }
-  .hb-search input {
-    width: 100%; box-sizing: border-box;
-    padding: 0.7rem 0.9rem; font-size: 1rem;
-    border: 1px solid var(--rule); border-radius: 999px;
-    background: var(--paper); color: var(--ink);
-    -webkit-appearance: none; appearance: none;
-  }
-  .hb-search input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-  .hb-count { color: var(--ink-muted, var(--ink-soft)); font-size: .82rem; margin: .5rem 0 1rem; min-height: 1em; }
-  .hb-list { display: flex; flex-direction: column; gap: .75rem; }
-  .hb-item { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1rem 1.1rem; }
-  .hb-item h2 { font-family: var(--font-display, serif); font-size: 1.15rem; margin: 0 0 .4rem; }
-  .hb-item h2 small { font-weight: 400; font-size: .72rem; color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 999px; padding: .05rem .45rem; margin-left: .35rem; vertical-align: middle; }
-  .hb-item p { margin: 0; line-height: 1.55; color: var(--ink); }
-  .hb-item code { background: var(--paper); padding: .08rem .35rem; border-radius: 4px; font-size: .88em; }
-  .hb-item.hb-hidden { display: none; }
-  .hb-item mark { background: color-mix(in srgb, var(--accent) 35%, transparent); color: inherit; border-radius: 2px; }
-  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
Index: src/views/pages/admin-listeners.ejs
===================================================================
--- src/views/pages/admin-listeners.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,53 +1,0 @@
-<%# Beheer -> Media -> Luisteraars (shaer-0nh).
-
-    Deze pagina had zijn eigen <style>-blok met eigen klassen (.lis-*) en week
-    daarmee af van de vier tabs ernaast -- andere breedte, andere kop, andere
-    rij. Nu op dezelfde ax-*-romp; wat overblijft is een lijst zoals audio en
-    playlists er ook een hebben. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'listeners', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('alis.lis_title') %></h1>
-      <%# Waarom dit een eigen tab is en niet een lijstje bij de volgers: dit
-          zijn accounts die aan de BIBLIOTHEEK hangen. Ze krijgen de muziek en
-          met opzet niet de gewone posts, en dat verschil hoort zichtbaar te
-          zijn -- ook voor wie later moet begrijpen waarom ze niet in de
-          volgerslijst staan. %>
-      <p class="ax-tagline"><%= t('alis.lis_intro') %></p>
-    </div>
-  </header>
-
-  <% if (!luisteraars.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#127911;</div>
-      <p><%= t('alis.lis_empty') %></p>
-    </div>
-  <% } else { %>
-    <p class="ax-tagline" style="margin-bottom:.75rem;"><strong><%= luisteraars.length %></strong> <%= t('alis.lis_count') %></p>
-    <ul class="ax-list">
-      <% luisteraars.forEach(function (l) { %>
-        <li class="ax-track">
-          <div class="ax-track-cover">
-            <% if (l.icon) { %><img src="<%= l.icon %>" alt="" loading="lazy" decoding="async"><% } else { %>&#9835;<% } %>
-          </div>
-          <div class="ax-track-meta">
-            <div class="ax-track-title">
-              <%= l.name || l.handle || l.actor_uri %>
-              <% if (l.last_error_at) { %><span class="ax-pill"><%= t('alis.lis_error') %></span><% } %>
-            </div>
-            <div class="ax-track-sub">
-              <% if (l.handle) { %><span><%= l.handle %></span><span class="ax-track-sep">&middot;</span><% } %>
-              <span><%= t('alis.lis_since') %> <%= formatDate(l.created_at) %></span>
-              <span class="ax-track-sep">&middot;</span>
-              <span><%= t('alis.lis_last') %>: <%= l.last_delivery_at ? formatDate(l.last_delivery_at) : t('alis.lis_never') %></span>
-            </div>
-          </div>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
Index: src/views/pages/admin-media.ejs
===================================================================
--- src/views/pages/admin-media.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-<%# Beheer -> Media: de afbeeldingenbibliotheek met opruiming.
-    De audio-helft zit onder /admin/audio, als eigen tab.
-
-    Deze pagina stond volledig op inline styles en viel daarmee buiten het
-    ax-*-systeem waar audio en playlists al op zaten. Nu op dezelfde romp:
-    zelfde breedte, zelfde kop, tabs op dezelfde plek. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'images', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('admin.t_media') %></h1>
-      <p class="ax-tagline">
-        <%= items.length %> <%= t('admin.media_count') %><% if (unusedCount) { %> &middot; <strong><%= unusedCount %></strong> <%= t('admin.media_unused') %><% } %>
-      </p>
-    </div>
-  </header>
-
-  <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
-
-  <% if (unusedCount) { %>
-  <div class="ax-actions-bar">
-    <button type="button" id="media-cleanup" class="ax-btn ax-btn-danger">&#128465; <%= t('admin.media_cleanup') %> (<%= unusedCount %>)</button>
-  </div>
-  <% } %>
-
-  <% if (!items.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#128247;</div>
-      <p><%= t('admin.media_empty') %></p>
-    </div>
-  <% } else { %>
-  <div class="ax-grid">
-    <% items.forEach(function(it){ %>
-      <div class="ax-tile media-card" data-file="<%= it.file %>">
-        <div class="ax-tile-media" style="aspect-ratio:1;">
-          <img src="<%= thumb(it.url, 320) %>" alt="" loading="lazy" decoding="async" style="object-fit:cover;height:100%;">
-          <% if (it.hasVideo) { %><span class="ax-tile-badge ax-tile-badge-r" title="animated cover">&#127916;</span><% } %>
-          <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %>
-        </div>
-        <div class="ax-tile-body">
-          <div class="ax-tile-meta"><%= it.kb %> KB &middot; <%= it.usedCount %>&times;</div>
-          <div class="ax-tile-actions">
-            <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>
-            <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">&#128465;</button>
-          </div>
-        </div>
-      </div>
-    <% }) %>
-  </div>
-  <% } %>
-</div>
-
-<%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %>
Index: src/views/pages/admin-migrate.ejs
===================================================================
--- src/views/pages/admin-migrate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,329 +1,0 @@
-<div class="container mig-wrap">
-  <h1><%= t('mig.title') %></h1>
-  <p class="mig-lead"><%= t('mig.lead') %></p>
-
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# ── Het stappenplan ────────────────────────────────────────── %>
-  <%# Bestaat omdat de VOLGORDE niet vanzelfsprekend is en een fout erin je
-      halverwege vastzet. Twee dingen die niemand raadt:
-        - de ophaalknop werkt PAS nadat de verhuizing is aangekondigd; de bron
-          geeft niets af aan een instantie die niet in zijn movedTo staat;
-        - met een zip kan het juist andersom, want een zip vraagt niemand iets.
-      En stap 5 staat erbij omdat hij bestaat, niet omdat er een knop voor is. %>
-  <section class="mig-card mig-plan">
-    <h2><%= t('mig.plan_title') %></h2>
-    <p class="form-hint"><%= t('mig.plan_hint') %></p>
-    <%# "hier" hangt af van WELKE kant je bekijkt. Deze pagina draait op allebei
-        de instanties, en een vaste tekst is dus op de helft van de schermen
-        onzin. Heeft deze site een moved_to, dan is dit de vertrekkende kant. %>
-    <%# Stap 1, 3 en 4 horen op de NIEUWE kant; stap 2 en 5 op de oude. Vanaf
-        welke instantie je kijkt bepaalt dus welk van de twee "hier" is. %>
-    <% var _hier = stap.isOud ? t('mig.plan_new')  : t('mig.plan_here');
-       var _daar = stap.isOud ? t('mig.plan_here') : t('mig.plan_old'); %>
-    <% if (stap.isOud) { %>
-      <p class="form-hint mig-warn"><%= t('mig.plan_from_old') %></p>
-    <% } %>
-    <ol class="mig-steps">
-      <li class="<%= stap.geclaimd ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_1') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-        <em><%= t('mig.plan_1_why') %></em>
-      </li>
-      <li class="<%= stap.aangekondigd ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_2') %></strong>
-        <span class="mig-waar"><%= _daar %></span>
-        <em><%= t('mig.plan_2_why') %></em>
-      </li>
-      <li class="<%= stap.opgehaald ? 'is-done' : '' %>">
-        <strong><%= t('mig.plan_3') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-        <em><%= t('mig.plan_3_why') %></em>
-      </li>
-      <li>
-        <strong><%= t('mig.plan_4') %></strong>
-        <span class="mig-waar"><%= _hier %></span>
-      </li>
-      <li class="mig-step-later">
-        <strong><%= t('mig.plan_5') %></strong>
-        <span class="mig-waar"><%= _daar %></span>
-        <em><%= t('mig.plan_5_why') %></em>
-      </li>
-    </ol>
-  </section>
-
-  <%# ── Je oude account claimen (FEP-7628 alsoKnownAs) ─────────── %>
-  <%# Bewust als EERSTE: zonder deze claim weigert de oude instantie de Move
-      met no_backreference, en kan de ophaalknop hieronder ook niets. Dit stond
-      op de site-bewerkpagina tussen de kleuren; daar zoekt niemand het als hij
-      aan het verhuizen is. %>
-  <section class="mig-card">
-    <h2><%= t('mig.alias_title') %></h2>
-    <p class="form-hint"><%= t('mig.alias_hint') %></p>
-    <form method="post" action="/admin/migrate/aliases">
-      <label for="mig-alias"><%= t('mig.alias_label') %></label>
-      <textarea id="mig-alias" name="ap_aliases" rows="3" class="mig-input"
-        placeholder="@jij@mastodon.social&#10;@jij@oude-klonkt.nl"><%= aliassen || '' %></textarea>
-      <p class="form-hint"><%= t('mig.alias_note') %></p>
-      <button type="submit" class="btn"><%= t('mig.alias_btn') %></button>
-    </form>
-  </section>
-
-  <%# ── Exporteren ─────────────────────────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.export_title') %></h2>
-    <p class="form-hint"><%= t('mig.export_hint') %></p>
-
-    <% if (telling) { %>
-      <ul class="mig-counts">
-        <li><strong><%= telling.posts %></strong> <%= t('mig.c_posts') %></li>
-        <li><strong><%= telling.replies %></strong> <%= t('mig.c_replies') %></li>
-        <li><strong><%= telling.media %></strong> <%= t('mig.c_media') %></li>
-        <%# Muziek APART tellen, niet onder "media". Dit ontbrak, en juist daardoor
-            kon een export van 140 nummers er als 4,5 MB uitzien zonder dat iemand
-            het merkte: de teller zei "39 mediabestanden" en zweeg over de rest. %>
-        <% if (telling.tracks) { %>
-          <%# Wat er ECHT meegaat, niet hoeveel rijen er in de database staan.
-              "8 nummers" met daaronder "van 3 ontbreekt het bestand" telt niet
-              op; 5 plus die waarschuwing wel. %>
-          <li><strong><%= telling.tracks - (telling.audioMissing || 0) %></strong> <%= t('mig.c_tracks') %></li>
-        <% } %>
-        <% if (telling.playlists) { %>
-          <li><strong><%= telling.playlists %></strong> <%= t('mig.c_playlists') %></li>
-        <% } %>
-        <% if (typeof telling.following === 'number') { %>
-          <li><strong><%= telling.following %></strong> <%= t('mig.c_following') %></li>
-        <% } %>
-        <li><strong><%= mb(telling.bytes) %></strong> <%= t('mig.c_size') %></li>
-      </ul>
-
-      <% if (telling.ontbrekend) { %>
-        <%# Niets stils: media die op schijf ontbreekt wordt geteld en gemeld, want
-            een archief dat stilzwijgend minder bevat is erger dan een waarschuwing. %>
-        <p class="alert alert-error"><%= t('mig.missing', { n: telling.ontbrekend }) %></p>
-      <% } %>
-
-      <% if (telling.audioMissing) { %>
-        <%# Een eigen zin voor muziek. "N mediaverwijzingen" zei niets over de
-            vraag die er toe doet: gaat mijn bibliotheek mee. %>
-        <p class="alert alert-error"><%= t('mig.audio_missing', { n: telling.audioMissing }) %></p>
-      <% } %>
-
-
-      <% if (telling.groot) { %>
-        <p class="alert alert-error"><%= t('mig.too_big') %></p>
-      <% } else { %>
-        <p><a class="btn btn-primary" href="/admin/migrate/export"><%= t('mig.export_btn') %></a></p>
-      <% } %>
-    <% } else { %>
-      <p class="form-hint"><%= t('mig.export_none') %></p>
-    <% } %>
-  </section>
-
-  <%# ── Rechtstreeks ophalen (FEP-1580) ────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.pull_title') %></h2>
-    <p class="form-hint"><%= t('mig.pull_hint') %></p>
-
-    <% if (haalVerslag) { %>
-      <div class="mig-report<%= haalVerslag.error ? '' : ' is-done' %>">
-        <% if (haalVerslag.error) { %>
-          <h3><%= t('mig.pull_failed') %></h3>
-          <%# Elke weigering krijgt een eigen zin. "Er ging iets mis" laat je
-              raden welke van de twee kanten van de afspraak ontbreekt, en dat
-              is precies wat je moet weten om het op te lossen. %>
-          <p><%= t('mig.e_' + haalVerslag.error) !== 'mig.e_' + haalVerslag.error
-                   ? t('mig.e_' + haalVerslag.error) : (haalVerslag.melding || haalVerslag.error) %></p>
-          <% if (haalVerslag.error === 'not_moved_here' && haalVerslag.movedTo) { %>
-            <p class="form-hint"><%= t('mig.e_points_at') %> <code><%= haalVerslag.movedTo %></code></p>
-          <% } %>
-        <% } else { %>
-          <h3><%= t('mig.pull_done') %></h3>
-          <ul class="mig-counts">
-            <li><strong><%= haalVerslag.posts %></strong> <%= t('mig.r_imported') %></li>
-            <% if (haalVerslag.overgeslagen) { %><li><strong><%= haalVerslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li><% } %>
-            <% if (haalVerslag.media) { %><li><strong><%= haalVerslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
-            <% if (haalVerslag.blocks) { %><li><strong><%= haalVerslag.blocks %></strong> <%= t('mig.r_blocks') %></li><% } %>
-            <% if (haalVerslag.tracksBinnen) { %><li><strong><%= haalVerslag.tracksBinnen %></strong> <%= t('mig.c_tracks') %></li><% } %>
-            <%# Aangevuld telt apart van nieuw. Een tweede ronde die "0 erbij"
-                meldt terwijl hij drie nummers compleet maakte, leest als niets
-                gedaan. %>
-            <% if (haalVerslag.tracksBijgewerkt) { %><li><strong><%= haalVerslag.tracksBijgewerkt %></strong> <%= t('mig.r_tracks_updated') %></li><% } %>
-            <% if (haalVerslag.postsBijgewerkt) { %><li><strong><%= haalVerslag.postsBijgewerkt %></strong> <%= t('mig.r_posts_updated') %></li><% } %>
-            <% if (haalVerslag.linksBijgetrokken) { %><li><strong><%= haalVerslag.linksBijgetrokken %></strong> <%= t('mig.r_links_fixed') %></li><% } %>
-            <% if (haalVerslag.playlistsBinnen) { %><li><strong><%= haalVerslag.playlistsBinnen %></strong> <%= t('mig.c_playlists') %></li><% } %>
-          </ul>
-          <% if (haalVerslag.tracksMislukt) { %>
-            <p class="alert alert-error"><%= t('mig.r_tracks_missing', { n: haalVerslag.tracksMislukt }) %></p>
-          <% } %>
-        <% } %>
-        <% if (haalVerslag.waarschuwingen && haalVerslag.waarschuwingen.length) { %>
-          <ul class="mig-warnlist">
-            <% haalVerslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
-          </ul>
-        <% } %>
-      </div>
-    <% } %>
-
-    <form method="post" action="/admin/migrate/pull">
-      <label for="mig-bron"><%= t('mig.pull_source') %></label>
-      <input type="url" id="mig-bron" name="bron" value="<%= bron || '' %>"
-             placeholder="@jij@oude-klonkt.nl" class="mig-input">
-      <p class="form-hint"><%= t('mig.pull_source_hint') %></p>
-      <button type="submit" class="btn"><%= t('mig.pull_btn') %></button>
-    </form>
-
-    <% if (mig && (mig.total || mig.moves)) { %>
-      <p class="form-hint mig-state">
-        <%= t('mig.state', { n: mig.total }) %>
-        <%= mig.complete ? t('mig.state_done') : t('mig.state_busy') %>
-      </p>
-    <% } %>
-  </section>
-
-  <%# ── Importeren ─────────────────────────────────────────────── %>
-  <section class="mig-card">
-    <h2><%= t('mig.import_title') %></h2>
-    <p class="form-hint"><%= t('mig.import_hint') %></p>
-
-    <% if (verslag) { %>
-      <div class="mig-report<%= verslag.echt ? ' is-done' : '' %>">
-        <h3><%= verslag.echt ? t('mig.r_done') : t('mig.r_dry') %></h3>
-        <p class="form-hint"><%= verslag.bestand %> &middot; <%= mb(verslag.bytes) %></p>
-        <ul class="mig-counts">
-          <li><strong><%= verslag.posts %></strong> <%= verslag.echt ? t('mig.r_imported') : t('mig.r_would') %></li>
-          <li><strong><%= verslag.overgeslagen %></strong> <%= t('mig.r_skipped') %></li>
-          <% if (verslag.overschreven) { %><li><strong><%= verslag.overschreven %></strong> <%= t('mig.r_overwritten') %></li><% } %>
-          <% if (verslag.mediaMissing) { %><li><strong><%= verslag.mediaMissing %></strong> <%= t('mig.r_media_missing') %></li><% } %>
-          <% if (verslag.media) { %><li><strong><%= verslag.media %></strong> <%= t('mig.r_media') %></li><% } %>
-          <%# Ook hier: muziek apart. Zonder deze twee regels zie je aan de
-              ontvangende kant net zo min of je bibliotheek is aangekomen. %>
-          <% if (verslag.tracks) { %><li><strong><%= verslag.tracks %></strong> <%= t('mig.c_tracks') %></li><% } %>
-          <% if (verslag.playlists) { %><li><strong><%= verslag.playlists %></strong> <%= t('mig.c_playlists') %></li><% } %>
-          <% if (verslag.linksBijgetrokken) { %><li><strong><%= verslag.linksBijgetrokken %></strong> <%= t('mig.r_links_fixed') %></li><% } %>
-        </ul>
-        <% if (verslag.tracksMissing) { %>
-          <p class="alert alert-error"><%= t('mig.r_tracks_missing', { n: verslag.tracksMissing }) %></p>
-        <% } %>
-        <% if (verslag.waarschuwingen && verslag.waarschuwingen.length) { %>
-          <ul class="mig-warnlist">
-            <% verslag.waarschuwingen.slice(0, 6).forEach(function (w) { %><li><%= w %></li><% }); %>
-          </ul>
-        <% } %>
-        <% if (verslag.idsBehouden === false) { %>
-          <%# Dit is de belangrijkste regel van het hele verslag: komt het archief
-              van een ANDER adres, dan krijgen de posts nieuwe id's en zijn het
-              nieuwe objecten voor de fediverse. %>
-          <p class="alert alert-error"><%= t('mig.r_new_ids') %></p>
-        <% } %>
-
-        <% if (!verslag.echt && verslag.posts > 0) { %>
-          <form method="post" action="/admin/migrate/import" enctype="multipart/form-data" class="mig-confirm">
-            <p class="form-hint"><%= t('mig.r_confirm_hint') %></p>
-            <label for="mig-file2"><%= t('mig.file_label') %></label>
-            <input type="file" id="mig-file2" name="archief" accept=".zip,application/zip" required>
-            <input type="hidden" name="echt" value="1">
-            <% if (verslag.overschrijf) { %><input type="hidden" name="overschrijf" value="1"><% } %>
-            <button type="submit" class="btn btn-primary"><%= t('mig.r_confirm_btn') %></button>
-          </form>
-        <% } %>
-      </div>
-    <% } %>
-
-    <form method="post" action="/admin/migrate/import" enctype="multipart/form-data">
-      <label for="mig-file"><%= t('mig.file_label') %></label>
-      <input type="file" id="mig-file" name="archief" accept=".zip,application/zip" required>
-
-      <label class="mig-check">
-        <input type="checkbox" name="overschrijf" value="1">
-        <%= t('mig.overwrite_label') %>
-      </label>
-      <p class="form-hint mig-warn"><%= t('mig.overwrite_hint') %></p>
-
-      <button type="submit" class="btn"><%= t('mig.check_btn') %></button>
-      <p class="form-hint"><%= t('mig.check_hint') %></p>
-    </form>
-  </section>
-
-  <%# ── De verhuizing aankondigen (FEP-7628 Move) ──────────────── %>
-  <%# ONDERAAN, en met een rode knop. Dit is de enige knop op deze pagina die
-      de fediverse in gaat en niet terug te draaien is: je volgers verhuizen
-      mee en dit account gaat op slot. Alles hierboven kun je overdoen. %>
-  <section class="mig-card mig-card-danger">
-    <h2><%= t('mig.move_title') %></h2>
-    <p class="form-hint"><%= t('mig.move_hint') %></p>
-    <% if (movedTo) { %>
-      <p class="alert alert-error"><%= t('mig.move_done') %> <code><%= movedTo %></code></p>
-    <% } else { %>
-      <form method="post" action="/admin/sites/<%= site.slug %>/move"
-            onsubmit="return confirm('<%= t('mig.move_confirm') %>');">
-        <input type="hidden" name="next" value="/admin/migrate">
-        <label for="mig-move"><%= t('mig.move_label') %></label>
-        <input type="text" id="mig-move" name="move_target" class="mig-input"
-               placeholder="@nieuw@andere-server.nl" required>
-        <p class="form-hint mig-warn"><%= t('mig.move_warn') %></p>
-        <button type="submit" class="btn btn-danger"><%= t('mig.move_btn') %></button>
-      </form>
-    <% } %>
-  </section>
-
-  <%# ── Wie je volgt ────────────────────────────────────────────── %>
-  <%# Stond op Connect, in een <details> zonder een greintje opmaak: labels als
-      losse tekst naast hun invoerveld, een textarea van vijf regels breed. Hier
-      krijgt hij de kaartopmaak van deze pagina, en hij hoort hier ook: je
-      volglijst meenemen is een migratie-optie. %>
-  <section class="mig-card">
-    <h2><%= t('mig.follows_title') %></h2>
-    <p class="form-hint"><%= t('tl.move_hint') %></p>
-    <p><a class="btn" href="/news/following.csv" download>&#11015; <%= t('tl.move_export') %></a></p>
-
-    <form method="post" action="/news/following/import" enctype="multipart/form-data">
-      <input type="hidden" name="next" value="/admin/migrate">
-      <label for="mig-volg-bestand"><%= t('tl.move_import_file') %></label>
-      <input type="file" id="mig-volg-bestand" name="csvfile" accept=".csv,text/csv,text/plain">
-
-      <label for="mig-volg-csv"><%= t('tl.move_import_lbl') %></label>
-      <textarea id="mig-volg-csv" name="csv" rows="5" spellcheck="false" class="mig-input"
-                placeholder="Account address,Show boosts,Notify on new posts,Languages,Featured"></textarea>
-      <p class="form-hint"><%= t('mig.follows_note') %></p>
-
-      <button type="submit" class="btn btn-primary"><%= t('tl.move_import') %></button>
-    </form>
-  </section>
-</div>
-
-<style>
-  .mig-wrap { max-width: 46rem; }
-  .mig-lead { color: var(--sub); margin-bottom: 1.5rem; }
-  .mig-card { border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; }
-  .mig-card h2 { margin: 0 0 .35rem; font-size: 1.1rem; }
-  .mig-counts { list-style: none; padding: 0; margin: .9rem 0; display: flex; flex-wrap: wrap; gap: .4rem 1.4rem; }
-  .mig-counts li { color: var(--sub); font-size: .94rem; }
-  .mig-counts strong { color: var(--ink); font-size: 1.05rem; }
-  .mig-card label { display: block; margin: .9rem 0 .35rem; font-weight: 600; font-size: .92rem; }
-  .mig-card input[type="file"] { display: block; margin-bottom: .6rem; }
-  .mig-check { display: flex; align-items: center; gap: .5rem; font-weight: 400 !important; }
-  .mig-input { width: 100%; max-width: 34rem; padding: .5rem .6rem; border: 1px solid var(--rule);
-               border-radius: 8px; background: transparent; color: var(--ink); font-size: .95rem; }
-  .mig-state { margin-top: .9rem; }
-  .mig-report code { word-break: break-all; font-size: .88rem; }
-  .mig-warn { color: var(--accent); }
-  .mig-plan { background: color-mix(in srgb, var(--ink, #000) 3%, transparent); }
-  .mig-steps { margin: 1rem 0 0; padding-left: 1.4rem; display: flex; flex-direction: column; gap: .7rem; }
-  .mig-steps li { color: var(--ink); }
-  .mig-steps li em { display: block; color: var(--sub); font-style: normal; font-size: .9rem; margin-top: .15rem; }
-  .mig-steps li.is-done > strong { text-decoration: line-through; opacity: .6; }
-  .mig-steps li.is-done::marker { content: '✓ '; }
-  .mig-steps li.mig-step-later { opacity: .8; }
-  .mig-waar { font-size: .8rem; padding: .05rem .4rem; border: 1px solid var(--rule); border-radius: 999px;
-              color: var(--sub); margin-left: .4rem; white-space: nowrap; }
-  .mig-card-danger { border-color: color-mix(in srgb, var(--accent, #c33) 45%, var(--rule)); }
-  textarea.mig-input { font-family: inherit; resize: vertical; }
-  .mig-report { border-left: 3px solid var(--accent); padding: .8rem 1rem; margin-bottom: 1.2rem;
-                background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border-radius: 0 8px 8px 0; }
-  .mig-report.is-done { border-left-color: #3fa843; }
-  .mig-report h3 { margin: 0 0 .3rem; font-size: 1rem; }
-  .mig-warnlist { margin: .5rem 0; padding-left: 1.2rem; color: var(--accent); font-size: .9rem; }
-  .mig-confirm { margin-top: .9rem; padding-top: .9rem; border-top: 1px solid var(--rule); }
-</style>
Index: src/views/pages/admin-newsletter.ejs
===================================================================
--- src/views/pages/admin-newsletter.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,68 +1,0 @@
-<%
-  var c = (typeof nlCounts !== 'undefined') ? nlCounts : { confirmed:0, pending:0, unsub:0 };
-  var msg = (typeof nlMsg !== 'undefined') ? nlMsg : '';
-  var msgKind = (typeof nlMsgKind !== 'undefined') ? nlMsgKind : '';
-  var hist = (typeof nlHistory !== 'undefined') ? nlHistory : [];
-  var smtp = (typeof nlSmtp !== 'undefined') ? nlSmtp : false;
-%>
-<section class="anl">
-  <h1><%= t('anews.title') %></h1>
-
-  <% if (msg) { %><div class="anl-msg anl-<%= msgKind %>"><%= msg %></div><% } %>
-
-  <div class="anl-stats">
-    <div class="anl-stat"><span class="anl-n"><%= c.confirmed %></span><span class="anl-l"><%= t('anews.confirmed') %></span></div>
-    <div class="anl-stat"><span class="anl-n"><%= c.pending %></span><span class="anl-l"><%= t('anews.pending') %></span></div>
-    <div class="anl-stat"><span class="anl-n"><%= c.unsub %></span><span class="anl-l"><%= t('anews.unsub') %></span></div>
-  </div>
-
-  <% if (!smtp) { %>
-    <div class="anl-msg anl-warn"><%= t('anews.smtp_warn_1') %> (<code>SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM</code> <%= t('anews.smtp_warn_2') %> <code>.env</code>).</div>
-  <% } %>
-
-  <p class="anl-share"><%= t('anews.share') %> <a href="<%= nlSubscribeUrl %>"><%= nlSubscribeUrl %></a></p>
-
-  <% var _sendConfirm = t('anews.send_confirm', { n: c.confirmed }); %>
-  <form method="POST" action="<%= siteUrlBase %>/admin/newsletter/send" class="anl-form" data-confirm="<%= _sendConfirm %>">
-    <label><%= t('anews.subject') %>
-      <input type="text" name="subject" required maxlength="200" value="<%= (typeof nlSubject!=='undefined')?nlSubject:'' %>" placeholder="<%= t('anews.subject_ph') %>">
-    </label>
-    <label><%= t('anews.body') %>
-      <textarea name="body" rows="10" required placeholder="<%= t('anews.body_ph') %>"><%= (typeof nlBody!=='undefined')?nlBody:'' %></textarea>
-    </label>
-    <button type="submit" class="anl-send" <%= smtp ? '' : 'disabled' %>><%= t('anews.send_btn', { n: c.confirmed }) %></button>
-  </form>
-
-  <% if (hist.length) { %>
-    <h2 class="anl-h2"><%= t('anews.sent_heading') %></h2>
-    <% var _recipients = t('anews.recipients'); %>
-    <ul class="anl-hist">
-      <% hist.forEach(function(h){ %>
-        <li><span class="anl-hist-subj"><%= h.subject %></span><span class="anl-hist-meta"><%= h.recipient_count %> <%= _recipients %> · <%= formatDateTime ? formatDateTime(h.sent_at) : h.sent_at %></span></li>
-      <% }); %>
-    </ul>
-  <% } %>
-</section>
-
-<style>
-  .anl { max-width: 720px; margin: 0 auto; padding: 24px 18px 64px; }
-  .anl h1 { margin: 0 0 18px; }
-  .anl-msg { padding: 11px 14px; border-radius: 10px; margin-bottom: 16px; font-size: 14px; }
-  .anl-ok { background: rgba(60,160,90,.15); }
-  .anl-bad { background: rgba(200,60,60,.15); }
-  .anl-warn { background: rgba(210,160,40,.15); }
-  .anl-stats { display: flex; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
-  .anl-stat { flex: 1 1 100px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; padding: 14px; text-align: center; }
-  .anl-n { display: block; font-size: 28px; font-weight: 700; }
-  .anl-l { font-size: 12px; opacity: .65; text-transform: uppercase; letter-spacing: .06em; }
-  .anl-share { font-size: 13px; opacity: .8; margin: 0 0 20px; }
-  .anl-form { display: flex; flex-direction: column; gap: 14px; }
-  .anl-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 13.5px; }
-  .anl-form input, .anl-form textarea { padding: 11px 13px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font: inherit; font-weight: 400; }
-  .anl-send { align-self: flex-start; padding: 11px 20px; border-radius: 10px; border: none; background: var(--accent, #6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-  .anl-send:disabled { opacity: .5; cursor: not-allowed; }
-  .anl-h2 { margin: 30px 0 12px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; opacity: .6; }
-  .anl-hist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .anl-hist li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.18); border-radius: 10px; flex-wrap: wrap; }
-  .anl-hist-meta { opacity: .6; font-size: 12.5px; }
-</style>
Index: src/views/pages/admin-paid.ejs
===================================================================
--- src/views/pages/admin-paid.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,74 +1,0 @@
-<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
-  <h1 style="margin:0 0 .3rem"><%= t('apaid.t') %></h1>
-  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
-    <%= t('apaid.intro') %>
-  </p>
-
-  <% if (saved) { %><p style="color:var(--accent);font-weight:600"><%= t('apaid.saved') %></p><% } %>
-  <% if (error) { %><p style="color:#c0392b"><%= error %></p><% } %>
-  <% if (!secretReady) { %>
-    <p style="color:#c0392b"><%= t('apaid.nokey') %></p>
-  <% } %>
-
-  <p style="margin:.2rem 0 1.2rem">
-    <%= t('apaid.status') %>
-    <% if (status.connected) { %>
-      <strong style="color:var(--accent)"><%= t('apaid.connected') %></strong> (<%= t('apaid.campaign') %> <%= status.campaignId %>)
-    <% } else if (status.configured) { %>
-      <strong><%= t('apaid.configured') %></strong>
-    <% } else { %>
-      <strong><%= t('apaid.notyet') %></strong>
-    <% } %>
-  </p>
-
-  <div style="border:1px solid var(--border,#333);border-radius:10px;padding:.9rem 1rem;margin:0 0 1.3rem;background:color-mix(in srgb,var(--accent,#6b8f71) 8%,transparent)">
-    <p style="margin:0 0 .4rem;font-weight:600"><%= t('apaid.redirect_h') %></p>
-    <p style="margin:0 0 .6rem;color:var(--ink-soft,#888);font-size:.9rem">
-      <%= t('apaid.redirect_p') %>
-    </p>
-    <div style="display:flex;gap:.5rem;align-items:center">
-      <input id="pd-redirect" type="text" readonly value="<%= redirectUri %>" style="flex:1;min-width:0;font-family:monospace;font-size:.9rem">
-      <button type="button" id="pd-copy" class="btn" data-copied="<%= t('apaid.copied') %>"><%= t('apaid.copy') %></button>
-    </div>
-  </div>
-
-  <form method="post" action="/admin/paid" style="display:flex;flex-direction:column;gap:.9rem">
-    <label><%= t('apaid.client_id') %>
-      <input type="text" name="client_id" value="<%= status.clientId || '' %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.client_secret') %>
-      <input type="password" name="client_secret" placeholder="<%= status.hasSecret ? t('apaid.unchanged') : '' %>" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.keep') %></small>
-    </label>
-    <label><%= t('apaid.campaign_id') %>
-      <input type="text" name="campaign_id" value="<%= status.campaignId || '' %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.public_page') %>
-      <input type="url" name="patreon_url" value="<%= status.patreonUrl || '' %>" placeholder="https://www.patreon.com/jouwnaam" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.public_help') %></small>
-    </label>
-    <label><%= t('apaid.access') %>
-      <input type="password" name="access_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
-    </label>
-    <label><%= t('apaid.refresh') %>
-      <input type="password" name="refresh_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
-      <small style="color:var(--ink-soft,#888)"><%= t('apaid.token_help') %></small>
-    </label>
-    <label><%= t('apaid.min_eur') %>
-      <input type="text" name="default_min_eur" value="<%= status.defaultMinCents ? (status.defaultMinCents/100).toFixed(2) : '' %>" inputmode="decimal" style="width:120px">
-    </label>
-    <div style="display:flex;gap:.6rem;align-items:center">
-      <button type="submit" class="btn btn-primary"><%= t('apaid.save') %></button>
-    </div>
-  </form>
-
-  <% if (status.configured || status.connected) { %>
-    <form method="post" action="/admin/paid/disconnect" data-confirm="<%= t('apaid.disconnect_confirm') %>" style="margin-top:1rem">
-      <button type="submit" class="btn btn-danger"><%= t('apaid.disconnect') %></button>
-    </form>
-  <% } %>
-
-  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
-</div>
-
-<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
Index: src/views/pages/admin-playlists.ejs
===================================================================
--- src/views/pages/admin-playlists.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-playlists.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -5,17 +5,13 @@
 const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
 %>
-<%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze
-    pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus
-    later te komen, anders wint de partial ervan. %>
-<%- include('../partials/admin-styles') %>
 <div class="container ax-page">
 
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'playlists', audioOn: true }) %>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('apl.title') %></h1>
+      <h1>Playlists</h1>
       <p class="ax-tagline">
-        <%= t('apl.tagline_pre') %> <code>[[playlist:&lt;id&gt;]]</code><%= t('apl.tagline_post') %>
+        Canonieke playlists. Bewerk een playlist hier en de wijzigingen werken
+        door in álle posts die hem gebruiken via <code>[[playlist:&lt;id&gt;]]</code>.
       </p>
     </div>
@@ -25,5 +21,5 @@
   <div class="ax-actions-bar">
     <button type="button" class="ax-btn ax-btn-primary" id="pl-new-btn">
-      ＋ <%= t('apl.new_playlist') %>
+      ＋ Nieuwe playlist
     </button>
   </div>
@@ -33,29 +29,14 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">📃</div>
-        <p><%= t('apl.none') %></p>
-        <p class="ax-empty-sub"><%= t('apl.none_sub') %></p>
+        <p>Nog geen playlists.</p>
+        <p class="ax-empty-sub">Maak er een via de knop hierboven, of via de 📃 knop in de post-editor.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      // Pre-fetch loop labels (defensive — keep `t` calls out of the loop body).
-      var _aplPlaylist = t('apl.pill_playlist');
-      var _aplAlbum    = t('apl.pill_album');
-      var _aplMixtape  = t('apl.pill_mixtape');
-      // Een soort met een eigen teken en een eigen woord. De ternair die hier
-      // stond kende er maar twee, en toonde een mixtape dus als album.
-      var _aplTeken = { album: '💿', playlist: '📃', mixtape: '📼' };
-      var _aplWoord = { album: _aplAlbum, playlist: _aplPlaylist, mixtape: _aplMixtape };
-      var _aplTrack    = t('apl.track');
-      var _aplTracks   = t('apl.tracks');
-      var _aplCopy     = t('apl.copy_click');
-      var _aplEdit     = t('apl.edit');
-      var _aplDelete   = t('apl.delete');
-    %>
     <ul class="ax-list">
       <% playlists.forEach(function(p) { %>
         <li class="ax-track" data-id="<%= p.id %>">
           <div class="ax-track-cover" <%- p.cover ? `style="background-image:url('${p.cover}'); background-size:cover; background-position:center"` : '' %>>
-            <% if (!p.cover) { %><span><%= _aplTeken[p.kind] || _aplTeken.album %></span><% } %>
+            <% if (!p.cover) { %><span><%= p.kind === 'playlist' ? '📃' : '💿' %></span><% } %>
           </div>
 
@@ -63,17 +44,17 @@
             <div class="ax-track-title">
               <%= p.title %>
-              <span class="ax-pill"><%= (_aplTeken[p.kind] || _aplTeken.album) + ' ' + (_aplWoord[p.kind] || _aplAlbum) %></span>
+              <span class="ax-pill"><%= p.kind === 'playlist' ? '📃 playlist' : '💿 album' %></span>
             </div>
             <div class="ax-track-sub">
-              <span><%= p.track_count %> <%= p.track_count === 1 ? _aplTrack : _aplTracks %></span>
+              <span><%= p.track_count %> <%= p.track_count === 1 ? 'track' : 'tracks' %></span>
               <% if (p.artist) { %><span class="ax-track-sep">·</span><span><%= p.artist %></span><% } %>
               <% if (p.year) { %><span class="ax-track-sep">·</span><span><%= p.year %></span><% } %>
             </div>
-            <code class="ax-embed" data-copy="[[playlist:<%= p.id %>]]" title="<%= _aplCopy %>">[[playlist:<%= p.id %>]]</code>
+            <code class="ax-embed" data-copy="[[playlist:<%= p.id %>]]" title="Klik om te kopiëren">[[playlist:<%= p.id %>]]</code>
           </div>
 
           <div class="ax-track-actions">
-            <button type="button" class="ax-icon-btn" data-pl-edit data-id="<%= p.id %>" aria-label="<%= _aplEdit %>" title="<%= _aplEdit %>">✎</button>
-            <button type="button" class="ax-icon-btn ax-icon-btn-danger" data-pl-delete data-id="<%= p.id %>" data-title="<%= p.title %>" aria-label="<%= _aplDelete %>" title="<%= _aplDelete %>">🗑</button>
+            <button type="button" class="ax-icon-btn" data-pl-edit data-id="<%= p.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
+            <button type="button" class="ax-icon-btn ax-icon-btn-danger" data-pl-delete data-id="<%= p.id %>" data-title="<%= p.title %>" aria-label="Verwijderen" title="Verwijderen">🗑</button>
           </div>
         </li>
@@ -92,10 +73,210 @@
 
 .ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
+.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
+.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
+.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }
+.ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
+.ax-back {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule); border-radius: 8px;
+  background: var(--paper); color: var(--ink); text-decoration: none;
+  font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
+}
+.ax-back:hover { border-color: var(--accent); }
+
+.ax-actions-bar {
+  display: flex;
+  margin-bottom: 1rem;
+}
+
+.ax-card {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  padding: 1.25rem;
+  margin-bottom: 1rem;
+}
+
+/* ─── Buttons (same primitives as admin-audio) ─────────────────── */
+.ax-btn {
+  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
+  padding: 0.6rem 1.1rem;
+  border: 1px solid var(--rule); background: var(--paper-2);
+  color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.95rem; font-weight: 500;
+  text-decoration: none; border-radius: 8px; cursor: pointer;
+  min-height: 44px;
+  transition: background 120ms, border-color 120ms;
+  -webkit-tap-highlight-color: transparent;
+}
+.ax-btn:hover { border-color: var(--accent); }
+.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
+.ax-btn-primary:hover { opacity: 0.92; }
+
+.ax-icon-btn {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 40px; height: 40px;
+  border: 1px solid var(--rule); border-radius: 8px;
+  background: var(--paper-2); color: var(--ink);
+  cursor: pointer; font-size: 1rem;
+  transition: background 120ms, border-color 120ms, color 120ms;
+}
+.ax-icon-btn:hover { border-color: var(--accent); }
+.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
+
+/* ─── Empty state ──────────────────────────────────────────────── */
+.ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }
+.ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }
+.ax-empty p { margin: 0; }
+.ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }
+
+/* ─── List items (same as audio tracks) ───────────────────────── */
+.ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
+.ax-track {
+  display: grid;
+  grid-template-columns: 64px 1fr auto;
+  gap: 0.85rem; align-items: center;
+  padding: 0.85rem;
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-radius: 12px;
+  transition: border-color 120ms;
+}
+.ax-track:hover { border-color: var(--accent); }
+.ax-track-cover {
+  width: 64px; height: 64px; border-radius: 8px;
+  background: var(--paper-2);
+  display: flex; align-items: center; justify-content: center;
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem; color: var(--accent);
+  flex-shrink: 0;
+}
+.ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
+.ax-track-title {
+  font-family: var(--font-display, serif);
+  font-weight: 600; font-size: 1.05rem; color: var(--ink);
+  display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
+}
+.ax-pill {
+  font-family: var(--font-ui);
+  font-size: 0.7rem; font-weight: 500;
+  color: var(--ink-soft);
+  background: var(--paper-2);
+  padding: 0.15em 0.55em; border-radius: 999px;
+  letter-spacing: 0;
+}
+.ax-track-sub {
+  font-size: 0.85rem;
+  color: var(--ink-muted, var(--ink-soft));
+  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
+}
+.ax-track-sep { opacity: 0.5; }
+.ax-embed {
+  display: inline-block;
+  background: var(--paper-2);
+  padding: 0.15rem 0.5rem;
+  border-radius: 4px;
+  font-family: var(--font-mono, ui-monospace, monospace);
+  font-size: 0.75rem;
+  color: var(--ink-soft);
+  cursor: pointer; user-select: all;
+  margin-top: 0.15rem;
+  word-break: break-all;
+  align-self: flex-start;
+  transition: background 120ms;
+}
+.ax-embed:hover { background: var(--rule); }
+.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
+
+.ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
 
 /* ─── Mobile ──────────────────────────────────────────────────── */
 @media (max-width: 480px) {
+  .ax-track {
+    grid-template-columns: 56px 1fr;
+    grid-template-areas: "cover meta" "actions actions";
+    gap: 0.6rem;
+  }
+  .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }
+  .ax-track-meta  { grid-area: meta; }
+  .ax-track-actions {
+    grid-area: actions;
+    justify-content: flex-end;
+    border-top: 1px solid var(--rule);
+    padding-top: 0.5rem;
+    margin-top: 0.25rem;
+  }
 }
 </style>
 
-<%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %>
+<script>
+(function() {
+  const csrf = '<%= _csrf %>';
+
+  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
+    if (typeof window.openPlaylistEditor === 'function') {
+      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
+    }
+  });
+
+  // Click-to-copy on shortcodes
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      try {
+        await navigator.clipboard.writeText(el.dataset.copy);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ gekopieerd';
+        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
+      } catch (_) { /* fall back to selection */ }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openPlaylistEditor === 'function') {
+        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
+      }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id;
+      const title = btn.dataset.title || id;
+      if (!confirm(`Playlist "${title}" verwijderen? Posts die deze playlist embedden tonen vanaf nu een placeholder.`)) return;
+      try {
+        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
+          method: 'POST',
+          headers: { 'X-CSRF-Token': csrf },
+          credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (j.ok) location.reload();
+        else alert('Verwijderen mislukt: ' + (j.error || ''));
+      } catch (err) {
+        alert('Verwijderen mislukt: ' + err.message);
+      }
+    });
+  });
+
+  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
+  // openPlaylistEditor is defined synchronously by the included partial, so
+  // it's available by the time this IIFE runs.
+  (function deepLinkEdit() {
+    const params = new URLSearchParams(location.search);
+    const editId = params.get('edit');
+    if (!editId) return;
+    if (typeof window.openPlaylistEditor !== 'function') return;
+    // Strip the query param immediately so reload after save doesn't re-open.
+    history.replaceState({}, '', location.pathname);
+    window.openPlaylistEditor({
+      mode: 'edit',
+      id: editId,
+      onSaved: () => location.reload(),
+    });
+  })();
+})();
+</script>
Index: src/views/pages/admin-push.ejs
===================================================================
--- src/views/pages/admin-push.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,61 +1,0 @@
-<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
-  <h1 style="margin:0 0 .3rem"><%= t('push.t') %></h1>
-  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
-    <%= t('push.intro') %>
-  </p>
-
-  <% if (!vapidKey) { %>
-    <p style="color:#c0392b"><%= t('push.unavailable') %></p>
-  <% } else { %>
-
-  <div id="np-unsupported" hidden style="color:#c0392b;margin:0 0 1rem">
-    <%= t('push.unsupported') %>
-  </div>
-  <div id="np-ios-hint" hidden style="border:1px solid var(--border,#333);border-radius:10px;padding:.8rem 1rem;margin:0 0 1rem;font-size:.92rem;color:var(--ink-soft,#888)">
-    <%= t('push.ios_hint') %>
-  </div>
-
-  <section style="border:1px solid var(--border,#333);border-radius:12px;padding:1rem 1.1rem;margin:0 0 1.2rem">
-    <p style="margin:0 0 .6rem"><strong><%= t('push.this_device') %></strong> <span id="np-state"><%= t('push.checking') %></span></p>
-    <div style="display:flex;gap:.6rem;flex-wrap:wrap">
-      <button type="button" id="np-on" class="btn btn-primary" hidden><%= t('push.enable') %></button>
-      <button type="button" id="np-off" class="btn" hidden><%= t('push.disable') %></button>
-      <button type="button" id="np-test" class="btn" hidden><%= t('push.test') %></button>
-    </div>
-    <fieldset id="np-alerts" hidden style="border:none;padding:0;margin:1rem 0 0">
-      <legend style="font-weight:600;margin-bottom:.4rem"><%= t('push.what') %></legend>
-      <label style="display:block"><input type="checkbox" data-alert="follow"> <%= t('push.a_follow') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="reply"> <%= t('push.a_reply') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="like"> <%= t('push.a_like') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="boost"> <%= t('push.a_boost') %></label>
-      <label style="display:block"><input type="checkbox" data-alert="dm"> <%= t('push.a_dm') %></label>
-      <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0"><%= t('push.saved') %></p>
-    </fieldset>
-  </section>
-
-  <% if (subscriptions.length) { %>
-    <h2 style="font-size:1.05rem;margin:0 0 .5rem"><%= t('push.devices') %></h2>
-    <ul style="list-style:none;padding:0;margin:0 0 1.2rem">
-      <% subscriptions.forEach(function (s) { %>
-        <li data-endpoint="<%= s.endpoint %>" style="display:flex;justify-content:space-between;gap:.8rem;align-items:center;padding:.45rem 0;border-bottom:1px solid var(--border,#2a2a2a)">
-          <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || t('push.device') %> <small style="color:var(--ink-soft,#888)"><%= t('push.since') %> <%= String(s.created_at || '').slice(0, 10) %></small></span>
-          <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>"><%= t('push.remove') %></button>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% } %>
-
-  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
-</div>
-
-<% if (vapidKey) { %>
-<script id="np-data" type="application/json"><%- JSON.stringify({
-  vapid: vapidKey,
-  alerts: defaultAlerts,
-  saved: subscriptions.reduce(function (m, s) { try { m[s.endpoint] = JSON.parse(s.alert_types || '{}'); } catch (e) { m[s.endpoint] = {}; } return m; }, {}),
-  i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
-}).replace(/</g, '\\u003c') %></script>
-<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
-<% } %>
Index: src/views/pages/admin-seo.ejs
===================================================================
--- src/views/pages/admin-seo.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,229 +1,0 @@
-<div class="container admin-site-edit-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('aseo.back') %></a></p>
-  <h1><%= t('aseo.title') %></h1>
-  <p class="admin-tagline"><%= t('aseo.tagline_pre') %> <strong><%= site.title %></strong> <%= t('aseo.tagline_post') %></p>
-
-  <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="audio-flash audio-flash--err"><%= error %></div><% } %>
-
-  <form method="post" action="/admin/seo" class="site-form">
-
-    <fieldset>
-      <legend><%= t('aseo.index_legend') %></legend>
-      <label class="cb">
-        <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
-        <span><%= t('aseo.index_label') %> <small class="form-hint-inline">— <%= t('aseo.index_hint_pre') %> <code>noindex</code> <%= t('aseo.index_hint_post') %></small></span>
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.title_legend') %></legend>
-      <label>
-        <span><%= t('aseo.title_template') %> <small class="form-hint-inline">— <%= t('aseo.title_template_hint_pre') %> <code>{title}</code> <%= t('aseo.title_template_hint_and') %> <code>{site}</code></small></span>
-        <input type="text" name="title_template" value="<%= site.title_template || '{title} — {site}' %>" maxlength="200" placeholder="{title} — {site}">
-      </label>
-      <label>
-        <span><%= t('aseo.default_desc') %> <small class="form-hint-inline">— <%= t('aseo.default_desc_hint') %></small></span>
-        <textarea name="default_description" rows="2" maxlength="500" placeholder="<%= t('aseo.default_desc_ph') %>"><%= site.default_description || '' %></textarea>
-      </label>
-      <label>
-        <span><%= t('aseo.canonical') %> <small class="form-hint-inline">— <%= t('aseo.canonical_hint') %></small></span>
-        <input type="text" name="canonical" value="<%= site.canonical || '' %>" placeholder="https://yourdomain.com" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.author') %> <small class="form-hint-inline">— <%= t('aseo.author_hint') %></small></span>
-        <input type="text" name="author" value="<%= site.author || '' %>" maxlength="120" placeholder="<%= t('aseo.author_ph') %>">
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.social_legend') %></legend>
-      <label>
-        <span><%= t('aseo.og_image') %> <small class="form-hint-inline">— <%= t('aseo.og_image_hint') %></small></span>
-        <input type="text" name="og_image_default" value="<%= site.og_image_default || '' %>" placeholder="https://yourdomain.com/share.jpg" maxlength="500">
-      </label>
-      <label>
-        <span><%= t('aseo.og_theme') %> <small class="form-hint-inline">— <%= t('aseo.og_theme_hint') %></small></span>
-        <select name="og_theme">
-          <option value="" <%= !site.og_theme ? 'selected' : '' %>><%= t('aseo.og_theme_auto') %></option>
-          <option value="light" <%= site.og_theme === 'light' ? 'selected' : '' %>><%= t('aseo.og_theme_light') %></option>
-          <option value="dark" <%= site.og_theme === 'dark' ? 'selected' : '' %>><%= t('aseo.og_theme_dark') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('aseo.og_locale') %> <small class="form-hint-inline">— <%= t('aseo.og_locale_hint_pre') %> <code>nl_NL</code> <%= t('aseo.og_locale_hint_or') %> <code>en_US</code></small></span>
-        <input type="text" name="og_locale" value="<%= site.og_locale || '' %>" placeholder="nl_NL" maxlength="32">
-      </label>
-      <label>
-        <span><%= t('aseo.twitter') %> <small class="form-hint-inline">— <%= t('aseo.twitter_hint') %></small></span>
-        <input type="text" name="twitter" value="<%= site.twitter || '' %>" placeholder="@jouwnaam" maxlength="64">
-      </label>
-      <label>
-        <span><%= t('aseo.fb_app') %> <small class="form-hint-inline">— <%= t('aseo.fb_app_hint') %></small></span>
-        <input type="text" name="facebook_app_id" value="<%= site.facebook_app_id || '' %>" maxlength="64">
-      </label>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.publisher_legend') %></legend>
-      <label>
-        <span><%= t('aseo.type') %></span>
-        <select name="schema_type">
-          <option value="Person" <%= site.schema_type === 'Organization' ? '' : 'selected' %>><%= t('aseo.type_person') %></option>
-          <option value="Organization" <%= site.schema_type === 'Organization' ? 'selected' : '' %>><%= t('aseo.type_org') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_name') %> <small class="form-hint-inline">— <%= t('aseo.publisher_name_hint') %></small></span>
-        <input type="text" name="publisher_name" value="<%= site.publisher_name || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_url') %></span>
-        <input type="text" name="publisher_url" value="<%= site.publisher_url || '' %>" placeholder="https://yourdomain.com" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.publisher_logo') %></span>
-        <input type="text" name="publisher_logo" value="<%= site.publisher_logo || '' %>" placeholder="https://yourdomain.com/logo.png" maxlength="500">
-      </label>
-    </fieldset>
-
-    <%# ── MUSICBRAINZ ─────────────────────────────────────────────
-        Direct onder Uitgever, en dat is geen willekeur: sameAs IS een
-        schema.org-eigenschap, en dat blok hierboven is waar ons schema.org
-        vandaan komt. Een koppeling hier voedt twee uitgangen tegelijk -- de
-        JSON-LD van de pagina en het fediverse-profiel (shaer-mbz). %>
-    <fieldset id="mb-veld">
-      <legend><%= t('aseo.mb_legend') %></legend>
-      <p class="form-hint"><%= t('aseo.mb_hint') %></p>
-
-      <input type="hidden" name="mb_artist_id" id="mb-id" value="<%= site.mb_artist_id || '' %>">
-      <input type="hidden" name="mb_artist_name" id="mb-naam" value="<%= site.mb_artist_name || '' %>">
-
-      <p class="mb-huidig" id="mb-huidig" <%= site.mb_artist_id ? '' : 'hidden' %>>
-        <%= t('aseo.mb_linked') %>
-        <strong id="mb-huidig-naam"><%= site.mb_artist_name || site.mb_artist_id || '' %></strong>
-        <a id="mb-huidig-link" target="_blank" rel="noopener"
-           href="https://musicbrainz.org/artist/<%= site.mb_artist_id || '' %>"><%= t('aseo.mb_open') %></a>
-        <button type="button" class="btn" id="mb-wis"><%= t('aseo.mb_unlink') %></button>
-      </p>
-      <%# De terug-weg. Een koppeling van onze kant is een BEWERING -- iedereen
-          kan een id typen. Pas als de MusicBrainz-pagina terugwijst is het een
-          paar, en dat staat hier met zoveel woorden zodat "eenzijdig" niet als
-          "klaar" leest. %>
-      <p class="mb-terugweg" id="mb-terugweg" hidden></p>
-
-      <label>
-        <span><%= t('aseo.mb_search_label') %> <small class="form-hint-inline">— <%= t('aseo.mb_search_hint') %></small></span>
-        <div class="mb-zoek">
-          <input type="text" id="mb-q" value="<%= site.publisher_name || site.title || '' %>"
-                 autocomplete="off" placeholder="<%= t('aseo.mb_placeholder') %>">
-          <button type="button" class="btn" id="mb-zoek-btn"><%= t('aseo.mb_search') %></button>
-        </div>
-      </label>
-      <div id="mb-uit" class="mb-uit" hidden></div>
-    </fieldset>
-
-    <fieldset>
-      <legend><%= t('aseo.verify_legend') %></legend>
-      <label>
-        <span><%= t('aseo.verify_google') %></span>
-        <input type="text" name="google_verification" value="<%= site.google_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_bing') %> <small class="form-hint-inline">— <%= t('aseo.verify_bing_hint') %></small></span>
-        <input type="text" name="bing_verification" value="<%= site.bing_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_pinterest') %> <small class="form-hint-inline">— <%= t('aseo.verify_pinterest_hint') %></small></span>
-        <input type="text" name="pinterest_verification" value="<%= site.pinterest_verification || '' %>" maxlength="200">
-      </label>
-      <label>
-        <span><%= t('aseo.verify_yandex') %></span>
-        <input type="text" name="yandex_verification" value="<%= site.yandex_verification || '' %>" maxlength="200">
-      </label>
-    </fieldset>
-
-    <button type="submit" class="btn btn-primary"><%= t('aseo.save') %></button>
-  </form>
-</div>
-
-<style>
-/* ── MusicBrainz-kandidaten: een kaartje per artiest ──────────────
-   Meerdere regels en geen opsomming op een rij: dit is een KEUZE, en de
-   toelichting die twee gelijknamige bands uit elkaar houdt hoort niet als
-   bijzin achter de naam weg te vallen. */
-.mb-uit { margin-top: 0.5rem; }
-.mb-lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.6rem; }
-.mb-kandidaat {
-  display: flex; flex-direction: column; gap: 0.2rem;
-  border: 1px solid var(--rule); border-radius: 8px;
-  padding: 0.7rem 0.85rem; background: var(--paper);
-  min-width: 0;
-}
-.mb-naam { font-weight: 600; font-size: 0.95rem; color: var(--ink); }
-.mb-toelichting { font-size: 0.85rem; color: var(--ink); }
-.mb-feiten { font-size: 0.8rem; color: var(--ink-muted); }
-.mb-voet { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.35rem; flex-wrap: wrap; }
-.mb-open { font-size: 0.8rem; }
-/* Een zwakke treffer als zwak tonen; bij een goede is een getal ruis. */
-.mb-zwak { font-size: 0.75rem; color: var(--ink-muted); }
-.mb-kies { margin-left: auto; }
-.mb-huidig { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; font-size: 0.9rem; }
-.mb-zoek { display: flex; gap: 0.5rem; min-width: 0; }
-.mb-zoek input { flex: 1 1 auto; min-width: 0; }
-/* De terug-weg: eenzijdig is geen fout, maar het mag ook niet als klaar lezen. */
-.mb-terugweg { font-size: 0.82rem; margin: 0.2rem 0 0; }
-.mb-terugweg.is-ok { color: var(--ok, #2e7d32); }
-.mb-terugweg.is-eenzijdig { color: var(--ink-muted); }
-
-.admin-site-edit-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
-.admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 0.4rem; }
-.admin-site-edit-page .admin-tagline { color: var(--ink-muted); margin: 0 0 1.75rem; }
-
-.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* <fieldset> does not shrink because the UA sets min-inline-size:min-content → mobile overflow.
-   min-width:0 forces it to shrink. */
-.site-form fieldset { min-width: 0; }
-.site-form input, .site-form textarea, .site-form select { min-width: 0; }
-.site-form fieldset {
-  border: 1px solid var(--rule);
-  border-radius: 8px;
-  padding: 1rem 1.25rem;
-  background: var(--paper-2);
-  display: flex;
-  flex-direction: column;
-  gap: 0.85rem;
-}
-.site-form fieldset legend { font-family: var(--font-display, serif); font-size: 1rem; padding: 0 0.4rem; }
-.site-form label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
-.site-form label.cb { flex-direction: row; align-items: flex-start; gap: 0.55rem; font-size: 0.9rem; color: var(--ink); }
-.site-form label.cb input { margin-top: 0.15rem; }
-.site-form label > span { line-height: 1.35; }
-.form-hint-inline { font-weight: 400; color: var(--ink-muted); font-size: 0.82em; }
-.site-form code { background: var(--paper); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.85em; }
-.site-form input[type="text"], .site-form textarea, .site-form select {
-  padding: 0.55rem 0.85rem;
-  border: 1px solid var(--rule);
-  border-radius: 5px;
-  background: var(--paper);
-  color: var(--ink);
-  font-family: inherit;
-  font-size: 0.95rem;
-  width: 100%;
-  box-sizing: border-box;
-}
-.site-form textarea { resize: vertical; min-height: 2.6rem; }
-.site-form select { max-width: 320px; }
-.site-form input:focus, .site-form textarea:focus, .site-form select:focus {
-  outline: none; border-color: var(--accent);
-}
-.seo-pointer p { margin: 0; font-size: 0.9rem; color: var(--ink-muted); }
-.seo-pointer a { color: var(--accent); font-weight: 600; }
-.site-form > button[type="submit"] { align-self: flex-start; }
-</style>
-
-<%# Servertekst naar mod/admin-seo.js. KORTE sleutels, zoals overal in dit
-    project -- de module leest T.mb_busy en niet T.aseo_mb_busy. Zonder dit
-    blok geeft pageData() een leeg object en valt alles terug op de
-    ingebakken Nederlandse tekst, ook voor een Engelse of Duitse lezer. %>
-<%- include('../partials/page-data', { pageData: { mb_busy: t('aseo.mb_busy'), mb_fail: t('aseo.mb_fail'), mb_none: t('aseo.mb_none'), mb_open: t('aseo.mb_open'), mb_pick: t('aseo.mb_pick'), mb_verified: t('aseo.mb_verified'), mb_unverified: t('aseo.mb_unverified'), mb_checking: t('aseo.mb_checking') } }) %>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,218 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aset.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aset.back_admin') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# ── Mode: Solo or Cirkels (ActivityPub) — the primary, top-of-page choice ── %>
-  <section class="set-card set-card--mode" style="margin-top:1rem">
-    <h2><%= t('aset.mode') %></h2>
-    <p class="set-help"><%= t('aset.mode_help') %></p>
-    <form method="post" action="/admin/settings/ap" class="set-form" id="mode-form"
-          data-was-cirkels="<%= (typeof apEnabledSetting === 'undefined' || apEnabledSetting) ? '1' : '0' %>"
-          data-confirm="<%= t('aset.mode_18plus_confirm') %>">
-      <label class="set-opt">
-        <input type="radio" name="mode" value="solo" <%= (typeof apEnabledSetting !== 'undefined' && !apEnabledSetting) ? 'checked' : '' %>>
-        <span><strong><%= t('aset.mode_solo') %></strong><small><%= t('aset.mode_solo_help') %></small></span>
-      </label>
-      <label class="set-opt">
-        <input type="radio" name="mode" value="cirkels" <%= (typeof apEnabledSetting === 'undefined' || apEnabledSetting) ? 'checked' : '' %>>
-        <span><strong><%= t('aset.mode_cirkels') %></strong><small><%= t('aset.mode_cirkels_help') %></small></span>
-      </label>
-      <div style="display:flex;gap:.6rem;align-items:flex-start;margin:.7rem 0 .2rem;padding:.7rem .85rem;border-radius:10px;border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);background:color-mix(in srgb,var(--accent) 7%,transparent)">
-        <span style="font-size:1.05rem;line-height:1.4;flex:0 0 auto" aria-hidden="true">🔞</span>
-        <span style="font-size:.85rem;line-height:1.5;color:var(--ink)"><%= t('aset.mode_18plus') %></span>
-      </div>
-      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
-    </form>
-    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
-  </section>
-
-  <%# ── Default language for visitors ── %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.default_lang') %></h2>
-    <p class="set-help"><%= t('aset.default_lang_hint') %></p>
-    <form method="post" action="/admin/settings" class="set-form">
-      <label class="set-field">
-        <span><%= t('aset.default_lang') %></span>
-        <select name="default_lang">
-          <option value=""<%= !defaultLang ? ' selected' : '' %>><%= t('aset.default_lang_auto') %></option>
-          <% langs.forEach(function(l){ %>
-            <option value="<%= l.code %>"<%= defaultLang === l.code ? ' selected' : '' %>><%= l.name %></option>
-          <% }); %>
-        </select>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
-    </form>
-  </section>
-
-  <%# ── Timezone ── %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.timezone') %></h2>
-    <p class="set-help"><%= t('aset.timezone_hint') %></p>
-    <form method="post" action="/admin/settings" class="set-form">
-      <label class="set-field">
-        <span><%= t('aset.timezone') %></span>
-        <select name="timezone">
-          <option value=""<%= !timezone ? ' selected' : '' %>><%= t('aset.timezone_auto') %></option>
-          <%
-            var _tz = ['Europe/Amsterdam','Europe/Brussels','Europe/Berlin','Europe/Paris','Europe/London','Europe/Madrid','Europe/Rome','Europe/Lisbon','Europe/Warsaw','Europe/Athens','UTC','America/New_York','America/Chicago','America/Los_Angeles','America/Sao_Paulo','Asia/Tokyo','Asia/Dubai','Australia/Sydney'];
-            if (timezone && _tz.indexOf(timezone) < 0) _tz.unshift(timezone);
-          %>
-          <% _tz.forEach(function(z){ %><option value="<%= z %>"<%= timezone === z ? ' selected' : '' %>><%= z %></option><% }); %>
-        </select>
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
-    </form>
-  </section>
-
-  <%# Mode-specific config — Circle settings below the mode card. (Hub removed.) %>
-
-  <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer
-      (env KLONKT_PREMIUM_ENABLED=on). Hidden by default; nothing gated. %>
-  <% if (premium && premium.enabled) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.premium') %></h2>
-    <p class="set-help"><%= t('aset.premium_help_1') %> <strong><%= t('aset.premium_lifetime') %></strong> <%= t('aset.premium_help_2') %></p>
-    <% if (premium.premium) { %>
-      <p class="set-help"><strong>✓ <%= t('aset.premium_active') %></strong>
-        <% if (premium.supportCents) { %><%= t('aset.lifetime_support') %> $<%= (premium.supportCents/100).toFixed(2) %>.<% } %></p>
-      <a href="/admin/patreon/disconnect" class="btn"><%= t('aset.patreon_disconnect') %></a>
-    <% } else if (premium.connected) { %>
-      <p class="set-help"><%= t('aset.patreon_no_lifetime') %><% if (premium.supportCents) { %>
-        (<%= t('aset.now') %> $<%= (premium.supportCents/100).toFixed(2) %>)<% } %>. <%= t('aset.patreon_support_again') %></p>
-      <a href="/admin/patreon/connect" class="btn btn-primary"><%= t('aset.patreon_reconnect') %></a>
-    <% } else { %>
-      <p class="set-help"><%= t('aset.patreon_not_connected') %></p>
-      <a href="/admin/patreon/connect" class="btn btn-primary"><%= t('aset.patreon_connect') %></a>
-    <% } %>
-  </section>
-  <% } %>
-
-  <%# Newsletter signup in the footer (premium). %>
-  <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.newsletter') %></h2>
-    <p class="set-help"><%= t('aset.newsletter_help_1') %> <strong><%= t('aset.newsletter_footer_field') %></strong> <%= t('aset.newsletter_help_2') %> <code><%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/nieuwsbrief</code>.)</p>
-    <form method="post" action="/admin/settings/footer" class="set-form">
-      <label class="set-toggle" style="display:flex;align-items:center;gap:.6rem;cursor:pointer">
-        <input type="checkbox" name="footer_newsletter" value="1" <%= footerNewsletter ? 'checked' : '' %>>
-        <span><%= t('aset.newsletter_show_footer') %></span>
-      </label>
-      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
-    </form>
-  </section>
-  <% } %>
-
-  <%# E-mail / SMTP — required for sending newsletters, notify-mails and password resets. %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aset.email_smtp') %></h2>
-    <p class="set-help"><%= t('aset.smtp_help_1') %> <strong><%= t('aset.smtp_help_newsletter') %></strong><%= t('aset.smtp_help_2') %> <strong><%= t('aset.smtp_help_notify') %></strong><%= t('aset.smtp_help_3') %>
-      <% if (smtp && smtp.configured) { %><br><strong><%= t('aset.status_set') %>&nbsp;✓</strong><% if (smtp.fromEnv) { %> <small>(<%= t('aset.via_env') %>)</small><% } %><% } else { %><br><%= t('aset.smtp_not_set') %><% } %></p>
-
-    <form method="post" action="/admin/settings/smtp" class="set-form">
-      <label><span><%= t('aset.smtp_host') %></span>
-        <input type="text" name="smtp_host" value="<%= smtp ? smtp.host : '' %>" placeholder="smtp.jouwprovider.nl" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_port') %> <small style="font-weight:400">— <%= t('aset.smtp_port_hint') %></small></span>
-        <input type="text" name="smtp_port" value="<%= (smtp && smtp.port) ? smtp.port : '' %>" placeholder="587" autocomplete="off" inputmode="numeric">
-      </label>
-      <label><span><%= t('aset.smtp_user') %></span>
-        <input type="text" name="smtp_user" value="<%= smtp ? smtp.user : '' %>" placeholder="jij@jouwprovider.nl" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_pass') %> <% if (smtp && smtp.passSet) { %><small style="font-weight:400">— <%= t('aset.smtp_pass_set_hint') %></small><% } %></span>
-        <input type="password" name="smtp_pass" placeholder="<%= (smtp && smtp.passSet) ? t('aset.smtp_pass_ph_set') : t('aset.smtp_pass_ph') %>" autocomplete="off">
-      </label>
-      <label><span><%= t('aset.smtp_from') %> <small style="font-weight:400">— <%= t('aset.smtp_from_hint') %></small></span>
-        <input type="text" name="smtp_from" value="<%= smtp ? smtp.from : '' %>" placeholder="<%= t('aset.smtp_from_ph') %>" autocomplete="off">
-      </label>
-      <div class="set-actions">
-        <button type="submit" class="btn btn-primary"><%= t('aset.smtp_save') %></button>
-        <% if (smtp && (smtp.host || smtp.passSet)) { %>
-          <button type="submit" name="clear" value="1" class="btn" formnovalidate><%= t('aset.clear') %></button>
-        <% } %>
-      </div>
-    </form>
-
-    <% if (smtp && smtp.configured) { %>
-      <form method="post" action="/admin/settings/smtp/test" class="set-form" style="margin-top:.8rem">
-        <label><span><%= t('aset.test_mail_to') %></span>
-          <input type="email" name="to" placeholder="jij@email.nl" autocomplete="off">
-        </label>
-        <div class="set-actions"><button type="submit" class="btn">✉ <%= t('aset.send_test_mail') %></button></div>
-      </form>
-    <% } %>
-  </section>
-
-</div>
-
-<style>
-.admin-page { max-width: 700px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.25rem; }
-.admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
-.admin-tagline a { color: var(--accent); }
-.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-/* Mode-specific card (Hub/Circle): accent border on the left so it visually
-   belongs to the selected mode, directly below the Mode card. */
-.set-card--mode { border-left: 3px solid var(--accent); }
-.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
-.set-help { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.25rem; }
-/* Google setup guide (collapsible) */
-.set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
-.set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; }
-.set-google-guide > summary::-webkit-details-marker { display: none; }
-.set-google-guide > summary::before { content: "▸ "; }
-.set-google-guide[open] > summary::before { content: "▾ "; }
-.set-google-steps { margin: 0; padding: 0 1rem 0.6rem 2rem; font-size: 0.88rem; color: var(--ink); line-height: 1.5; }
-.set-google-steps li { margin: 0.35rem 0; }
-.set-google-guide code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
-.set-google-guide .set-help { padding: 0 0.85rem; }
-.set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; }
-/* Bare <label>s inside a set-form inherit the same neat stacked style as .set-field:
-   label text on top, input full-width. (.set-toggle = checkbox row, excluded.) */
-.set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; }
-.set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-.set-form > label:not(.set-toggle):not(.set-opt) > span small { font-weight: 400; }
-.set-form > label:not(.set-toggle):not(.set-opt) input {
-  width: 100%; box-sizing: border-box; padding: 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem;
-}
-.set-form > label:not(.set-toggle):not(.set-opt) input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.set-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; }
-.set-opt { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; border: 1px solid var(--rule); border-radius: 8px; cursor: pointer; background: var(--paper); }
-.set-opt:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
-.set-opt input { margin-top: 0.25rem; flex-shrink: 0; }
-.set-opt span { display: flex; flex-direction: column; gap: 0.2rem; }
-.set-opt small { color: var(--ink-muted); font-size: 0.82rem; }
-.set-opt code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 3px; font-size: 0.85em; }
-.set-form .btn { align-self: flex-start; }
-.set-field { display: flex; flex-direction: column; gap: 0.3rem; }
-.set-field > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
-.set-field input, .set-field textarea {
-  width: 100%; box-sizing: border-box; padding: 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px; background: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem;
-}
-.set-field textarea { resize: vertical; }
-.set-field input:focus, .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-/* Selects styled the same as inputs instead of the bare native dropdown:
-   custom chevron, accent focus ring, full width. */
-.set-form select {
-  appearance: none; -webkit-appearance: none;
-  width: 100%; box-sizing: border-box;
-  padding: 0.55rem 2.2rem 0.55rem 0.7rem;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background-color: var(--paper); color: var(--ink);
-  font: inherit; font-size: 0.95rem; cursor: pointer;
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
-  background-repeat: no-repeat; background-position: right 0.7rem center;
-  transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-.set-form select:hover { border-color: var(--ink-soft, #888); }
-.set-form select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.set-form select option { background: var(--paper); color: var(--ink); }
-.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
-.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
-</style>
Index: src/views/pages/admin-shows.ejs
===================================================================
--- src/views/pages/admin-shows.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-<%
-  var ss = (typeof shows !== 'undefined') ? shows : [];
-  var msg = (typeof msg !== 'undefined') ? msg : '';
-  var smtp = (typeof smtp !== 'undefined') ? smtp : false;
-  var nc = (typeof notifyCount !== 'undefined') ? notifyCount : 0;
-%>
-<section class="ash">
-  <p><a href="/admin" class="btn">&larr; <%= t('ashow.back_admin') %></a></p>
-  <h1><%= t('ashow.title') %></h1>
-
-  <% if (msg) { %><div class="ash-msg ash-<%= (typeof msgKind!=='undefined')?msgKind:'' %>"><%= msg %></div><% } %>
-
-  <% var agOn = (typeof agendaEnabled !== 'undefined') && agendaEnabled; %>
-  <form method="POST" action="<%= siteUrlBase %>/admin/shows/toggle" style="margin:0 0 14px">
-    <label style="display:flex;align-items:center;gap:10px;cursor:pointer;font-weight:600">
-      <input type="checkbox" name="enabled" value="1" <%= agOn ? 'checked' : '' %> data-autosubmit style="width:18px;height:18px;accent-color:var(--accent)">
-      <span><%= t('ashow.show_toggle') %> <small style="font-weight:400;opacity:.7"><%= t('ashow.show_toggle_hint') %></small></span>
-    </label>
-    <noscript><button type="submit" class="btn"><%= t('ashow.save') %></button></noscript>
-  </form>
-  <% if (!agOn) { %><div class="ash-msg"><%= t('ashow.off_notice_1') %> <strong><%= t('ashow.off') %></strong> <%= t('ashow.off_notice_2') %></div><% } %>
-
-  <p class="ash-note"><strong><%= nc %></strong> <%= t('ashow.subscribers') %>
-    <% if (!smtp) { %><br><%= t('ashow.smtp_warn') %><% } %></p>
-
-  <form method="POST" action="<%= siteUrlBase %>/admin/shows" class="ash-form">
-    <div class="ash-grid">
-      <label><%= t('ashow.f_date') %><input type="date" name="date" required></label>
-      <label><%= t('ashow.f_time') %><input type="time" name="time"></label>
-      <label><%= t('ashow.f_city') %><input type="text" name="city" required placeholder="Amsterdam"></label>
-      <label><%= t('ashow.f_country') %><input type="text" name="country" placeholder="NL"></label>
-      <label class="ash-wide"><%= t('ashow.f_venue') %><input type="text" name="venue" placeholder="Paradiso"></label>
-      <label class="ash-wide"><%= t('ashow.f_ticket') %><input type="url" name="ticket_url" placeholder="https://..."></label>
-      <label class="ash-wide"><%= t('ashow.f_notes') %><input type="text" name="notes" placeholder="<%= t('ashow.f_notes_ph') %>"></label>
-    </div>
-    <label class="ash-check"><input type="checkbox" name="notify" value="1" <%= smtp ? '' : 'disabled' %>> <%= t('ashow.notify_label') %><%= smtp ? '' : ' ' + t('ashow.smtp_required') %></label>
-    <button type="submit" class="ash-add"><%= t('ashow.add_event') %></button>
-  </form>
-
-  <% if (ss.length) { %>
-    <% var _delConfirm = t('ashow.del_confirm'); %>
-    <ul class="ash-list">
-      <% ss.forEach(function(s){ %>
-        <li class="ash-item">
-          <span class="ash-when"><%= s.date %><% if (s.time) { %> <%= s.time %><% } %></span>
-          <span class="ash-where"><%= s.city %><% if (s.venue) { %> — <%= s.venue %><% } %></span>
-          <form method="POST" action="<%= siteUrlBase %>/admin/shows/<%= s.id %>/delete" data-confirm="<%= _delConfirm %>" style="margin:0">
-            <button type="submit" class="ash-del">🗑</button>
-          </form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="ash-empty"><%= t('ashow.empty') %></p>
-  <% } %>
-</section>
-
-<style>
-  .ash { max-width: 680px; margin: 0 auto; padding: 24px 18px 64px; }
-  .ash h1 { margin: .4rem 0 14px; }
-  .ash-msg { padding: 10px 14px; border-radius: 10px; margin-bottom: 14px; }
-  .ash-ok { background: rgba(60,160,90,.15); } .ash-bad { background: rgba(200,60,60,.15); }
-  .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; }
-  .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; }
-  /* Mobile-first: single column (two 1fr columns with non-shrinkable date/text inputs
-     combined to ~374px > phone width). Two columns only from 520px onwards. */
-  .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
-  @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } }
-  .ash-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; min-width: 0; }
-  .ash-grid .ash-wide { grid-column: 1 / -1; }
-  .ash-grid input { min-width: 0; padding: 9px 11px; border-radius: 8px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font: inherit; font-weight: 400; }
-  .ash-check { display: flex; align-items: center; gap: 8px; margin: 12px 0; font-size: 13.5px; }
-  .ash-add { padding: 10px 18px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-  .ash-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .ash-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.18); border-radius: 10px; }
-  .ash-when { font-variant-numeric: tabular-nums; font-weight: 600; flex: 0 0 auto; }
-  .ash-where { flex: 1 1 auto; opacity: .85; }
-  .ash-del { background: none; border: none; cursor: pointer; font-size: 16px; opacity: .6; }
-  .ash-empty { opacity: .7; }
-</style>
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-site-edit.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,5 +1,5 @@
 <div class="container admin-site-edit-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('asite.back_admin') %></a></p>
-  <h1><%= isNew ? t('asite.title_new') : t('asite.title_edit') + ': ' + site.title %></h1>
+  <p><a href="/admin/sites" class="btn">&larr; Sites</a></p>
+  <h1><%= isNew ? 'New site' : 'Edit: ' + site.title %></h1>
 
   <% if (typeof success !== 'undefined' && success) { %><div class="audio-flash audio-flash--ok"><%= success %></div><% } %>
@@ -9,32 +9,28 @@
 
     <fieldset>
-      <legend><%= t('asite.identity') %></legend>
-      <label>
-        <span><%= t('asite.slug') %> <% if (!isNew) { %><em><%= t('asite.slug_fixed') %></em><% } %></span>
-        <span class="slug-url">
-          <span class="slug-url-host" id="slug-host"
-                data-prefix="/">website.com/</span><input
-                type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
-                pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>"
-                autocapitalize="none" autocorrect="off" spellcheck="false">
-        </span>
-      </label>
-      <label>
-        <span><%= t('asite.field_title') %> <small class="form-hint-inline"><%= t('asite.field_title_hint') %></small></span>
+      <legend>Identity</legend>
+      <label>
+        <span>Slug <% if (!isNew) { %><em>(immutable)</em><% } %></span>
+        <input type="text" name="slug" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %>
+               pattern="[a-z0-9_-]{2,40}"
+               placeholder="bedrijf1">
+      </label>
+      <label>
+        <span>Title <small class="form-hint-inline">— shown in the header and as display name</small></span>
         <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
       </label>
       <label>
-        <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span>
+        <span>Tagline <small class="form-hint-inline">— short one-liner</small></span>
         <input type="text" name="tagline" value="<%= site.tagline || '' %>" maxlength="200">
       </label>
       <label>
-        <span><%= t('asite.bio') %> <small class="form-hint-inline"><%= t('asite.bio_hint') %></small></span>
+        <span>Bio / description <small class="form-hint-inline">— shown in the profile header and used for SEO</small></span>
         <textarea name="description" rows="3" maxlength="500"><%= site.description || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.profile_photo') %></span>
+        <span>Profile photo</span>
         <div class="photo-picker" id="photo-picker">
           <div class="photo-thumb" id="photo-thumb" <%= site.profile_photo ? '' : 'data-empty' %>>
-            <img id="photo-preview" src="<%= avatar(site.profile_photo || '', 128) %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
+            <img id="photo-preview" src="<%= site.profile_photo || '' %>" alt="" <%= site.profile_photo ? '' : 'hidden' %>>
             <span class="photo-empty" aria-hidden="true">
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@@ -42,8 +38,8 @@
           </div>
           <div class="photo-controls">
-            <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="<%= t('asite.photo_url_placeholder') %>">
+            <input type="text" name="profile_photo" id="photo-url" value="<%= site.profile_photo || '' %>" placeholder="/media/avatars/foo.jpg of https://…">
             <div class="photo-actions">
-              <button type="button" class="btn" id="photo-upload-trigger"><%= t('asite.photo_upload') %></button>
-              <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>><%= t('asite.photo_clear') %></button>
+              <button type="button" class="btn" id="photo-upload-trigger">📷 Upload</button>
+              <button type="button" class="btn" id="photo-clear" <%= site.profile_photo ? '' : 'hidden' %>>Verwijder</button>
               <small id="photo-status" class="form-hint photo-status"></small>
             </div>
@@ -53,15 +49,15 @@
       </label>
       <label>
-        <span><%= t('asite.language') %></span>
+        <span>Language (ISO code)</span>
         <input type="text" name="language" value="<%= site.language || 'nl' %>" maxlength="8">
       </label>
       <label class="cb">
         <input type="checkbox" name="profile_enabled" value="1" <%= site.profile_enabled ? 'checked' : '' %>>
-        <span><%= t('asite.profile_enabled') %></span>
+        <span>Show profile header below the nav</span>
       </label>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.appearance') %></legend>
+      <legend>Appearance</legend>
 
       <%# ── Curated accent picker ───────────────────────────────────────
@@ -70,5 +66,5 @@
           still what's stored in the DB — we just constrain WHICH hex. %>
       <fieldset class="accent-grid">
-        <legend><%= t('asite.accent_color') %></legend>
+        <legend>Accent color</legend>
         <% const _curAccent = (site.accent || '#c2410c').toLowerCase(); %>
         <% accents.forEach(function(a) { %>
@@ -87,25 +83,25 @@
           visitors with a localStorage override, still win over this default. %>
       <fieldset class="theme-default">
-        <legend><%= t('asite.theme_default') %></legend>
+        <legend>Default theme for new visitors</legend>
         <% const td = site.theme_override || ''; %>
         <label class="cb">
           <input type="radio" name="theme_override" value=""      <%= td === ''      ? 'checked' : '' %>>
-          <span><%= t('asite.theme_auto') %></span>
+          <span>Auto (volg device-voorkeur)</span>
         </label>
         <label class="cb">
           <input type="radio" name="theme_override" value="light" <%= td === 'light' ? 'checked' : '' %>>
-          <span><%= t('asite.theme_light') %></span>
+          <span>Light</span>
         </label>
         <label class="cb">
           <input type="radio" name="theme_override" value="dark"  <%= td === 'dark'  ? 'checked' : '' %>>
-          <span><%= t('asite.theme_dark') %></span>
+          <span>Dark</span>
         </label>
       </fieldset>
 
       <fieldset class="palette-grid">
-        <legend><%= t('asite.palette') %></legend>
+        <legend>Palette</legend>
         <% palettes.forEach(function(p) { %>
           <label class="palette-swatch">
-            <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'klonkt') === p.key ? 'checked' : '' %>>
+            <input type="radio" name="palette" value="<%= p.key %>" <%= (site.palette || 'sage') === p.key ? 'checked' : '' %>>
             <span class="palette-preview" aria-hidden="true">
               <span class="pp-half pp-light" style="background: <%= p.light.paper %>; color: <%= p.light.ink %>;">
@@ -124,33 +120,38 @@
     </fieldset>
 
-    <%# Live preview: selecting an accent / theme / palette re-themes this page
-        instantly (reverts on reload; persists on save). %>
-    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
-
     <fieldset>
-      <legend><%= t('asite.behavior') %></legend>
+      <legend>Behaviour</legend>
       <label class="cb">
         <input type="checkbox" name="is_public" value="1" <%= site.is_public ? 'checked' : '' %>>
-        <span><%= t('asite.is_public') %></span>
+        <span>Public site (uncheck for closed circle)</span>
       </label>
       <label class="cb">
         <input type="checkbox" name="robots_index" value="1" <%= site.robots_index ? 'checked' : '' %>>
-        <span><%= t('asite.robots_index') %></span>
-      </label>
-      <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>
+        <span>Search engines may index (sitemap.xml is hidden if off)</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="require_login_to_comment" value="1" <%= site.require_login_to_comment ? 'checked' : '' %>>
+        <span>Require login to comment</span>
+      </label>
+      <label>
+        <span>Comment moderation</span>
+        <select name="comments_moderation_mode">
+          <option value="trust"    <%= (site.comments_moderation_mode || 'trust') === 'trust'    ? 'selected' : '' %>>Trust mode (auto-approve)</option>
+          <option value="moderate" <%= site.comments_moderation_mode === 'moderate' ? 'selected' : '' %>>Moderate (queue for review)</option>
+        </select>
+      </label>
       <label class="cb">
         <input type="checkbox" name="enable_audio_player" value="1" <%= site.enable_audio_player ? 'checked' : '' %>>
-        <span><%= t('asite.enable_audio') %></span>
-      </label>
-      <% } %>
-      <label class="cb">
-        <input type="checkbox" name="approve_followers" value="1" <%= site.approve_followers ? 'checked' : '' %>>
-        <span><%= t('asite.approve_followers') %></span>
+        <span>Enable audio player + embeds</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="enable_prutter" value="1" <%= site.enable_prutter ? 'checked' : '' %>>
+        <span>Enable Prutter (direct messaging)</span>
       </label>
     </fieldset>
 
     <fieldset class="profile-links-fieldset">
-      <legend><%= t('asite.links') %></legend>
-      <p class="form-hint"><%= t('asite.links_hint') %></p>
+      <legend>Social / streaming links</legend>
+      <p class="form-hint">Shown as branded icons on the profile header. Add as many as you like.</p>
 
       <div id="profile-links-rows">
@@ -180,84 +181,179 @@
       </template>
 
-      <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
-    </fieldset>
-
-    <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
-        exporteren, importeren en ophalen (Robin: alle migratie-opties op de
-        migrate-pagina). Ze hoorden hier ook niet thuis: dit scherm gaat over hoe
-        je site eruitziet, en een verhuizing is geen uiterlijk. Alleen de
-        wegwijzer blijft, want wie ze hier zocht moet ze kunnen vinden. %>
+      <button type="button" id="profile-link-add" class="btn">+ Add link</button>
+    </fieldset>
+
     <fieldset>
-      <legend><%= t('asite.aliases') %></legend>
-      <p class="form-hint"><%= t('asite.moved_to_migrate') %></p>
-      <p><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a></p>
+      <legend>Feed view</legend>
+      <label>
+        <span>Default view for the homepage</span>
+        <select name="feed_view_default">
+          <option value="timeline" <%= (site.feed_view_default || 'timeline') === 'timeline' ? 'selected' : '' %>>Timeline (chronological list)</option>
+          <option value="grid"     <%= site.feed_view_default === 'grid' ? 'selected' : '' %>>Grid (cards)</option>
+        </select>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
+        <span>Show timeline ↔ grid switcher above the feed</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
+        <span>Show search button in nav</span>
+      </label>
+      <label class="cb">
+        <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
+        <span>Show archive link in nav</span>
+      </label>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.feed_view') %></legend>
-      <%# Twee vragen, en ze zijn niet hetzelfde: WELKE tweede weergave je site
-          aanbiedt naast Grid, en waarin hij opent. %>
-      <label>
-        <span><%= t('asite.feed_alt') %></span>
-        <select name="feed_alt_view">
-          <% var _a = ['timeline','auto'].indexOf(site.feed_alt_view) >= 0 ? site.feed_alt_view : 'reader'; %>
-          <option value="reader"   <%= _a === 'reader'   ? 'selected' : '' %>><%= t('asite.feed_alt_reader') %></option>
-          <option value="timeline" <%= _a === 'timeline' ? 'selected' : '' %>><%= t('asite.feed_alt_timeline') %></option>
-          <option value="auto"     <%= _a === 'auto'     ? 'selected' : '' %>><%= t('asite.feed_alt_auto') %></option>
-        </select>
-      </label>
-      <label>
-        <span><%= t('asite.feed_default') %></span>
-        <select name="feed_view_default">
-          <option value="reader" <%= site.feed_view_default !== 'grid' ? 'selected' : '' %>><%= t('asite.feed_alt_' + _a) %></option>
-          <option value="grid"     <%= site.feed_view_default === 'grid' ? 'selected' : '' %>><%= t('asite.feed_grid') %></option>
-        </select>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="feed_view_switch" value="1" <%= site.feed_view_switch ? 'checked' : '' %>>
-        <span><%= t('asite.feed_switch') %></span>
-      </label>
-      <%# Alleen van toepassing op Lezen: geeft elk bericht daar een eigen scherm. %>
-      <label class="cb">
-        <input type="checkbox" name="reader_full_page" value="1" <%= site.reader_full_page ? 'checked' : '' %>>
-        <span><%= t('asite.reader_full_page') %></span>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="show_search" value="1" <%= site.show_search ? 'checked' : '' %>>
-        <span><%= t('asite.show_search') %></span>
-      </label>
-      <label class="cb">
-        <input type="checkbox" name="show_archive_link" value="1" <%= site.show_archive_link ? 'checked' : '' %>>
-        <span><%= t('asite.show_archive') %></span>
-      </label>
-    </fieldset>
-
-    <fieldset class="seo-pointer">
-      <legend><%= t('asite.seo') %></legend>
-      <p><%= t('asite.seo_pointer') %> <a href="/admin/seo"><%= t('asite.seo_link') %></a>.</p>
+      <legend>SEO &amp; social</legend>
+      <label>
+        <span>Title template (use {title} and {site})</span>
+        <input type="text" name="title_template" value="<%= site.title_template || '{title} — {site}' %>" maxlength="200">
+      </label>
+      <label>
+        <span>Canonical base URL (for production HTTPS)</span>
+        <input type="text" name="canonical" value="<%= site.canonical || '' %>" placeholder="https://your-domain.com">
+      </label>
+      <label>
+        <span>Twitter handle (with @)</span>
+        <input type="text" name="twitter" value="<%= site.twitter || '' %>" placeholder="@yourname" maxlength="64">
+      </label>
+      <details>
+        <summary>Search-engine verification</summary>
+        <label>
+          <span>Google site verification</span>
+          <input type="text" name="google_verification"    value="<%= site.google_verification    || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Bing (msvalidate.01)</span>
+          <input type="text" name="bing_verification"      value="<%= site.bing_verification      || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Pinterest (p:domain_verify)</span>
+          <input type="text" name="pinterest_verification" value="<%= site.pinterest_verification || '' %>" maxlength="200">
+        </label>
+        <label>
+          <span>Yandex</span>
+          <input type="text" name="yandex_verification"    value="<%= site.yandex_verification    || '' %>" maxlength="200">
+        </label>
+      </details>
     </fieldset>
 
     <fieldset>
-      <legend><%= t('asite.custom_legend') %> <em><%= t('asite.optional') %></em></legend>
-      <label>
-        <span><%= t('asite.custom_css') %></span>
+      <legend>Custom CSS &amp; HTML <em>(optional)</em></legend>
+      <label>
+        <span>Custom CSS (injected as &lt;style&gt; in &lt;head&gt;)</span>
         <textarea name="custom_css" rows="3" placeholder=".foo { color: red; }"><%= site.custom_css || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.custom_head') %></span>
+        <span>Custom &lt;head&gt; HTML (analytics, extra metas)</span>
         <textarea name="custom_head_html" rows="3" placeholder="<meta name=&quot;...&quot;>"><%= site.custom_head_html || '' %></textarea>
       </label>
       <label>
-        <span><%= t('asite.custom_foot') %></span>
+        <span>Custom footer HTML</span>
         <textarea name="custom_foot_html" rows="3"><%= site.custom_foot_html || '' %></textarea>
       </label>
     </fieldset>
 
-    <button type="submit" class="btn btn-primary"><%= isNew ? t('asite.submit_create') : t('asite.submit_save') %></button>
+    <button type="submit" class="btn btn-primary"><%= isNew ? 'Create site' : 'Save changes' %></button>
   </form>
-
 </div>
 
-
+<script>
+(function() {
+  // Profile-links repeater: add row from <template>, remove on click.
+  var rows = document.getElementById('profile-links-rows');
+  var tpl  = document.getElementById('profile-link-template');
+  var add  = document.getElementById('profile-link-add');
+  if (!rows || !tpl || !add) return;
+
+  add.addEventListener('click', function() {
+    var clone = tpl.content.cloneNode(true);
+    rows.appendChild(clone);
+  });
+  rows.addEventListener('click', function(e) {
+    if (e.target && e.target.classList.contains('pl-remove')) {
+      var row = e.target.closest('.profile-link-row');
+      if (row) row.remove();
+    }
+  });
+})();
+
+// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
+// then write the returned URL into the visible input. Live thumb preview.
+(function() {
+  var picker  = document.getElementById('photo-picker');
+  if (!picker) return;
+  var thumb   = document.getElementById('photo-thumb');
+  var preview = document.getElementById('photo-preview');
+  var urlEl   = document.getElementById('photo-url');
+  var trigger = document.getElementById('photo-upload-trigger');
+  var clear   = document.getElementById('photo-clear');
+  var field   = document.getElementById('photo-upload-field');
+  var status  = document.getElementById('photo-status');
+
+  function showPreview(url) {
+    if (url) {
+      preview.src = url;
+      preview.hidden = false;
+      thumb.removeAttribute('data-empty');
+      clear.hidden = false;
+    } else {
+      preview.src = '';
+      preview.hidden = true;
+      thumb.setAttribute('data-empty', '');
+      clear.hidden = true;
+    }
+  }
+
+  if (urlEl) {
+    urlEl.addEventListener('input', function() {
+      showPreview(urlEl.value.trim());
+    });
+  }
+
+  if (trigger && field) {
+    trigger.addEventListener('click', function() { field.click(); });
+    field.addEventListener('change', async function() {
+      var file = field.files && field.files[0];
+      if (!file) return;
+      status.classList.remove('is-error');
+      status.textContent = 'Uploaden…';
+      try {
+        var fd = new FormData();
+        fd.append('photo', file);
+        var r = await fetch('/admin/sites/upload-photo', {
+          method: 'POST',
+          body: fd,
+          credentials: 'same-origin',
+        });
+        var j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
+        urlEl.value = j.url;
+        showPreview(j.url);
+        status.textContent = 'Geüpload ✓';
+        setTimeout(function() { status.textContent = ''; }, 2000);
+      } catch (e) {
+        status.classList.add('is-error');
+        status.textContent = 'Mislukt: ' + e.message;
+      } finally {
+        field.value = '';
+      }
+    });
+  }
+
+  if (clear) {
+    clear.addEventListener('click', function() {
+      urlEl.value = '';
+      showPreview('');
+      status.textContent = '';
+      // Note: doesn't delete the file from disk — saving the form with empty
+      // URL leaves the file orphaned on the server. Acceptable for now.
+    });
+  }
+})();
+</script>
 
 <style>
@@ -265,11 +361,5 @@
 .admin-site-edit-page h1 { font-family: var(--font-display, serif); font-size: 1.8rem; margin: 0 0 1.5rem; }
 
-.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
-   therefore does NOT shrink → the entire form became wider than the screen. min-width:0
-   forces it to shrink; form fields get max-width:100% + border-box so they
-   never overflow their fieldset. */
-.site-form fieldset { min-width: 0; }
-.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
+.site-form { display: flex; flex-direction: column; gap: 1.5rem; }
 .site-form fieldset {
   border: 1px solid var(--rule);
@@ -310,58 +400,4 @@
 .site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
 
-/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
-.slug-url {
-  display: flex; align-items: stretch;
-  border: 1px solid var(--rule); border-radius: 5px;
-  background: var(--paper); overflow: hidden;
-  transition: border-color 120ms;
-}
-.slug-url:focus-within { border-color: var(--accent); }
-.slug-url-host {
-  display: inline-flex; align-items: center;
-  padding-left: 0.85rem;
-  color: var(--ink-muted, var(--ink-soft));
-  white-space: nowrap; user-select: none;
-  font-size: 0.95rem;
-}
-.site-form .slug-url-input {
-  flex: 1 1 auto; min-width: 0;
-  border: none !important; border-radius: 0 !important;
-  background: transparent !important;
-  color: var(--accent) !important; font-weight: 700;
-  padding: 0.55rem 0.85rem 0.55rem 0.1rem;
-}
-.site-form .slug-url-input:focus { outline: none; }
-.site-form .slug-url-input[readonly] {
-  opacity: 1; cursor: default; background: transparent !important;
-}
-
-/* Selects: same look as the inputs, with a custom chevron instead of the bare
-   native arrow. The open option list is still rendered by the browser. */
-.site-form select {
-  appearance: none;
-  -webkit-appearance: none;
-  padding: 0.55rem 2.2rem 0.55rem 0.85rem;
-  border: 1px solid var(--rule);
-  border-radius: 5px;
-  background-color: var(--paper);
-  color: var(--ink);
-  font-family: inherit;
-  font-size: 0.95rem;
-  cursor: pointer;
-  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
-  background-repeat: no-repeat;
-  background-position: right 0.7rem center;
-  transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-.site-form select:hover { border-color: var(--ink-soft, #888); }
-.site-form select:focus,
-.site-form input[type="text"]:focus,
-.site-form textarea:focus {
-  outline: none;
-  border-color: var(--accent, #c2410c);
-  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #c2410c) 22%, transparent);
-}
-
 /* Reuse the palette-swatch styles from account.ejs */
 .palette-swatch {
@@ -474,16 +510,8 @@
   .photo-controls { gap: .55rem; }
 }
-/* Mobile-first: platform selector on its own line (full width), URL input + remove
-   button below. Prevents horizontal overflow on phones (fixed 160px column +
-   non-shrinkable input together ~447px > screen width). */
 .profile-link-row {
-  display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
-}
-.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
-.profile-link-row input { min-width: 0; }
-@media (min-width: 520px) {
-  .profile-link-row { grid-template-columns: 160px 1fr auto; }
-  .profile-link-row select { grid-column: auto; }
-}
+  display: grid; grid-template-columns: 160px 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
+}
+.profile-link-row select { padding: 0.45rem 0.6rem; }
 .profile-links-fieldset details > label { margin-top: 0.4rem; }
 </style>
Index: src/views/pages/admin-sites.ejs
===================================================================
--- src/views/pages/admin-sites.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-sites.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,9 +1,9 @@
 <div class="container ax-page">
 
-  <p><a href="/admin" class="btn">&larr; <%= t('asit2.back') %></a></p>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('asit2.title') %></h1>
-      <p class="ax-tagline"><%= t('asit2.tagline') %></p>
+      <h1>Sites</h1>
+      <p class="ax-tagline">Beheer alle sites op deze installatie.</p>
     </div>
   </header>
@@ -13,5 +13,5 @@
 
   <div class="ax-actions-bar">
-    <a href="/admin/sites/new" class="ax-btn ax-btn-primary">＋ <%= t('asit2.new_site') %></a>
+    <a href="/admin/sites/new" class="ax-btn ax-btn-primary">＋ Nieuwe site</a>
   </div>
 
@@ -20,30 +20,9 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">🌐</div>
-        <p><%= t('asit2.empty') %></p>
-        <p class="ax-empty-sub"><%= t('asit2.empty_sub') %></p>
+        <p>Nog geen sites.</p>
+        <p class="ax-empty-sub">Maak er een via de knop hierboven.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      var _s_primary    = t('asit2.pill_primary');
-      var _s_primary_t  = t('asit2.pill_primary_title');
-      var _s_public     = t('asit2.pill_public');
-      var _s_public_t   = t('asit2.pill_public_title');
-      var _s_private    = t('asit2.pill_private');
-      var _s_private_t  = t('asit2.pill_private_title');
-      var _s_noindex    = t('asit2.pill_noindex');
-      var _s_noindex_t  = t('asit2.pill_noindex_title');
-      var _s_by         = t('asit2.by');
-      var _s_t_posts    = t('asit2.t_posts');
-      var _s_l_posts    = t('asit2.l_posts');
-      var _s_t_created  = t('asit2.t_created');
-      var _s_l_created  = t('asit2.l_created');
-      var _s_makeprim   = t('asit2.make_primary');
-      var _s_makeprim_t = t('asit2.make_primary_title');
-      var _s_makeprim_c = t('asit2.make_primary_confirm');
-      var _s_edit       = t('asit2.edit');
-      var _s_del        = t('asit2.delete');
-      var _s_del_c      = t('asit2.delete_confirm');
-    %>
     <ul class="ax-list">
       <% sites.forEach(function(s) { %>
@@ -56,15 +35,12 @@
             <div class="ax-site-meta">
               <div class="ax-site-name">
-                <a href="/user/<%= s.slug %>"><%= s.title %></a>
-                <% if (s.is_primary) { %>
-                  <span class="ax-pill ax-pill-primary" title="<%= _s_primary_t %>">★ <%= _s_primary %></span>
-                <% } %>
+                <a href="/sites/<%= s.slug %>"><%= s.title %></a>
                 <% if (s.is_public) { %>
-                  <span class="ax-pill ax-pill-ok" title="<%= _s_public_t %>">✓ <%= _s_public %></span>
+                  <span class="ax-pill ax-pill-ok" title="Publiek zichtbaar">✓ public</span>
                 <% } else { %>
-                  <span class="ax-pill" title="<%= _s_private_t %>">🔒 <%= _s_private %></span>
+                  <span class="ax-pill" title="Niet publiek">🔒 private</span>
                 <% } %>
                 <% if (!s.robots_index) { %>
-                  <span class="ax-pill" title="<%= _s_noindex_t %>">🚫🤖 <%= _s_noindex %></span>
+                  <span class="ax-pill" title="Niet geïndexeerd door zoekmachines">🚫🤖 noindex</span>
                 <% } %>
               </div>
@@ -73,5 +49,5 @@
                 <% if (s.owner_username) { %>
                   <span class="ax-track-sep">·</span>
-                  <span><%= _s_by %> <%= s.owner_username %></span>
+                  <span>door <%= s.owner_username %></span>
                 <% } %>
               </div>
@@ -80,25 +56,19 @@
 
           <div class="ax-site-stats">
-            <span class="ax-stat" title="<%= _s_t_posts %>">
+            <span class="ax-stat" title="Aantal posts">
               <span class="ax-stat-num"><%= s.post_count %></span>
-              <span class="ax-stat-label"><%= _s_l_posts %></span>
+              <span class="ax-stat-label">posts</span>
             </span>
-            <span class="ax-stat" title="<%= _s_t_created %>">
+            <span class="ax-stat" title="Aangemaakt op">
               <span class="ax-stat-num ax-stat-date"><%= formatDate(s.created_at) %></span>
-              <span class="ax-stat-label"><%= _s_l_created %></span>
+              <span class="ax-stat-label">created</span>
             </span>
           </div>
 
           <div class="ax-site-controls">
-            <% if (!s.is_primary && (typeof canMutate === 'undefined' || canMutate)) { %>
-              <form method="post" action="/admin/sites/<%= s.slug %>/make-primary" style="margin:0;display:inline"
-                    data-confirm="<%= _s_makeprim_c %>">
-                <button type="submit" class="ax-icon-btn" aria-label="<%= _s_makeprim %>" title="<%= _s_makeprim_t %>">★</button>
-              </form>
-            <% } %>
-            <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="<%= _s_edit %>" title="<%= _s_edit %>">✎</a>
+            <a href="/admin/sites/<%= s.slug %>/edit" class="ax-icon-btn" aria-label="Bewerken" title="Bewerken">✎</a>
             <form method="post" action="/admin/sites/<%= s.slug %>/delete" class="ax-delete-form"
-                  data-confirm="<%= _s_del_c %>">
-              <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _s_del %>" title="<%= _s_del %>">🗑</button>
+                  onsubmit="return confirm('Site verwijderen? Lukt alleen als er geen posts zijn.')">
+              <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
             </form>
           </div>
@@ -228,9 +198,4 @@
   background: rgba(40,160,90,0.12);
 }
-.ax-pill-primary {
-  color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 14%, transparent);
-  font-weight: 600;
-}
 
 /* ─── Stats (2 mini-columns for sites) ────────────────────────── */
Index: src/views/pages/admin-stats.ejs
===================================================================
--- src/views/pages/admin-stats.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,177 +1,0 @@
-<div class="container stats-page">
-  <p><a href="/admin" class="btn">&larr; <%= t('nav.admin') %></a></p>
-  <h1><%= t('astat.title') %></h1>
-  <p class="stats-note"><%= t('astat.intro') %></p>
-
-  <% if (typeof myIp !== 'undefined' && myIp) { %>
-  <form method="post" action="/admin/stats/exclude-ip" class="stats-note" style="margin:.2rem 0 1.2rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
-    <span><%= t('astat.your_ip') %> <code><%= myIp %></code> —
-      <% if (ipExcluded) { %><strong><%= t('astat.ip_not_counted') %></strong><% } else { %><%= t('astat.ip_counted') %><% } %></span>
-    <button type="submit" class="btn" style="padding:3px 10px;font-size:.85rem;"><%= ipExcluded ? t('astat.ip_count') : t('astat.ip_exclude') %></button>
-  </form>
-  <% } %>
-
-  <% var s = stats; var _max = Math.max(1, ...s.series.map(function(d){ return d.pageviews; })); %>
-
-  <div class="stats-cards">
-    <div class="stat-card"><div class="stat-num"><%= s.totals.visitors %></div><div class="stat-label"><%= t('astat.visitor_days', { n: s.days }) %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.pageviews %></div><div class="stat-label"><%= t('astat.pageviews_days', { n: s.days }) %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.plays %></div><div class="stat-label"><%= t('astat.plays_total') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= s.totals.postViews %></div><div class="stat-label"><%= t('astat.postviews_total') %></div></div>
-  </div>
-
-  <% if (typeof s.allTime !== 'undefined') { %>
-    <p class="stats-note"><%= t('astat.alltime_pre') %> <strong><%= s.allTime.pageviews %></strong> <%= t('astat.alltime_mid') %> · <strong><%= s.allTime.visitorDays %></strong> <%= t('astat.alltime_post') %></p>
-  <% } %>
-
-  <details class="stats-help">
-    <summary><%= t('astat.help_summary') %></summary>
-    <div class="stats-help-body">
-      <dl class="stats-help-defs">
-        <div><dt><%= t('astat.help_vd_term') %></dt><dd><%= t('astat.help_vd_a') %> <em><%= t('astat.help_vd_em') %></em><%= t('astat.help_vd_b') %></dd></div>
-        <div><dt><%= t('astat.help_pv_term') %></dt><dd><%= t('astat.help_pv') %></dd></div>
-        <div><dt><%= t('astat.help_plays_term') %></dt><dd><%= t('astat.help_plays') %></dd></div>
-        <div><dt><%= t('astat.help_postviews_term') %></dt><dd><%= t('astat.help_postviews') %></dd></div>
-      </dl>
-      <p class="stats-help-note"><%= t('astat.help_footer') %></p>
-    </div>
-  </details>
-
-  <div class="stats-period">
-    <%= t('astat.period') %>
-    <% [7,14,30,90].forEach(function(n){ %>
-      <a href="/admin/stats?days=<%= n %>" class="<%= s.days === n ? 'is-on' : '' %>"><%= n %>d</a>
-    <% }); %>
-  </div>
-
-  <section class="stats-chart-wrap">
-    <div class="stats-chart-head">
-      <h2><%= t('astat.last_n_days', { n: s.days }) %></h2>
-      <div class="stats-legend">
-        <span class="lg lg-vis"><%= t('astat.lg_visitor_days') %></span>
-        <span class="lg lg-pv"><%= t('astat.lg_pageviews') %></span>
-      </div>
-    </div>
-    <div class="stats-chart">
-      <% var _step = s.days <= 14 ? 1 : (s.days <= 31 ? 4 : 10);
-         s.series.forEach(function(d, i){
-           var pvPct = Math.round(d.pageviews / _max * 100);
-           var visPct = Math.round(d.visitors / _max * 100);
-      %>
-        <div class="sc-col" title="<%= t('astat.bar_title', { day: d.day, pv: d.pageviews, vd: d.visitors }) %>">
-          <div class="sc-bar">
-            <span class="sc-pv" style="height: <%= pvPct %>%"></span>
-            <span class="sc-vis" style="height: <%= visPct %>%"></span>
-          </div>
-          <%# Unlabeled columns get an NBSP so every .sc-day keeps its text line: an empty
-              span collapses to 0 height and made the 30/90-day columns jump around. %>
-          <span class="sc-day"><%= (i % _step === 0) ? d.day.slice(8) : ' ' %></span>
-        </div>
-      <% }); %>
-    </div>
-  </section>
-
-  <div class="stats-tops">
-    <section class="stats-top">
-      <h2><%= t('astat.top_posts') %></h2>
-      <% if (!s.topPosts.length || s.topPosts[0].views === 0) { %>
-        <p class="stats-empty"><%= t('astat.no_views') %></p>
-      <% } else { %>
-        <ol>
-          <% s.topPosts.forEach(function(p){ %>
-            <li><a href="/<%= p.slug %>"><%= p.title %></a><span class="stats-count"><%= p.views %></span></li>
-          <% }); %>
-        </ol>
-      <% } %>
-    </section>
-    <section class="stats-top">
-      <h2><%= t('astat.most_played') %></h2>
-      <% if (!s.topTracks.length || s.topTracks[0].plays === 0) { %>
-        <p class="stats-empty"><%= t('astat.no_plays') %></p>
-      <% } else { %>
-        <ol>
-          <% s.topTracks.forEach(function(t){ %>
-            <li><span><%= t.title %></span><span class="stats-count"><%= t.plays %></span></li>
-          <% }); %>
-        </ol>
-      <% } %>
-    </section>
-  </div>
-
-  <% if (typeof s.referrers !== 'undefined' && s.referrers.length) { %>
-    <section class="stats-top" style="margin-top:1.2rem">
-      <h2>🌐 <%= t('astat.sources') %></h2>
-      <ol>
-        <% s.referrers.forEach(function(r){ %>
-          <li><span><%= r.host %></span><span class="stats-count"><%= r.n %></span></li>
-        <% }); %>
-      </ol>
-    </section>
-  <% } %>
-
-  <% if (typeof linkClicks !== 'undefined' && linkClicks.length) { %>
-    <section class="stats-top" style="margin-top:1.2rem">
-      <h2>🔗 <%= t('astat.linkbio_clicks') %></h2>
-      <ol>
-        <% linkClicks.forEach(function(l){ %>
-          <li><a href="<%= l.url %>" target="_blank" rel="noopener"><%= l.url.replace(/^https?:\/\//,'').slice(0,48) %></a><span class="stats-count"><%= l.clicks %></span></li>
-        <% }); %>
-      </ol>
-    </section>
-  <% } %>
-</div>
-
-<style>
-.stats-page { max-width: 820px; margin: 2.5rem auto; padding: 0 1rem; }
-.stats-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: .5rem 0 .25rem; }
-.stats-note { color: var(--ink-muted); font-size: .85rem; margin: 0 0 1.5rem; max-width: 60ch; }
-.stats-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin-bottom: 2rem; }
-.stat-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 10px; padding: 1rem 1.1rem; }
-.stat-num { font-family: var(--font-display, serif); font-size: 1.9rem; line-height: 1; color: var(--accent); }
-.stat-label { color: var(--ink-muted); font-size: .82rem; margin-top: .35rem; }
-
-.stats-chart-wrap { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.1rem 1.2rem 1rem; margin-bottom: 2rem; }
-.stats-chart-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: .5rem; }
-.stats-chart-head h2 { font-size: 1.05rem; margin: 0; }
-.stats-legend { display: flex; gap: .9rem; font-size: .78rem; color: var(--ink-muted); }
-.lg::before { content: ""; display: inline-block; width: .7rem; height: .7rem; border-radius: 2px; margin-right: .3rem; vertical-align: -1px; }
-.lg-vis::before { background: var(--accent); }
-.lg-pv::before { background: color-mix(in srgb, var(--accent) 28%, transparent); }
-.stats-chart { display: flex; align-items: flex-end; gap: 2px; height: 160px; margin-top: 1rem; overflow: hidden; }
-.sc-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
-.sc-bar { position: relative; width: 70%; max-width: 26px; min-width: 2px; height: 140px; display: flex; align-items: flex-end; }
-.sc-day { white-space: nowrap; }
-.sc-pv, .sc-vis { position: absolute; bottom: 0; left: 0; width: 100%; border-radius: 3px 3px 0 0; min-height: 2px; }
-.sc-pv { background: color-mix(in srgb, var(--accent) 28%, transparent); }
-.sc-vis { background: var(--accent); }
-.sc-day { font-size: .68rem; color: var(--ink-muted); margin-top: .35rem; }
-
-.stats-tops { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; }
-@media (max-width: 560px) { .stats-tops { grid-template-columns: 1fr; } }
-.stats-top { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1rem 1.2rem; }
-.stats-top h2 { font-size: 1.05rem; margin: 0 0 .6rem; }
-.stats-top ol { margin: 0; padding-left: 1.2rem; }
-.stats-top li { padding: .35rem 0; display: flex; justify-content: space-between; gap: .5rem; align-items: baseline; }
-.stats-top a { color: var(--ink); text-decoration: none; }
-.stats-top a:hover { color: var(--accent); }
-.stats-count { color: var(--accent); font-weight: 700; font-variant-numeric: tabular-nums; }
-.stats-empty { color: var(--ink-muted); font-size: .88rem; margin: 0; }
-.stats-period { margin: .2rem 0 1.2rem; font-size: .9rem; color: var(--ink-muted); }
-.stats-period a { display: inline-block; margin-left: .4rem; padding: .2rem .6rem; border: 1px solid var(--rule); border-radius: 999px; text-decoration: none; color: var(--ink); }
-.stats-period a.is-on { background: var(--accent); color: #fff; border-color: var(--accent); }
-.stats-help { border: 1px solid var(--rule); border-radius: 12px; background: var(--paper-2); margin: 0 0 1.25rem; }
-.stats-help summary { cursor: pointer; padding: .7rem 1rem; font-weight: 600; font-size: .9rem; list-style: none; display: flex; align-items: center; gap: .5rem; }
-.stats-help summary::-webkit-details-marker { display: none; }
-.stats-help summary::before { content: '\203A'; font-size: 1.15rem; line-height: 1; color: var(--ink-soft); transition: transform .15s; }
-.stats-help[open] summary::before { transform: rotate(90deg); }
-.stats-help-body { padding: 0 1rem 1rem; }
-.stats-help-defs { margin: 0; }
-.stats-help-defs > div { display: grid; grid-template-columns: 130px 1fr; gap: .2rem 1.1rem; align-items: baseline;
-  padding: .55rem 0; border-top: 1px solid color-mix(in srgb, var(--rule) 55%, transparent); }
-.stats-help-defs dt { font-weight: 700; font-size: .85rem; color: var(--ink); }
-.stats-help-defs dd { margin: 0; font-size: .85rem; line-height: 1.5; color: var(--ink-soft); }
-.stats-help-defs dd em { font-style: italic; color: var(--ink); }
-.stats-help-note { margin: .85rem 0 0; padding-top: .7rem; border-top: 1px solid var(--rule);
-  font-size: .8rem; line-height: 1.55; color: var(--ink-muted); }
-@media (max-width: 480px) { .stats-help-defs > div { grid-template-columns: 1fr; gap: .1rem; } }
-</style>
Index: src/views/pages/admin-updates.ejs
===================================================================
--- src/views/pages/admin-updates.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,95 +1,0 @@
-<div class="container admin-page">
-  <h1><%= t('aupd.title') %></h1>
-  <p><a href="/admin" class="btn">&larr; <%= t('aupd.back_admin') %></a></p>
-
-  <% if (success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <section class="set-card">
-    <h2><%= t('aupd.version_heading') %></h2>
-
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.app_version') %></span>
-      <span class="upd-val"><%= appVersion || '—' %></span>
-    </div>
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.current') %></span>
-      <span class="upd-val">
-        <% if (currentSha) { %><code><%= currentSha %></code> <span class="upd-desc"><%= currentDesc %></span>
-        <% } else { %><em><%= t('aupd.current_unknown') %></em><% } %>
-      </span>
-    </div>
-    <div class="upd-row">
-      <span class="upd-label"><%= t('aupd.latest') %></span>
-      <span class="upd-val">
-        <% if (latestSha) { %><code><%= latestSha %></code> <span class="upd-desc"><%= latestDesc %></span>
-        <% } else { %><em><%= t('aupd.latest_failed') %></em><% } %>
-      </span>
-    </div>
-
-    <div class="upd-status">
-      <% if (!canCheck) { %>
-        <span class="upd-badge warn">&#9888; <%= t('aupd.no_source') %></span>
-      <% } else if (upToDate) { %>
-        <span class="upd-badge ok">&#10003; <%= t('aupd.uptodate') %></span>
-      <% } else { %>
-        <span class="upd-badge new">&#8635; <%= t('aupd.update_available') %><% if (behind) { %> (<%= behind === '1' ? t('aupd.behind_one', { n: behind }) : t('aupd.behind_many', { n: behind }) %>)<% } %></span>
-      <% } %>
-    </div>
-
-    <% if (canCheck && canSelfUpdate) { %>
-    <form method="post" action="/admin/updates/run" class="set-form"
-          data-confirm="<%= t('aupd.run_confirm') %>">
-      <button type="submit" class="btn btn-primary">
-        <%= upToDate ? '↻ ' + t('aupd.redeploy') : '⬆ ' + t('aupd.update_now') %>
-      </button>
-    </form>
-    <% } else if (typeof manualCommand !== 'undefined' && manualCommand && !upToDate) { %>
-    <p class="set-help" style="margin:.2rem 0 .45rem"><%= t('aupd.manual_hint') %></p>
-    <pre class="upd-cmd"><code><%= manualCommand %></code></pre>
-    <% } %>
-
-    <p class="set-help" style="margin-top:1rem">
-      <%= t('aupd.help') %>
-    </p>
-  </section>
-
-  <% if (changes && changes.length) { %>
-  <section class="set-card" style="margin-top:1rem">
-    <h2><%= t('aupd.changes_heading') %></h2>
-    <ul class="upd-changes">
-      <% changes.forEach(function (c) { %>
-        <li><span class="upd-ch-date"><%= c.date %></span> <span class="upd-ch-msg"><%= c.msg %></span></li>
-      <% }) %>
-    </ul>
-  </section>
-  <% } %>
-</div>
-
-<style>
-.admin-page { max-width: 700px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 .25rem; }
-.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 1rem; }
-.set-help { color: var(--ink-muted); font-size: .9rem; }
-.upd-row { display: flex; gap: 1rem; padding: .55rem 0; border-bottom: 1px solid var(--rule); align-items: baseline; }
-.upd-label { width: 90px; flex-shrink: 0; font-size: .8rem; font-weight: 600; color: var(--ink-muted); }
-.upd-val { font-size: .9rem; }
-.upd-val code { background: var(--paper); padding: .1rem .4rem; border-radius: 4px; font-size: .85em; }
-.upd-desc { color: var(--ink-muted); }
-.upd-status { margin: 1.1rem 0; }
-.upd-cmd { background: var(--paper); border: 1px solid var(--rule); border-radius: 6px; padding: .6rem .8rem; margin: 0; overflow-x: auto; }
-.upd-cmd code { font-size: .9em; }
-.upd-badge { font-size: .8rem; font-weight: 700; padding: .25rem .7rem; border-radius: 20px; }
-.upd-badge.ok { background: #d1fae5; color: #065f46; }
-.upd-badge.new { background: #fef3c7; color: #92400e; }
-.upd-badge.warn { background: #fee2e2; color: #991b1b; }
-.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
-.alert-success { background: #d1fae5; color: #065f46; border: 1px solid #a7f3d0; }
-.alert-error { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
-.upd-changes { list-style: none; padding: 0; margin: 0; }
-.upd-changes li { display: flex; gap: .7rem; padding: .45rem 0; border-bottom: 1px solid var(--rule); font-size: .9rem; align-items: baseline; }
-.upd-changes li:last-child { border-bottom: 0; }
-.upd-ch-date { color: var(--ink-muted); flex-shrink: 0; font-size: .8rem; font-variant-numeric: tabular-nums; }
-.upd-ch-msg { word-break: break-word; }
-</style>
Index: src/views/pages/admin-users.ejs
===================================================================
--- src/views/pages/admin-users.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin-users.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,9 +1,9 @@
 <div class="container ax-page">
 
-  <p><a href="/admin" class="btn">&larr; <%= t('ausr.back') %></a></p>
   <header class="ax-header">
+    <a href="/admin" class="ax-back" aria-label="Terug naar admin">←</a>
     <div>
-      <h1><%= t('ausr.title') %></h1>
-      <p class="ax-tagline"><%= t('ausr.tagline_a') %> <strong><%= t('ausr.role_kijker') %></strong> <%= t('ausr.tagline_b') %></p>
+      <h1>Users</h1>
+      <p class="ax-tagline">Beheer gebruikers, rollen, en verwijderingen.</p>
     </div>
   </header>
@@ -16,24 +16,8 @@
       <div class="ax-empty">
         <div class="ax-empty-icon">👤</div>
-        <p><%= t('ausr.empty') %></p>
+        <p>Geen gebruikers.</p>
       </div>
     </div>
   <% } else { %>
-    <%
-      var _u_self      = t('ausr.you');
-      var _u_t_sites   = t('ausr.t_sites');
-      var _u_t_posts   = t('ausr.t_posts');
-      var _u_t_joined  = t('ausr.t_joined');
-      var _u_l_sites   = t('ausr.l_sites');
-      var _u_l_posts   = t('ausr.l_posts');
-      var _u_l_joined  = t('ausr.l_joined');
-      var _u_newklonkt = t('ausr.new_klonkt');
-      var _u_rol       = t('ausr.role_label');
-      var _u_r_kijker  = t('ausr.role_kijker');
-      var _u_r_member  = t('ausr.role_member');
-      var _u_r_admin   = t('ausr.role_admin');
-      var _u_r_god     = t('ausr.role_god');
-      var _u_del       = t('ausr.delete');
-    %>
     <ul class="ax-list">
       <% users.forEach(function(u) { %>
@@ -42,5 +26,5 @@
           <div class="ax-user-id">
             <% if (u.avatar_url) { %>
-              <img class="ax-avatar" src="<%= avatar(u.avatar_url, 128) %>" alt="">
+              <img class="ax-avatar" src="<%= u.avatar_url %>" alt="">
             <% } else { %>
               <span class="ax-avatar ax-avatar-empty"><%= u.username.charAt(0).toUpperCase() %></span>
@@ -49,5 +33,5 @@
               <div class="ax-user-name">
                 <a href="/users/<%= encodeURIComponent(u.username) %>"><%= u.username %></a>
-                <% if (u.id === user.id) { %><span class="ax-pill"><%= _u_self %></span><% } %>
+                <% if (u.id === user.id) { %><span class="ax-pill">jij</span><% } %>
               </div>
               <div class="ax-user-email"><%= u.email %></div>
@@ -56,15 +40,15 @@
 
           <div class="ax-user-stats">
-            <span class="ax-stat" title="<%= _u_t_sites %>">
+            <span class="ax-stat" title="Sites">
               <span class="ax-stat-num"><%= u.site_count %></span>
-              <span class="ax-stat-label"><%= _u_l_sites %></span>
+              <span class="ax-stat-label">sites</span>
             </span>
-            <span class="ax-stat" title="<%= _u_t_posts %>">
+            <span class="ax-stat" title="Posts">
               <span class="ax-stat-num"><%= u.post_count %></span>
-              <span class="ax-stat-label"><%= _u_l_posts %></span>
+              <span class="ax-stat-label">posts</span>
             </span>
-            <span class="ax-stat" title="<%= _u_t_joined %>">
+            <span class="ax-stat" title="Geregistreerd op">
               <span class="ax-stat-num ax-stat-date"><%= formatDate(u.created_at) %></span>
-              <span class="ax-stat-label"><%= _u_l_joined %></span>
+              <span class="ax-stat-label">joined</span>
             </span>
           </div>
@@ -73,25 +57,18 @@
             <form method="post" action="/admin/users/<%= u.id %>/role" class="ax-role-form">
               <label class="ax-role">
-                <span><%= _u_rol %></span>
-                <select name="role" data-autosubmit <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
-                  <option value="kijker" <%= u.role === 'kijker' ? 'selected' : '' %>><%= _u_r_kijker %></option>
-                  <option value="member" <%= u.role === 'member' ? 'selected' : '' %>><%= _u_r_member %></option>
-                  <option value="admin"  <%= u.role === 'admin'  ? 'selected' : '' %>><%= _u_r_admin %></option>
-                  <option value="god"    <%= u.role === 'god'    ? 'selected' : '' %>><%= _u_r_god %></option>
+                <span>Rol</span>
+                <select name="role" onchange="this.form.submit()" <% if (u.id === user.id) { %>disabled<% } %>>
+                  <option value="member" <%= u.role === 'member' ? 'selected' : '' %>>member</option>
+                  <option value="admin"  <%= u.role === 'admin'  ? 'selected' : '' %>>admin</option>
+                  <option value="god"    <%= u.role === 'god'    ? 'selected' : '' %>>god</option>
                 </select>
               </label>
             </form>
 
-            <% if (u.id !== user.id && canMutate) { %>
-              <% var _warn = (u.post_count + u.site_count > 0)
-                   ? (t('ausr.del_warn', { n: u.post_count }) + ' ')
-                   : ''; %>
+            <% if (u.id !== user.id && (u.post_count + u.site_count === 0)) { %>
               <form method="post" action="/admin/users/<%= u.id %>/delete" class="ax-delete-form"
-                    data-confirm="<%= t('ausr.del_confirm', { name: u.username }) %> <%= _warn %><%= t('ausr.del_undo') %>">
-                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _u_del %>" title="<%= _u_del %>">🗑</button>
+                    onsubmit="return confirm('Verwijder gebruiker <%= u.username %>?')">
+                <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
               </form>
-            <% } else { %>
-              <%# Empty placeholder so the role column aligns on every row. %>
-              <span class="ax-icon-btn-spacer" aria-hidden="true"></span>
             <% } %>
           </div>
@@ -215,5 +192,5 @@
 /* ─── Role select + delete ────────────────────────────────────── */
 .ax-user-controls {
-  display: flex; gap: 0.5rem; align-items: flex-end;
+  display: flex; gap: 0.5rem; align-items: center;
   flex-shrink: 0;
 }
@@ -231,7 +208,4 @@
 }
 .ax-role select {
-  width: 112px;
-  height: 40px;
-  box-sizing: border-box;
   padding: 0.4rem 1.75rem 0.4rem 0.6rem;
   border: 1px solid var(--rule);
@@ -248,7 +222,4 @@
 }
 .ax-role select:disabled { cursor: not-allowed; opacity: 0.5; }
-.ax-ro { align-items: center; }
-.ax-ro input { width: 18px; height: 18px; cursor: pointer; margin-top: 0.15rem; accent-color: var(--accent); }
-.ax-ro input:disabled { cursor: not-allowed; opacity: 0.5; }
 .ax-delete-form { margin: 0; }
 .ax-icon-btn {
@@ -262,5 +233,4 @@
 .ax-icon-btn:hover { border-color: var(--accent); }
 .ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
-.ax-icon-btn-spacer { display: inline-block; width: 40px; height: 40px; flex-shrink: 0; }
 
 /* ─── Mobile: stack everything vertically ─────────────────────── */
Index: src/views/pages/admin-videos.ejs
===================================================================
--- src/views/pages/admin-videos.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,53 +1,0 @@
-<%# Beheer -> Media -> Video's (Robins opdracht, 30-7): de video's die via C2S
-    binnenkwamen (Shaers composer, de reddingsboei), met gebruik en opruiming.
-
-    De voorvertoning is de video zelf met preload="metadata", zodat de browser
-    het eerste beeld als miniatuur toont tot echte poster-generatie landt
-    (bead: ffmpeg ontbreekt).
-
-    Zelfde romp als de andere mediapagina's; stond hiervoor op inline styles. %>
-<%- include('../partials/admin-styles') %>
-<div class="container ax-page">
-
-  <%- include('../partials/admin-back') %>
-  <%- include('../partials/media-tabs', { active: 'videos', audioOn: audioOn }) %>
-  <header class="ax-header">
-    <div>
-      <h1><%= t('admin.t_media') %></h1>
-      <p class="ax-tagline"><%= items.length %> <%= t('admin.videos_count') %></p>
-    </div>
-  </header>
-
-  <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
-
-  <% if (!items.length) { %>
-    <div class="ax-empty">
-      <div class="ax-empty-icon">&#127916;</div>
-      <p><%= t('admin.videos_empty') %></p>
-    </div>
-  <% } else { %>
-  <div class="ax-grid" style="--ax-tile:220px;">
-    <% items.forEach(function(it){ %>
-      <div class="ax-tile media-card" data-file="<%= it.file %>">
-        <div class="ax-tile-media" style="aspect-ratio:16/10;background:#000;">
-          <video src="<%= it.url %>" preload="metadata" controls playsinline style="height:100%;object-fit:contain;"></video>
-          <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %>
-        </div>
-        <div class="ax-tile-body">
-          <div class="ax-tile-meta" style="word-break:break-all;"><%= it.file %></div>
-          <div class="ax-tile-meta"><%= it.kb %> KB &middot; <%= it.usedCount %>&times;</div>
-          <div class="ax-tile-actions">
-            <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>
-            <% if (!it.usedCount) { %>
-            <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">&#128465;</button>
-            <% } %>
-          </div>
-        </div>
-      </div>
-    <% }) %>
-  </div>
-  <% } %>
-</div>
-
-<%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %>
Index: src/views/pages/admin.ejs
===================================================================
--- src/views/pages/admin.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/admin.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,77 +1,74 @@
 <div class="container admin-page">
-  <h1><%= t('admin.title') %></h1>
-  <%
-    var _premOn = (typeof premiumEnabled !== 'undefined' && premiumEnabled);
-    var _premActive = _premOn && (typeof isPremium !== 'undefined' && isPremium);
-    var _premCls = _premActive ? 'on' : (_premOn ? 'off' : 'free');
-    var _premLbl = _premActive ? ('★ ' + t('adash.prem_active'))
-                 : (_premOn ? ('⚠ ' + t('adash.prem_unlinked'))
-                            : t('adash.prem_layeroff'));
-  %>
-  <a class="prem-badge <%= _premCls %>" href="/admin/settings" title="<%= t('admin.b_settings') %>"><%= _premLbl %></a>
-  <style>
-    .prem-badge{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;letter-spacing:.02em;padding:.3rem .85rem;border-radius:999px;text-decoration:none;border:1px solid transparent;margin:.1rem 0 .3rem}
-    .prem-badge.on{background:rgba(232,176,75,.16);color:var(--accent);border-color:rgba(232,176,75,.45)}
-    .prem-badge.off{background:rgba(239,68,68,.12);color:#ef9a9a;border-color:rgba(239,68,68,.35)}
-    .prem-badge.free{background:var(--paper-2);color:var(--ink-muted);border-color:var(--rule)}
-    .prem-badge:hover{filter:brightness(1.08)}
-  </style>
-  <% if (typeof circlesOn !== 'undefined' && circlesOn) { %>
-    <p class="admin-tagline"><%= t('admin.tagline_cirkels') %></p>
-  <% } else { %>
-    <p class="admin-tagline"><%= t('admin.tagline_solo') %></p>
-  <% } %>
+  <h1>Admin</h1>
+  <p class="admin-tagline">God-level overview. <em>(Stub — full dashboard later.)</em></p>
 
-  <nav class="admin-quick-links" aria-label="<%= t('nav.admin') %>">
-      <a href="/posts/new" class="btn"><%= t('admin.b_newpost') %></a>
-      <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a>
-      <% if (primarySite) { %>
-        <a href="/admin/sites/<%= primarySite.slug %>/edit" class="btn"><%= t('admin.b_look') %></a>
-      <% } else { %>
-        <a href="/admin/sites/new" class="btn"><%= t('admin.b_makesite') %></a>
-      <% } %>
-      <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>
-      <% if (primarySite) { %><a href="/admin/migrate" class="btn"><%= t('admin.b_migrate') %></a><% } %>
-      <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>
-    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-      <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
-      <a href="/admin/paid" class="btn"><%= t('admin.b_paid') %></a>
-      <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
-      <% if (primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
-      <% if (primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %>
-      <% if (primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %>
-      <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a>
-    <% } %>
-    <%# Fediverse pages (News / Notifications / My replies / Blocking) are reached via the
-        "Fediverse" nav button + the bell + the tabbed section — not duplicated here. %>
-    <a href="/admin/push" class="btn"><%= t('admin.b_push') %></a>
-    <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a>
-    <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
+  <nav class="admin-quick-links" aria-label="Beheer-snelkoppelingen">
+    <a href="/admin/sites" class="btn">🌐 Sites</a>
+    <a href="/admin/users" class="btn">👥 Users</a>
+    <a href="/admin/audio" class="btn">🎵 Audio tracks</a>
+    <a href="/admin/playlists" class="btn">📃 Playlists</a>
+    <a href="/admin/comments" class="btn">💬 Comments</a>
   </nav>
 
   <div class="admin-stats">
-    <div class="stat-card"><div class="stat-num"><%= stats.posts %></div><div class="stat-label"><%= t('admin.st_posts') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= stats.published %></div><div class="stat-label"><%= t('admin.st_published') %></div></div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.users %></div>
+      <div class="stat-label">Users</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.sites %></div>
+      <div class="stat-label">Sites</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.posts %></div>
+      <div class="stat-label">Posts</div>
+    </div>
+    <div class="stat-card">
+      <div class="stat-num"><%= stats.published %></div>
+      <div class="stat-label">Published</div>
+    </div>
   </div>
 
-  <% if (typeof posts !== 'undefined' && posts && posts.length) { %>
-    <% var _drafts = posts.filter(function(p){ return p.isDraft; }).length; %>
-    <h2><%= t('admin.sec_posts') %><% if (_drafts) { %> <span class="pl-draftcount"><%= _drafts %> <%= t('admin.draft').toLowerCase() %><%= _drafts === 1 ? '' : 's' %></span><% } %></h2>
-    <ul class="post-admin-list">
-      <% posts.forEach(function(p) { %>
-        <li class="pal-row<%= p.isDraft ? ' is-draft' : '' %>">
-          <a class="pal-title" href="<%= p.editUrl %>">
-            <% if (p.isDraft) { %><span class="pal-badge"><%= t('admin.draft') %></span><% } %>
-            <span class="pal-name"><%= p.title || '(zonder titel)' %></span>
-          </a>
-          <span class="pal-actions">
-            <a class="pal-link" href="<%= p.editUrl %>"><%= t('admin.edit') %></a>
-            <% if (!p.isDraft) { %><a class="pal-link" href="<%= p.viewUrl %>"><%= t('admin.view') %></a><% } %>
-          </span>
-        </li>
-      <% }); %>
-    </ul>
+  <% if (sites && sites.length) { %>
+    <h2>Sites</h2>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Slug</th><th>Title</th><th>Owner</th><th>Created</th></tr>
+      </thead>
+      <tbody>
+        <% sites.forEach(function(s) { %>
+          <tr>
+            <td><code><%= s.slug %></code></td>
+            <td><%= s.title %></td>
+            <td><%= s.owner_username || '—' %></td>
+            <td><%= formatDate(s.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
   <% } %>
 
+  <% if (users && users.length) { %>
+    <h2>Users</h2>
+    <table class="admin-table">
+      <thead>
+        <tr><th>Username</th><th>Email</th><th>Role</th><th>Joined</th></tr>
+      </thead>
+      <tbody>
+        <% users.forEach(function(u) { %>
+          <tr>
+            <td><%= u.username %></td>
+            <td><%= u.email %></td>
+            <td><span class="role-pill role-<%= u.role %>"><%= u.role %></span></td>
+            <td><%= formatDate(u.created_at) %></td>
+          </tr>
+        <% }); %>
+      </tbody>
+    </table>
+  <% } %>
+
+  <p class="admin-todo">
+    <strong>Coming later:</strong> create/delete sites, manage users, theme picker, audio toggles, Prutter settings.
+  </p>
 </div>
 
@@ -82,18 +79,4 @@
 .admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
 .admin-tagline em { font-style: normal; opacity: 0.7; }
-
-/* Posts/concepten-lijst */
-.pl-draftcount { font-family: var(--font-ui, system-ui); font-size: 0.8rem; font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.55rem; border-radius: 99px; vertical-align: middle; }
-.post-admin-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
-.pal-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.9rem; border: 1px solid var(--rule); border-radius: 10px; background: var(--paper-2); transition: border-color 120ms; }
-.pal-row:hover { border-color: var(--accent); }
-.pal-row.is-draft { border-left: 3px solid var(--accent); }
-.pal-title { display: inline-flex; align-items: center; gap: 0.6rem; min-width: 0; color: var(--ink); text-decoration: none; font-weight: 600; }
-.pal-title:hover { color: var(--accent); }
-.pal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pal-badge { flex-shrink: 0; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.5rem; border-radius: 99px; }
-.pal-actions { display: flex; gap: 0.75rem; flex-shrink: 0; }
-.pal-link { color: var(--ink-muted); font-size: 0.85rem; text-decoration: none; white-space: nowrap; }
-.pal-link:hover { color: var(--accent); text-decoration: underline; }
 
 /* Quick-links grid — mobile-first.
Index: src/views/pages/archive.ejs
===================================================================
--- src/views/pages/archive.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/archive.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,19 +1,32 @@
-<%# Archief — altijd de chronologische jaar/maand-lijst. Het archief negeert
-    bewust de timeline/grid-keuze (geen grid-weergave): de view-switcher is hier
-    grijs en springt bij klik terug naar de feed. Zie de CSS in style.css. %>
+<%
+// Archive — chronological feed of all posts.
+//
+// Renders both a timeline view (year/month grouped link list) AND a grid
+// view (flat tiles) — same toggle pattern as home.ejs. The view-switcher
+// at the top lets the user pick. Body class is `on-archive`; the inline
+// <style> below adds an `.on-archive` clause to the existing home-only
+// feed toggle in style.css (cleaner than editing the global CSS).
+//
+// Flatten posts for grid mode: skip the year/month grouping (it doesn't
+// translate well to a tile grid). Newest-first order is preserved by the
+// route's SQL ORDER BY.
+const _flatPosts = [];
+Object.keys(grouped).sort((a, b) => b - a).forEach(function(y) {
+  Object.keys(grouped[y]).forEach(function(m) {
+    grouped[y][m].forEach(function(p) { _flatPosts.push(p); });
+  });
+});
+%>
 
-<!-- ========== TIJDLIJN (jaar/maand gegroepeerd) ========== -->
+<%# View switcher used to be rendered here; now lives in shell.ejs above
+    #pcms-main, visible on every non-admin page. %>
+
+<!-- ========== TIMELINE (year/month grouped) ========== -->
 <div class="container archive-page feed-timeline">
-  <% var _archBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
-  <p class="archive-back-row">
-    <a href="<%= _archBase %>/" class="btn archive-back"
-       hx-get="<%= _archBase %>/?partial=1" hx-target="#pcms-main"
-       hx-push-url="<%= _archBase %>/" hx-indicator="#pcms-loading">&larr; <%= t('parch.back') %></a>
-  </p>
-  <h1><%= t('parch.title') %></h1>
-  <p class="archive-meta"><%= totalPosts === 1 ? t('parch.count_one', { n: totalPosts }) : t('parch.count_many', { n: totalPosts }) %></p>
+  <h1>Archive</h1>
+  <p class="archive-meta"><%= totalPosts %> post<%= totalPosts !== 1 ? 's' : '' %></p>
 
   <% if (totalPosts === 0) { %>
-    <p class="empty-state"><%= t('parch.empty') %></p>
+    <p class="empty-state">No posts yet.</p>
   <% } %>
 
@@ -42,7 +55,17 @@
 </div>
 
+<!-- ========== GRID (only visible when body[data-feed-view="grid"]) ========== -->
+<% if (_flatPosts.length > 0) { %>
+<section class="feed-grid container" aria-label="Grid-weergave">
+  <div class="grid-tiles">
+    <% _flatPosts.forEach(function(post) { %>
+      <%- include('../partials/post-tile', { post: post }) %>
+    <% }); %>
+  </div>
+</section>
+<% } %>
+
 <style>
 .archive-page { max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
-.archive-back-row { margin: 0 0 1rem; }
 .archive-page h1 { font-family: var(--font-display, serif); margin: 0 0 0.25rem; }
 .archive-meta { color: var(--ink-muted); margin-bottom: 2rem; }
Index: src/views/pages/auth-login.ejs
===================================================================
--- src/views/pages/auth-login.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-login.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,30 +1,23 @@
-<%
-  // The login page shows the admin username/password form. (Public listener
-  // login via Google was removed — interaction now runs via the fediverse.)
-  var _admin = (typeof adminLogin !== 'undefined' && adminLogin);
-%>
 <div class="container auth-page">
-  <h1><%= _admin ? t('auth.admin_login_title') : t('nav.login') %></h1>
+  <h1>Login</h1>
   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <% if (_admin) { %>
-    <%# Hidden admin login (password). %>
-    <form method="post" action="/auth/login" class="auth-form">
-      <% if (typeof next !== 'undefined' && next) { %>
-        <input type="hidden" name="next" value="<%= next %>">
-      <% } %>
-      <label>
-        <span><%= t('auth.username_or_email') %></span>
-        <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
-      </label>
-      <label>
-        <span><%= t('auth.password') %></span>
-        <input type="password" name="password" required autocomplete="current-password">
-      </label>
-      <button type="submit" class="btn btn-primary"><%= t('nav.login') %></button>
-      <p class="auth-link auth-link-muted"><a href="/auth/reset-request"><%= t('auth.forgot') %></a></p>
-    </form>
-  <% } %>
+  <form method="post" action="/auth/login" class="auth-form">
+    <% if (typeof next !== 'undefined' && next) { %>
+      <input type="hidden" name="next" value="<%= next %>">
+    <% } %>
+    <label>
+      <span>Username or email</span>
+      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username">
+    </label>
+    <label>
+      <span>Password</span>
+      <input type="password" name="password" required autocomplete="current-password">
+    </label>
+    <button type="submit" class="btn btn-primary">Login</button>
+    <p class="auth-link">No account?
+      <a href="/auth/register<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Register</a>
+    </p>
+    <p class="auth-link auth-link-muted"><a href="/auth/reset-request">Forgot password?</a></p>
+  </form>
 </div>
-
Index: src/views/pages/auth-register.ejs
===================================================================
--- src/views/pages/auth-register.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-register.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,4 @@
 <div class="container auth-page">
-  <h1><%= t('setup.title') %></h1>
-  <p class="auth-intro"><%= t('setup.intro') %></p>
-
-  <div class="setup-lang" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 20px">
-    <span style="opacity:.7;font-size:.9rem"><%= t('setup.lang_label') %>:</span>
-    <% (typeof langs !== 'undefined' ? langs : []).forEach(function(L){ %>
-      <a href="/lang/<%= L.code %>?r=/auth/register"
-         style="padding:5px 13px;border-radius:8px;text-decoration:none;font-weight:600;font-size:.9rem;border:1px solid <%= L.active ? 'var(--accent)' : 'var(--rule, #2b3140)' %>;color:<%= L.active ? 'var(--accent)' : 'inherit' %>"><%= L.name %></a>
-    <% }) %>
-  </div>
-
+  <h1>Register</h1>
   <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
   <form method="post" action="/auth/register" class="auth-form">
@@ -17,27 +7,19 @@
     <% } %>
     <label>
-      <span><%= t('setup.f_sitename') %></span>
-      <input type="text" name="siteName" value="<%= typeof siteName !== 'undefined' ? siteName : '' %>" required autofocus maxlength="80" placeholder="<%= t('setup.sitename_ph') %>">
+      <span>Username (3-32 chars, letters/numbers/_-)</span>
+      <input type="text" name="username" value="<%= username %>" required autofocus autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
     </label>
     <label>
-      <span><%= t('auth.f_username') %></span>
-      <input type="text" id="setup-username" name="username" value="<%= username %>" required autocomplete="username" pattern="[a-zA-Z0-9_-]{3,32}">
-      <small class="setup-handle-hint"><%= t('setup.username_note') %> <code id="setup-handle">@username@…</code></small>
-    </label>
-    <label>
-      <span><%= t('auth.f_email') %></span>
+      <span>Email</span>
       <input type="email" name="email" value="<%= email %>" required autocomplete="email">
     </label>
     <label>
-      <span><%= t('auth.f_password') %></span>
+      <span>Password (min 8 chars)</span>
       <input type="password" name="password" required minlength="8" autocomplete="new-password">
     </label>
-    <button type="submit" class="btn btn-primary"><%= t('setup.submit') %></button>
+    <button type="submit" class="btn btn-primary">Create account</button>
+    <p class="auth-link">Already registered?
+      <a href="/auth/login<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">Login</a>
+    </p>
   </form>
 </div>
-
-<style>
-  .setup-handle-hint { display: block; margin-top: .35rem; font-size: .82rem; line-height: 1.4; color: var(--ink-soft, #888); }
-  .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
-</style>
-<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
Index: src/views/pages/auth-reset-request.ejs
===================================================================
--- src/views/pages/auth-reset-request.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-reset-request.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,12 +1,12 @@
 <div class="container auth-page">
-  <h1><%= t('arrq.title') %></h1>
+  <h1>Reset password</h1>
 
   <% if (sent) { %>
     <div class="alert alert-success">
-      <%= t('arrq.sent') %>
+      If an account exists for that email, a reset link has been sent.
     </div>
     <% if (devResetUrl) { %>
       <div class="alert alert-info">
-        <%- t('arrq.no_mailserver') %>
+        <strong>Dev mode:</strong> reset link below (no email server configured).
         <p style="margin: 0.5rem 0 0; word-break: break-all;">
           <a href="<%= devResetUrl %>"><%= devResetUrl %></a>
@@ -14,10 +14,7 @@
       </div>
     <% } %>
-    <% if (typeof mailer !== 'undefined' && !mailer) { %>
-      <p class="auth-link-muted"><%- t('arrq.no_mail_cli') %></p>
-    <% } %>
-    <p class="auth-link"><a href="/auth/login"><%= t('arrq.back_login') %></a></p>
+    <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
   <% } else { %>
-    <p class="auth-tagline"><%= t('arrq.tagline') %></p>
+    <p class="auth-tagline">Enter your email and we'll send a reset link.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -25,9 +22,9 @@
     <form method="post" action="/auth/reset-request" class="auth-form">
       <label>
-        <span><%= t('arrq.email') %></span>
+        <span>Email</span>
         <input type="email" name="email" required autofocus autocomplete="email">
       </label>
-      <button type="submit" class="btn btn-primary"><%= t('arrq.submit') %></button>
-      <p class="auth-link"><a href="/auth/login"><%= t('arrq.back_login') %></a></p>
+      <button type="submit" class="btn btn-primary">Send reset link</button>
+      <p class="auth-link"><a href="/auth/login">&larr; Back to login</a></p>
     </form>
   <% } %>
Index: src/views/pages/auth-reset.ejs
===================================================================
--- src/views/pages/auth-reset.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/auth-reset.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,15 +1,14 @@
 <div class="container auth-page">
-  <h1><%= t('arst.title') %></h1>
+  <h1>Reset password</h1>
 
   <% if (!token) { %>
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
     <p class="auth-link">
-      <a href="/auth/reset-request"><%= t('arst.request_new') %></a>
+      <a href="/auth/reset-request">Request a new reset link</a>
       &middot;
-      <a href="/auth/login"><%= t('arst.back_login') %></a>
+      <a href="/auth/login">Back to login</a>
     </p>
   <% } else { %>
-    <p class="auth-tagline"><%- t('arst.set_for', { username: '<strong>' + username + '</strong>' }) %></p>
-    <%# arst.set_for contains {username}; rendered with raw output because username is wrapped in a strong tag %>
+    <p class="auth-tagline">Set a new password for <strong><%= username %></strong>.</p>
 
     <% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -17,12 +16,12 @@
     <form method="post" action="/auth/reset/<%= token %>" class="auth-form">
       <label>
-        <span><%= t('arst.new_pw') %></span>
+        <span>New password (min 8 chars)</span>
         <input type="password" name="new_password" required minlength="8" autofocus autocomplete="new-password">
       </label>
       <label>
-        <span><%= t('arst.confirm_pw') %></span>
+        <span>Confirm new password</span>
         <input type="password" name="confirm" required minlength="8" autocomplete="new-password">
       </label>
-      <button type="submit" class="btn btn-primary"><%= t('arst.submit') %></button>
+      <button type="submit" class="btn btn-primary">Set password</button>
     </form>
   <% } %>
Index: src/views/pages/authorize-interaction.ejs
===================================================================
--- src/views/pages/authorize-interaction.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,314 +1,0 @@
-<div class="auth-interact">
-  <% if (typeof manage !== 'undefined' && manage) { %>
-    <%- include('../partials/fedi-tabs', { active: 'reacties' }) %>
-    <h1 class="auth-interact-title"><%= t('fedi.manage_title') %></h1>
-
-    <%# Interact bookmarklet — drag to bookmarks bar, then click on any fediverse post. %>
-    <div class="fedi-bm">
-      <a class="fedi-bm-btn" id="fedi-bm-btn" href="#" draggable="true" title="<%= t('fedi.bm_help') %>">
-        <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
-        <%= t('fedi.bm_label') %>
-      </a>
-      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
-    </div>
-    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
-    <% if (!manage.length) { %>
-      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
-    <% } else { %>
-      <ul class="fedi-manage">
-        <% manage.forEach(function(m){ %>
-          <li class="fedi-manage-item">
-            <div class="fedi-manage-head">
-              <span class="fedi-manage-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></span>
-              <% if (m.to_handle) { %><span class="fedi-manage-to"><%= m.to_handle %></span><% } %>
-              <% if (m.created_at && typeof formatDateTime === 'function') { %><span class="fedi-manage-time"><%= formatDateTime(m.created_at) %></span><% } %>
-            </div>
-            <div class="fedi-manage-content"><%- m.content %></div>
-            <div class="fedi-manage-foot">
-              <button type="button" class="fedi-edit-btn" aria-expanded="false"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg> <%= t('fedi.edit') %></button>
-              <% if (m.in_reply_to) { %>
-              <a class="fedi-goto-btn" href="<%= m.in_reply_to %>" target="_blank" rel="nofollow noopener"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg> <%= t('fedi.goto_post') %></a>
-              <% } %>
-              <form method="post" action="/fediverse/<%= m.id %>/delete" class="fedi-del-form" data-confirm="<%= t('fedi.delete_confirm') %>">
-                <button type="submit" class="fedi-del-btn"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <%= t('comments.delete') %></button>
-              </form>
-            </div>
-            <div class="fedi-edit-form">
-              <%- include('../partials/reply-editor', {
-                action: '/fediverse/' + m.id + '/edit',
-                placeholder: t('fedi.reply_ph'),
-                submitLabel: t('fedi.save_edit'),
-                rows: 3,
-                initialHtml: m.content,
-                initialText: m.editable,
-                defaultLang: m.language,
-                noAttach: true,
-              }) %>
-            </div>
-          </li>
-        <% }); %>
-      </ul>
-      
-    <% } %>
-    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
-  <% } else if (typeof liked !== 'undefined' && liked) { %>
-    <h1 class="auth-interact-title">★ <%= t('fedi.liked_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.liked_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof boosted !== 'undefined' && boosted) { %>
-    <h1 class="auth-interact-title">🔁 <%= t('fedi.boosted_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.boosted_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof followed !== 'undefined' && followed) { %>
-    <h1 class="auth-interact-title"><%= t('fedi.followed_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.followed_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.view_profile') %></a><% } %>
-      <a class="btn btn-primary" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof sent !== 'undefined' && sent) { %>
-    <h1 class="auth-interact-title"><%= t('fedi.remote_sent_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.remote_sent') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn btn-primary" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof reported !== 'undefined' && reported) { %>
-    <h1 class="auth-interact-title">🚩 <%= t('fedi.reported_title') %></h1>
-    <p class="auth-interact-note"><%= t('fedi.reported_done') %></p>
-    <p class="auth-interact-actions">
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof voted !== 'undefined' && voted) { %>
-    <h1 class="auth-interact-title">📊 <%= t('poll.voted_title') %></h1>
-    <p class="auth-interact-note"><%= t('poll.voted_done') %></p>
-    <p class="auth-interact-actions">
-      <% if (uri) { %><a class="btn btn-primary" href="<%= uri %>" rel="nofollow noopener"><%= t('fedi.remote_view_original') %></a><% } %>
-      <a class="btn" href="/"><%= t('fedi.remote_back') %></a>
-    </p>
-  <% } else if (typeof followTarget !== 'undefined' && followTarget) { %>
-  <h1 class="auth-interact-title"><%= t('fedi.follow_heading') %></h1>
-  <p class="auth-interact-meta">
-    <%= t('fedi.follow_intro') %>
-    <a href="<%= followTarget.actor_url %>" rel="nofollow noopener" target="_blank"><strong><%= followTarget.actor_name %></strong></a>
-    <span class="fedi-handle"><%= followTarget.actor_handle %></span>
-  </p>
-  <form method="post" action="/authorize_interaction/follow" class="auth-interact-form">
-    <input type="hidden" name="uri" value="<%= followTarget.actor_uri %>">
-    <div class="auth-interact-actions">
-      <button type="submit" class="btn btn-primary"><%= t('fedi.follow_btn') %></button>
-      <a href="/" class="btn"><%= t('comments.cancel') %></a>
-    </div>
-  </form>
-  <p class="auth-interact-note"><%= t('fedi.remote_as', { site: siteTitle }) %></p>
-  <% } else { %>
-  <h1 class="auth-interact-title"><%= t('fedi.remote_title') %></h1>
-
-  <% if (!target) { %>
-    <p class="auth-interact-note"><%= t('fedi.remote_notfound') %></p>
-    <form method="get" action="/authorize_interaction" class="auth-interact-form">
-      <input type="url" name="uri" placeholder="https://…/een-post" value="<%= uri || '' %>" required>
-      <button type="submit" class="btn btn-primary"><%= t('fedi.remote_load') %></button>
-    </form>
-  <% } else { %>
-    <p class="auth-interact-meta">
-      <%= t('fedi.remote_replying_to') %>
-      <a href="<%= target.actor_url %>" rel="nofollow noopener" target="_blank"><strong><%= target.actor_name %></strong></a>
-      <span class="fedi-handle"><%= target.actor_handle %></span>
-    </p>
-    <blockquote class="auth-interact-preview">
-      <% if (target.content) { %><div class="auth-interact-content"><%- target.content %></div>
-      <% } else if (target.preview) { %><%= target.preview %><% } %>
-      <% (target.images || []).forEach(function(im){ %>
-        <img class="auth-interact-img" src="<%= im %>" alt="" loading="lazy">
-      <% }); %>
-    </blockquote>
-    <% if (target.url || uri) { %>
-      <a class="auth-view-src" href="<%= target.url || uri %>" target="_blank" rel="nofollow noopener">
-        <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg>
-        <span><%= t('fedi.remote_view_original') %></span>
-      </a>
-    <% } %>
-    <% if (target.poll) { var _pl = target.poll; var _tot = _pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _vn = (_pl.voters!=null?_pl.voters:_tot); %>
-      <% if (!_pl.closed) { %>
-        <form method="post" action="/authorize_interaction/vote" class="auth-poll">
-          <input type="hidden" name="uri" value="<%= uri %>">
-          <% _pl.options.forEach(function(o){ %>
-            <label class="auth-poll-choice"><input type="<%= _pl.multiple ? 'checkbox' : 'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label>
-          <% }); %>
-          <div class="auth-interact-actions">
-            <button type="submit" class="btn btn-primary"><%= t('poll.vote') %></button>
-          </div>
-          <p class="auth-poll-foot"><%= _vn %> <%= _vn === 1 ? t('poll.voter_one') : t('poll.voter_many') %> · <%= t('poll.open') %><% if (_pl.endTime) { %> · <%= t('poll.closes') %> <%= new Date(_pl.endTime).toLocaleString() %><% } %><% if (_pl.multiple) { %> · <%= t('poll.multiple') %><% } %></p>
-        </form>
-      <% } else { %>
-        <div class="auth-poll">
-          <% _pl.options.forEach(function(o){ var _pct = _tot ? Math.round((o.count||0)*100/_tot) : 0; %>
-            <div class="auth-poll-res"><span class="auth-poll-fill" style="width:<%= _pct %>%"></span><span class="auth-poll-name"><%= o.name %></span><span class="auth-poll-pct"><%= _pct %>%</span></div>
-          <% }); %>
-          <p class="auth-poll-foot"><%= _vn %> <%= _vn === 1 ? t('poll.voter_one') : t('poll.voter_many') %> · <%= t('poll.closed') %><% if (_pl.multiple) { %> · <%= t('poll.multiple') %><% } %></p>
-        </div>
-      <% } %>
-    <% } %>
-    <% var _liked = (typeof reacted !== 'undefined' && reacted.liked); var _boosted = (typeof reacted !== 'undefined' && reacted.boosted); %>
-    <div class="auth-interact-react">
-      <form method="post" action="/authorize_interaction/like" class="fedi-react-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <button type="submit" class="fedi-bigact fedi-bigact-like<%= _liked ? ' is-on' : '' %>" data-on="<%= t('fedi.unlike_short') %>" data-off="<%= t('fedi.like_short') %>">
-          <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-          <span class="fedi-bigact-label"><%= _liked ? t('fedi.unlike_short') : t('fedi.like_short') %></span>
-        </button>
-      </form>
-      <form method="post" action="/authorize_interaction/boost" class="fedi-react-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <button type="submit" class="fedi-bigact fedi-bigact-boost<%= _boosted ? ' is-on' : '' %>" data-on="<%= t('tl.unboost') %>" data-off="<%= t('fedi.boost_short') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
-          <span class="fedi-bigact-label"><%= _boosted ? t('tl.unboost') : t('fedi.boost_short') %></span>
-        </button>
-      </form>
-    </div>
-    <% if (locals.mayReply !== false) { %>
-    <p class="auth-interact-or"><%= t('fedi.or_reply') %></p>
-    <p class="auth-interact-where">💬 <%= t('fedi.reply_where') %></p>
-    <div class="auth-interact-form">
-      <%- include('../partials/reply-editor', {
-        action: '/authorize_interaction',
-        hiddenFields: [{ name: 'uri', value: uri }],
-        placeholder: t('fedi.reply_ph'),
-        submitLabel: t('fedi.send'),
-        rows: 4,
-        participants: (target && target.actor_uri) ? [{
-          uri: target.actor_uri,
-          url: target.actor_url || target.actor_uri,
-          handle: target.actor_handle || '',
-        }] : [],
-      }) %>
-      <div class="auth-interact-actions"><a href="<%= uri %>" class="btn"><%= t('comments.cancel') %></a></div>
-    </div>
-    <% } %>
-    <p class="auth-interact-note"><%= t('fedi.remote_as', { site: siteTitle }) %></p>
-    <details class="auth-report">
-      <summary>🚩 <%= t('fedi.report_open') %></summary>
-      <form method="post" action="/authorize_interaction/report" class="auth-interact-form">
-        <input type="hidden" name="uri" value="<%= uri %>">
-        <input type="hidden" name="actor_uri" value="<%= target.actor_uri %>">
-        <p class="auth-report-note"><%= t('fedi.report_where') %></p>
-        <textarea name="reason" rows="3" maxlength="3000" placeholder="<%= t('fedi.report_ph') %>"></textarea>
-        <div class="auth-interact-actions">
-          <button type="submit" class="btn btn-danger"><%= t('fedi.report_send') %></button>
-        </div>
-      </form>
-    </details>
-  <% } %>
-  <% } %>
-</div>
-
-<style>
-  .auth-interact { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .auth-interact-title { margin: 0 0 1rem; }
-  .auth-interact-meta { color: var(--ink-soft, #888); margin: 0 0 .75rem; }
-  .auth-interact-preview {
-    margin: 0 0 1rem; padding: .75rem 1rem; border-left: 3px solid var(--accent, #888);
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border-radius: 0 12px 12px 0;
-    color: var(--ink, inherit);
-  }
-  .auth-interact-form { display: flex; flex-direction: column; gap: .75rem; }
-  .auth-interact-form textarea, .auth-interact-form input[type=url] {
-    width: 100%; box-sizing: border-box; padding: .6rem .8rem; font: inherit; resize: vertical;
-    border-radius: 12px; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent);
-    background: var(--paper, #fff); color: var(--ink, #000);
-  }
-  .auth-interact-actions { display: flex; gap: .5rem; align-items: center; }
-  .auth-interact-note { color: var(--ink-soft, #999); font-size: .85rem; margin-top: 1rem; }
-  .auth-interact-where { margin: .25rem 0 .85rem; padding: .6rem .8rem; font-size: .85rem; line-height: 1.5; border-radius: 10px; background: color-mix(in srgb, var(--accent, #888) 12%, transparent); color: var(--ink, inherit); }
-  .auth-interact-content { line-height: 1.55; }
-  .auth-interact-content p { margin: .35rem 0; }
-  .auth-interact-content p:first-child { margin-top: 0; }
-  .auth-interact-img { max-width: 100%; height: auto; border-radius: 10px; margin: .5rem 0 0; display: block; }
-  .auth-interact-orig { margin: .75rem 0 0; font-size: .85rem; }
-  .auth-interact-orig a { color: var(--accent, #06c); }
-  .auth-view-src { display: inline-flex; align-items: center; gap: .5rem; margin: 0 0 1.1rem; padding: .6rem 1rem;
-    border: 1px solid color-mix(in srgb, var(--accent, #888) 45%, transparent); border-radius: 12px;
-    background: color-mix(in srgb, var(--accent, #888) 10%, transparent); color: var(--accent, #06c);
-    text-decoration: none; font-weight: 600; font-size: .92rem; transition: background .15s, border-color .15s; }
-  .auth-view-src:hover { background: color-mix(in srgb, var(--accent, #888) 18%, transparent); border-color: var(--accent, #888); }
-  .auth-interact-react { display: flex; gap: .6rem; flex-wrap: wrap; margin: 0 0 .25rem; }
-  .auth-interact-react form { margin: 0; }
-  .fedi-bigact { display: inline-flex; align-items: center; gap: .5rem; padding: .55rem 1.1rem; border-radius: 999px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent);
-    color: var(--ink, inherit); font: inherit; font-weight: 600; font-size: .92rem; cursor: pointer; white-space: nowrap;
-    transition: background .12s, border-color .12s; }
-  .fedi-bigact svg { width: 17px; height: 17px; flex: 0 0 17px; }
-  .fedi-bigact-like svg { color: #e8b04b; }
-  .fedi-bigact-like:hover, .fedi-bigact-like.is-on { background: color-mix(in srgb, #e8b04b 16%, transparent); border-color: color-mix(in srgb, #e8b04b 55%, transparent); }
-  .fedi-bigact-boost svg { color: #2fa85a; }
-  .fedi-bigact-boost:hover, .fedi-bigact-boost.is-on { background: color-mix(in srgb, #2fa85a 16%, transparent); border-color: color-mix(in srgb, #2fa85a 55%, transparent); }
-  .fedi-manage { list-style: none; padding: 0; margin: 1.25rem 0 1rem; display: flex; flex-direction: column; gap: .7rem; }
-  .fedi-manage-item { padding: .9rem 1rem; border-radius: 14px; background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent); transition: border-color .15s ease, box-shadow .15s ease; }
-  .fedi-manage-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 28%, transparent); box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .fedi-manage-head { display: flex; align-items: center; gap: .5rem; margin: 0 0 .5rem; }
-  .fedi-manage-icon { flex: 0 0 26px; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
-    background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); }
-  .fedi-manage-icon svg { width: 14px; height: 14px; }
-  .fedi-manage-to { font-weight: 600; color: var(--ink, inherit); font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .fedi-manage-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .fedi-manage-content { line-height: 1.55; overflow-wrap: anywhere; padding-left: calc(26px + .5rem); }
-  .fedi-manage-content p { margin: .15rem 0; }
-  .fedi-manage-content p:first-child { margin-top: 0; }
-  .fedi-manage-content p:last-child { margin-bottom: 0; }
-  .fedi-manage-foot { display: flex; align-items: center; justify-content: flex-start; flex-wrap: wrap; gap: .5rem; margin-top: .65rem; }
-  .fedi-goto-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, var(--accent, #888) 45%, transparent); background: color-mix(in srgb, var(--accent, #888) 12%, transparent); color: var(--accent, #06c); border-radius: 999px; padding: .3rem .8rem; font-size: .82rem; text-decoration: none; transition: background .15s ease; }
-  .fedi-goto-btn:hover { background: color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-  .fedi-del-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, #d9534f 45%, transparent); background: color-mix(in srgb, #d9534f 12%, transparent); color: #e06b66; border-radius: 999px; padding: .3rem .8rem; font: inherit; font-size: .82rem; cursor: pointer; transition: background .15s ease; }
-  .fedi-del-btn:hover { background: color-mix(in srgb, #d9534f 24%, transparent); }
-  .fedi-bm { margin: 0 0 1.5rem; padding: 1rem; border-radius: 14px;
-    border: 1px dashed color-mix(in srgb, var(--accent, #888) 45%, transparent);
-    background: color-mix(in srgb, var(--accent, #888) 7%, transparent); }
-  .fedi-bm-btn { display: inline-flex; align-items: center; gap: .45rem; padding: .5rem 1rem; border-radius: 999px;
-    background: var(--accent, #06c); color: #fff; font: inherit; font-weight: 600; font-size: .9rem; text-decoration: none;
-    cursor: grab; user-select: none; box-shadow: 0 2px 8px color-mix(in srgb, var(--accent, #000) 30%, transparent); }
-  .fedi-bm-btn:active { cursor: grabbing; }
-  .fedi-bm-btn.nudge { animation: fedi-bm-nudge .6s ease; }
-  @keyframes fedi-bm-nudge { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
-  .fedi-bm-help { color: var(--ink-soft, #888); font-size: .82rem; line-height: 1.5; margin: .6rem 0 0; }
-  .fedi-edit-btn { display: inline-flex; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, var(--ink, #888) 28%, transparent);
-    background: color-mix(in srgb, var(--ink, #888) 8%, transparent); color: var(--ink-soft, #aaa); border-radius: 999px; padding: .3rem .8rem;
-    font: inherit; font-size: .82rem; cursor: pointer; transition: background .15s ease, color .15s ease; }
-  .fedi-edit-btn:hover { background: color-mix(in srgb, var(--ink, #888) 16%, transparent); }
-  .fedi-edit-btn.is-open { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
-  .fedi-del-form { margin-left: auto; }
-  .fedi-edit-form { display: none; margin: .55rem 0 0; }
-  .fedi-edit-form.is-open { display: block; }
-  .fedi-edit-ta { width: 100%; box-sizing: border-box; min-height: 70px; resize: vertical; padding: .55rem .7rem;
-    border: 1.5px solid var(--rule, #333); border-radius: 10px; background: var(--paper-2, #14141a); color: var(--ink, #eee); font: inherit; font-size: .9rem; }
-  .fedi-edit-ta:focus { outline: none; border-color: var(--accent); }
-  .fedi-edit-actions { display: flex; justify-content: flex-end; margin-top: .5rem; }
-  .fedi-edit-save { padding: .4rem 1rem; border: 0; border-radius: 999px; background: var(--accent); color: var(--paper, #fff);
-    font: inherit; font-weight: 600; font-size: .82rem; cursor: pointer; }
-  /* Poll ballot / results on the interact page */
-  .auth-poll { display: flex; flex-direction: column; gap: 8px; margin: 0 0 1rem; }
-  .auth-poll-choice { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; cursor: pointer;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-  .auth-poll-choice:hover { border-color: var(--accent); }
-  .auth-poll-choice input { accent-color: var(--accent); }
-  .auth-poll-res { position: relative; padding: 9px 12px; border-radius: 10px; overflow: hidden; display: flex; align-items: center; gap: 8px;
-    background: color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .auth-poll-fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 22%, transparent); z-index: 0; }
-  .auth-poll-name { position: relative; z-index: 1; flex: 1; font-size: .95rem; }
-  .auth-poll-pct { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 500; }
-  .auth-poll-foot { font-size: .82rem; color: var(--ink-soft, #888); margin: .1rem 0 0; }
-  .auth-report { margin: .6rem 0 0; }
-  .auth-report summary { cursor: pointer; font-size: .85rem; color: var(--ink-soft, #888); }
-  .auth-report summary:hover, .auth-report[open] summary { color: #c0392b; }
-  .auth-report[open] summary { margin-bottom: .5rem; }
-  .auth-report-note { font-size: .82rem; color: var(--ink-muted, #999); margin: 0 0 .5rem; }
-</style>
-
-
Index: src/views/pages/blocks.ejs
===================================================================
--- src/views/pages/blocks.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,42 +1,0 @@
-<div class="bl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'blokkeren' }) %>
-  <h1 class="bl-title"><%= t('blk.title') %></h1>
-  <p class="bl-lead"><%= t('blk.lead') %></p>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <form method="post" action="/blocking/add" class="bl-form">
-    <input type="text" name="target" placeholder="@naam@server.social  •  server.social" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('blk.block_btn') %></button>
-  </form>
-
-  <% if (!blocks || !blocks.length) { %>
-    <p class="bl-empty"><%= t('blk.empty') %></p>
-  <% } else { %>
-    <ul class="bl-list">
-      <% blocks.forEach(function(b){ %>
-        <li class="bl-item">
-          <span class="bl-kind"><%= b.kind === 'domain' ? '🌐' : '👤' %></span>
-          <span class="bl-label"><%= b.label || b.target %></span>
-          <form method="post" action="/blocking/remove"><input type="hidden" name="target" value="<%= b.target %>"><button type="submit" class="bl-unblock"><%= t('blk.unblock') %></button></form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-  .bl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .bl-title { margin: 0 0 .25rem; }
-  .bl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; }
-  .bl-form { display: flex; gap: .5rem; margin: 0 0 1.25rem; flex-wrap: wrap; }
-  .bl-form input { flex: 1; min-width: 220px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .bl-empty { color: var(--ink-soft, #888); }
-  .bl-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .5rem; }
-  .bl-item { display: flex; align-items: center; gap: .6rem; padding: .55rem .8rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); border: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent); }
-  .bl-kind { font-size: 1.05rem; }
-  .bl-label { flex: 1; min-width: 0; overflow-wrap: anywhere; }
-  .bl-unblock { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .bl-unblock:hover { color: var(--ink, #000); }
-</style>
Index: src/views/pages/changelog.ejs
===================================================================
--- src/views/pages/changelog.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,38 +1,0 @@
-<div class="container changelog-page">
-  <p><a href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/" class="btn">&larr; <%= t('chlog.back') %></a></p>
-  <h1><%= t('chlog.title') %></h1>
-  <p class="cl-meta">
-    <%= t('chlog.app_version') %> <strong>v<%= appVersion %></strong>
-    <% if (typeof user !== 'undefined' && user && user.role === 'god') { %>
-      <span class="cl-sep">·</span> <a href="/admin/updates"><%= t('chlog.manage_updates') %></a>
-    <% } %>
-  </p>
-  <div class="changelog-body"><%- changelogHtml %></div>
-</div>
-
-<style>
-.changelog-page { max-width: 760px; margin: 3rem auto; padding: 0 1rem; }
-.changelog-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.4rem; }
-.cl-meta { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.75rem; }
-.cl-meta a { color: var(--accent); }
-.cl-sep { opacity: 0.5; margin: 0 0.2rem; }
-.changelog-body { color: var(--ink); line-height: 1.65; }
-.changelog-body h2 {
-  font-family: var(--font-display, serif); font-size: 1.3rem;
-  margin: 2rem 0 0.5rem; padding-top: 1.25rem; border-top: 1px solid var(--rule);
-}
-.changelog-body h2:first-of-type { border-top: none; padding-top: 0; margin-top: 1rem; }
-.changelog-body h3 { font-size: 1rem; margin: 1.1rem 0 0.4rem; color: var(--ink-soft, var(--ink-muted)); }
-.changelog-body ul { margin: 0.3rem 0 0.9rem; padding-left: 1.3rem; }
-.changelog-body li { margin: 0.25rem 0; }
-.changelog-body blockquote {
-  margin: 1rem 0; padding: 0.6rem 0.9rem;
-  border-left: 3px solid var(--accent); background: var(--paper-2);
-  border-radius: 0 8px 8px 0; font-size: 0.9rem; color: var(--ink-muted);
-}
-.changelog-body code {
-  background: var(--paper-2); padding: 0.05rem 0.35rem;
-  border-radius: 4px; font-size: 0.88em;
-}
-.changelog-body a { color: var(--accent); }
-</style>
Index: src/views/pages/circle-feed.ejs
===================================================================
--- src/views/pages/circle-feed.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,135 +1,0 @@
-<div class="container cirkel-hero">
-  <span class="cirkel-badge" aria-hidden="true">
-    <svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
-      <defs>
-        <marker id="cf-a" viewBox="0 0 10 10" refX="6.5" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path d="M1.5 1.5 L8.5 5 L1.5 8.5 Z" fill="currentColor" stroke="none"/></marker>
-      </defs>
-      <line x1="28.95" y1="43.5" x2="63.05" y2="26.5" stroke-width="2.6" marker-start="url(#cf-a)" marker-end="url(#cf-a)"/>
-      <line x1="72" y1="32" x2="72" y2="66" stroke-width="2.6" marker-end="url(#cf-a)"/>
-      <line x1="28.8" y1="52.7" x2="63.2" y2="71.3" stroke-width="2.6" marker-end="url(#cf-a)"/>
-      <circle cx="20" cy="48" r="7" stroke-width="2.4"/>
-      <circle cx="72" cy="22" r="7" stroke-width="2.4"/>
-      <circle cx="72" cy="76" r="7" stroke-width="2.4"/>
-    </svg>
-  </span>
-  <h1><%= t('cfeed.title') %></h1>
-  <p class="cirkel-sub"><%= t('cfeed.sub') %></p>
-  <% if (typeof sites !== 'undefined' && sites && sites.length) { %>
-    <% if (sites.length <= 5) { %>
-    <div class="cirkel-members">
-      <% sites.forEach(function(s){ %>
-        <% if (s.url) { %><a class="cirkel-member" href="<%= s.url %>" target="_blank" rel="noopener" title="<%= s.name %>"><% } else { %><span class="cirkel-member" title="<%= s.name %>"><% } %>
-          <span class="cirkel-avatar"<% if (s.avatar) { %> style="background-image:url('<%= s.avatar %>');background-color:transparent"<% } %>><% if (!s.avatar) { %><%= (s.name || '?').charAt(0).toUpperCase() %><% } %></span>
-          <span class="cirkel-member-name"><%= s.name %></span>
-        <% if (s.url) { %></a><% } else { %></span><% } %>
-      <% }); %>
-    </div>
-    <% } else { %>
-      <%# More than 5: the count pill becomes a button that opens a modal listing every
-          member (avatar + name + link). CSS-only via a hidden checkbox — no JS, so it's
-          CSP- (no inline handler) and htmx-safe. Checkbox must precede .cirkel-modal so
-          the `:checked ~` sibling selector matches. %>
-      <input type="checkbox" id="cirkel-more-cb" class="cirkel-more-cb" hidden>
-      <label for="cirkel-more-cb" class="cirkel-count cirkel-count-btn" role="button" tabindex="0"><%= t('cfeed.count', { n: sites.length }) %></label>
-      <div class="cirkel-modal" role="dialog" aria-modal="true" aria-label="<%= t('cfeed.title') %>">
-        <label for="cirkel-more-cb" class="cirkel-modal-backdrop" aria-label="<%= t('cfeed.close') %>"></label>
-        <div class="cirkel-modal-card">
-          <label for="cirkel-more-cb" class="cirkel-modal-x" aria-label="<%= t('cfeed.close') %>" role="button" tabindex="0">&times;</label>
-          <h2 class="cirkel-modal-h"><%= t('cfeed.count', { n: sites.length }) %></h2>
-          <ul class="cirkel-modal-list">
-            <% sites.forEach(function(s){ %>
-              <li>
-                <% if (s.url) { %><a class="cirkel-modal-link" href="<%= s.url %>" target="_blank" rel="noopener"><% } else { %><span class="cirkel-modal-link"><% } %>
-                  <span class="cirkel-avatar"<% if (s.avatar) { %> style="background-image:url('<%= s.avatar %>');background-color:transparent"<% } %>><% if (!s.avatar) { %><%= (s.name || '?').charAt(0).toUpperCase() %><% } %></span>
-                  <span class="cirkel-member-name"><%= s.name %></span>
-                  <% if (s.url) { %><span class="cirkel-modal-go" aria-hidden="true">↗</span><% } %>
-                <% if (s.url) { %></a><% } else { %></span><% } %>
-              </li>
-            <% }); %>
-          </ul>
-        </div>
-      </div>
-    <% } %>
-  <% } %>
-</div>
-
-<% if (!posts.length) { %>
-  <div class="container">
-    <p class="cirkel-empty"><%= t('cfeed.empty') %></p>
-  </div>
-<% } else { %>
-
-  <%# Same view as the home page: timeline (post-card) + grid (post-tile),
-      switched by the view-switcher via body[data-feed-view]. The remote posts
-      are shaped to the local post format in circle.js (with external_url,
-      so cards link to the source site). %>
-
-  <!-- ===== TIMELINE (standaard) ===== -->
-  <div class="container feed-timeline">
-    <ul class="post-list feed-timeline-list" id="post-list">
-      <% posts.forEach(function(post){ %>
-        <%- include('../partials/post-card', { post: post }) %>
-      <% }); %>
-    </ul>
-  </div>
-
-  <!-- ===== GRID (body[data-feed-view="grid"]) ===== -->
-  <section class="feed-grid container" aria-label="<%= t('cfeed.grid_view') %>">
-    <div class="grid-tiles" id="grid-tiles">
-      <% posts.forEach(function(post){ %>
-        <%- include('../partials/post-tile', { post: post }) %>
-      <% }); %>
-    </div>
-  </section>
-
-  <% if (typeof hasMore !== 'undefined') { %>
-  <div class="container feed-more-wrap">
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: '/cirkel' }) %>
-  </div>
-  <% } %>
-
-<% } %>
-
-<style>
-.cirkel-hero { text-align: center; max-width: 640px; margin: 2.5rem auto 2rem; }
-.cirkel-badge { display: block; width: 100px; height: 100px; margin: 0 auto .7rem; color: var(--accent);
-  filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 65%, transparent)) drop-shadow(0 0 1px var(--accent)); }
-.cirkel-badge svg { width: 100%; height: 100%; }
-.cirkel-hero h1 { font-family: var(--font-display, serif); font-size: 2.4rem; margin: 0 0 .3rem; }
-.cirkel-sub { color: var(--ink-muted); margin: 0 0 1.25rem; font-size: 1.02rem; }
-.cirkel-members { display: flex; flex-wrap: wrap; gap: .55rem; justify-content: center; }
-.cirkel-member { display: inline-flex; align-items: center; gap: .45rem;
-  padding: .28rem .8rem .28rem .3rem; border: 1px solid var(--rule); border-radius: 999px;
-  text-decoration: none; color: var(--ink); background: var(--paper-2);
-  transition: border-color .15s, transform .15s; }
-.cirkel-member:hover { border-color: var(--accent); transform: translateY(-1px); }
-.cirkel-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
-  background-size: cover; background-position: center; background-color: var(--accent);
-  color: #fff; display: inline-flex; align-items: center; justify-content: center;
-  font-weight: 700; font-size: .82rem; }
-.cirkel-member-name { font-size: .9rem; font-weight: 600; }
-.cirkel-count { display: inline-block; color: var(--ink-muted); font-size: .92rem; font-weight: 600;
-  padding: .35rem 1rem; border: 1px solid var(--rule); border-radius: 999px; background: var(--paper-2); }
-.cirkel-count-btn { cursor: pointer; user-select: none; transition: border-color .15s, color .15s, transform .15s; }
-.cirkel-count-btn:hover { border-color: var(--accent); color: var(--ink); transform: translateY(-1px); }
-.cirkel-empty { color: var(--ink-muted); text-align: center; margin: 2rem auto; }
-.cirkel-empty a { color: var(--accent); }
-/* CSS-only "more members" modal (toggled by the hidden checkbox, no JS) */
-.cirkel-more-cb { position: absolute; width: 0; height: 0; opacity: 0; pointer-events: none; }
-.cirkel-modal { display: none; position: fixed; inset: 0; z-index: 1000; align-items: center; justify-content: center; padding: 1rem; }
-.cirkel-more-cb:checked ~ .cirkel-modal { display: flex; }
-.cirkel-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.45); cursor: pointer; }
-.cirkel-modal-card { position: relative; background: var(--paper); color: var(--ink); border-radius: 18px;
-  padding: 1.4rem 1.2rem; width: min(420px, 92vw); max-height: 78vh; overflow-y: auto; text-align: left;
-  box-shadow: 0 20px 60px rgba(0,0,0,.32); }
-.cirkel-modal-x { position: absolute; top: .55rem; right: .65rem; width: 34px; height: 34px; border-radius: 50%;
-  display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.45rem;
-  line-height: 1; color: var(--ink-muted); background: var(--paper-2); }
-.cirkel-modal-x:hover { color: var(--ink); }
-.cirkel-modal-h { font-family: var(--font-display, serif); font-size: 1.3rem; margin: .1rem 0 1rem; text-align: center; }
-.cirkel-modal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .25rem; }
-.cirkel-modal-link { display: flex; align-items: center; gap: .6rem; padding: .45rem .5rem; border-radius: 12px;
-  text-decoration: none; color: var(--ink); transition: background .15s; }
-.cirkel-modal-link:hover { background: var(--paper-2); }
-.cirkel-modal-go { margin-left: auto; color: var(--accent); font-weight: 700; }
-</style>
Index: src/views/pages/connect.ejs
===================================================================
--- src/views/pages/connect.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,198 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'connect' }) %>
-  <h1 class="tl-title"><%= t('tl.connect') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <%# FEP-633c §2 + §3.6: who guards this account, and how available each of
-      them is. Connect is the "who am I connected to" page, and a guardian is
-      the one connection a ward should never have to hunt for. Availability in
-      buddy-list language on the responsibility axis; the labels are UI, the
-      wire keeps the spec terms. Never public: this page is the owner's. %>
-  <% if (typeof myGuardians !== 'undefined' && myGuardians.length) { %>
-    <section class="conn-guardians">
-      <h2 class="conn-gtitle">&#128737; <%= t('msg.guardians_label') %></h2>
-      <ul class="conn-glist">
-        <% myGuardians.forEach(function(g){
-             var _st = g.availability === 'away' ? 'away' : (g.availability === 'dormant' ? 'dormant' : 'active');
-             var _lbl = _st === 'away' ? t('msg.g_away', { date: g.awayUntil ? formatDate(g.awayUntil) : '?' })
-                      : (_st === 'dormant' ? t('msg.g_offline') : t('msg.g_available')); %>
-          <li class="conn-guardian">
-            <span class="conn-gdot conn-gdot--<%= _st %>" aria-hidden="true"></span>
-            <span class="conn-ghandle"><%= g.handle %></span>
-            <span class="conn-gstate"><%= _lbl %></span>
-          </li>
-        <% }); %>
-      </ul>
-    </section>
-  <% } %>
-
-  <% var _moved = (typeof movedTo !== 'undefined' && movedTo) ? movedTo : null; %>
-  <% if (_moved) { %>
-    <%# De uitgaande kant staat op slot (FEP-7628). Dat hoort te blijken voordat
-        je op een knop drukt, niet daarna uit een foutmelding. Lezen blijft wel
-        gewoon werken, dus de lijst hieronder staat er nog. %>
-    <div class="alert alert-error tl-moved">
-      <strong><%= t('tl.moved_title') %></strong>
-      <p><%= t('tl.moved_lead') %> <a href="<%= _moved %>"><%= _moved %></a></p>
-      <p class="form-hint"><%= t('tl.moved_hint') %></p>
-    </div>
-  <% } else { %>
-  <form method="post" action="/news/follow" class="tl-follow">
-    <input type="text" name="handle" placeholder="@naam@server.social of site.com" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('tl.follow_btn') %></button>
-    <label class="tl-follow-ab" title="<%= t('tl.autoboost_hint') %>"><input type="checkbox" name="auto_boost" value="1"> &#10024; <%= t('tl.autoboost_follow') %></label>
-  </form>
-  <% } %>
-
-  <%# Je volglijst meenemen staat sinds 14-8 op /admin/migrate, bij de rest van
-      de verhuizing (Robin: alle migratie-opties op de migrate-pagina). Hier
-      stond hij bovendien zonder enige opmaak: labels als losse tekst naast hun
-      invoerveld. Op die pagina erft hij de kaartopmaak. %>
-
-  <%# De eigenaarspoort (Robins wens, 18-8): volgverzoeken die wachten op
-      een beslissing van de eigenaar. Alleen buiten voogdij — een ward ziet
-      dit nooit, want daar beslissen de guardians. De poort zelf zit hier
-      ook: aan/uit op de plek waar de verzoeken staan. %>
-  <% if (!(myGuardians && myGuardians.length)) { %>
-    <form method="post" action="/connect/approve-followers" class="tl-foll-ab" title="<%= t('tl.approve_toggle_hint') %>">
-      <label><input type="checkbox" name="on" value="1" <%= (typeof approveFollowers !== 'undefined' && approveFollowers) ? 'checked' : '' %> data-autosubmit> &#128737; <%= t('tl.approve_toggle') %></label>
-    </form>
-  <% } %>
-  <% var folreqs = (typeof followRequests !== 'undefined' && followRequests) || []; %>
-  <% if (folreqs.length) { %>
-    <h2 class="conn-h"><%= t('tl.folreq_title') %></h2>
-    <p class="conn-unreach-lead"><%= t('tl.folreq_sub') %></p>
-    <ul class="tl-foll-list">
-      <% folreqs.forEach(function (f) { var disp = f.follower_name || f.follower_handle || f.follower_uri; %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (f.follower_icon) { %><img src="<%= avatar(f.follower_icon, 96) %>" alt=""><% } else { %><%= String(disp || '?').replace(/^@/, '').charAt(0).toUpperCase() %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.follower_uri %>" target="_blank" rel="nofollow noopener"><%= disp %></a>
-            <% if (f.follower_handle && f.follower_name) { %><span class="conn-sub"><%= f.follower_handle %></span><% } %>
-          </span>
-          <form method="post" action="/follow-requests/approve"><input type="hidden" name="id" value="<%= f.id %>"><button type="submit" class="btn btn-primary"><%= t('tl.folreq_accept') %></button></form>
-          <form method="post" action="/follow-requests/deny"><input type="hidden" name="id" value="<%= f.id %>"><button type="submit" class="tl-unfollow"><%= t('tl.folreq_deny') %></button></form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <%
-    var conns = connections || [];
-    var reachable   = conns.filter(function (c) { return !c.unreachable; });
-    var unreachable = conns.filter(function (c) { return c.unreachable; });
-    // Display fields for one connection: name/handle (fallback: derive from actor_uri).
-    function connView(c) {
-      var host = '', user = '';
-      try { var u = new URL(c.actor_uri); host = u.hostname; user = (u.pathname.split('/').filter(Boolean).pop() || ''); } catch (e) {}
-      var handle = c.handle || (user ? ('@' + user + '@' + host) : c.actor_uri);
-      var display = c.name || handle;
-      return { handle: handle, display: display,
-               initial: String(display || '?').replace(/^@/, '').charAt(0).toUpperCase(),
-               profileUrl: c.url || c.actor_uri };
-    }
-    function arrow(dir) { return dir === 'mutual' ? '↔' : (dir === 'following' ? '→' : '←'); }
-  %>
-
-  <% if (reachable.length) { %>
-    <ul class="tl-foll-list">
-      <% reachable.forEach(function (c) { var v = connView(c); %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
-            <span class="conn-sub">
-              <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
-              <% if (c.follower && c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } %>
-            </span>
-          </span>
-          <% if (c.following) { %>
-            <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
-              <input type="hidden" name="actor_uri" value="<%= c.actor_uri %>">
-              <label><input type="checkbox" name="auto_boost" value="1" <%= c.auto_boost ? 'checked' : '' %> data-autosubmit> &#10024; <%= t('tl.autoboost') %></label>
-            </form>
-            <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= c.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
-          <% } %>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.connect_empty') %></p>
-  <% } %>
-
-  <% if (unreachable.length) { %>
-    <details class="conn-unreach">
-      <summary>&#9888; <%= t('tl.unreachable') %> (<%= unreachable.length %>)</summary>
-      <p class="conn-unreach-lead"><%= t('tl.unreachable_lead') %></p>
-      <ul class="tl-foll-list">
-        <% unreachable.forEach(function (c) { var v = connView(c); %>
-          <li class="tl-foll is-stale">
-            <span class="tl-foll-av"><% if (c.icon) { %><img src="<%= avatar(c.icon, 96) %>" alt=""><% } else { %><%= v.initial %><% } %></span>
-            <span class="tl-foll-meta">
-              <a href="<%= v.profileUrl %>" target="_blank" rel="nofollow noopener"><%= v.display %></a>
-              <span class="conn-sub">
-                <span class="conn-dir conn-dir--<%= c.direction %>"><%= arrow(c.direction) %> <%= t('tl.dir_' + c.direction) %></span>
-                <% if (c.last_delivery_at) { %><span class="conn-deliv">&middot; <%= t('tl.last_delivery') %>: <%= formatDateTime(c.last_delivery_at) %></span><% } else { %><span class="conn-never">&middot; <%= t('tl.never_delivered') %></span><% } %>
-                <% if (c.last_error_at) { %><span class="conn-err">&middot; <%= t('tl.delivery_failed') %>: <%= formatDateTime(c.last_error_at) %></span><% } %>
-              </span>
-            </span>
-            <% if (c.follower_id) { %>
-              <form method="post" action="/followers/<%= c.follower_id %>/remove" onsubmit="return confirm('<%= t('tl.remove_confirm') %>')">
-                <button type="submit" class="tl-unfollow"><%= t('tl.remove_follower') %></button>
-              </form>
-            <% } %>
-          </li>
-        <% }); %>
-      </ul>
-    </details>
-  <% } %>
-</div>
-
-<style>
-  /* Guardians (FEP-633c 3.6): the buddy-list dot on the responsibility axis.
-     Green available, yellow declared away with an end, grey observed dormant
-     (one answer restores). Set apart from the follow list on purpose: these
-     are not people you chose to follow, they are the ones looking after you. */
-  .conn-guardians { margin: 0 0 1.25rem; padding: .8rem 1rem; border-radius: 12px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
-    border-left: 3px solid #35a2ff; }
-  .conn-gtitle { margin: 0 0 .5rem; font-size: .95rem; }
-  .conn-glist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .35rem; }
-  .conn-guardian { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
-  .conn-gdot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
-  .conn-gdot--active { background: #4caf7d; }
-  .conn-gdot--away { background: #e0a83a; }
-  .conn-gdot--dormant { background: #8a93a3; }
-  .conn-ghandle { font-weight: 600; }
-  .conn-gstate { color: var(--ink-soft, #888); font-size: .82rem; }
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .75rem; }
-  .tl-empty { color: var(--ink-soft, #888); }
-  .tl-follow { display: flex; gap: .5rem; margin: 0 0 1.25rem; flex-wrap: wrap; align-items: center; }
-  .tl-follow input[type="text"] { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .tl-follow-ab { font-size: .85rem; color: var(--ink-soft, #888); display: inline-flex; align-items: center; gap: .3rem; cursor: pointer; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll.is-stale { background: color-mix(in srgb, #c0392b 10%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-av img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .conn-sub { font-size: .78rem; color: var(--ink-soft, #888); display: inline-flex; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
-  .conn-dir { font-weight: 600; }
-  .conn-dir--mutual   { color: var(--accent, #06c); }
-  .conn-dir--following { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
-  .conn-dir--follower  { color: color-mix(in srgb, var(--ink, #000) 62%, transparent); }
-  .conn-never, .conn-err { color: #c0392b; }
-  .tl-foll-ab { margin: 0; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-foll-ab label { display: inline-flex; align-items: center; gap: .25rem; cursor: pointer; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; white-space: nowrap; }
-  .tl-unfollow:hover { color: var(--ink, #000); }
-  .conn-unreach { margin: 1.25rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .75rem; }
-  .conn-unreach summary { cursor: pointer; font-weight: 600; color: #c0392b; }
-  .conn-unreach-lead { font-size: .8rem; color: var(--ink-soft, #888); margin: .5rem 0 .75rem; }
-</style>
Index: src/views/pages/download.ejs
===================================================================
--- src/views/pages/download.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,56 +1,0 @@
-<%
-  var _ready_h1     = t('dl.ready_title');
-  var _ready_sub    = t('dl.ready_sub');
-  var _manual       = t('dl.manual');
-  var _capture_sub  = t('dl.capture_sub');
-  var _email_ph     = t('dl.email_ph');
-  var _download_btn = t('dl.download_btn');
-%>
-<%
-  var tr = (typeof dlTrack !== 'undefined') ? dlTrack : {};
-  var st = (typeof dlState !== 'undefined') ? dlState : 'form';
-  var fileUrl = siteUrlBase + '/download/' + tr.id + '/bestand';
-%>
-<section class="dl">
-  <div class="dl-card">
-    <div class="dl-head">
-      <% if (tr.cover_url) { %><span class="dl-cover" style="background-image:url('<%= tr.cover_url %>')"></span><% } else { %><span class="dl-cover dl-cover-empty">♪</span><% } %>
-      <div>
-        <div class="dl-title"><%= tr.title %></div>
-        <% if (tr.artist) { %><div class="dl-artist"><%= tr.artist %></div><% } %>
-      </div>
-    </div>
-
-    <% if (st === 'ready') { %>
-      <h1 class="dl-h1"><%= _ready_h1 %></h1>
-      <p class="dl-sub"><%= _ready_sub %></p>
-      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
-      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
-    <% } else { %>
-      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
-      <p class="dl-sub"><%= _capture_sub %></p>
-      <% if (typeof dlError !== 'undefined' && dlError) { %><p class="dl-err"><%= dlError %></p><% } %>
-      <form method="POST" action="<%= siteUrlBase %>/download/<%= tr.id %>" class="dl-form">
-        <input type="email" name="email" required placeholder="<%= _email_ph %>" value="<%= (typeof dlPrefill!=='undefined')?dlPrefill:'' %>" autocomplete="email">
-        <button type="submit" class="dl-go">⬇ <%= _download_btn %></button>
-      </form>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .dl { max-width: 520px; margin: 0 auto; padding: 48px 18px; }
-  .dl-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 28px; }
-  .dl-head { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
-  .dl-cover { width: 56px; height: 56px; border-radius: 10px; background-size: cover; background-position: center; display: grid; place-items: center; flex: 0 0 auto; }
-  .dl-cover-empty { background: var(--accent,#6b8f71); color: #fff; opacity: .85; }
-  .dl-title { font-weight: 700; font-size: 17px; }
-  .dl-artist { opacity: .65; font-size: 13px; }
-  .dl-h1 { font-size: clamp(22px,4.5vw,30px); margin: 0 0 8px; }
-  .dl-sub { opacity: .85; line-height: 1.55; margin: 0 0 18px; }
-  .dl-err { color: #c43c3c; margin: 0 0 12px; }
-  .dl-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .dl-form input { flex: 1 1 200px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
-</style>
-<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
Index: src/views/pages/downloads.ejs
===================================================================
--- src/views/pages/downloads.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,44 +1,0 @@
-<%
-  var ts = (typeof dlTracks !== 'undefined') ? dlTracks : [];
-  // NB: de loop hieronder gebruikt 't' als track-variabele, die de vertaal-t()
-  // overschaduwt. Daarom het knoplabel hier al ophalen.
-  var _dlBtn = t('downloads.btn');
-%>
-<article class="container post-page">
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-  <section class="dls">
-  <h1 class="dls-h1"><%= t('downloads.title') %></h1>
-  <p class="dls-sub"><%= t('downloads.sub') %></p>
-
-  <% if (!ts.length) { %>
-    <p class="dls-empty"><%= t('downloads.empty') %></p>
-  <% } else { %>
-    <ul class="dls-list">
-      <% ts.forEach(function(t){ %>
-        <li class="dls-item">
-          <% if (t.cover_url) { %><span class="dls-cover" style="background-image:url('<%= t.cover_url %>')"></span><% } else { %><span class="dls-cover dls-cover-empty">♪</span><% } %>
-          <span class="dls-meta">
-            <span class="dls-title"><%= t.title %></span>
-            <% if (t.artist) { %><span class="dls-artist"><%= t.artist %></span><% } %>
-          </span>
-          <a class="dls-btn" href="<%= siteUrlBase %>/download/<%= t.id %>"><%= _dlBtn %></a>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-  </section>
-</article>
-
-<style>
-  .dls { padding: 0 0 64px; }
-  .dls-h1 { font-size: clamp(26px,5vw,38px); margin: 0 0 8px; }
-  .dls-sub { opacity: .8; margin: 0 0 24px; }
-  .dls-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
-  .dls-item { display: flex; align-items: center; gap: 14px; padding: 10px 12px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; }
-  .dls-cover { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 8px; background-size: cover; background-position: center; display: grid; place-items: center; }
-  .dls-cover-empty { background: var(--accent,#6b8f71); color: #fff; opacity: .85; }
-  .dls-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .dls-title { font-weight: 600; }
-  .dls-artist { font-size: 12.5px; opacity: .65; }
-  .dls-btn { flex: 0 0 auto; padding: 9px 15px; border-radius: 999px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; font-size: 13.5px; }
-</style>
Index: src/views/pages/embed-player.ejs
===================================================================
--- src/views/pages/embed-player.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,104 +1,0 @@
-<!DOCTYPE html>
-<html lang="nl">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title><%= site.title %> — speler</title>
-<style>
-  :root { --ac: <%= (site && site.accent) ? site.accent : '#6b8f71' %>; }
-  * { box-sizing: border-box; }
-  html, body { margin: 0; }
-  body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: #14161a; color: #f2f3f5; }
-  .ep { display: flex; flex-direction: column; height: 100vh; }
-  .ep-now { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.08); }
-  .ep-play { flex: 0 0 auto; width: 44px; height: 44px; border-radius: 50%; border: none; background: var(--ac); color: #fff; font-size: 18px; cursor: pointer; display: grid; place-items: center; }
-  .ep-meta { flex: 1 1 auto; min-width: 0; }
-  .ep-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .ep-artist { font-size: 12px; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .ep-bar { height: 4px; background: rgba(255,255,255,.12); cursor: pointer; }
-  .ep-fill { height: 100%; width: 0; background: var(--ac); }
-  .ep-list { flex: 1 1 auto; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
-  .ep-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.05); }
-  .ep-item:hover { background: rgba(255,255,255,.05); }
-  .ep-item.on { color: var(--ac); }
-  .ep-num { width: 20px; text-align: right; opacity: .5; font-size: 13px; font-variant-numeric: tabular-nums; }
-  .ep-it-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
-  .ep-dur { opacity: .5; font-size: 12px; font-variant-numeric: tabular-nums; }
-  .ep-foot { padding: 8px 14px; font-size: 11px; opacity: .5; text-align: center; border-top: 1px solid rgba(255,255,255,.08); }
-  .ep-foot a { color: inherit; }
-  .ep-empty { padding: 24px; text-align: center; opacity: .6; }
-  /* Slim, themed scrollbar for the track list (instead of the chunky default). */
-  .ep-list { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.28) transparent; }
-  .ep-list::-webkit-scrollbar { width: 8px; }
-  .ep-list::-webkit-scrollbar-track { background: transparent; }
-  .ep-list::-webkit-scrollbar-thumb { background-color: rgba(255,255,255,.22); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
-  .ep-list::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--ac) 70%, #fff); }
-</style>
-</head>
-<body>
-<div class="ep">
-  <div class="ep-now">
-    <button class="ep-play" id="ep-play" aria-label="Afspelen">▶</button>
-    <div class="ep-meta">
-      <div class="ep-title" id="ep-title"><%= site.title %></div>
-      <div class="ep-artist" id="ep-artist">Selecteer een nummer</div>
-    </div>
-  </div>
-  <div class="ep-bar" id="ep-bar"><div class="ep-fill" id="ep-fill"></div></div>
-  <% if (!embedTracks.length) { %>
-    <div class="ep-empty">Geen nummers beschikbaar.</div>
-  <% } else { %>
-    <ul class="ep-list" id="ep-list">
-      <% embedTracks.forEach(function(t, i){ %>
-        <li class="ep-item" data-i="<%= i %>">
-          <span class="ep-num"><%= i+1 %></span>
-          <span class="ep-it-title"><%= t.title %><% if (t.artist) { %> — <span style="opacity:.6"><%= t.artist %></span><% } %></span>
-          <span class="ep-dur" data-dur="<%= t.duration || 0 %>"></span>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-  <div class="ep-foot">via <a href="<%= siteUrlBase %>/" target="_blank" rel="noopener"><%= site.title %></a> · Klonkt</div>
-</div>
-<audio id="ep-audio" preload="none" playsinline></audio>
-<script>
-(function(){
-  var TRACKS = <%- JSON.stringify(embedTracks) %>;
-  var audio = document.getElementById('ep-audio');
-  var playBtn = document.getElementById('ep-play');
-  var titleEl = document.getElementById('ep-title');
-  var artistEl = document.getElementById('ep-artist');
-  var fill = document.getElementById('ep-fill');
-  var bar = document.getElementById('ep-bar');
-  var list = document.getElementById('ep-list');
-  var cur = -1;
-  function fmt(s){ s = parseInt(s,10)||0; var m=Math.floor(s/60), x=s%60; return m+':'+String(x).padStart(2,'0'); }
-  // Vul de duur-labels.
-  document.querySelectorAll('.ep-dur').forEach(function(el){ var d=parseInt(el.getAttribute('data-dur'),10)||0; if(d) el.textContent=fmt(d); });
-  function mark(){ document.querySelectorAll('.ep-item').forEach(function(li){ li.classList.toggle('on', parseInt(li.getAttribute('data-i'),10)===cur); }); }
-  function load(i, play){
-    if(i<0||i>=TRACKS.length) return;
-    cur=i; var t=TRACKS[i];
-    titleEl.textContent=t.title||'Naamloos';
-    artistEl.textContent=t.artist||'';
-    audio.src=t.url;
-    mark();
-    if(play){ audio.play().catch(function(){}); }
-  }
-  playBtn.addEventListener('click', function(){
-    if(cur<0){ load(0, true); return; }
-    if(audio.paused) audio.play().catch(function(){}); else audio.pause();
-  });
-  if(list) list.addEventListener('click', function(e){
-    var li=e.target.closest('.ep-item'); if(!li) return;
-    load(parseInt(li.getAttribute('data-i'),10), true);
-  });
-  audio.addEventListener('play', function(){ playBtn.textContent='⏸'; });
-  audio.addEventListener('pause', function(){ playBtn.textContent='▶'; });
-  audio.addEventListener('timeupdate', function(){ if(audio.duration) fill.style.width=(audio.currentTime/audio.duration*100)+'%'; });
-  audio.addEventListener('ended', function(){ if(cur+1<TRACKS.length) load(cur+1, true); else { playBtn.textContent='▶'; fill.style.width='0'; } });
-  bar.addEventListener('click', function(e){ if(!audio.duration) return; var r=bar.getBoundingClientRect(); audio.currentTime=(e.clientX-r.left)/r.width*audio.duration; });
-})();
-</script>
-</body>
-</html>
Index: src/views/pages/epk.ejs
===================================================================
--- src/views/pages/epk.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,94 +1,0 @@
-<%
-  // Duur mm:ss
-  function fmtDur(s){ s = parseInt(s,10)||0; var m=Math.floor(s/60), x=s%60; return m+':'+String(x).padStart(2,'0'); }
-  var _accent = (site && site.accent) ? site.accent : '';
-  var _photo = (site && site.profile_photo) ? site.profile_photo : '';
-  var _isMail = epkContact && epkContact.indexOf('@') !== -1 && epkContact.indexOf(' ') === -1;
-  var _isUrl = epkContact && /^https?:\/\//i.test(epkContact);
-%>
-<section class="epk" <%= _accent ? ('style="--epk-accent:' + _accent + '"') : '' %>>
-  <header class="epk-hero">
-    <% if (_photo) { %><div class="epk-photo"><img src="<%= avatar(_photo, 320) %>" alt="<%= site.title %>"></div><% } %>
-    <div class="epk-hero-text">
-      <div class="epk-kicker"><%= t('epk.kicker') %><% if (typeof user !== 'undefined' && user && user.role === 'god') { %> <a class="epk-edit-link" href="/admin/epk">✎ <%= t('epk.edit') %></a><% } %></div>
-      <h1 class="epk-title"><%= site.title %></h1>
-      <% if (epkBio) { %><p class="epk-bio"><%= epkBio %></p><% } %>
-      <div class="epk-actions">
-        <% if (_isMail) { %>
-          <a class="epk-btn epk-btn-primary" href="mailto:<%= epkContact %>">✉ <%= t('epk.contact_booking') %></a>
-        <% } else if (_isUrl) { %>
-          <a class="epk-btn epk-btn-primary" href="<%= epkContact %>" target="_blank" rel="noopener"><%= t('epk.contact_booking') %></a>
-        <% } %>
-        <a class="epk-btn" href="<%= siteUrlBase %>/"><%= t('epk.view_site') %></a>
-        <% if (_photo) { %><a class="epk-btn" href="<%= _photo %>" download>⬇ <%= t('epk.press_photo') %></a><% } %>
-      </div>
-    </div>
-  </header>
-
-  <% if (epkTracks && epkTracks.length) { %>
-    <div class="epk-block">
-      <h2 class="epk-h2"><%= t('epk.most_played') %></h2>
-      <ul class="epk-tracks">
-        <% epkTracks.forEach(function(t, i){ %>
-          <li class="epk-track">
-            <span class="epk-rank"><%= i + 1 %></span>
-            <% if (t.cover_url) { %><span class="epk-cover" style="background-image:url('<%= t.cover_url %>')"></span><% } else { %><span class="epk-cover epk-cover-empty">♪</span><% } %>
-            <span class="epk-track-meta">
-              <span class="epk-track-title"><%= t.title %></span>
-              <% if (t.artist) { %><span class="epk-track-artist"><%= t.artist %></span><% } %>
-            </span>
-            <% if (t.duration) { %><span class="epk-track-dur"><%= fmtDur(t.duration) %></span><% } %>
-          </li>
-        <% }); %>
-      </ul>
-    </div>
-  <% } %>
-
-  <% if (epkPosts && epkPosts.length) { %>
-    <div class="epk-block">
-      <h2 class="epk-h2"><%= t('epk.recent') %></h2>
-      <ul class="epk-posts">
-        <% epkPosts.forEach(function(p){ %>
-          <li><a href="<%= siteUrlBase %>/<%= p.slug %>"><%= p.title %></a></li>
-        <% }); %>
-      </ul>
-    </div>
-  <% } %>
-
-  <footer class="epk-foot">
-    <% if (_isMail) { %><a href="mailto:<%= epkContact %>"><%= epkContact %></a> · <% } %>
-    <a href="<%= siteUrlBase %>/"><%= site.title %></a>
-  </footer>
-</section>
-
-<style>
-  .epk { max-width: 860px; margin: 0 auto; padding: 28px 18px 64px; --epk-accent: var(--accent, #6b8f71); }
-  .epk-hero { display: flex; gap: 26px; align-items: center; flex-wrap: wrap; margin-bottom: 40px; }
-  .epk-photo { flex: 0 0 auto; }
-  .epk-photo img { width: 190px; height: 190px; object-fit: cover; border-radius: 18px; box-shadow: 0 8px 30px rgba(0,0,0,.18); }
-  .epk-hero-text { flex: 1 1 280px; min-width: 240px; }
-  .epk-kicker { text-transform: uppercase; letter-spacing: .14em; font-size: 12px; font-weight: 700; color: var(--epk-accent); margin-bottom: 6px; }
-  .epk-edit-link { text-transform: none; letter-spacing: normal; font-weight: 600; font-size: 12px; margin-left: 8px; padding: 2px 8px; border: 1px solid rgba(128,128,128,.4); border-radius: 999px; color: inherit; opacity: .8; text-decoration: none; }
-  .epk-edit-link:hover { opacity: 1; border-color: var(--epk-accent); color: var(--epk-accent); }
-  .epk-title { font-size: clamp(30px, 6vw, 52px); line-height: 1.05; margin: 0 0 12px; }
-  .epk-bio { font-size: 16px; line-height: 1.6; opacity: .9; margin: 0 0 18px; max-width: 52ch; white-space: pre-line; }
-  .epk-actions { display: flex; gap: 10px; flex-wrap: wrap; }
-  .epk-btn { display: inline-block; padding: 9px 15px; border-radius: 999px; border: 1px solid rgba(128,128,128,.35); text-decoration: none; font-size: 13.5px; font-weight: 600; color: inherit; }
-  .epk-btn-primary { background: var(--epk-accent); border-color: var(--epk-accent); color: #fff; }
-  .epk-block { margin: 34px 0; }
-  .epk-h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .1em; opacity: .6; margin: 0 0 14px; }
-  .epk-tracks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .epk-track { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border: 1px solid rgba(128,128,128,.18); border-radius: 12px; }
-  .epk-rank { flex: 0 0 auto; width: 20px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; opacity: .45; }
-  .epk-cover { flex: 0 0 auto; width: 42px; height: 42px; border-radius: 8px; background-size: cover; background-position: center; display: grid; place-items: center; }
-  .epk-cover-empty { background: var(--epk-accent); color: #fff; opacity: .85; }
-  .epk-track-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .epk-track-title { font-weight: 600; }
-  .epk-track-artist { font-size: 12.5px; opacity: .65; }
-  .epk-track-dur { font-variant-numeric: tabular-nums; opacity: .6; font-size: 13px; }
-  .epk-posts { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
-  .epk-posts a { text-decoration: none; color: inherit; border-bottom: 1px solid transparent; }
-  .epk-posts a:hover { border-bottom-color: var(--epk-accent); }
-  .epk-foot { margin-top: 44px; padding-top: 18px; border-top: 1px solid rgba(128,128,128,.2); font-size: 13px; opacity: .7; }
-  .epk-foot a { color: inherit; }
-</style>
Index: src/views/pages/fan-gate.ejs
===================================================================
--- src/views/pages/fan-gate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,60 +1,0 @@
-<div class="container fg-navwrap">
-  <%# Same post navigation (Newer/Older) as on a regular post, so a visitor
-      on the fan gate can still browse rather than getting stuck. Same
-      dimensions as .post-page (720px / padding 0 1rem) for pixel-perfect alignment. %>
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-</div>
-
-<section class="fg">
-  <div class="fg-card">
-    <div class="fg-lock">🔒</div>
-    <h1 class="fg-h1"><%= t('fgate.title') %></h1>
-    <% if (typeof fgTitle !== 'undefined' && fgTitle) { %><p class="fg-which">"<%= fgTitle %>"</p><% } %>
-    <p class="fg-sub"><%= t('fgate.sub') %></p>
-
-    <%# De hoofdweg: bewijs bij je EIGEN server wie je bent (OpenWebAuth,
-        FEP-61cf). Dit is de vraag die fan_only altijd al stelde -- volg je deze
-        site? -- in plaats van "heb je hier een account", wat juist de mensen
-        buitensloot voor wie de poort openstond. Geen account hier, geen
-        wachtwoord hier, geen cookie van een derde. %>
-    <form class="fg-owa" method="post" action="/owa/login">
-      <input type="hidden" name="next" value="<%= fgNext || '/' %>">
-      <label class="fg-label" for="fg-handle"><%= t('fgate.owa_label') %></label>
-      <div class="fg-row">
-        <input class="fg-input" id="fg-handle" name="handle" type="text" inputmode="email"
-               autocomplete="username" spellcheck="false" placeholder="@jij@jouwserver.nl" required>
-        <button class="fg-btn" type="submit"><%= t('fgate.owa_go') %></button>
-      </div>
-      <p class="fg-hint"><%= t('fgate.owa_hint') %></p>
-      <% if (typeof owaError !== 'undefined' && owaError) { %>
-        <p class="fg-error"><%= t('fgate.owa_failed') %></p>
-      <% } %>
-    </form>
-
-    <%# De lokale inlog blijft, maar als tweede: die is voor wie hier echt een
-        account heeft, en dat is de uitzondering. %>
-    <p class="fg-alt"><a href="/auth/login?next=<%= encodeURIComponent(fgNext || '/') %>"><%= t('fgate.login') %></a></p>
-  </div>
-</section>
-
-<style>
-  /* EXACT same class combination (.container + override) and values as
-     .post-page in post.ejs, so the post-nav aligns identically in the cascade. */
-  .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
-  .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .fg-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 36px 28px; }
-  .fg-lock { font-size: 40px; margin-bottom: 8px; }
-  .fg-h1 { font-size: 26px; margin: 0 0 6px; }
-  .fg-which { font-style: italic; opacity: .8; margin: 0 0 12px; }
-  .fg-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .fg-btn { display: inline-block; padding: 12px 22px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; border: 0; font-family: inherit; font-size: 1rem; cursor: pointer; }
-  .fg-owa { margin: 0 0 18px; text-align: left; }
-  .fg-label { display: block; font-size: .85rem; opacity: .8; margin-bottom: 6px; }
-  .fg-row { display: flex; gap: 8px; flex-wrap: wrap; }
-  .fg-input { flex: 1 1 12rem; min-width: 0; padding: 12px 14px; border-radius: 10px;
-              border: 1px solid rgba(128,128,128,.35); background: transparent; color: inherit;
-              font-family: inherit; font-size: 1rem; }
-  .fg-hint { font-size: .8rem; opacity: .65; margin: 8px 0 0; line-height: 1.5; }
-  .fg-error { font-size: .85rem; margin: 8px 0 0; color: #d66; }
-  .fg-alt { margin: 0; font-size: .85rem; opacity: .7; }
-</style>
Index: src/views/pages/fedi-notifications.ejs
===================================================================
--- src/views/pages/fedi-notifications.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,86 +1,0 @@
-<div class="nt-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'meldingen' }) %>
-  <h1 class="nt-title"><%= t('notif.title') %></h1>
-  <% if (!items || !items.length) { %>
-    <p class="nt-empty"><%= t('notif.empty') %></p>
-  <% } else { %>
-    <ul class="nt-list">
-      <% items.forEach(function(n){
-           var _ic = n.type === 'follow' ? 'follow' : (n.type === 'like' ? 'like' : (n.type === 'announce' ? 'boost' : (n.type === 'report' ? 'report' : (n.type === 'mention' ? 'mention' : 'reply'))));
-      %>
-        <li class="nt-item nt-<%= _ic %>">
-          <span class="nt-icon" aria-hidden="true">
-            <% if (_ic === 'like') { %><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-            <% } else if (_ic === 'boost') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
-            <% } else if (_ic === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
-            <% } else if (_ic === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
-            <% } else if (_ic === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>
-            <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %>
-          </span>
-          <div class="nt-body">
-            <div class="nt-line">
-              <a class="nt-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%= n.name || n.handle %></a>
-              <% if (n.handle && n.name) { %><span class="nt-handle"><%= n.handle %></span><% } %>
-              <% if (n.created_at) { %><span class="nt-time"><%= formatDateTime(n.created_at) %></span><% } %>
-            </div>
-            <div class="nt-what">
-              <% if (n.type === 'follow') { %><%= t('notif.followed') %>
-              <% } else if (n.type === 'like') { %><%= t('notif.liked') %>
-              <% } else if (n.type === 'announce') { %><%= t('notif.boosted') %>
-              <% } else if (n.type === 'report') { %><%= t('notif.reported') %>
-              <% } else if (n.type === 'mention') { %><%= t('notif.mentioned') %>
-              <% } else { %><%= t('notif.replied') %><% } %>
-              <% if (n.post_slug) { %><a class="nt-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
-              <% if (n.type === 'mention' && n.note_url) { %><a class="nt-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-            </div>
-            <% if (n.type === 'report') { %>
-              <% if (n.content) { %><div class="nt-content"><%= n.content %></div><% } else { %><div class="nt-content nt-noreason"><%= t('notif.report_noreason') %></div><% } %>
-              <% if (n.objects && n.objects.length) { %>
-                <div class="nt-report-about">
-                  <% n.objects.forEach(function (o) { %>
-                    <a class="nt-post" href="/<%= o.slug %>"><%= t('notif.report_about') %>: <%= o.title %></a>
-                  <% }); %>
-                </div>
-              <% } %>
-            <% } else if ((n.type === 'reply' || n.type === 'mention') && n.content) { %><div class="nt-content"><%- n.content %></div><% } %>
-          </div>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-  .nt-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .fedi-back { margin: 0 0 1.25rem; }
-  .nt-title { margin: 0 0 1.25rem; }
-  .nt-empty { color: var(--ink-soft, #888); }
-  .nt-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .6rem; }
-  .nt-item { display: flex; gap: .85rem; padding: .9rem 1rem; border-radius: 14px; align-items: flex-start;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .nt-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 30%, transparent);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .nt-icon { flex: 0 0 38px; width: 38px; height: 38px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center; }
-  .nt-icon svg { width: 18px; height: 18px; }
-  .nt-report-about { margin-top: .35rem; display: flex; flex-direction: column; gap: .15rem; }
-  .nt-noreason { font-style: italic; opacity: .7; }
-  .nt-like .nt-icon { background: color-mix(in srgb, #e8b04b 16%, transparent); color: #e8b04b; }
-  .nt-boost .nt-icon { background: color-mix(in srgb, #2fa85a 16%, transparent); color: #2fa85a; }
-  .nt-follow .nt-icon, .nt-reply .nt-icon { background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); }
-  .nt-report .nt-icon { background: color-mix(in srgb, #c0392b 16%, transparent); color: #c0392b; }
-  .nt-mention .nt-icon { background: color-mix(in srgb, #8e6cf0 16%, transparent); color: #8e6cf0; }
-  .nt-body { flex: 1; min-width: 0; }
-  .nt-line { display: flex; align-items: baseline; gap: .4rem; }
-  .nt-who { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .nt-who:hover { text-decoration: underline; }
-  .nt-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .nt-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .nt-what { color: var(--ink-soft, #aaa); margin-top: .12rem; line-height: 1.4; }
-  .nt-post { color: var(--accent, #06c); font-weight: 600; }
-  .nt-content { margin: .45rem 0 0; padding: .55rem .75rem; border-radius: 10px; line-height: 1.5;
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, inherit); overflow-wrap: anywhere; }
-  .nt-content p { margin: .2rem 0; } .nt-content p:first-child { margin-top: 0; } .nt-content p:last-child { margin-bottom: 0; }
-</style>
Index: src/views/pages/followers.ejs
===================================================================
--- src/views/pages/followers.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,58 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'followers' }) %>
-  <h1 class="tl-title"><%= t('tl.followers') %><% if (followers && followers.length) { %> (<%= followers.length %>)<% } %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <p class="tl-lead"><%= t('tl.followers_lead') %></p>
-
-  <% if (followers && followers.length) { %>
-    <ul class="tl-foll-list">
-      <% followers.forEach(function(f){
-           var host='', user='';
-           try { var u = new URL(f.actor_uri); host = u.hostname; user = (u.pathname.split('/').filter(Boolean).pop() || ''); } catch (e) {}
-           var handle = user ? ('@' + user + '@' + host) : f.actor_uri;
-           var stale = !f.last_delivery_at || (f.last_error_at && (!f.last_delivery_at || f.last_error_at > f.last_delivery_at));
-      %>
-        <li class="tl-foll<%= stale ? ' is-stale' : '' %>">
-          <span class="tl-foll-av"><%= (user || host || '?').charAt(0).toUpperCase() %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= handle %></a>
-            <span class="tl-foll-deliv">
-              <% if (f.last_delivery_at) { %>
-                <%= t('tl.last_delivery') %>: <%= formatDateTime(f.last_delivery_at) %>
-              <% } else { %>
-                <span class="tl-foll-never"><%= t('tl.never_delivered') %></span>
-              <% } %>
-              <% if (f.last_error_at) { %><span class="tl-foll-err">· <%= t('tl.delivery_failed') %>: <%= formatDateTime(f.last_error_at) %></span><% } %>
-            </span>
-          </span>
-          <form method="post" action="/followers/<%= f.id %>/remove" onsubmit="return confirm('<%= t('tl.remove_confirm') %>')">
-            <button type="submit" class="tl-unfollow"><%= t('tl.remove_follower') %></button>
-          </form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.empty_followers') %></p>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .25rem; }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; font-size: .9rem; line-height: 1.5; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll.is-stale { background: color-mix(in srgb, #c0392b 10%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .tl-foll-deliv { color: var(--ink-soft, #888); font-size: .78rem; }
-  .tl-foll-never { color: #c0392b; font-weight: 600; }
-  .tl-foll-err { color: #c0392b; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; white-space: nowrap; }
-  .tl-unfollow:hover { color: #c0392b; border-color: #c0392b; }
-  .tl-empty { color: var(--ink-soft, #888); }
-</style>
Index: src/views/pages/following.ejs
===================================================================
--- src/views/pages/following.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,81 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'following' }) %>
-  <h1 class="tl-title"><%= t('tl.tab_following') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <div class="tl-explain">
-    <p><span class="tl-explain-tag">✨ <%= t('tl.autoboost') %></span> — <%= t('tl.autoboost_hint') %></p>
-  </div>
-
-  <form method="post" action="/news/follow" class="tl-follow">
-    <input type="text" name="handle" placeholder="@naam@server.social of site.com" autocomplete="off" spellcheck="false" required>
-    <button type="submit" class="btn btn-primary"><%= t('tl.follow_btn') %></button>
-    <label class="tl-follow-ab" title="<%= t('tl.autoboost_hint') %>"><input type="checkbox" name="auto_boost" value="1"> ✨ <%= t('tl.autoboost_follow') %></label>
-  </form>
-
-  <details class="tl-move">
-    <summary><%= t('tl.move_title') %></summary>
-    <p class="tl-move-hint"><%= t('tl.move_hint') %></p>
-    <p><a class="btn" href="/news/following.csv" download>⬇ <%= t('tl.move_export') %></a></p>
-    <form method="post" action="/news/following/import">
-      <label class="tl-move-lbl" for="tl-move-csv"><%= t('tl.move_import_lbl') %></label>
-      <textarea id="tl-move-csv" name="csv" rows="5" spellcheck="false"
-                placeholder="Account address,Show boosts,Notify on new posts,Languages,Featured"></textarea>
-      <button type="submit" class="btn btn-primary"><%= t('tl.move_import') %></button>
-    </form>
-  </details>
-
-  <% if (following && following.length) { %>
-    <h2 class="tl-sub"><%= t('tl.following') %> (<%= following.length %>)</h2>
-    <ul class="tl-foll-list">
-      <% following.forEach(function(f){ %>
-        <li class="tl-foll">
-          <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= avatar(f.icon, 96) %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
-          <span class="tl-foll-meta">
-            <a href="<%= f.url || f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= f.name || f.handle %></a>
-            <span class="tl-foll-handle"><%= f.handle %></span>
-            <% if (f.status === 'pending') { %><span class="tl-pending"><%= t('tl.pending') %></span><% } %>
-          </span>
-          <form method="post" action="/news/autoboost" class="tl-foll-ab" title="<%= t('tl.autoboost_hint') %>">
-            <input type="hidden" name="actor_uri" value="<%= f.actor_uri %>">
-            <label><input type="checkbox" name="auto_boost" value="1" <%= f.auto_boost ? 'checked' : '' %> data-autosubmit> ✨ <%= t('tl.autoboost') %></label>
-          </form>
-          <form method="post" action="/news/unfollow"><input type="hidden" name="actor_uri" value="<%= f.actor_uri %>"><button type="submit" class="tl-unfollow"><%= t('tl.unfollow') %></button></form>
-        </li>
-      <% }); %>
-    </ul>
-  <% } else { %>
-    <p class="tl-empty"><%= t('tl.empty_following') %></p>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .25rem; }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; }
-  .tl-explain { margin: 0 0 1.25rem; padding: .85rem 1rem; border-radius: 12px;
-    background: color-mix(in srgb, var(--accent, #888) 8%, transparent);
-    border: 1px solid color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-  .tl-explain p { margin: .3rem 0; font-size: .85rem; color: var(--ink-soft, #888); line-height: 1.5; }
-  .tl-explain-tag { font-weight: 600; color: var(--ink, inherit); white-space: nowrap; }
-  .tl-sub { font-size: 1.1rem; margin: 1.5rem 0 .75rem; }
-  .tl-follow { display: flex; gap: .5rem; margin: 0 0 1rem; flex-wrap: wrap; align-items: center; }
-  .tl-follow input[type="text"] { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
-  .tl-follow-ab { font-size: .85rem; color: var(--ink-soft, #888); display: inline-flex; align-items: center; gap: .3rem; cursor: pointer; }
-  .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
-  .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
-  .tl-foll-av { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
-  .tl-foll-av img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
-  .tl-foll-meta a { color: var(--ink, inherit); text-decoration: none; font-weight: 600; }
-  .tl-foll-meta a:hover { text-decoration: underline; }
-  .tl-foll-handle { color: var(--ink-soft, #888); font-size: .82rem; }
-  .tl-pending { font-size: .72rem; color: var(--ink-soft, #999); }
-  .tl-foll-ab { margin: 0; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-foll-ab label { display: inline-flex; align-items: center; gap: .25rem; cursor: pointer; }
-  .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
-  .tl-unfollow:hover { color: var(--ink, #000); }
-  .tl-empty { color: var(--ink-soft, #888); }
-</style>
Index: src/views/pages/guardian.ejs
===================================================================
--- src/views/pages/guardian.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,107 +1,0 @@
-<!DOCTYPE html>
-<html lang="<%= lang %>">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title><%= t('guardian.app_name') %></title>
-  <link rel="manifest" href="/guardian/manifest.webmanifest">
-  <link rel="icon" href="/guardian/icon.svg" type="image/svg+xml">
-  <meta name="theme-color" content="#ff6b35">
-  <link rel="stylesheet" href="/guardian/app.css">
-</head>
-<body>
-  <header class="g-head">
-    <span class="g-buoy">🛟</span>
-    <div class="g-head-txt">
-      <h1><%= t('guardian.app_name') %></h1>
-      <p class="g-sub"><%= t('guardian.tagline') %></p>
-    </div>
-    <% if (sites.length > 1) { %>
-    <select id="site-picker" aria-label="<%= t('guardian.acting_as') %>">
-      <% for (const s of sites) { %>
-      <option value="<%= s.slug %>" <%= s.slug === state.site ? 'selected' : '' %>>@<%= s.slug %></option>
-      <% } %>
-    </select>
-    <% } else { %>
-    <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
-    <% } %>
-</header>
-
-  <main>
-    <!-- 1. Hulpverzoeken, uitgelicht over alle wards heen. Dat is waar dit voor
-            bestaat, en het moet opvallen zonder dat je eerst een kind opent.
-            De volledige geschiedenis per kind staat in dat kind zijn paneel. -->
-    <section id="help-section">
-      <div class="g-sec-head">
-        <h2><%= t('guardian.help_title') %></h2>
-        <span id="help-count" class="g-badge" hidden></span>
-      </div>
-      <p class="g-sec-sub"><%= t('guardian.help_sub') %></p>
-      <div id="help-list" class="g-list"></div>
-      <p id="help-empty" class="g-empty"><%= t('guardian.help_empty') %></p>
-    </section>
-
-    <!-- 2. Nieuwe ward adopteren. -->
-    <section id="adopt-section">
-      <div class="g-sec-head"><h2><%= t('guardian.adopt_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.adopt_sub') %></p>
-      <form id="adopt-form" autocomplete="off">
-        <input id="adopt-handle" type="text" inputmode="email" autocomplete="off"
-               placeholder="@kind@server.eu" aria-label="<%= t('guardian.adopt_label') %>">
-        <button type="submit" id="adopt-btn"><%= t('guardian.adopt_btn') %></button>
-      </form>
-      <p id="adopt-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 3. Verzonden aanvragen (nog niet beantwoord). -->
-    <section id="pending-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian.pending_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.pending_sub') %></p>
-      <div id="pending-list" class="g-list"></div>
-    </section>
-
-    <!-- 4. Mijn wards. Elk kind is een regel die opent naar een paneel met
-            alles over dat kind: instellingen, volgverzoeken, hulpvragen en
-            recente berichten. Een guardian denkt per kind, niet per functie. -->
-    <section id="wards-section">
-      <div class="g-sec-head"><h2><%= t('guardian.wards_title') %></h2></div>
-      <div id="wards-list" class="g-list"></div>
-      <p id="wards-empty" class="g-empty"><%= t('guardian.wards_empty') %></p>
-    </section>
-
-    <!-- 4b. Even afwezig (FEP-633c 3.6.1): de verantwoordelijke handeling.
-            Wie zich afmeldt telt niet mee in de besluiten, en een enkel
-            antwoord brengt je meteen terug. Alleen zichtbaar met wards. -->
-    <section id="away-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian.away_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.away_sub') %></p>
-      <div class="g-away-row">
-        <button id="away-week" class="quiet small"><%= t('guardian.away_week') %></button>
-        <button id="away-month" class="quiet small"><%= t('guardian.away_month') %></button>
-      </div>
-      <p id="away-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 5. Meldingen. -->
-    <section id="push-section">
-      <div class="g-sec-head"><h2><%= t('guardian.push_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.push_sub') %></p>
-      <button id="push-toggle" class="quiet" data-on-label="<%= t('guardian.push_off') %>"
-              data-off-label="<%= t('guardian.push_on') %>"><%= t('guardian.push_on') %></button>
-      <p id="push-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 6. Het logboek (FEP-633c §4.2). Onderaan en ingeklapt, want hier vraagt
-         niets om een antwoord: het staat er zodat een besluit -- en vooral de
-         REDEN ervan -- ergens na te lezen is. Zonder dit merkte een ward een
-         weigering alleen doordat er iets uit een lijst verdween. -->
-    <section id="log-section">
-      <div class="g-sec-head"><h2><%= t('guardian.panel_history') %></h2></div>
-      <div id="g-log"></div>
-    </section>
-  </main>
-
-  <script type="application/json" id="guardian-state"><%- JSON.stringify(state) %></script>
-  <script src="/guardian/app.js" defer></script>
-</body>
-</html>
Index: src/views/pages/home.ejs
===================================================================
--- src/views/pages/home.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/home.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,5 +11,4 @@
 
 const allPosts = [...pinnedPosts, ...posts];
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 
@@ -18,18 +17,4 @@
     non-admin page. %>
 
-<%# FEP-7628 slice 3: this account moved. The signpost comes FIRST, before
-    any post: a visitor should not scroll a dead feed to find out we left.
-    typeof-guard because an old route may render this template for one
-    request right after a deploy (view cache fills on first hit). %>
-<% if (typeof movedTo !== 'undefined' && movedTo) { %>
-  <div class="container">
-    <aside class="moved-banner">
-      <p class="moved-banner-lead"><%= t('phome.moved_lead') %></p>
-      <a class="moved-banner-link" href="<%= movedTo %>" rel="me noopener"><%= movedToLabel || movedTo %></a>
-      <p class="moved-banner-hint"><%= t('phome.moved_hint') %></p>
-    </aside>
-  </div>
-<% } %>
-
 <!-- ========== TIMELINE ========== -->
 <div class="container feed-timeline">
@@ -37,12 +22,12 @@
   <% if (allPosts.length === 0) { %>
     <div class="empty">
-      <h1><%= t('phome.empty_title') %></h1>
-      <p><%= t('phome.empty_sub') %></p>
-      <% if (user && canMutate && permissions.canCreatePost(user, site)) { %>
+      <h1>Hier is het nog stil.</h1>
+      <p>Nog geen posts. Spannend.</p>
+      <% if (user && permissions.canCreatePost(user, site)) { %>
         <p style="margin-top:2rem">
-          <a class="btn" href="<%= _base %>/posts/new"
-             hx-get="<%= _base %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-             hx-push-url="<%= _base %>/posts/new" hx-indicator="#pcms-loading">
-            <%= t('phome.write_first') %> →
+          <a class="btn" href="/posts/new"
+             hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+             hx-push-url="/posts/new" hx-indicator="#pcms-loading">
+            Schrijf je eerste post →
           </a>
         </p>
@@ -50,5 +35,5 @@
     </div>
   <% } else { %>
-    <ul class="post-list feed-timeline-list" id="post-list">
+    <ul class="post-list feed-timeline-list">
       <% allPosts.forEach(function(post) { %>
         <%- include('../partials/post-card', { post: post }) %>
@@ -59,41 +44,8 @@
 </div>
 
-<!-- ========== LEZEN (alleen zichtbaar bij body[data-feed-view="reader"]) ==========
-     Hetzelfde stel berichten, maar heel: één per scherm, met de grenzen door
-     CSS-snapping. Geen eigen route en geen eigen shell -- de balken blijven
-     staan, want je bent nog gewoon op de feed en moet terug kunnen schakelen.
-     Leeg? Dan valt de lege staat van de tijdlijn hierboven in beeld; deze
-     sectie rendert dan niet. -->
-<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %>
-<section class="feed-reader" aria-label="<%= t('switch.reader') %>">
-  <%# Terug naar boven. Staat IN de HTML en niet in JavaScript, zodat hij er ook
-      is als de module niet laadt -- dan doet hij niets, maar hij springt ook niet
-      opeens in beeld. Verschijnt pas als je ver genoeg bent (mod/read.js). %>
-  <%# Een bericht omhoog of omlaag. Alleen in paginamodus zichtbaar (CSS). %>
-  <%# Twee echte balken over de volle breedte. Tikzones zonder zichtbaar element
-      werkten niet: de onderste strook ligt onder de mini-speler en de tabbalk,
-      en die vangen de tik voordat de leesstroom hem ziet. Een balk kan daar wel
-      bovenop. %>
-  <button type="button" class="read-bar read-bar--top" id="read-prev" aria-label="<%= t('read.prev') %>">
-    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>
-  </button>
-  <button type="button" class="read-bar read-bar--bottom" id="read-next-nav" aria-label="<%= t('read.next') %>">
-    <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-  </button>
-  <button type="button" id="read-top" class="read-top" aria-label="<%= t('read.to_top') %>" title="<%= t('read.to_top') %>">
-    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
-  </button>
-  <div class="read-stream" id="read-stream" data-base="<%= _base %>">
-    <% readerItems.forEach(function(it) { %>
-      <%- include('../partials/read-article', { post: it.post, entry: it.entry }) %>
-    <% }); %>
-  </div>
-</section>
-<% } %>
-
 <!-- ========== GRID (only visible when body[data-feed-view="grid"]) ========== -->
 <% if (allPosts.length > 0) { %>
-<section class="feed-grid container" aria-label="<%= t('phome.grid_view') %>">
-  <div class="grid-tiles" id="grid-tiles">
+<section class="feed-grid container" aria-label="Grid-weergave">
+  <div class="grid-tiles">
     <% allPosts.forEach(function(post) { %>
       <%- include('../partials/post-tile', { post: post }) %>
@@ -102,7 +54,2 @@
 </section>
 <% } %>
-<% if (typeof hasMore !== 'undefined' && allPosts.length > 0) { %>
-<div class="container feed-more-wrap">
-  <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: '/' }) %>
-</div>
-<% } %>
Index: src/views/pages/linkbio.ejs
===================================================================
--- src/views/pages/linkbio.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,44 +1,0 @@
-<%
-  var links = (typeof lbLinks !== 'undefined') ? lbLinks : [];
-  var _photo = (site && site.profile_photo) ? site.profile_photo : (typeof siteOwnerAvatar !== 'undefined' ? siteOwnerAvatar : '');
-  var _name = (site && (site.profile_name || site.title)) || '';
-  var _bio = (site && (site.profile_bio || site.tagline)) || '';
-%>
-<section class="lb">
-  <div class="lb-head">
-    <% if (_photo) { %><span class="lb-photo" style="background-image:url('<%= _photo %>')"></span><% } else { %><span class="lb-photo lb-photo-empty">♪</span><% } %>
-    <h1 class="lb-name"><%= _name %></h1>
-    <% if (_bio) { %><p class="lb-bio"><%= _bio %></p><% } %>
-  </div>
-
-  <% if (!links.length) { %>
-    <p class="lb-empty"><%= t('lbio.empty') %></p>
-  <% } else { %>
-    <div class="lb-links">
-      <% links.forEach(function(l){ %>
-        <a class="lb-link" href="<%= siteUrlBase %>/links/go/<%= l.i %>" target="_blank" rel="noopener" <%= l.brand ? ('style="--lb-brand:' + l.brand + '"') : '' %>>
-          <% if (l.svg) { %><span class="lb-ico"><%- l.svg %></span><% } %>
-          <span class="lb-label"><%= l.label %></span>
-        </a>
-      <% }); %>
-    </div>
-  <% } %>
-
-  <p class="lb-foot"><a href="<%= siteUrlBase %>/">← <%= t('lbio.back_to_site') %></a></p>
-</section>
-
-<style>
-  .lb { max-width: 480px; margin: 0 auto; padding: 40px 18px 64px; text-align: center; }
-  .lb-photo { display: inline-block; width: 96px; height: 96px; border-radius: 50%; background-size: cover; background-position: center; margin-bottom: 14px; }
-  .lb-photo-empty { background: var(--accent,#6b8f71); color: #fff; display: grid; place-items: center; font-size: 36px; }
-  .lb-name { font-size: 24px; margin: 0 0 6px; }
-  .lb-bio { opacity: .8; margin: 0 0 26px; }
-  .lb-links { display: flex; flex-direction: column; gap: 12px; }
-  .lb-link { display: flex; align-items: center; gap: 12px; justify-content: center; padding: 14px 18px; border-radius: 14px; border: 1px solid rgba(128,128,128,.28); text-decoration: none; color: inherit; font-weight: 600; transition: transform .08s ease, border-color .15s ease; }
-  .lb-link:hover { transform: translateY(-1px); border-color: var(--lb-brand, var(--accent,#6b8f71)); }
-  .lb-ico { width: 22px; height: 22px; display: inline-flex; color: var(--lb-brand, currentColor); }
-  .lb-ico svg { width: 22px; height: 22px; }
-  .lb-empty { opacity: .7; }
-  .lb-foot { margin-top: 30px; font-size: 13px; opacity: .6; }
-  .lb-foot a { color: inherit; }
-</style>
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,244 +1,0 @@
-<div class="msg-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
-  <h1 class="msg-title"><%= t('msg.title') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
-
-  <%# FEP-633c: a pending guardianship offer is a special message: the kid
-      answers here (accept/reject travels the same C2S pipeline as the apps). %>
-  <% if (typeof guardianOffers !== 'undefined' && guardianOffers.length) { %>
-    <% guardianOffers.forEach(function(o){ %>
-    <div class="alert" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;border-left:3px solid #ff6b35;">
-      <span style="font-size:1.4em;">&#128735;</span>
-      <div style="flex:1;min-width:200px;">
-        <strong><%= o['shaer:candidateHandle'] || o['shaer:candidate'] %></strong><br>
-        <span><%= t('msg.guard_offer') %></span>
-      </div>
-      <form method="post" action="<%= (typeof moreBase !== 'undefined' ? moreBase : '') %>/messages/guardianship" style="display:flex;gap:8px;">
-        <input type="hidden" name="offer" value="<%= o.id %>">
-        <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button>
-        <button class="btn" type="submit" name="answer" value="reject" style="opacity:.7;"><%= t('msg.guard_reject') %></button>
-      </form>
-    </div>
-    <% }); %>
-  <% } %>
-
-  <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
-    <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
-        een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
-        draad staan. Activiteit en Moderatie blijven wat ze waren. %>
-    <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
-    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
-    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
-    <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
-  </div>
-  <div class="msg-search">
-    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
-    <input type="search" id="msg-q" placeholder="<%= t('msg.search_ph') %>" autocomplete="off" spellcheck="false" aria-label="<%= t('msg.search_ph') %>">
-  </div>
-
-  <% var _seen = (typeof seenAt !== 'undefined' && seenAt) ? seenAt : 0; %>
-  <% if (!items || !items.length) { %>
-    <p class="msg-empty"><%= t('msg.empty') %></p>
-  <% } else { %>
-    <ul class="msg-list" data-show="all" id="msg-list">
-      <% items.forEach(function(n){ %><%- include('../partials/msg-item', { n: n, seen: _seen }) %><% }); %>
-    </ul>
-    <p class="msg-nomatch" hidden><%= t('msg.no_match') %></p>
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages' }) %>
-  <% } %>
-
-  <%# Interact bookmarklet — moved here from the old Reacties page. %>
-  <details class="msg-bm">
-    <summary><%= t('fedi.bm_label') %></summary>
-    <p class="msg-bm-help"><%= t('fedi.bm_help') %></p>
-    <a class="fedi-bm-btn" id="fedi-bm-btn" href="#" draggable="true" title="<%= t('fedi.bm_help') %>">
-      <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
-      <%= t('fedi.bm_label') %>
-    </a>
-  </details>
-</div>
-
-<%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js.
-    Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een
-    link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op
-    body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %>
-
-<style>
-  .msg-wrap { max-width: 640px; margin: 1rem auto; padding: 0 1rem; }
-  .msg-title { margin: 0 0 .9rem; }
-  .msg-empty { color: var(--ink-soft, #888); }
-
-  .msg-filters { display: flex; gap: .4rem; margin: 0 0 1.1rem; flex-wrap: wrap; }
-  .msg-chip { border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: none;
-    color: var(--ink-soft, #888); font: inherit; font-size: .84rem; font-weight: 600;
-    padding: .3rem .85rem; border-radius: 999px; cursor: pointer; }
-  .msg-chip.is-on { background: var(--accent, #06c); border-color: var(--accent, #06c); color: #fff; }
-
-  .msg-search { display: flex; align-items: center; gap: .5rem; margin: 0 0 1.1rem;
-    padding: .45rem .7rem; border-radius: 10px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); }
-  .msg-search:focus-within { border-color: var(--accent, #06c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #06c) 18%, transparent); }
-  .msg-search svg { width: 16px; height: 16px; color: var(--ink-soft, #888); flex-shrink: 0; }
-  .msg-search input { flex: 1; border: none; background: none; color: inherit; font: inherit; outline: none; min-width: 0; }
-  .msg-nomatch { color: var(--ink-soft, #888); text-align: center; padding: 1.2rem 0; }
-
-  .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
-
-
-  .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .msg-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 30%, transparent);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-  .msg-item.is-new { border-color: color-mix(in srgb, var(--accent, #06c) 45%, transparent);
-    background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
-  .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
-
-  /* ── Gesprekken ──────────────────────────────────────────────────────
-     NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
-     gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
-     draadkop NAAST de bubbels te staan in plaats van erboven. */
-  .msg-thread { display: block; }
-  .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
-  /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
-     kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
-     Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
-  /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
-     geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
-     systeemblauw in 13px. */
-  .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
-    font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
-    padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
-    color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
-    box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
-    max-width: 100%; overflow: hidden; white-space: nowrap;
-    /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
-       niet te raken. De uitsparing blijft klein, het doel wordt groter. */
-    min-height: 32px; }
-  .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
-  .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
-    transition: transform .18s ease; }
-  /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
-     naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
-     waard. */
-  .msg-thread.is-collapsed .msg-thread-msgs,
-  .msg-thread.is-collapsed .msg-thread-actions { display: none; }
-  /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
-  .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
-  .msg-thread.is-collapsed.is-search-open .msg-thread-actions { display: flex; }
-  .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
-  .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
-  @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
-  /* De zwaai in de kop: klein, en met dezelfde rust als het aantal ernaast.
-     Hij staat er ook als het gesprek open is -- dan zegt de bubbel het al, maar
-     een kop die van stand verandert leest als iets dat gebeurde. */
-  .msg-thread-wave { flex: 0 0 auto; font-size: .95em; line-height: 1; }
-  .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
-    padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-  .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
-    margin-inline-start: auto; min-width: 0; max-width: 100%;
-    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
-  .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
-    border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
-  .msg-sub > .msg-body { min-width: 0; }
-  /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
-     .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
-     kaart zodra de regel smaller werd dan zijn inhoud. */
-  .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
-    background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
-  /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
-  .msg-sub .msg-line { flex-wrap: wrap; }
-
-  /* Antwoorden en zwaaien naast elkaar, ingeklapt, en sinds 25-8 BOVEN de
-     berichten -- daar staat het nieuwste, en daar antwoord je dus op. Heette
-     msg-thread-foot toen het onderaan stond; de marge verhuisde mee van top
-     naar bottom, anders plakt de rij tegen de eerste bubbel.
-     Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
-     wat deze weergave moest oplossen. */
-  .msg-thread-actions { display: flex; align-items: flex-start; gap: .5rem; margin-bottom: .5rem; flex-wrap: wrap; }
-  .msg-reply { flex: 1 1 12rem; min-width: 0; }
-  .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
-  .msg-reply[open] > summary { margin-bottom: .4rem; }
-  .msg-reply-form { min-width: 0; }
-  .msg-wave button { line-height: 1.2; }
-
-  /* Zwaaien gebeurt ter plekke (mod/messages.js). Twee toestanden na de klik:
-     gelukt, dan vervangt de bevestiging de knoppen -- en mislukt, dan komen de
-     knoppen terug met een schud, zodat een tweede poging voor de hand ligt.
-     De bestaande klasse `nudge` kon hier niet voor dienen: die heeft alleen
-     CSS op authorize-interaction, dus op deze pagina zou hij niets doen. */
-  .msg-wave-done { font-size: .82rem; color: var(--ink-soft, #888); white-space: nowrap; }
-  .msg-wave.is-failed, .msg-quickreply.is-failed { animation: msg-wave-shake .4s ease; }
-  @keyframes msg-wave-shake {
-    0%, 100% { transform: translateX(0); }
-    25% { transform: translateX(-4px); }
-    75% { transform: translateX(4px); }
-  }
-  @media (prefers-reduced-motion: reduce) {
-    .msg-wave.is-failed, .msg-quickreply.is-failed { animation: none; outline: 2px solid var(--accent); }
-  }
-
-  @media (max-width: 560px) {
-    /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
-       al naast, en de inhoud heeft de breedte harder nodig. */
-    .msg-sub > .msg-av { display: none; }
-    .msg-thread-msgs { padding-inline-start: .55rem; }
-    .msg-thread-post { margin-inline-start: 0; }
-    .msg-item { padding: .7rem .6rem; gap: .6rem; }
-  }
-
-  .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center; font-weight: 700;
-    background: color-mix(in srgb, var(--accent, #888) 16%, transparent); color: var(--accent, #06c); overflow: visible; }
-  .msg-av img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; background: #fff; }
-  .msg-av-sent svg { width: 17px; height: 17px; }
-  .msg-dot { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%;
-    display: inline-flex; align-items: center; justify-content: center;
-    border: 2px solid var(--paper, #fff); background: var(--accent, #06c); color: #fff; }
-  .msg-dot svg { width: 10px; height: 10px; }
-  .msg-dot-like { background: #e8b04b; } .msg-dot-boost { background: #2fa85a; }
-  .msg-dot-report { background: #c0392b; } .msg-dot-mention { background: #8e6cf0; }
-
-  .msg-body { flex: 1; min-width: 0; }
-  .msg-line { display: flex; align-items: baseline; gap: .4rem; }
-  .msg-who { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  a.msg-who:hover { text-decoration: underline; }
-  .msg-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .msg-new { width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #06c); flex: 0 0 auto; align-self: center; }
-  .msg-time { color: var(--ink-soft, #999); font-size: .76rem; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }
-  .msg-what { color: var(--ink-soft, #aaa); margin-top: .12rem; line-height: 1.4; }
-  .msg-what .msg-post { color: var(--accent, #06c); font-weight: 600; margin-left: .25rem; }
-  .msg-priv { display: inline-block; margin-left: .3rem; padding: .05rem .5rem; border-radius: 999px; font-size: .72rem; font-weight: 700;
-    background: color-mix(in srgb, #8e6cf0 14%, transparent); color: #8e6cf0; }
-  .msg-content { margin: .45rem 0 0; padding: .55rem .75rem; border-radius: 10px; line-height: 1.5;
-    background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, inherit); overflow-wrap: anywhere; }
-  .msg-content p { margin: .2rem 0; } .msg-content p:first-child { margin-top: 0; } .msg-content p:last-child { margin-bottom: 0; }
-
-  .msg-poll { margin: .5rem 0 0; display: flex; flex-direction: column; gap: .4rem; }
-  .msg-poll-opt { display: flex; flex-direction: column; gap: .2rem; }
-  .msg-poll-top { display: flex; justify-content: space-between; gap: .5rem; font-size: .85rem; }
-  .msg-poll-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-  .msg-poll-pct { font-variant-numeric: tabular-nums; color: var(--ink-soft, #888); flex-shrink: 0; }
-  .msg-poll-bar { height: 7px; border-radius: 999px; overflow: hidden; background: color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .msg-poll-bar span { display: block; height: 100%; border-radius: 999px; background: var(--accent, #06c); }
-  .msg-poll-total { font-size: .78rem; color: var(--ink-soft, #888); margin-top: .1rem; }
-
-  .msg-actions { display: flex; gap: .7rem; align-items: center; margin-top: .45rem; }
-  .msg-edit summary { cursor: pointer; font-size: .8rem; color: var(--ink-soft, #888); font-weight: 600; }
-  .msg-edit-form { margin-top: .4rem; display: flex; flex-direction: column; gap: .4rem; }
-  .msg-edit-form textarea { width: 100%; font: inherit; padding: .5rem .65rem; border-radius: 8px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
-  .msg-del { background: none; border: none; padding: 0; font: inherit; font-size: .8rem; font-weight: 600;
-    color: var(--ink-soft, #888); cursor: pointer; }
-  .msg-del:hover { color: #c0392b; }
-
-  .msg-bm { margin: 1.4rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); padding-top: .8rem; }
-  .msg-bm summary { cursor: pointer; color: var(--ink-soft, #888); font-weight: 600; font-size: .88rem; }
-  .msg-bm-help { color: var(--ink-soft, #888); font-size: .82rem; line-height: 1.5; margin: .5rem 0 .6rem; }
-</style>
Index: src/views/pages/my-site.ejs
===================================================================
--- src/views/pages/my-site.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,73 +1,0 @@
-<div class="container admin-page">
-  <p><a href="/" class="btn">&larr; <%= t('myst.overview') %></a></p>
-  <h1><%= t('myst.title') %></h1>
-  <p class="admin-tagline"><%= mySite.title %></p>
-
-  <nav class="admin-quick-links" aria-label="<%= t('myst.quick_links_aria') %>">
-    <a href="/user/<%= mySite.slug %>/posts/new" class="btn">✍️ <%= t('myst.new_post') %></a>
-    <a href="/admin/sites/<%= mySite.slug %>/edit" class="btn">🎨 <%= t('myst.appearance') %></a>
-    <a href="/user/<%= mySite.slug %>" class="btn">👁️ <%= t('myst.view_site') %></a>
-    <a href="/account" class="btn">👤 <%= t('myst.account') %></a>
-  </nav>
-
-  <div class="admin-stats">
-    <div class="stat-card"><div class="stat-num"><%= mine.posts %></div><div class="stat-label"><%= t('myst.posts') %></div></div>
-    <div class="stat-card"><div class="stat-num"><%= mine.published %></div><div class="stat-label"><%= t('myst.published') %></div></div>
-  </div>
-
-  <% if (typeof posts !== 'undefined' && posts && posts.length) { %>
-    <% var _drafts = posts.filter(function(p){ return p.isDraft; }).length; %>
-    <%
-      var _lblBadge = t('myst.draft_badge');
-      var _lblUntitled = t('myst.untitled');
-      var _lblEdit = t('myst.edit');
-      var _lblView = t('myst.view');
-    %>
-    <h2 class="ms-posts-title"><%= t('myst.posts') %><% if (_drafts) { %> <span class="pl-draftcount"><%= _drafts === 1 ? t('myst.draft_count_one', { n: _drafts }) : t('myst.draft_count_many', { n: _drafts }) %></span><% } %></h2>
-    <ul class="post-admin-list">
-      <% posts.forEach(function(p) { %>
-        <li class="pal-row<%= p.isDraft ? ' is-draft' : '' %>">
-          <a class="pal-title" href="<%= p.editUrl %>">
-            <% if (p.isDraft) { %><span class="pal-badge"><%= _lblBadge %></span><% } %>
-            <span class="pal-name"><%= p.title || _lblUntitled %></span>
-          </a>
-          <span class="pal-actions">
-            <a class="pal-link" href="<%= p.editUrl %>"><%= _lblEdit %></a>
-            <% if (!p.isDraft) { %><a class="pal-link" href="<%= p.viewUrl %>"><%= _lblView %></a><% } %>
-          </span>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-</div>
-
-<style>
-.admin-page { max-width: 1000px; margin: 3rem auto; padding: 0 1rem; }
-.admin-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 0.25rem; }
-.admin-tagline { color: var(--ink-muted); margin: 0 0 2rem; }
-.admin-quick-links {
-  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
-  gap: 0.5rem; margin: 0 0 1.5rem; padding: 0;
-}
-.admin-quick-links .btn { justify-content: center; padding: 0.7rem 0.9rem; font-weight: 600; text-align: center; }
-.admin-quick-links .btn:hover { background: color-mix(in srgb, var(--accent) 8%, var(--paper-2)); border-color: var(--accent); }
-.admin-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; }
-.stat-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 8px; padding: 1rem 1.25rem; text-align: center; }
-.stat-num { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; font-family: var(--font-display, serif); }
-.stat-label { color: var(--ink-muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 0.25rem; }
-
-/* Posts/concepten-lijst */
-.ms-posts-title { font-family: var(--font-display, serif); font-size: 1.4rem; margin: 2rem 0 0.75rem; }
-.pl-draftcount { font-family: var(--font-ui, system-ui); font-size: 0.8rem; font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.55rem; border-radius: 99px; vertical-align: middle; }
-.post-admin-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
-.pal-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.7rem 0.9rem; border: 1px solid var(--rule); border-radius: 10px; background: var(--paper-2); transition: border-color 120ms; }
-.pal-row:hover { border-color: var(--accent); }
-.pal-row.is-draft { border-left: 3px solid var(--accent); }
-.pal-title { display: inline-flex; align-items: center; gap: 0.6rem; min-width: 0; color: var(--ink); text-decoration: none; font-weight: 600; }
-.pal-title:hover { color: var(--accent); }
-.pal-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pal-badge { flex-shrink: 0; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); padding: 0.1rem 0.5rem; border-radius: 99px; }
-.pal-actions { display: flex; gap: 0.75rem; flex-shrink: 0; }
-.pal-link { color: var(--ink-muted); font-size: 0.85rem; text-decoration: none; white-space: nowrap; }
-.pal-link:hover { color: var(--accent); text-decoration: underline; }
-</style>
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,163 +1,0 @@
-<div class="tl-wrap">
-  <%- include('../partials/fedi-tabs', { active: 'feed' }) %>
-  <div class="tl-titlebar">
-    <h1 class="tl-title"><%= t('tl.title') %></h1>
-    <button type="button" id="tl-paste-btn" class="tl-paste-btn" title="<%= t('fedi.remote_interact') %>" aria-label="<%= t('fedi.remote_interact') %>">
-      <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
-    </button>
-  </div>
-  <% if (typeof movedTo !== 'undefined' && movedTo) { %>
-    <%# Eén uitleg bovenaan in plaats van een tooltip per knop: een tooltip op een
-        uitgeschakelde knop is toch onzichtbaar. Lezen blijft werken, dus de Krant
-        zelf staat er gewoon onder. %>
-    <div class="alert alert-error tl-moved">
-      <strong><%= t('tl.moved_title') %></strong>
-      <p><%= t('tl.moved_lead') %> <a href="<%= movedTo %>"><%= movedTo %></a></p>
-      <p class="form-hint"><%= t('tl.moved_hint') %></p>
-    </div>
-  <% } %>
-  <p class="tl-lead"><%= t('tl.lead') %></p>
-
-  <div class="tl-paste" id="tl-paste">
-    <div class="tl-paste-card" role="dialog" aria-label="<%= t('fedi.remote_interact') %>">
-      <button type="button" class="tl-paste-x" aria-label="<%= t('fedi.cancel') %>">&times;</button>
-      <h3 class="tl-paste-title"><%= t('fedi.remote_interact') %></h3>
-      <input type="url" class="tl-paste-input" inputmode="url" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="<%= t('tl.paste_ph') %>">
-      <div class="tl-paste-actions">
-        <button type="button" class="tl-paste-cancel"><%= t('fedi.cancel') %></button>
-        <button type="button" class="tl-paste-go"><%= t('tl.paste_go') %></button>
-      </div>
-    </div>
-  </div>
-  <style>
-    .tl-titlebar { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
-    .tl-boost-by { display: flex; align-items: center; gap: .35rem; font-size: .8rem; color: var(--ink-soft); margin: 0 0 .45rem .2rem; }
-    .tl-boost-by svg { color: #16a34a; flex: 0 0 auto; }
-    .tl-boost-by strong { color: var(--ink); font-weight: 600; }
-    .tl-paste-btn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px;
-      border: 1px solid var(--rule); border-radius: 999px; background: var(--paper-2); color: var(--ink-soft); cursor: pointer; transition: color .15s, border-color .15s, background .15s; }
-    .tl-paste-btn:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
-    .tl-paste { position: fixed; inset: 0; z-index: 2147483601; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,.55); }
-    .tl-paste.is-open { display: flex; }
-    .tl-paste-card { position: relative; width: 100%; max-width: 460px; background: var(--paper); color: var(--ink); border: 1px solid var(--rule);
-      border-radius: 16px; padding: 1.4rem 1.4rem 1.2rem; box-shadow: 0 18px 50px rgba(0,0,0,.4); }
-    .tl-paste-x { position: absolute; top: .5rem; right: .6rem; width: 2rem; height: 2rem; border: 0; border-radius: 999px; background: transparent; color: var(--ink-soft); font-size: 1.4rem; line-height: 1; cursor: pointer; }
-    .tl-paste-title { margin: 0 0 .8rem; font-size: 1.1rem; }
-    .tl-paste-input { width: 100%; box-sizing: border-box; padding: .6rem .8rem; border: 1.5px solid var(--rule); border-radius: 10px; background: var(--paper-2); color: var(--ink); font: inherit; font-size: .95rem; }
-    .tl-paste-input:focus { outline: none; border-color: var(--accent); }
-    .tl-paste-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
-    .tl-paste-actions button { padding: .5rem 1.1rem; border-radius: 999px; font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; border: 0; }
-    .tl-paste-cancel { background: transparent; border: 1.5px solid var(--rule); color: var(--ink); }
-    .tl-paste-go { background: var(--accent); color: var(--paper); }
-  </style>
-  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
-
-  <% if (!timeline || !timeline.length) { %>
-    <div class="tl-empty">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-      <p><%= t('tl.empty') %></p>
-    </div>
-  <% } else { %>
-    <ol class="tl-feed" id="tl-feed">
-      <% timeline.forEach(function(p){ %>
-        <%- include('../partials/tl-item', { p: p }) %>
-      <% }); %>
-    </ol>
-    <%- include('../partials/load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news' }) %>
-  <% } %>
-</div>
-
-<style>
-  .tl-wrap { max-width: 640px; margin: 1rem auto 3rem; padding: 0 1rem; }
-  .tl-title { margin: 0 0 .15rem; font-size: clamp(1.5rem, 4vw, 1.8rem); }
-  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.5rem; }
-  .tl-empty { text-align: center; color: var(--ink-soft, #888); margin: 3rem auto; }
-  .tl-empty svg { width: 44px; height: 44px; color: color-mix(in srgb, var(--accent, #888) 60%, transparent); margin: 0 auto .75rem; display: block; }
-
-  .tl-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
-  .tl-item { padding: 1.1rem 1.15rem; border-radius: 16px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
-    transition: border-color .15s ease, box-shadow .15s ease; }
-  .tl-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 32%, transparent);
-    box-shadow: 0 2px 14px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
-
-  .tl-head { display: flex; align-items: center; gap: .65rem; margin: 0 0 .65rem; }
-  .tl-avatar { flex: 0 0 44px; width: 44px; height: 44px; border-radius: 50%; overflow: hidden;
-    display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1.1rem;
-    background: color-mix(in srgb, var(--accent, #888) 22%, transparent); color: var(--accent, #555);
-    box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #888) 18%, transparent); }
-  .tl-avatar img { width: 100%; height: 100%; object-fit: cover; background: #fff; }
-  .tl-id { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
-  .tl-author { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .tl-author:hover { text-decoration: underline; }
-  .tl-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-  .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; }
-
-  /* .tl-content, .tl-quote* and .tl-media* now live in partials/shared-styles,
-     next to partials/note-body: the markup is shared with Berichten and the
-     Guardian PWA, so the styling has to be too. What stays here is Krant-only. */
-  /* Long posts collapse to a max height with a fade + "read more" (added by JS only when it overflows). */
-  .tl-content.tl-clamp { max-height: 20em; overflow: hidden;
-    -webkit-mask-image: linear-gradient(180deg, #000 72%, transparent);
-    mask-image: linear-gradient(180deg, #000 72%, transparent); }
-  .tl-readmore { display: inline-block; margin: .15rem 0 .1rem; padding: .15rem 0;
-    background: none; border: 0; color: var(--accent, #06c); font: inherit; font-weight: 600;
-    font-size: .85rem; cursor: pointer; }
-  .tl-readmore:hover { text-decoration: underline; }
-
-  .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; }
-  .tl-poll-form { display: flex; flex-direction: column; gap: 8px; }
-  .tl-poll-choice { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--line, rgba(128,128,128,.3)); border-radius: 10px; cursor: pointer; }
-  .tl-poll-choice:hover { border-color: var(--accent); }
-  .tl-poll-choice input { accent-color: var(--accent); }
-  .tl-poll-btn { align-self: flex-start; margin-top: 2px; }
-  .tl-poll-res { position: relative; padding: 9px 12px; border-radius: 10px; overflow: hidden; background: var(--paper-2, rgba(128,128,128,.08)); display: flex; align-items: center; gap: 8px; }
-  .tl-poll-fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 22%, transparent); z-index: 0; }
-  .tl-poll-res.is-mine .tl-poll-fill { background: color-mix(in srgb, var(--accent) 40%, transparent); }
-  .tl-poll-name { position: relative; z-index: 1; flex: 1; font-size: .95rem; }
-  .tl-poll-pct { position: relative; z-index: 1; font-variant-numeric: tabular-nums; font-weight: 500; }
-  .tl-poll-foot { font-size: .82rem; color: var(--ink-soft, #888); }
-
-  .tl-embed { margin: .75rem 0 0; border-radius: 12px; overflow: hidden; background: var(--paper-2, #111); }
-  .tl-embed-frame { width: 100%; border: 0; display: block; aspect-ratio: 16 / 9; }
-  .tl-embed-spotify { aspect-ratio: auto; height: 152px; }
-  .tl-embed-sc { aspect-ratio: auto; height: 166px; }
-  .tl-embed-klonkt { aspect-ratio: auto; height: 180px; background: var(--paper, #fff); }
-  .tl-embed-bandcamp { aspect-ratio: auto; height: 470px; }
-  .tl-embed-apple { aspect-ratio: auto; height: 175px; }
-  .tl-embed-open { display: inline-block; margin: .45rem .9rem 0 0; font-size: .82rem; font-weight: 600; color: var(--accent); text-decoration: none; }
-  .tl-embed-open:hover { text-decoration: underline; }
-
-  .tl-orig { display: inline-block; margin: .7rem 0 0; font-size: .82rem; color: var(--accent, #06c); text-decoration: none; }
-  .tl-orig:hover { text-decoration: underline; }
-
-  /* Uniform circular action buttons with coloured icons (no emoji). */
-  .tl-actions { display: flex; gap: .5rem; margin-top: .9rem; align-items: center; }
-  /* Verhuisd account: liken, boosten en reageren worden door de service toch
-     geweigerd, dus toon dat ook. Blokkeren blijft aanklikbaar (veiligheidsklep). */
-  .tl-act[disabled] { opacity: .35; cursor: not-allowed; }
-  .tl-actions.is-moved { position: relative; }
-  .tl-act-form { margin: 0; display: inline-flex; }
-  .tl-act { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 50%; padding: 0; box-sizing: border-box;
-    display: inline-flex; align-items: center; justify-content: center;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 3%, transparent);
-    color: var(--ink-soft, #888); cursor: pointer; -webkit-tap-highlight-color: transparent;
-    transition: background .12s, border-color .12s, transform .1s; }
-  .tl-act svg { width: 18px; height: 18px; display: block; }
-  .tl-act:active { transform: scale(.9); }
-  .tl-act-like svg { color: #e8b04b; }
-  .tl-act-like:hover { background: color-mix(in srgb, #e8b04b 15%, transparent); border-color: color-mix(in srgb, #e8b04b 50%, transparent); }
-  .tl-act-like.is-on { background: color-mix(in srgb, #e8b04b 18%, transparent); border-color: color-mix(in srgb, #e8b04b 60%, transparent); }
-  .tl-act-boost svg { color: #2fa85a; }
-  .tl-act-boost:hover { background: color-mix(in srgb, #2fa85a 15%, transparent); border-color: color-mix(in srgb, #2fa85a 50%, transparent); }
-  .tl-act-boost.is-on { background: color-mix(in srgb, #2fa85a 18%, transparent); border-color: color-mix(in srgb, #2fa85a 60%, transparent); }
-  .tl-act-reply svg { color: var(--accent, #06c); }
-  .tl-act-reply:hover { background: color-mix(in srgb, var(--accent, #888) 14%, transparent); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent); }
-  .tl-act-block svg { color: #e0524d; }
-  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
-</style>
-<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
Index: src/views/pages/newsletter.ejs
===================================================================
--- src/views/pages/newsletter.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,54 +1,0 @@
-<%
-  var _site = site || {};
-  var _title = _site.title || t('news.this_artist');
-  var st = (typeof nlState !== 'undefined') ? nlState : 'form';
-%>
-<section class="nl">
-  <div class="nl-card">
-    <% if (st === 'form') { %>
-      <h1 class="nl-h1"><%= t('news.form_title') %></h1>
-      <p class="nl-sub"><%= t('news.form_sub_before') %><strong><%= _title %></strong><%= t('news.form_sub_after') %></p>
-      <form method="POST" action="<%= siteUrlBase %>/nieuwsbrief" class="nl-form">
-        <input type="email" name="email" required placeholder="<%= t('news.email_ph') %>" value="<%= (typeof nlPrefill !== 'undefined') ? nlPrefill : '' %>" autocomplete="email">
-        <button type="submit" class="nl-btn"><%= t('news.subscribe') %></button>
-      </form>
-    <% } else if (st === 'check') { %>
-      <h1 class="nl-h1"><%= t('news.check_title') %></h1>
-      <p class="nl-sub"><%= t('news.check_sub_before') %><strong><%= (typeof nlEmail!=='undefined')?nlEmail:t('news.your_address') %></strong><%= t('news.check_sub_after') %></p>
-    <% } else if (st === 'done') { %>
-      <h1 class="nl-h1"><%= t('news.done_title') %></h1>
-      <p class="nl-sub"><%= t('news.done_sub_before') %><strong><%= _title %></strong><%= t('news.done_sub_after') %></p>
-    <% } else if (st === 'confirmed') { %>
-      <h1 class="nl-h1"><%= t('news.confirmed_title') %></h1>
-      <p class="nl-sub"><%= t('news.confirmed_sub_before') %><strong><%= _title %></strong><%= t('news.confirmed_sub_after') %></p>
-    <% } else if (st === 'unsubbed') { %>
-      <h1 class="nl-h1"><%= t('news.unsubbed_title') %></h1>
-      <p class="nl-sub"><%= t('news.unsubbed_sub') %></p>
-    <% } else if (st === 'invalid') { %>
-      <h1 class="nl-h1"><%= t('news.invalid_title') %></h1>
-      <p class="nl-sub"><%= t('news.invalid_sub') %></p>
-      <p><a class="nl-btn nl-btn-ghost" href="<%= siteUrlBase %>/nieuwsbrief"><%= t('news.back') %></a></p>
-    <% } else if (st === 'smtperror') { %>
-      <h1 class="nl-h1"><%= t('news.smtperror_title') %></h1>
-      <p class="nl-sub"><%= t('news.smtperror_sub') %></p>
-    <% } else if (st === 'badtoken') { %>
-      <h1 class="nl-h1"><%= t('news.badtoken_title') %></h1>
-      <p class="nl-sub"><%= t('news.badtoken_sub') %></p>
-      <p><a class="nl-btn nl-btn-ghost" href="<%= siteUrlBase %>/nieuwsbrief"><%= t('news.to_subscribe') %></a></p>
-    <% } else { %>
-      <h1 class="nl-h1"><%= t('news.error_title') %></h1>
-      <p class="nl-sub"><%= t('news.error_sub') %></p>
-    <% } %>
-  </div>
-</section>
-
-<style>
-  .nl { max-width: 560px; margin: 0 auto; padding: 48px 18px; }
-  .nl-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 32px 28px; }
-  .nl-h1 { font-size: clamp(24px, 5vw, 34px); margin: 0 0 10px; }
-  .nl-sub { font-size: 15.5px; line-height: 1.6; opacity: .85; margin: 0 0 20px; }
-  .nl-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .nl-form input { flex: 1 1 220px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .nl-btn { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent, #6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
-  .nl-btn-ghost { background: transparent; color: inherit; border: 1px solid rgba(128,128,128,.4); }
-</style>
Index: src/views/pages/oauth-consent.ejs
===================================================================
--- src/views/pages/oauth-consent.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,60 +1,0 @@
-<div class="oa-wrap">
-  <h1 class="oa-title"><%= t('oauth.title') %></h1>
-  <p class="oa-lead">
-    <strong><%= client.client_name %></strong>
-    <%= t('oauth.wants_access') %>
-  </p>
-
-  <form method="post" action="/oauth/authorize" class="oa-form">
-    <input type="hidden" name="client_id" value="<%= params.client_id %>">
-    <input type="hidden" name="redirect_uri" value="<%= params.redirect_uri %>">
-    <input type="hidden" name="code_challenge" value="<%= params.code_challenge %>">
-    <input type="hidden" name="scope" value="<%= params.scope %>">
-    <input type="hidden" name="state" value="<%= params.state %>">
-
-    <label class="oa-label"><%= t('oauth.post_as') %></label>
-    <% if (sites.length === 1) { %>
-      <input type="hidden" name="site_slug" value="<%= sites[0].slug %>">
-      <div class="oa-single"><%= sites[0].title || sites[0].slug %> <span class="oa-slug">@<%= sites[0].slug %></span></div>
-    <% } else { %>
-      <select name="site_slug" class="oa-select">
-        <% sites.forEach(function(s){ %>
-          <option value="<%= s.slug %>"><%= s.title || s.slug %> (@<%= s.slug %>)</option>
-        <% }); %>
-      </select>
-    <% } %>
-
-    <ul class="oa-scopes">
-      <li><%= t('oauth.scope_read') %></li>
-      <li><%= t('oauth.scope_write') %></li>
-    </ul>
-
-    <div class="oa-actions">
-      <button type="submit" name="decision" value="deny" class="btn oa-deny"><%= t('oauth.deny') %></button>
-      <button type="submit" name="decision" value="allow" class="btn btn-primary oa-allow"><%= t('oauth.allow') %></button>
-    </div>
-  </form>
-  <p class="oa-foot"><%= t('oauth.foot') %></p>
-</div>
-
-<style>
-  .oa-wrap { max-width: 460px; margin: 2.5rem auto; padding: 1.75rem 1.5rem; border-radius: 16px;
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-  .oa-title { margin: 0 0 .6rem; font-size: 1.4rem; }
-  .oa-lead { color: var(--ink-soft, #999); line-height: 1.55; margin: 0 0 1.4rem; }
-  .oa-lead strong { color: var(--ink, inherit); }
-  .oa-label { display: block; font-weight: 700; font-size: .82rem; margin: 0 0 .4rem; color: var(--ink-soft, #888); text-transform: uppercase; letter-spacing: .03em; }
-  .oa-single { padding: .6rem .8rem; border-radius: 10px; background: color-mix(in srgb, var(--ink, #000) 5%, transparent); font-weight: 600; }
-  .oa-slug { color: var(--ink-soft, #999); font-weight: 400; }
-  .oa-select { width: 100%; padding: .6rem .8rem; border-radius: 10px; font: inherit;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); background: transparent; color: var(--ink, inherit); }
-  .oa-scopes { list-style: none; padding: .9rem 0 0; margin: .9rem 0 0; border-top: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
-    display: flex; flex-direction: column; gap: .5rem; color: var(--ink-soft, #aaa); }
-  .oa-scopes li { position: relative; padding-left: 1.5rem; line-height: 1.4; }
-  .oa-scopes li::before { content: "✓"; position: absolute; left: 0; color: #2fa85a; font-weight: 700; }
-  .oa-actions { display: flex; gap: .7rem; margin-top: 1.5rem; }
-  .oa-deny { flex: 0 0 auto; }
-  .oa-allow { flex: 1; }
-  .oa-foot { color: var(--ink-soft, #888); font-size: .78rem; line-height: 1.5; margin: 1.1rem 0 0; }
-</style>
Index: src/views/pages/owa-consent.ejs
===================================================================
--- src/views/pages/owa-consent.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,69 +1,0 @@
-<%
-// Het toestemmingsscherm van /magic.
-//
-// Dit scherm bestaat omdat de FEP er onder "Information leakage" om vraagt:
-// OpenWebAuth geeft een STERKE identiteitsclaim af aan elke site die erom
-// vraagt, en desnoods zonder dat je het merkt. De omweg langs je eigen server is
-// het enige moment waarop je nog kunt zeggen: deze site niet.
-//
-// Daarom staat de doelhost groot en apart. Niet als versiering: wie hier
-// doorklikt vertelt die host wie hij is, en dat hoort de zin te zijn die hij
-// gelezen heeft.
-var _sites = (typeof owaSites !== 'undefined' && owaSites) ? owaSites : [];
-%>
-
-<section class="owa">
-  <div class="owa-card">
-    <h1 class="owa-h1"><%= t('owa.title') %></h1>
-
-    <p class="owa-host"><%= owaHost %></p>
-    <p class="owa-sub"><%= t('owa.sub') %></p>
-
-    <form method="post" action="/magic">
-      <input type="hidden" name="bdest" value="<%= owaBdest %>">
-
-      <% if (_sites.length === 1) { %>
-        <input type="hidden" name="slug" value="<%= _sites[0].slug %>">
-        <p class="owa-as"><%= t('owa.as') %> <strong><%= _sites[0].title || _sites[0].slug %></strong></p>
-      <% } else { %>
-        <%# Meer dan één site: kiezen is hier geen luxe. Ondertekenen en
-            ontsleutelen kan alleen met een sleutel die hij ook echt beheert. %>
-        <p class="owa-as"><%= t('owa.choose') %></p>
-        <ul class="owa-list">
-          <% _sites.forEach(function(s, i) { %>
-            <li>
-              <label>
-                <input type="radio" name="slug" value="<%= s.slug %>"<%= i === 0 ? ' checked' : '' %>>
-                <span><%= s.title || s.slug %></span>
-              </label>
-            </li>
-          <% }); %>
-        </ul>
-      <% } %>
-
-      <div class="owa-actions">
-        <button class="owa-btn" type="submit"><%= t('owa.go') %></button>
-        <a class="owa-no" href="/"><%= t('owa.cancel') %></a>
-      </div>
-    </form>
-
-    <p class="owa-fine"><%= t('owa.fine') %></p>
-  </div>
-</section>
-
-<style>
-  .owa { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .owa-card { border: 1px solid rgba(128,128,128,.2); border-radius: 18px; padding: 36px 28px; }
-  .owa-h1 { font-size: 22px; margin: 0 0 14px; }
-  .owa-host { font-size: 20px; font-weight: 600; margin: 0 0 6px; word-break: break-all; }
-  .owa-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .owa-as { font-size: .95rem; margin: 0 0 10px; }
-  .owa-list { list-style: none; padding: 0; margin: 0 0 18px; text-align: left; }
-  .owa-list li { padding: 6px 0; }
-  .owa-list label { display: flex; gap: 10px; align-items: center; cursor: pointer; }
-  .owa-actions { display: flex; gap: 14px; align-items: center; justify-content: center; flex-wrap: wrap; }
-  .owa-btn { padding: 12px 22px; border-radius: 10px; border: 0; background: var(--accent,#6b8f71);
-             color: #fff; font-weight: 600; font-family: inherit; font-size: 1rem; cursor: pointer; }
-  .owa-no { font-size: .9rem; opacity: .7; }
-  .owa-fine { font-size: .8rem; opacity: .6; line-height: 1.5; margin: 18px 0 0; }
-</style>
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,66 +1,0 @@
-<div class="container pg-navwrap">
-  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
-</div>
-
-<article class="pg-page" id="pg-page">
-  <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %>
-
-  <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
-    <div class="pg-teaser"><p><%= pgTeaser %></p></div>
-  <% } %>
-
-  <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
-      hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
-      Alleen de tekst zit achter de poort, en die staat hier niet -- de route
-      geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
-  <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
-    <div class="pg-audio post-content"><%- pgAudio %></div>
-  <% } %>
-
-  <% var _hasPatron = (typeof pgPatronUrl !== 'undefined' && pgPatronUrl); %>
-  <section class="pg-card">
-    <div class="pg-lock">💶</div>
-    <h2 class="pg-h2"><%= t('pgate.h') %></h2>
-    <p class="pg-sub">
-      <% if (typeof pgCents !== 'undefined' && pgCents) { %><%= t('pgate.sub_cents', { eur: (pgCents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
-    </p>
-
-    <div class="pg-actions">
-      <% if (_hasPatron) { %>
-        <a class="pg-btn" href="<%= pgPatronUrl %>" target="_blank" rel="noopener"><%= t('pgate.join') %></a>
-        <button type="button" id="pg-unlock" class="pg-btn pg-btn-ghost"><%= t('pgate.unlock_have') %></button>
-      <% } else { %>
-        <button type="button" id="pg-unlock" class="pg-btn"><%= t('pgate.unlock') %></button>
-      <% } %>
-    </div>
-    <p id="pg-status" class="pg-status" hidden></p>
-  </section>
-</article>
-
-<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr).
-    De WebAuthn-bibliotheek stond hier als `<script src>` en is weg (shaer-0i6):
-    deze pagina komt via een link binnen de site als htmx-fragment binnen, en
-    dan draagt zo'n tag de nonce van een ander verzoek en weigert de CSP hem.
-    De module haalt hem nu zelf op met loadWebAuthn() uit lib.js. %>
-
-<style>
-  .pg-navwrap { max-width: 720px; margin: 0.5rem auto 1.5rem; padding: 0 1rem; }
-  .pg-page { max-width: 720px; margin: 0 auto 3rem; padding: 0 1rem; }
-  .pg-title { font-family: var(--font-display, serif); font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 1rem; }
-  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
-    -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
-  .pg-audio { margin: 1.5rem 0 0; }
-  .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
-  .pg-lock { font-size: 38px; margin-bottom: 6px; }
-  .pg-h2 { font-size: 22px; margin: 0 0 8px; }
-  .pg-sub { opacity: .85; line-height: 1.6; margin: 0 auto 12px; max-width: 34em; }
-  .pg-btn { display: inline-block; text-align: center; text-decoration: none; padding: 12px 24px; border: none; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer;
-    background: var(--accent, #6b8f71); color: #fff; }
-  .pg-btn:disabled { opacity: .6; cursor: default; }
-  .pg-btn:hover { filter: brightness(1.05); }
-  .pg-actions { display: flex; flex-direction: column; gap: 10px; align-items: stretch; max-width: 320px; margin: 0 auto; }
-  .pg-btn-ghost { background: transparent; color: var(--ink, #222); border: 1px solid color-mix(in srgb, var(--ink, #000) 22%, transparent); }
-  .pg-status { margin: 12px 0 0; opacity: .9; }
-  .pg-status.is-err { color: #c0392b; }
-</style>
-<%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %>
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,30 +1,0 @@
-<section class="pk">
-  <div class="pk-card">
-    <div class="pk-ic">🔑</div>
-    <h1 class="pk-h1"><%= t('ppk.h') %></h1>
-    <p class="pk-sub">
-      <%= t('ppk.sub') %>
-    </p>
-    <button type="button" id="pk-go" class="pk-btn"><%= t('ppk.make') %></button>
-    <p id="pk-status" class="pk-status" hidden></p>
-  </div>
-</section>
-
-<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr).
-    De WebAuthn-bibliotheek stond hier als `<script src>` en is weg (shaer-0i6):
-    deze pagina komt via een link binnen de site als htmx-fragment binnen, en
-    dan draagt zo'n tag de nonce van een ander verzoek en weigert de CSP hem.
-    De module haalt hem nu zelf op met loadWebAuthn() uit lib.js. %>
-
-<style>
-  .pk { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .pk-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
-  .pk-ic { font-size: 40px; margin-bottom: 8px; }
-  .pk-h1 { font-size: 24px; margin: 0 0 6px; }
-  .pk-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .pk-btn { padding: 12px 24px; border: none; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font: inherit; font-weight: 600; cursor: pointer; }
-  .pk-btn:disabled { opacity: .6; cursor: default; }
-  .pk-status { margin: 14px 0 0; opacity: .9; }
-  .pk-status.is-err { color: #c0392b; }
-</style>
-<%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %>
Index: src/views/pages/paid-result.ejs
===================================================================
--- src/views/pages/paid-result.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-<%
-  var _slug = (typeof postSlug !== 'undefined' && postSlug) ? postSlug : '';
-  var _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-  var _patron = (typeof patronUrl !== 'undefined' && patronUrl) ? patronUrl : '';
-%>
-<section class="pr">
-  <div class="pr-card">
-    <div class="pr-ic">🔒</div>
-    <% if (reason === 'notpatron') { %>
-      <h1 class="pr-h1"><%= t('pres.notpatron_h') %></h1>
-      <p class="pr-sub"><%= t('pres.notpatron_p') %></p>
-    <% } else if (reason === 'tier') { %>
-      <h1 class="pr-h1"><%= t('pres.tier_h') %></h1>
-      <p class="pr-sub"><%= t('pres.tier_p', { need: ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2), have: ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) }) %></p>
-    <% } else if (reason === 'expired') { %>
-      <h1 class="pr-h1"><%= t('pres.expired_h') %></h1>
-      <p class="pr-sub"><%= t('pres.expired_p') %></p>
-    <% } else { %>
-      <h1 class="pr-h1"><%= t('pres.declined_h') %></h1>
-      <p class="pr-sub"><%= t('pres.declined_p') %></p>
-    <% } %>
-
-    <div class="pr-actions">
-      <% if ((reason === 'notpatron' || reason === 'tier') && _patron) { %>
-        <a class="pr-btn" href="<%= _patron %>" target="_blank" rel="noopener"><%= t('pres.join') %></a>
-      <% } %>
-      <% if (_slug) { %>
-        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/<%= _slug %>"><%= t('pres.back_post') %></a>
-      <% } else { %>
-        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/"><%= t('pres.back_site') %></a>
-      <% } %>
-    </div>
-  </div>
-</section>
-
-<style>
-  .pr { max-width: 460px; margin: 0 auto; padding: 56px 18px; text-align: center; }
-  .pr-card { border: 1px solid color-mix(in srgb, var(--ink,#000) 16%, transparent); border-radius: 18px; padding: 34px 26px; }
-  .pr-ic { font-size: 38px; margin-bottom: 6px; }
-  .pr-h1 { font-size: 23px; margin: 0 0 8px; }
-  .pr-sub { opacity: .85; line-height: 1.6; margin: 0 0 20px; }
-  .pr-actions { display: flex; flex-direction: column; gap: 10px; align-items: stretch; }
-  .pr-btn { display: inline-block; padding: 11px 20px; border-radius: 10px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; }
-  .pr-btn-ghost { background: transparent; color: var(--ink,#222); border: 1px solid color-mix(in srgb, var(--ink,#000) 22%, transparent); }
-</style>
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/post-edit.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -9,100 +9,29 @@
 // and styling changed.
 const _hasCover = !!post.cover_image_url;
-// In hub mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise
-// the request falls back to the primary site (siteUrlBase empty) and the post
-// renders headerless (bareChrome) after saving. In solo mode _base is empty.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 <div class="container post-edit-page">
-  <h1><%= isNew ? t('pedit.title_new') : t('pedit.title_edit') %></h1>
-
-  <form method="post" action="<%= _base %><%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
-
-    <%# ── TYPE (segmented) + type-specific inputs ────────────────
-        Post type lives at the TOP (above the body), and the selected type
-        reveals its own input: Audio → inline upload, Video → embed URL,
-        Foto → cover/insert hint. %>
-    <% var ptype = post.type || 'post'; %>
-    <% var TYPE_ICONS = { post: '📝', foto: '📷', video: '🎬', album: '💿', playlist: '📃', mixtape: '📼', audio: '🎵' }; %>
-    <%# Audio is geen keuze meer (shaer-cyg): alles wat muziek is landt op Album
-        of Playlist. De knop blijft alleen staan zolang deze post nog audio IS,
-        zodat een oude post niet stilzwijgend van type verandert bij het openen. %>
-    <% var TYPES = (typeof keuzeTypes !== 'undefined' && keuzeTypes.length)
-                     ? keuzeTypes.slice() : ['post','foto','video','album','playlist','mixtape']; %>
-    <% if (ptype === 'audio' && TYPES.indexOf('audio') < 0) TYPES.push('audio'); %>
-    <section class="pe-card pe-type-card">
-      <div class="pe-section-title"><%= t('pedit.s_type') %></div>
-      <input type="hidden" name="type" id="pe-type-input" value="<%= ptype %>">
-      <div class="pe-typeseg" role="radiogroup" aria-label="<%= t('pedit.s_type') %>">
-        <% TYPES.forEach(function (tt) { %>
-          <button type="button" class="pe-typeseg-btn<%= ptype === tt ? ' is-active' : '' %>"
-                  data-type="<%= tt %>" role="radio" aria-checked="<%= ptype === tt ? 'true' : 'false' %>">
-            <span class="pe-typeseg-ic" aria-hidden="true"><%= TYPE_ICONS[tt] %></span>
-            <span><%= t('pedit.type_' + tt) %></span>
-          </button>
-        <% }); %>
-      </div>
-
-      <%# Muziek: inline upload (transcodes + drops [[track]] into the post).
-          Album, Playlist en Mixtape delen dit paneel -- het verschil zit in de
-          playlist die je insluit, niet in wat je hier uploadt. Audio staat er
-          alleen nog voor oude posts. %>
-      <div class="pe-type-panel" data-panel="album playlist mixtape audio" hidden>
-        <div class="pe-audio-up" id="pe-audio-drop" tabindex="0" role="button" aria-label="<%= t('pedit.audio_up_drop') %>">
-          <input type="file" id="pe-audio-file" accept="audio/*,.mp3,.m4a,.ogg,.opus,.flac,.wav" multiple hidden>
-          <span class="pe-audio-up-ic" aria-hidden="true">🎵</span>
-          <strong><%= t('pedit.audio_up_drop') %></strong>
-          <small><%= t('pedit.audio_up_hint') %></small>
-        </div>
-        <ul class="pe-audio-list" id="pe-audio-list"></ul>
-      </div>
-
-      <%# Video: paste a URL → embed chip %>
-      <div class="pe-type-panel" data-panel="video" hidden>
-        <label class="pe-field">
-          <span><%= t('pedit.video_up_title') %></span>
-          <div class="pe-video-row">
-            <input type="url" id="pe-video-url" inputmode="url" autocapitalize="none" spellcheck="false"
-                   placeholder="<%= t('pedit.video_up_ph') %>">
-            <button type="button" class="pe-btn pe-btn-secondary" id="pe-video-insert"><%= t('pedit.video_up_btn') %></button>
-          </div>
-        </label>
-      </div>
-
-      <%# Foto: light hint toward cover + insert-image %>
-      <div class="pe-type-panel" data-panel="foto" hidden>
-        <p class="pe-foto-hint">🖼 <%= t('pedit.foto_up_hint') %></p>
-      </div>
-    </section>
+  <h1><%= isNew ? 'Nieuwe post' : 'Post bewerken' %></h1>
+
+  <form method="post" action="<%= isNew ? '/posts/create' : '/posts/' + post.slug + '/save' %>" class="post-edit-form">
 
     <%# ── IDENTITY ─────────────────────────────────────────────── %>
     <section class="pe-card">
       <label class="pe-field">
-        <span><%= t('pedit.f_title') %></span>
-        <input type="text" name="title" value="<%= post.title %>" required>
+        <span>Titel</span>
+        <input type="text" name="title" value="<%= post.title %>" required autofocus>
       </label>
       <div class="pe-row pe-row-2">
         <label class="pe-field">
-          <span><%= t('pedit.f_slug') %></span>
-          <input type="text" name="slug" value="<%= post.slug %>" placeholder="<%= t('pedit.slug_placeholder') %>">
+          <span>Slug (URL)</span>
+          <input type="text" name="slug" value="<%= post.slug %>" placeholder="auto van titel als leeg">
         </label>
         <label class="pe-field">
-          <span><%= t('pedit.f_tags') %> <small><%= t('pedit.tags_hint') %></small></span>
+          <span>Tags <small>(komma-gescheiden)</small></span>
           <input type="text" name="tags" value="<%= Array.isArray(post.tags) ? post.tags.join(', ') : '' %>">
         </label>
       </div>
       <label class="pe-field">
-        <span><%= t('pedit.f_excerpt') %></span>
+        <span>Excerpt <small>(preview tekst)</small></span>
         <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
-        <small class="pe-hint" style="opacity:.7"><%- t('pedit.excerpt_hint') %></small>
-      </label>
-      <% var _postLang = (typeof post.language !== 'undefined' && post.language) ? post.language : (typeof lang !== 'undefined' ? lang : 'en'); %>
-      <label class="pe-field">
-        <span><%= t('pedit.f_language') %> <small><%= t('pedit.language_hint') %></small></span>
-        <select name="language" id="pe-language" style="max-width:220px">
-          <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
-            <option value="<%= l[0] %>" <%= l[0] === _postLang ? 'selected' : '' %>><%= l[1] %></option>
-          <% }); %>
-        </select>
       </label>
     </section>
@@ -110,5 +39,5 @@
     <%# ── COVER ────────────────────────────────────────────────── %>
     <section class="pe-card">
-      <div class="pe-section-title"><%= t('pedit.s_cover') %></div>
+      <div class="pe-section-title">Cover</div>
       <div class="pe-cover-row">
         <div class="pe-cover-thumb" id="cover-preview-wrap" <%= _hasCover ? '' : 'data-empty' %>>
@@ -118,21 +47,12 @@
         <div class="pe-cover-actions">
           <label class="pe-field">
-            <span><%= t('pedit.f_cover_url') %></span>
-            <input type="text" name="cover_image_url" id="cover-url-field"
+            <span>Cover URL</span>
+            <input type="url" name="cover_image_url" id="cover-url-field"
                    value="<%= post.cover_image_url || '' %>"
-                   inputmode="url" autocapitalize="none" spellcheck="false"
-                   placeholder="<%= t('pedit.cover_url_placeholder') %>">
+                   placeholder="https://…  of upload met de knop">
           </label>
-          <label class="pe-field">
-            <span><%= t('pedit.f_cover_alt') %></span>
-            <input type="text" name="cover_alt" id="cover-alt-field" maxlength="1500"
-                   value="<%= post.cover_alt || '' %>"
-                   placeholder="<%= t('pedit.cover_alt_placeholder') %>">
-          </label>
-          <%# Muted loop MP4 for an animated cover (auto-made from an animated WebP). Hidden — set by the uploader. %>
-          <input type="hidden" name="cover_video_url" id="cover-video-field" value="<%= post.cover_video_url || '' %>">
           <div class="pe-cover-upload">
             <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
-              📷 <%= t('pedit.cover_upload_btn') %>
+              📷 Upload nieuwe cover
             </button>
             <input type="file" id="cover-upload-field" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
@@ -146,59 +66,51 @@
     <section class="pe-card pe-card-content">
       <div class="pe-section-title">
-        <%= t('pedit.s_content') %>
-        <small class="pe-content-hint"><%= t('pedit.content_hint') %></small>
+        Content
+        <small>Sleep een afbeelding om in te voegen · selecteer tekst om op te maken</small>
       </div>
       <div class="pe-editor-frame">
         <div class="pe-toolbar" id="pe-toolbar" role="toolbar" aria-label="Format">
-          <button type="button" id="pe-fs-done" class="pe-fs-done" title="<%= t('pedit.tb_done_title') %>">✓ <%= t('pedit.tb_done') %></button>
-          <button type="button" data-cmd="bold" title="<%= t('pedit.tb_bold_title') %>" aria-label="<%= t('pedit.tb_bold') %>">
+          <button type="button" data-cmd="bold" title="Vet (Ctrl+B)" aria-label="Vet">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/></svg>
           </button>
-          <button type="button" data-cmd="italic" title="<%= t('pedit.tb_italic_title') %>" aria-label="<%= t('pedit.tb_italic') %>">
+          <button type="button" data-cmd="italic" title="Cursief (Ctrl+I)" aria-label="Cursief">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
           </button>
-          <button type="button" data-cmd="underline" title="<%= t('pedit.tb_underline') %>" aria-label="<%= t('pedit.tb_underline') %>">
+          <button type="button" data-cmd="underline" title="Onderstreept" aria-label="Onderstreept">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
           </button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="<%= t('pedit.tb_h2') %>">H2</button>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="<%= t('pedit.tb_h3') %>">H3</button>
-          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p"  title="<%= t('pedit.tb_p') %>">¶</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h2" title="Kop">H2</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="h3" title="Subkop">H3</button>
+          <button type="button" class="pe-tb-text" data-cmd="formatBlock" data-arg="p"  title="Paragraaf">¶</button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" data-cmd="insertUnorderedList" title="<%= t('pedit.tb_ul') %>" aria-label="<%= t('pedit.tb_ul') %>">
+          <button type="button" data-cmd="insertUnorderedList" title="Lijst" aria-label="Lijst">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg>
           </button>
-          <button type="button" data-cmd="insertOrderedList" title="<%= t('pedit.tb_ol') %>" aria-label="<%= t('pedit.tb_ol') %>">
+          <button type="button" data-cmd="insertOrderedList" title="Genummerde lijst" aria-label="Genummerde lijst">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>
           </button>
-          <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="<%= t('pedit.tb_quote') %>" aria-label="<%= t('pedit.tb_quote') %>">
+          <button type="button" data-cmd="formatBlock" data-arg="blockquote" title="Quote" aria-label="Quote">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5C6 17.5 4 19 3 19v2z"/><path d="M14 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5c-.5 4.5-2.5 6-3.5 6v2z"/></svg>
           </button>
-          <button type="button" data-cmd="link-prompt" title="<%= t('pedit.tb_link_title') %>" aria-label="<%= t('pedit.tb_link') %>">
+          <button type="button" data-cmd="link-prompt" title="Link (Ctrl+K)" aria-label="Link">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
           </button>
-          <button type="button" data-cmd="code-wrap" title="<%= t('pedit.tb_code_title') %>" aria-label="<%= t('pedit.tb_code') %>">
+          <button type="button" data-cmd="code-wrap" title="Code (inline)" aria-label="Code">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
           </button>
           <span class="pe-toolbar-sep" aria-hidden="true"></span>
-          <button type="button" id="insert-image-btn" title="<%= t('pedit.tb_image_title') %>" aria-label="<%= t('pedit.tb_image') %>">
+          <button type="button" id="insert-image-btn" title="Afbeelding invoegen" aria-label="Afbeelding">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
           </button>
-          <button type="button" id="insert-track-btn" title="<%= t('pedit.tb_track_title') %>" aria-label="<%= t('pedit.tb_track') %>">
+          <button type="button" id="insert-track-btn" title="Track invoegen" aria-label="Track">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
           </button>
-          <button type="button" id="insert-playlist-btn" title="<%= t('pedit.tb_playlist_title') %>" aria-label="<%= t('pedit.tb_playlist') %>">
+          <button type="button" id="insert-playlist-btn" title="Playlist invoegen" aria-label="Playlist">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>
           </button>
-          <button type="button" id="insert-embed-btn" title="<%= t('pedit.tb_embed_title') %>" aria-label="<%= t('pedit.tb_embed') %>">
-            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>
-          </button>
           <span class="pe-toolbar-spacer"></span>
-          <button type="button" data-cmd="removeFormat" title="<%= t('pedit.tb_clear') %>" aria-label="<%= t('pedit.tb_clear') %>">
+          <button type="button" data-cmd="removeFormat" title="Wis opmaak" aria-label="Wis opmaak">
             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M5 5l14 14" stroke-linecap="round"/></svg>
-          </button>
-          <button type="button" id="pe-fullscreen-btn" title="<%= t('pedit.tb_fullscreen') %>" aria-label="<%= t('pedit.tb_fullscreen') %>" aria-pressed="false">
-            <svg class="fs-icon-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
-            <svg class="fs-icon-compress" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
           </button>
         </div>
@@ -209,15 +121,10 @@
              role="textbox"
              aria-multiline="true"
-             aria-label="<%= t('pedit.editor_aria') %>"></div>
-
-        <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen
-            via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %>
-        <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
+             aria-label="Content"
+             data-placeholder="Begin met schrijven…"></div>
 
         <%# Hidden field is what actually submits to the server. The visible
             contenteditable's serialized HTML is copied here on submit. %>
-        <%# value = de BESTAANDE inhoud: laadt de module niet (oude cache, js uit),
-    dan slaat het formulier de post ongewijzigd op in plaats van hem te wissen. %>
-        <input type="hidden" name="content" id="content-hidden" value="<%= post.content || '' %>">
+        <input type="hidden" name="content" id="content-hidden" value="">
 
         <%# Initial content is injected as a JSON string in a script tag so we
@@ -228,5 +135,5 @@
         <div class="pe-editor-status">
           <small id="content-upload-status" class="pe-status"></small>
-          <span class="pe-char-count"><span id="char-count">0</span> <%= t('pedit.chars') %></span>
+          <span class="pe-char-count"><span id="char-count">0</span> tekens</span>
         </div>
 
@@ -237,130 +144,38 @@
     <%# ── META ─────────────────────────────────────────────────── %>
     <section class="pe-card">
-      <div class="pe-section-title"><%= t('pedit.s_publication') %></div>
-      <label class="pe-field">
-        <span><%= t('pedit.f_status') %></span>
-        <% var _st = isNew ? 'published' : (post.status || 'draft'); %>
-        <select name="status">
-          <option value="published" <%= _st === 'published' ? 'selected' : '' %>><%= t('pedit.status_published') %></option>
-          <option value="draft"     <%= _st === 'draft'     ? 'selected' : '' %>><%= t('pedit.status_draft') %></option>
-          <option value="archived"  <%= _st === 'archived'  ? 'selected' : '' %>><%= t('pedit.status_archived') %></option>
-        </select>
-      </label>
+      <div class="pe-section-title">Publicatie</div>
+      <div class="pe-row pe-row-2">
+        <label class="pe-field">
+          <span>Status</span>
+          <select name="status">
+            <option value="draft"     <%= post.status === 'draft'     ? 'selected' : '' %>>Draft</option>
+            <option value="published" <%= post.status === 'published' ? 'selected' : '' %>>Published</option>
+            <option value="archived"  <%= post.status === 'archived'  ? 'selected' : '' %>>Archived</option>
+          </select>
+        </label>
+        <label class="pe-field">
+          <span>Type</span>
+          <select name="type">
+            <% var ptype = post.type || 'post'; %>
+            <option value="post"  <%= ptype === 'post'  ? 'selected' : '' %>>Post</option>
+            <option value="foto"  <%= ptype === 'foto'  ? 'selected' : '' %>>Foto</option>
+            <option value="video" <%= ptype === 'video' ? 'selected' : '' %>>Video</option>
+            <option value="audio" <%= ptype === 'audio' ? 'selected' : '' %>>Audio</option>
+          </select>
+        </label>
+      </div>
       <div class="pe-checkboxes">
-        <div class="pe-pin">
-          <label class="pe-checkbox">
-            <input type="checkbox" id="pin-toggle" <%= (Number(post.pinned) > 0) ? 'checked' : '' %>>
-            <span>📌 <%= t('pedit.pin_label') %></span>
-          </label>
-          <input type="hidden" name="pinned" id="pin-rank" value="<%= post.pinned || 0 %>">
-          <div class="pe-pin-pos" id="pin-pos" <%= (Number(post.pinned) > 0) ? '' : 'hidden' %>>
-            <span class="pe-pin-steps">
-              <button type="button" class="pe-pin-btn" id="pin-up" aria-label="<%= t('pedit.pin_up') %>">▲</button>
-              <button type="button" class="pe-pin-btn" id="pin-down" aria-label="<%= t('pedit.pin_down') %>">▼</button>
-            </span>
-            <span class="pe-pin-label" id="pin-label"></span>
-          </div>
-        </div>
+        <label class="pe-pinned-rank">
+          <span>📌 Pinned rank</span>
+          <input type="number" name="pinned" min="0" step="1"
+                 inputmode="numeric" pattern="[0-9]*"
+                 value="<%= post.pinned || 0 %>"
+                 title="0 = niet pinned. 1 = bovenaan, 2 = daaronder, etc.">
+          <small>0 = niet pinned · 1 = bovenaan · 2 daaronder · …</small>
+        </label>
         <label class="pe-checkbox">
           <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
-          <span>🚫 <%= t('pedit.noindex_label') %></span>
+          <span>🚫 noindex (verberg voor zoekmachines)</span>
         </label>
-        <label class="pe-checkbox">
-          <input type="checkbox" name="nsfw" value="1" id="pe-nsfw" <%= post.nsfw ? 'checked' : '' %>>
-          <span>🔞 <%= t('pedit.nsfw_label') %></span>
-        </label>
-        <input type="text" name="content_warning" id="pe-cw" value="<%= post.content_warning || '' %>" maxlength="200"
-               placeholder="<%= t('pedit.nsfw_cw_ph') %>"
-               style="width:100%;box-sizing:border-box;margin:6px 0 2px;font-size:13px;padding:7px 9px">
-        <% var _fediOpen = (typeof fediOpenAudio !== 'undefined' && fediOpenAudio); %>
-        <label class="pe-checkbox">
-          <%# One-way: once opened, the file has federated — re-gating would be false security, so the box locks. %>
-          <input type="checkbox" name="fedi_open_audio" value="1" id="pe-fedi-audio" <%= _fediOpen ? 'checked disabled' : '' %>>
-          <span>🌐 <%= t('pedit.fedi_audio_label') %></span>
-        </label>
-        <% if (_fediOpen) { %>
-          <p style="font-size:12px;opacity:.7;margin:2px 0 0 26px"><%= t('pedit.fedi_audio_locked') %></p>
-        <% } else { %>
-          <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
-          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
-        <% } %>
-        
-        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
-           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
-           var _pollLocked = (typeof pollLocked !== 'undefined' && pollLocked);
-           var _pollOpts = (_poll && Array.isArray(_poll.options) && _poll.options.length) ? _poll.options : [{ name: '' }, { name: '' }]; %>
-        <label class="pe-checkbox" style="margin-top:8px">
-          <input type="checkbox" name="poll_enabled" value="1" id="pe-poll-toggle" <%= _poll ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
-          <span>📊 <%= t('pedit.poll_label') %></span>
-        </label>
-        <style>
-          .pe-poll { margin-top: 6px; }
-          .pe-poll-locked { font-size: 12px; opacity: .7; margin: 0 0 6px; }
-          .pe-poll-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
-          .pe-poll-opt { flex: 1; min-width: 0; box-sizing: border-box; font-size: 13px; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--rule, rgba(128,128,128,.35)); background: transparent; color: inherit; }
-          .pe-poll-opt:focus { outline: none; border-color: var(--accent); }
-          .pe-poll-del { flex: 0 0 auto; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; border: 1px solid var(--rule, rgba(128,128,128,.35)); border-radius: 7px; background: transparent; color: var(--ink-muted, #999); cursor: pointer; transition: color .12s, border-color .12s; }
-          .pe-poll-del:hover { border-color: #c0392b; color: #c0392b; }
-          .pe-poll-del[hidden] { display: none; }
-          .pe-poll-add { display: block; width: fit-content; font-size: 12.5px; padding: 6px 12px; border-radius: 7px; border: 1px dashed var(--rule, rgba(128,128,128,.45)); background: transparent; color: inherit; cursor: pointer; margin: 0 0 10px; transition: color .12s, border-color .12s; }
-          .pe-poll-add:hover { border-color: var(--accent); color: var(--accent); }
-          .pe-poll-add:disabled { opacity: .4; cursor: default; border-style: dashed; }
-          .pe-poll-durlabel { display: block; font-size: 12.5px; opacity: .8; margin: 8px 0 4px; }
-          .pe-poll-dur { padding: 7px 9px; border-radius: 7px; border: 1px solid var(--rule, rgba(128,128,128,.35)); background: transparent; color: inherit; font-size: 13px; }
-        </style>
-        <%# NB: keep the condition INSIDE the quoted attribute — escaped output (equals-tag)
-            turns a whole emitted style attribute into entity soup (fields showed always). %>
-        <div id="pe-poll-fields" class="pe-poll" style="<%= _poll ? '' : 'display:none' %>">
-          <% if (_pollLocked) { %><p class="pe-poll-locked"><%= t('pedit.poll_locked') %></p><% } %>
-          <div id="pe-poll-opts" data-ph="<%= t('pedit.poll_option_ph') %>" data-del="<%= t('pedit.poll_remove') %>">
-            <% _pollOpts.forEach(function (o) { %>
-              <div class="pe-poll-row">
-                <input type="text" name="poll_option" class="pe-poll-opt" maxlength="100" value="<%= (o && o.name) || '' %>" placeholder="<%= t('pedit.poll_option_ph') %>" <%= _pollLocked ? 'disabled' : '' %>>
-                <% if (!_pollLocked) { %><button type="button" class="pe-poll-del" aria-label="<%= t('pedit.poll_remove') %>" title="<%= t('pedit.poll_remove') %>">&times;</button><% } %>
-              </div>
-            <% }); %>
-          </div>
-          <% if (!_pollLocked) { %><button type="button" id="pe-poll-add" class="pe-poll-add">+ <%= t('pedit.poll_add') %></button><% } %>
-          <label class="pe-checkbox">
-            <input type="checkbox" name="poll_multiple" value="1" <%= (_poll && _poll.multiple) ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
-            <span><%= t('pedit.poll_multiple') %></span>
-          </label>
-          <label class="pe-poll-durlabel"><%= t('pedit.poll_duration') %></label>
-          <select name="poll_duration" class="pe-poll-dur" <%= _pollLocked ? 'disabled' : '' %>>
-            <% [['300','5m'],['1800','30m'],['3600','1h'],['21600','6h'],['43200','12h'],['86400','1d'],['259200','3d'],['604800','7d']].forEach(function (d) { %>
-              <option value="<%= d[0] %>" <%= d[0] === '86400' ? 'selected' : '' %>><%= t('pedit.poll_dur_' + d[1]) %></option>
-            <% }); %>
-          </select>
-        </div>
-        
-        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
-          <label class="pe-checkbox">
-            <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
-            <span>🔒 <%= t('pedit.fan_only_label') %></span>
-          </label>
-          <label class="pe-checkbox" style="margin-top:8px">
-            <input type="checkbox" name="paid" value="1" id="pe-paid" <%= post.paid ? 'checked' : '' %>>
-            <span>💶 Betaalde post (supporters ontgrendelen met Patreon)</span>
-          </label>
-          <div id="pe-paid-price" style="margin:6px 0 0 26px;<%= post.paid ? '' : 'display:none' %>">
-            <label style="font-size:.85rem;opacity:.8">Vereist steunbedrag (euro, leeg = standaard)
-              <input type="text" name="paid_min_eur" inputmode="decimal" style="width:100px"
-                     value="<%= post.paid_min_cents ? (post.paid_min_cents/100).toFixed(2) : '' %>">
-            </label>
-          </div>
-          
-          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
-          <label class="pe-checkbox" style="margin-top:8px">
-            <input type="checkbox" name="schedule_enabled" value="1" id="pe-sched-toggle" <%= _scheduled ? 'checked' : '' %>>
-            <span>📅 <%= t('pedit.schedule_label') %></span>
-          </label>
-          <div id="pe-sched-fields" style="margin-top:6px;<%= _scheduled ? '' : 'display:none' %>">
-            <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px"><%= t('pedit.publish_at_label') %></label>
-            <input type="datetime-local" id="pe-publish-at" name="publish_at" <%= _scheduled ? '' : 'disabled' %> data-iso="<%= post.publish_at || '' %>" value="" style="padding:8px 10px;border-radius:8px;border:1px solid var(--rule,rgba(128,128,128,.4));background:transparent;color:inherit">
-            <% if (post.status === 'scheduled' && post.publish_at) { %><div style="font-size:12px;opacity:.7;margin-top:4px"><span id="pe-sched-when" data-iso="<%= post.publish_at %>" data-label="<%= t('pedit.scheduled_prefix') %>">⏳ <%= t('pedit.scheduled_for', { d: post.publish_at }) %></span></div><% } %>
-            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
-          </div>
-          
-        <% } %>
       </div>
     </section>
@@ -368,10 +183,10 @@
     <%# ── ACTIONS (sticky at bottom) ──────────────────────────── %>
     <div class="pe-actions">
-      <a href="<%= _base %><%= isNew ? '/' : '/' + post.slug %>" class="pe-btn"><%= t('pedit.cancel') %></a>
+      <a href="<%= isNew ? '/' : '/' + post.slug %>" class="pe-btn">Annuleren</a>
       <div class="pe-actions-spacer"></div>
       <% if (!isNew && post.status !== 'published') { %>
-        <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 <%= t('pedit.publish') %></button>
+        <button type="submit" name="action" value="publish" class="pe-btn pe-btn-success">📤 Publish</button>
       <% } %>
-      <button type="submit" class="pe-btn pe-btn-primary">💾 <%= t('pedit.save') %></button>
+      <button type="submit" class="pe-btn pe-btn-primary">💾 Opslaan</button>
     </div>
   </form>
@@ -526,86 +341,4 @@
 }
 
-/* ─── Post type: segmented control + type-aware panels ──────────── */
-.pe-typeseg {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 0.4rem;
-}
-.pe-typeseg-btn {
-  display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
-  padding: 0.7rem 0.4rem;
-  border: 1px solid var(--rule);
-  border-radius: 9px;
-  background: var(--paper-2);
-  color: var(--ink-soft);
-  font-size: 0.85rem; font-weight: 600;
-  cursor: pointer;
-  transition: border-color 120ms, background 120ms, color 120ms;
-}
-.pe-typeseg-btn:hover { border-color: var(--accent); color: var(--ink); }
-.pe-typeseg-btn .pe-typeseg-ic { font-size: 1.3rem; line-height: 1; }
-.pe-typeseg-btn.is-active {
-  border-color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
-  color: var(--ink);
-}
-.pe-typeseg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
-@media (max-width: 480px) {
-  .pe-typeseg-btn { font-size: 0.78rem; padding: 0.6rem 0.25rem; }
-  .pe-typeseg-btn .pe-typeseg-ic { font-size: 1.15rem; }
-}
-
-.pe-type-panel { margin-top: 0.2rem; }
-
-/* Audio drop/upload zone */
-.pe-audio-up {
-  display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
-  text-align: center;
-  padding: 1.4rem 1rem;
-  border: 2px dashed var(--rule);
-  border-radius: 10px;
-  background: var(--paper-2);
-  color: var(--ink-soft);
-  cursor: pointer;
-  transition: border-color 120ms, background 120ms;
-}
-.pe-audio-up:hover,
-.pe-audio-up:focus-visible { border-color: var(--accent); outline: none; }
-.pe-audio-up.is-drag {
-  border-color: var(--accent);
-  background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
-}
-.pe-audio-up .pe-audio-up-ic { font-size: 1.7rem; line-height: 1; }
-.pe-audio-up strong { color: var(--ink); font-size: 0.95rem; }
-.pe-audio-up small { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); max-width: 42ch; }
-
-.pe-audio-list { list-style: none; margin: 0.7rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
-.pe-audio-item {
-  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
-  padding: 0.5rem 0.7rem;
-  border: 1px solid var(--rule);
-  border-radius: 7px;
-  background: var(--paper-2);
-  font-size: 0.85rem;
-}
-.pe-audio-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pe-audio-item-state { flex: none; font-size: 0.8rem; color: var(--ink-soft); }
-.pe-audio-item.is-done { border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); }
-.pe-audio-item.is-done .pe-audio-item-state { color: var(--accent); }
-.pe-audio-item.is-fail .pe-audio-item-state { color: #d9534f; }
-
-/* Video URL row */
-.pe-video-row { display: flex; gap: 0.5rem; }
-.pe-video-row input { flex: 1 1 auto; min-width: 0; }
-.pe-video-row input {
-  box-sizing: border-box; padding: 0.6rem 0.75rem;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background: var(--paper-2); color: var(--ink); font-size: 0.95rem;
-}
-.pe-video-row input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-.pe-video-row .pe-btn { flex: none; }
-
-.pe-foto-hint { margin: 0; font-size: 0.88rem; color: var(--ink-soft); }
-
 /* ─── Editor frame (WYSIWYG: top toolbar, contenteditable body, status bar) ─── */
 .pe-editor-frame {
@@ -633,6 +366,4 @@
   width: 32px; height: 32px;
   padding: 0;
-  touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
-  -webkit-user-select: none; user-select: none;
   background: transparent;
   border: 1px solid transparent;
@@ -645,21 +376,14 @@
   -webkit-tap-highlight-color: transparent;
 }
-/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
-   after a tap, making the active/inactive state unreadable. */
-@media (hover: hover) {
-  .pe-toolbar button:hover {
-    background: var(--paper-2);
-    color: var(--accent);
-  }
+.pe-toolbar button:hover {
+  background: var(--paper-2);
+  color: var(--accent);
 }
 .pe-toolbar button:active { transform: scale(.94); }
-/* Active formatting = unmistakably filled with the accent colour. On mobile
-   it's immediately clear when e.g. bold is ON (tap again = off). */
 .pe-toolbar button.is-active {
-  background: var(--accent);
-  color: #fff;
-  border-color: var(--accent);
-}
-.pe-toolbar button.is-active svg { color: #fff; }
+  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
+  color: var(--accent);
+  border-color: color-mix(in srgb, var(--accent) 30%, transparent);
+}
 .pe-toolbar button svg { width: 16px; height: 16px; }
 .pe-toolbar button.pe-tb-text { font-family: var(--font-display, serif); font-weight: 700; }
@@ -671,42 +395,4 @@
 }
 .pe-toolbar-spacer { flex: 1; }
-
-/* ─── Full-screen writing mode ─── */
-.fs-icon-compress { display: none; }
-.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
-.pe-editor-frame.pe-fs .fs-icon-compress { display: inline; }
-.pe-editor-frame.pe-fs {
-  position: fixed; inset: 0; z-index: 1000;
-  margin: 0; border-top: 0;
-  height: 100dvh;
-  background: var(--paper);
-  overflow: hidden;   /* only the text field scrolls, not the frame itself */
-}
-/* In fullscreen the writing field fills the remaining space and scrolls itself
-   (max-height: none overrides the mobile box limit below). */
-.pe-editor-frame.pe-fs .pe-editor {
-  flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
-}
-/* iOS: in fullscreen the toolbar sits at the very top → push it below the
-   camera / Dynamic Island with the top safe-area inset. */
-.pe-editor-frame.pe-fs .pe-toolbar { padding-top: calc(.4rem + env(safe-area-inset-top, 0px)); }
-/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
-   stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
-   whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
-   and on touch scrollbars are hidden → no double bar needed. */
-
-/* "Done" button: only visible in fullscreen (left side of toolbar), clear
-   accent-pill instead of a square icon. */
-/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
-   Done button also shows inline as a small button). Only shown in fullscreen → large. */
-.pe-toolbar button.pe-fs-done { display: none; }
-.pe-editor-frame.pe-fs .pe-fs-done {
-  display: inline-flex; align-items: center; gap: .35rem;
-  width: auto !important; height: auto !important; min-height: 40px;
-  padding: .55rem 1.3rem !important; margin-right: .5rem;
-  background: var(--accent); color: #fff;
-  font-weight: 700; font-size: 1.05rem; border-radius: 999px;
-}
-.pe-editor-frame.pe-fs .pe-fs-done:hover { background: var(--accent); color: #fff; }
 
 /* Editor body — looks like prose, behaves like a textarea */
@@ -726,40 +412,14 @@
 }
 .pe-editor:focus { outline: none; }
-/* Inline (non-fullscreen) the writing field simply grows with the content — no
-   internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
-   always goes fullscreen (see JS), where the field fills the page and is the sole
-   scroller. */
-.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
-
-/* Touch: the inline content field is not a text field but a tap target → fullscreen
-   editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
-   so the hint is always visible without sitting in the middle of the text. */
-.pe-editor.pe-tap-to-edit { cursor: pointer; }
-.pe-edit-hint { display: none; }
-@media (pointer: coarse) {
-  .pe-editor-frame:not(.pe-fs) .pe-edit-hint {
-    display: block;
-    position: sticky;
-    bottom: 4.5rem;   /* above the sticky Save/Cancel bar */
-    width: max-content;
-    max-width: calc(100% - 2rem);
-    margin: .4rem auto;
-    background: var(--accent); color: #fff;
-    font-size: .9rem; font-weight: 700; padding: .5rem 1.1rem; border-radius: 999px;
-    box-shadow: 0 4px 14px rgba(0,0,0,.35);
-    pointer-events: none; text-align: center; white-space: nowrap;
-  }
-  /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
-  .pe-content-hint { display: none; }
-  /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
-     so no formatting toolbar and no border. Just the content preview + the
-     "tap to edit" pill. The toolbar only appears in fullscreen. */
-  .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
-  .pe-editor-frame:not(.pe-fs) { border-top: 0; }
-  .pe-editor-frame:not(.pe-fs) .pe-editor { min-height: 8rem; }
-}
 .pe-editor.is-dragover {
   outline: 2px dashed var(--accent);
   outline-offset: -10px;
+}
+.pe-editor[data-placeholder]:empty::before,
+.pe-editor[data-placeholder]:has(br:only-child)::before {
+  content: attr(data-placeholder);
+  color: var(--ink-muted, #999);
+  pointer-events: none;
+  opacity: .55;
 }
 /* Inline prose styles inside the editor — match the public post styling so
@@ -1118,19 +778,29 @@
 }
 
-/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
-.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
-.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
-.pe-pin-pos[hidden] { display: none; }
-.pe-pin-steps { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
-.pe-pin-btn {
-  width: 30px; height: 28px; padding: 0;
-  border: none; background: var(--paper-2); color: var(--ink);
-  cursor: pointer; font-size: 0.68rem; line-height: 1;
-  display: inline-flex; align-items: center; justify-content: center;
-}
-.pe-pin-btn + .pe-pin-btn { border-left: 1px solid var(--rule); }
-.pe-pin-btn:hover { background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); color: var(--accent); }
-.pe-pin-btn:disabled { opacity: 0.35; cursor: default; }
-.pe-pin-label { font-size: 0.85rem; color: var(--accent); font-weight: 600; }
+/* Pinned rank: replaces the old boolean checkbox with a number input.
+   Stack the label / input / hint vertically — small footprint. */
+.pe-pinned-rank {
+  display: flex; flex-direction: column; gap: 0.25rem;
+  font-size: 0.95rem;
+  color: var(--ink);
+}
+.pe-pinned-rank input[type="number"] {
+  width: 80px;
+  padding: 0.4rem 0.6rem; min-height: 36px;
+  border: 1px solid var(--rule); border-radius: 6px;
+  background: var(--paper-2); color: var(--ink);
+  font-family: var(--font-ui, system-ui), sans-serif;
+  font-size: 0.95rem;
+  font-variant-numeric: tabular-nums;
+  -webkit-appearance: none; appearance: textfield;
+}
+.pe-pinned-rank input[type="number"]:focus {
+  outline: 2px solid var(--accent); outline-offset: -1px;
+  border-color: var(--accent);
+}
+.pe-pinned-rank small {
+  font-size: 0.78rem;
+  color: var(--ink-muted, var(--ink-soft));
+}
 
 /* ─── Sticky action footer ──────────────────────────────────────── */
@@ -1141,5 +811,5 @@
   gap: 0.5rem;
   padding: 0.85rem 1rem;
-  margin: 0.5rem -1rem 0;   /* extend to viewport edges on a narrow container */
+  margin: 0.5rem -1rem 0;   /* extend to viewport edges on tight container */
   background: color-mix(in srgb, var(--paper) 94%, transparent);
   -webkit-backdrop-filter: blur(8px);
@@ -1150,48 +820,507 @@
 .pe-actions-spacer { flex: 1; }
 
-/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
-   so two bars don't stack at the bottom (Save bar + tab bar). The
-   Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
-   no tab offset needed. On desktop the tab bar is already hidden. */
-body:has(.post-edit-page) .bottom-tab { display: none; }
-/* Audio player (if playing) no longer lifted 56px for the now-hidden
-   tab bar, otherwise it would float above the action bar. */
-body:has(.post-edit-page) .audio-player { bottom: 0; }
-
-/* ── Image editor (rotate / crop / mirror) ── */
-.imed-backdrop {
-  position: fixed; inset: 0; z-index: 9999;
-  display: flex; align-items: center; justify-content: center;
-  background: rgba(0,0,0,.62); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
-  padding: 14px;
-}
-.imed-modal {
-  display: flex; flex-direction: column; gap: 12px;
-  width: 100%; max-width: 640px; max-height: 92vh;
-  background: var(--paper, #fff); color: var(--ink, #111);
-  border: 1px solid var(--rule, rgba(128,128,128,.3)); border-radius: 14px;
-  padding: 14px; box-sizing: border-box;
-}
-.imed-stage {
-  height: 58vh; flex: 0 0 auto;
-  background: var(--paper-2, rgba(128,128,128,.08)); border-radius: 10px; overflow: hidden;
-}
-.imed-stage img { display: block; max-width: 100%; }
-/* Cropper's container must fill the full stage height — otherwise it collapses
-   in a flex column without an explicit height → empty edit window (no image). */
-.imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
-.imed-tools {
-  display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;
-}
-.imed-tools button {
-  width: 42px; height: 42px; font-size: 18px; line-height: 1;
-  border: 1px solid var(--rule, rgba(128,128,128,.4)); border-radius: 10px;
-  background: var(--paper-2, transparent); color: inherit; cursor: pointer;
-}
-.imed-tools button:hover { border-color: var(--accent); }
-.imed-actions { display: flex; gap: 8px; justify-content: flex-end; }
+/* On a body that already has a fixed bottom-tab nav (mobile), nudge the
+   sticky footer up so it doesn't sit under it. The bottom-tab is ~64px tall. */
+body.has-bottom-tab .pe-actions {
+  bottom: 64px;
+}
 </style>
 
-
+<script>
+(function() {
+
+  // ── Cover upload ────────────────────────────────────────────────
+  const coverField   = document.getElementById('cover-upload-field');
+  const coverTrigger = document.getElementById('cover-upload-trigger');
+  const coverUrl     = document.getElementById('cover-url-field');
+  const coverStatus  = document.getElementById('cover-upload-status');
+  const coverWrap    = document.getElementById('cover-preview-wrap');
+  const coverImg     = document.getElementById('cover-preview-img');
+
+  async function uploadImage(file) {
+    const fd = new FormData();
+    fd.append('image', file);
+    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
+    if (!res.ok) {
+      const j = await res.json().catch(() => ({}));
+      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
+    }
+    return await res.json();   // {url, size, mime}
+  }
+
+  function showCoverPreview(url) {
+    if (!coverWrap || !coverImg) return;
+    if (url) {
+      coverImg.src = url;
+      coverImg.hidden = false;
+      coverWrap.removeAttribute('data-empty');
+      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
+      if (emptyIcon) emptyIcon.remove();
+    } else {
+      coverImg.hidden = true;
+      coverImg.src = '';
+      coverWrap.setAttribute('data-empty', '');
+      if (!coverWrap.querySelector('.pe-cover-empty')) {
+        const span = document.createElement('span');
+        span.className = 'pe-cover-empty';
+        span.textContent = '🖼';
+        coverWrap.appendChild(span);
+      }
+    }
+  }
+
+  if (coverTrigger && coverField) {
+    coverTrigger.addEventListener('click', () => coverField.click());
+  }
+  if (coverField) {
+    coverField.addEventListener('change', async () => {
+      if (!coverField.files[0]) return;
+      coverStatus.classList.remove('is-error');
+      coverStatus.textContent = 'Uploaden…';
+      try {
+        const j = await uploadImage(coverField.files[0]);
+        coverUrl.value = j.url;
+        showCoverPreview(j.url);
+        coverStatus.textContent = 'Geüpload ✓';
+        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
+      } catch (e) {
+        coverStatus.classList.add('is-error');
+        coverStatus.textContent = 'Mislukt: ' + e.message;
+      }
+    });
+  }
+  // Live-update preview when user pastes a URL manually
+  if (coverUrl) {
+    coverUrl.addEventListener('input', () => {
+      const v = coverUrl.value.trim();
+      if (v) showCoverPreview(v); else showCoverPreview('');
+    });
+  }
+
+  // ── WYSIWYG editor (P58) ────────────────────────────────────────
+  // Architecture:
+  //   - Visible <div contenteditable> (`#content-editor`) is what the user
+  //     types in; it shows real HTML (formatted, not raw markup).
+  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
+  //     On submit we serialize the editor's HTML into it, with shortcode
+  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
+  //   - Initial content comes from a <script type="application/json"> tag
+  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
+  //     and walk text nodes to render shortcode tokens as chips.
+  const contentField  = document.getElementById('content-upload-field');
+  const contentBtn    = document.getElementById('insert-image-btn');
+  const contentStatus = document.getElementById('content-upload-status');
+  const editor        = document.getElementById('content-editor');
+  const hiddenField   = document.getElementById('content-hidden');
+  const charCountEl   = document.getElementById('char-count');
+  const initialEl     = document.getElementById('initial-content');
+  const toolbar       = document.getElementById('pe-toolbar');
+  const form          = editor && editor.closest('form');
+
+  if (!editor) return;
+
+  // ── Shortcode chip rendering / serialization ────────────────────
+  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
+  // — but we DON'T want to chipify text the user is mid-typing inside an
+  // HTML attribute; since chipify only walks text nodes (never attribute
+  // values) that's already safe.
+  const SC_RE = /\[\[(track|album|playlist):([^\]]+)\]\]/g;
+
+  const SC_ICONS = {
+    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
+    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
+    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
+  };
+
+  function chipLabel(kind, value) {
+    if (kind === 'track') {
+      // UUIDs are noisy — show a 6-char prefix for visual hint
+      const v = String(value || '');
+      return 'Track ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
+    }
+    if (kind === 'album')    return 'Album: ' + value;
+    if (kind === 'playlist') return 'Playlist: ' + value;
+    return value;
+  }
+
+  function makeChip(kind, value) {
+    const span = document.createElement('span');
+    span.className = 'sc-chip';
+    span.contentEditable = 'false';
+    span.setAttribute('data-sc', kind + ':' + value);
+    span.innerHTML =
+      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
+      '<span class="sc-chip-label"></span>';
+    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
+    return span;
+  }
+
+  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
+  function chipifyShortcodes(root) {
+    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
+    const targets = [];
+    while (walker.nextNode()) {
+      const n = walker.currentNode;
+      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
+      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
+      if (SC_RE.test(n.nodeValue)) targets.push(n);
+      SC_RE.lastIndex = 0;
+    }
+    for (const node of targets) {
+      const txt = node.nodeValue;
+      const frag = document.createDocumentFragment();
+      let last = 0;
+      let m;
+      SC_RE.lastIndex = 0;
+      while ((m = SC_RE.exec(txt)) !== null) {
+        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
+        frag.appendChild(makeChip(m[1], m[2].trim()));
+        last = m.index + m[0].length;
+      }
+      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
+      node.parentNode.replaceChild(frag, node);
+    }
+  }
+
+  // Inverse of chipify: clone the editor, replace every chip with its text.
+  function serializeChips(rootClone) {
+    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
+    for (const c of chips) {
+      const txt = '[[' + c.getAttribute('data-sc') + ']]';
+      c.replaceWith(document.createTextNode(txt));
+    }
+  }
+
+  // ── Boot: load initial content as HTML, then render shortcodes as chips
+  try {
+    const initial = JSON.parse(initialEl.textContent || '""');
+    editor.innerHTML = initial || '';
+    chipifyShortcodes(editor);
+  } catch (e) {
+    console.error('[editor] could not parse initial content', e);
+    editor.innerHTML = '';
+  }
+
+  // ── Char counter
+  function updateCharCount() {
+    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
+    if (charCountEl) charCountEl.textContent = String(text.length);
+  }
+  updateCharCount();
+  editor.addEventListener('input', updateCharCount);
+
+  // ── Toolbar wiring
+  function execCmd(cmd, arg) {
+    editor.focus();
+    document.execCommand(cmd, false, arg);
+    updateToolbarState();
+    updateCharCount();
+  }
+  function wrapCode() {
+    const sel = window.getSelection();
+    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
+    const range = sel.getRangeAt(0);
+    const code = document.createElement('code');
+    code.textContent = sel.toString();
+    range.deleteContents();
+    range.insertNode(code);
+    // Move caret after the new node
+    range.setStartAfter(code);
+    range.collapse(true);
+    sel.removeAllRanges();
+    sel.addRange(range);
+    editor.focus();
+  }
+  function linkPrompt() {
+    const url = window.prompt('Link URL (https://… of /pad)');
+    if (!url) return;
+    execCmd('createLink', url);
+  }
+
+  if (toolbar) {
+    toolbar.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-cmd]');
+      if (!btn) return;
+      e.preventDefault();
+      const cmd = btn.dataset.cmd;
+      const arg = btn.dataset.arg || null;
+      if (cmd === 'link-prompt') linkPrompt();
+      else if (cmd === 'code-wrap') wrapCode();
+      else execCmd(cmd, arg);
+    });
+  }
+
+  // Reflect bold/italic/list state on the toolbar buttons
+  function updateToolbarState() {
+    if (!toolbar) return;
+    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
+    for (const cmd of cmds) {
+      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
+      if (!btn) continue;
+      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
+    }
+  }
+  document.addEventListener('selectionchange', () => {
+    if (document.activeElement === editor) updateToolbarState();
+  });
+
+  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
+  editor.addEventListener('keydown', (e) => {
+    const mod = e.ctrlKey || e.metaKey;
+    if (!mod) return;
+    const k = e.key.toLowerCase();
+    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
+    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
+    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
+    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
+  });
+
+  // Paste: keep it simple — strip formatting unless user wants it. Default
+  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
+  // styles from clipboard only when shift is held — otherwise plain text.
+  editor.addEventListener('paste', (e) => {
+    if (e.shiftKey) return; // user wants formatted paste
+    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+    if (text == null) return;
+    e.preventDefault();
+    document.execCommand('insertText', false, text);
+  });
+
+  // ── Image upload (button + drag-drop into the editor)
+  async function uploadAndInsertImage(file) {
+    contentStatus.classList.remove('is-error');
+    contentStatus.textContent = 'Uploaden…';
+    try {
+      const j = await uploadImage(file);
+      const img = '<img src="' + j.url + '" alt="">';
+      editor.focus();
+      document.execCommand('insertHTML', false, img);
+      contentStatus.textContent = 'Ingevoegd ✓';
+      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
+      updateCharCount();
+    } catch (e) {
+      contentStatus.classList.add('is-error');
+      contentStatus.textContent = 'Mislukt: ' + e.message;
+    }
+  }
+
+  if (contentBtn && contentField) {
+    contentBtn.addEventListener('click', () => contentField.click());
+    contentField.addEventListener('change', () => {
+      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
+      contentField.value = '';
+    });
+
+    editor.addEventListener('dragover', (e) => {
+      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+        e.preventDefault();
+        editor.classList.add('is-dragover');
+      }
+    });
+    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
+    editor.addEventListener('drop', async (e) => {
+      editor.classList.remove('is-dragover');
+      const files = e.dataTransfer && e.dataTransfer.files;
+      if (!files || !files.length) return;
+      e.preventDefault();
+      for (const f of files) {
+        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
+      }
+    });
+  }
+
+  // ── Insert chip helpers (track / playlist)
+  function insertChip(kind, value) {
+    editor.focus();
+    const chip = makeChip(kind, value);
+    // Insert at caret using the Selection API (execCommand insertNode)
+    const sel = window.getSelection();
+    if (sel && sel.rangeCount > 0) {
+      const range = sel.getRangeAt(0);
+      range.deleteContents();
+      range.insertNode(chip);
+      // Insert a trailing space so the user can keep typing after the chip
+      const space = document.createTextNode('\u00A0');
+      chip.after(space);
+      range.setStartAfter(space);
+      range.collapse(true);
+      sel.removeAllRanges();
+      sel.addRange(range);
+    } else {
+      editor.appendChild(chip);
+      editor.appendChild(document.createTextNode('\u00A0'));
+    }
+    updateCharCount();
+  }
+
+  // ── Track insert: opens the track-picker modal (P59)
+  const trackBtn = document.getElementById('insert-track-btn');
+  const trackPicker = document.getElementById('track-picker');
+  if (trackBtn && trackPicker) {
+    const tpList   = document.getElementById('tp-list');
+    const tpEmpty  = document.getElementById('tp-empty');
+    const tpSearch = document.getElementById('tp-search');
+    let tpCache = null;       // cached tracks list (fetched once per page load)
+    let tpLastFocus = null;   // element to restore focus to on close
+
+    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
+
+    function fmtDur(sec) {
+      sec = Math.max(0, Math.floor(sec || 0));
+      const m = Math.floor(sec / 60), s = sec % 60;
+      return m + ':' + String(s).padStart(2, '0');
+    }
+    function escAttr(s) {
+      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+      }[c]));
+    }
+
+    function renderList(filter) {
+      if (!Array.isArray(tpCache)) return;
+      const q = (filter || '').trim().toLowerCase();
+      const filtered = q
+        ? tpCache.filter(t =>
+            (t.title  || '').toLowerCase().includes(q) ||
+            (t.artist || '').toLowerCase().includes(q))
+        : tpCache;
+
+      if (!filtered.length) {
+        tpList.innerHTML = '';
+        tpEmpty.textContent = q ? 'Geen tracks gevonden voor "' + q + '"' : 'Nog geen tracks. Upload via Beheer → Audio.';
+        tpList.appendChild(tpEmpty);
+        return;
+      }
+
+      tpList.innerHTML = filtered.map(t => {
+        const cov = t.cover
+          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
+          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
+        const dis = t.playable ? '' : ' aria-disabled="true"';
+        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
+        return (
+          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
+            cov +
+            '<span class="tp-meta">' +
+              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
+              sub +
+            '</span>' +
+            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
+          '</button>'
+        );
+      }).join('');
+    }
+
+    async function loadTracks() {
+      if (Array.isArray(tpCache)) return tpCache;
+      tpEmpty.textContent = 'Tracks laden…';
+      try {
+        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
+        const j = await r.json();
+        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
+      } catch (e) {
+        tpCache = [];
+        tpEmpty.textContent = 'Kon tracks niet laden: ' + e.message;
+      }
+      return tpCache;
+    }
+
+    function openPicker() {
+      tpLastFocus = document.activeElement;
+      trackPicker.hidden = false;
+      trackPicker.setAttribute('aria-hidden', 'false');
+      document.body.classList.add('tp-locked');
+      tpSearch.value = '';
+      renderList('');
+      // Defer focus so the open animation doesn't get jumped
+      setTimeout(() => tpSearch.focus(), 30);
+    }
+    function closePicker() {
+      trackPicker.hidden = true;
+      trackPicker.setAttribute('aria-hidden', 'true');
+      document.body.classList.remove('tp-locked');
+      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
+        try { tpLastFocus.focus(); } catch(_) {}
+      }
+    }
+
+    trackBtn.addEventListener('click', async () => {
+      openPicker();
+      await loadTracks();
+      renderList(tpSearch.value);
+    });
+
+    // Close: backdrop click, [data-tp-close], or Escape
+    trackPicker.addEventListener('click', (e) => {
+      if (e.target.closest('[data-tp-close]')) {
+        closePicker();
+        return;
+      }
+      const row = e.target.closest('.tp-row[data-track-id]');
+      if (row) {
+        if (row.getAttribute('aria-disabled') === 'true') return;
+        const id = row.dataset.trackId;
+        if (id) {
+          insertChip('track', id);
+          closePicker();
+        }
+      }
+    });
+    document.addEventListener('keydown', (e) => {
+      if (!trackPicker.hidden && e.key === 'Escape') {
+        e.preventDefault();
+        closePicker();
+      }
+    });
+
+    // Live filter
+    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
+  }
+
+  // ── Playlist insert (open existing or create new via modal)
+  const playlistBtn = document.getElementById('insert-playlist-btn');
+  if (playlistBtn) {
+    playlistBtn.addEventListener('click', async () => {
+      if (typeof window.openPlaylistEditor !== 'function') {
+        alert('Playlist editor niet geladen');
+        return;
+      }
+      try {
+        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
+        const j = await r.json();
+        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
+          const choice = prompt(
+            'Bestaande playlists:\n\n' +
+            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
+            '\n\nKies een nummer om in te voegen, leeg = nieuwe maken:'
+          );
+          if (choice && /^\d+$/.test(choice.trim())) {
+            const idx = parseInt(choice.trim(), 10) - 1;
+            if (idx >= 0 && idx < j.playlists.length) {
+              insertChip('playlist', j.playlists[idx].id);
+              return;
+            }
+          }
+          if (choice === null) return;
+        }
+      } catch (_) { /* fall through to create */ }
+
+      window.openPlaylistEditor({
+        mode: 'create',
+        onSaved: ({ id }) => insertChip('playlist', id),
+      });
+    });
+  }
+
+  // ── Submit: serialize editor contents into the hidden field
+  if (form && hiddenField) {
+    form.addEventListener('submit', () => {
+      const clone = editor.cloneNode(true);
+      serializeChips(clone);
+      hiddenField.value = clone.innerHTML;
+    });
+  }
+})();
+</script>
 
 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
@@ -1202,6 +1331,6 @@
   <div class="tp-sheet" role="dialog" aria-modal="true" aria-labelledby="tp-title">
     <header class="tp-header">
-      <h2 id="tp-title" class="tp-h2"><%= t('pedit.tp_title') %></h2>
-      <button type="button" class="tp-close" data-tp-close aria-label="<%= t('pedit.tp_close') %>">
+      <h2 id="tp-title" class="tp-h2">Track invoegen</h2>
+      <button type="button" class="tp-close" data-tp-close aria-label="Sluiten">
         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
       </button>
@@ -1211,8 +1340,8 @@
         <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
       </span>
-      <input type="search" class="tp-search" id="tp-search" placeholder="<%= t('pedit.tp_search_placeholder') %>" autocomplete="off" inputmode="search">
+      <input type="search" class="tp-search" id="tp-search" placeholder="Zoek op titel of artiest…" autocomplete="off" inputmode="search">
     </div>
-    <div class="tp-list" id="tp-list" role="listbox" aria-label="<%= t('pedit.tp_list_aria') %>">
-      <div class="tp-empty" id="tp-empty"><%= t('pedit.js_tracks_loading') %></div>
+    <div class="tp-list" id="tp-list" role="listbox" aria-label="Tracks">
+      <div class="tp-empty" id="tp-empty">Tracks laden…</div>
     </div>
   </div>
@@ -1225,3 +1354,2 @@
   <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 <% } %>
-<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/post.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,28 +1,6 @@
-<%
-// In hub mode the site is accessible under /user/<slug>; all site-scoped links
-// (tag, edit, delete, comments) must carry that prefix otherwise they fall back
-// to the primary site → 404 / wrong site. In solo mode _base = ''.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-%>
-<article class="container post-page<%= post.nsfw ? ' nsfw-gate' : '' %>">
-
-  <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
-  <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
-
-  <% if (post.nsfw) { %>
-    <div class="nsfw-banner">
-      <span class="nsfw-banner-icon">🔞</span>
-      <span class="nsfw-banner-text"><%= post.content_warning || t('post.nsfw_warning') %></span>
-      <button type="button" class="nsfw-banner-btn nsfw-reveal"><%= t('post.nsfw_show') %></button>
-    </div>
-  <% } %>
-
+<article class="container post-page">
   <% if (post.cover_image_url) { %>
     <figure class="post-cover">
-      <img src="<%= post.cover_image_url %>" alt="<%= post.cover_alt || '' %>"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-    </figure>
-  <% } else if (post.cover_video_url) { %>
-    <figure class="post-cover">
-      <video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 1280) %>" controls loop muted playsinline preload="metadata"></video>
+      <img src="<%= post.cover_image_url %>" alt="">
     </figure>
   <% } %>
@@ -49,34 +27,11 @@
   </div>
 
-  <% if (typeof poll !== 'undefined' && poll) { %>
-    <section class="poll" aria-label="<%= t('poll.aria') %>">
-      <% poll.options.forEach(function(o){ var _lead = poll.total > 0 && o.count === Math.max.apply(null, poll.options.map(function(x){return x.count;})); %>
-        <div class="poll-opt<%= _lead ? ' is-lead' : '' %>">
-          <div class="poll-bar" style="width:<%= o.pct %>%"></div>
-          <span class="poll-name"><%= o.name %></span>
-          <span class="poll-pct"><%= o.pct %>%</span>
-        </div>
-      <% }); %>
-      <div class="poll-meta">
-        <span><%= poll.voters %> <%= poll.voters === 1 ? t('poll.voter_one') : t('poll.voter_many') %></span>
-        <span aria-hidden="true">·</span>
-        <% if (poll.closed) { %>
-          <span><%= t('poll.closed') %></span>
-        <% } else if (poll.endTime) { %>
-          <span><%= t('poll.closes') %> <%= new Date(poll.endTime).toLocaleString() %></span>
-        <% } %>
-        <% if (poll.multiple) { %><span aria-hidden="true">·</span><span><%= t('poll.multiple') %></span><% } %>
-      </div>
-      <p class="poll-note"><%= t('poll.fedi_only') %></p>
-    </section>
-  <% } %>
-
   <% if (post.tags && post.tags.length > 0) { %>
     <div class="post-tags">
       <% post.tags.forEach(t => { %>
-        <a class="tag" href="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
-           hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1"
+        <a class="tag" href="/tag/<%= encodeURIComponent(t) %>"
+           hx-get="/tag/<%= encodeURIComponent(t) %>?partial=1"
            hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
+           hx-push-url="/tag/<%= encodeURIComponent(t) %>"
            hx-indicator="#pcms-loading">#<%= t %></a>
       <% }); %>
@@ -84,31 +39,16 @@
   <% } %>
 
-  <div class="post-share" style="margin:1.25rem 0;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
-    <button type="button" class="btn post-share-btn" data-share data-share-title="<%= post.title %>">🔗 <%= t('post.share') %></button>
-    <%# Interact via the fediverse — for VISITORS (reply/like/boost from their own
-        server). Same flow as the CTA in the fediverse section; hidden for the
-        owner/admin (their own post) and when AP is off. %>
-    <% if (post.status === 'published' && (typeof apEnabled === 'undefined' || apEnabled) && (typeof canManageSite === 'undefined' || !canManageSite)) { %>
-      <% var _shareAbsUrl = (typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + (typeof _base !== 'undefined' && _base ? _base : '') + '/' + post.slug; %>
-      <button type="button" class="btn fedi-remote-reply-btn" data-fedi-uri="<%= _shareAbsUrl %>" data-fedi-ph="<%= t('fedi.remote_ph') %>">
-        <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
-        <%= t('fedi.remote_interact') %>
-      </button>
-    <% } %>
-    <span class="post-share-feedback" id="post-share-feedback" style="color:var(--accent);font-size:.85rem" hidden><%= t('post.share_copied') %></span>
-  </div>
-
   <!-- Inline admin actions: only visible if user has permission -->
   <% if (user && (permissions.canEditPost(user, post, site) || permissions.canDeletePost(user, post, site))) { %>
     <aside class="post-actions">
       <% if (permissions.canEditPost(user, post, site)) { %>
-        <a href="<%= _base %>/posts/<%= post.slug %>/edit" class="btn"
-           hx-get="<%= _base %>/posts/<%= post.slug %>/edit?partial=1" hx-target="#pcms-main"
-           hx-push-url="<%= _base %>/posts/<%= post.slug %>/edit" hx-indicator="#pcms-loading">
+        <a href="/posts/<%= post.slug %>/edit" class="btn"
+           hx-get="/posts/<%= post.slug %>/edit?partial=1" hx-target="#pcms-main"
+           hx-push-url="/posts/<%= post.slug %>/edit" hx-indicator="#pcms-loading">
           ✏️ Edit
         </a>
       <% } %>
       <% if (permissions.canDeletePost(user, post, site)) { %>
-        <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" data-confirm="Delete this post?">
+        <form method="post" action="/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
           <button type="submit" class="btn btn-danger">🗑 Delete</button>
         </form>
@@ -117,38 +57,140 @@
   <% } %>
 
-  <!-- Fediverse interactions — single place: ⭐ like (clickable) + 🔁/💬 counts + reply -->
-  <% var _fedi = (typeof fediverse !== 'undefined' && fediverse) ? fediverse : { thread: [], likeCount: 0, announceCount: 0 }; %>
-  <% if (post.status === 'published' && (typeof apEnabled === 'undefined' || apEnabled)) { %>
-    <section class="post-fediverse post-comments" id="fediverse">
-      <h2 class="comments-heading"><%= t('fedi.heading') %></h2>
-      <div class="fedi-stats">
-        <span class="fedi-stat" title="<%= t('fedi.likes') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
-          <span><%= _fedi.likeCount %></span>
-        </span>
-        <span class="fedi-stat" title="<%= t('fedi.boosts') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
-          <span><%= _fedi.announceCount %></span>
-        </span>
-        <span class="fedi-stat" title="<%= t('fedi.replies') %>">
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
-          <span><%= (_fedi.thread || []).length %></span>
-        </span>
-      </div>
-      <% if (_fedi.thread && _fedi.thread.length) { %>
-        <ol class="comments-list">
-          <% _fedi.thread.forEach(function(n){ %>
-            <li class="comment"><%- include('../partials/fedi-node', { n: n, t: t, canManageSite: (typeof canManageSite !== 'undefined' ? canManageSite : false), _base: _base, siteAvatar: (typeof siteAvatar !== 'undefined' ? siteAvatar : null), formatDateTime: formatDateTime, postSlug: post.slug }) %></li>
-          <% }); %>
-        </ol>
-      <% } %>
-      <%# The "Interact via the fediverse" button for visitors now lives next to
-          the Share button at the top of the post (single, prominent place). %>
-    </section>
-  <% } %>
-  <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt
-    door de CSP geweigerd zodra je deze pagina via een link binnen de site opent
-    -- zie shaer-0i6. %>
-
+  <!-- Comments -->
+  <section class="post-comments" id="comments">
+    <h2 class="comments-heading">
+      <%= totalComments %> comment<%= totalComments === 1 ? '' : 's' %>
+    </h2>
+
+    <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %>
+      <% /* If the URL has ?pending=1, the previous submit landed in the queue */ %>
+    <% } %>
+    <script>
+      (function() {
+        if (location.search.indexOf('pending=1') !== -1) {
+          var s = document.createElement('div');
+          s.className = 'comments-pending-flash';
+          s.textContent = 'Your comment is awaiting moderation. It will appear once an admin approves it.';
+          document.currentScript.parentNode.insertBefore(s, document.currentScript);
+        }
+      })();
+    </script>
+
+    <% if (!comments || !comments.length) { %>
+      <p class="comments-empty">No comments yet.<% if (!user) { %> <a href="/auth/login?next=<%= encodeURIComponent('/' + post.slug + '#comments') %>">Log in</a> to start the conversation.<% } %></p>
+    <% } else { %>
+      <ol class="comments-list">
+        <% comments.forEach(function(c) { %>
+          <li class="comment" id="comment-<%= c.id %>">
+            <div class="comment-avatar">
+              <% if (c.author_avatar) { %>
+                <img src="<%= c.author_avatar %>" alt="">
+              <% } else { %>
+                <span><%= c.author_username.charAt(0).toUpperCase() %></span>
+              <% } %>
+            </div>
+            <div class="comment-body">
+              <div class="comment-meta">
+                <a class="comment-author" href="/users/<%= encodeURIComponent(c.author_username) %>"><%= c.author_username %></a>
+                <span class="comment-time" title="<%= c.created_at %>"><%= formatDateTime(c.created_at) %></span>
+              </div>
+              <div class="comment-content"><%= c.content %></div>
+              <div class="comment-actions">
+                <% if (user) { %>
+                  <button type="button" class="comment-reply-btn" data-reply-to="<%= c.id %>">Reply</button>
+                <% } %>
+                <% if (user && permissions.canDeleteComment(user, c, site)) { %>
+                  <form method="post" action="/comments/<%= c.id %>/delete" style="display:inline" onsubmit="return confirm('Delete this comment?')">
+                    <button type="submit" class="comment-delete-btn">Delete</button>
+                  </form>
+                <% } %>
+              </div>
+
+              <!-- Inline reply form (hidden by default) -->
+              <% if (user) { %>
+                <form method="post" action="/comments" class="comment-reply-form" hidden data-reply-form-for="<%= c.id %>">
+                  <input type="hidden" name="post_slug" value="<%= post.slug %>">
+                  <input type="hidden" name="parent_comment_id" value="<%= c.id %>">
+                  <textarea name="content" rows="3" maxlength="4000" placeholder="Reply to <%= c.author_username %>…" required></textarea>
+                  <div class="comment-reply-form-actions">
+                    <button type="submit" class="btn btn-primary">Reply</button>
+                    <button type="button" class="btn comment-cancel-reply">Cancel</button>
+                  </div>
+                </form>
+              <% } %>
+
+              <!-- Replies (1 level deep) -->
+              <% if (c.replies && c.replies.length) { %>
+                <ol class="comment-replies">
+                  <% c.replies.forEach(function(r) { %>
+                    <li class="comment comment-reply" id="comment-<%= r.id %>">
+                      <div class="comment-avatar">
+                        <% if (r.author_avatar) { %>
+                          <img src="<%= r.author_avatar %>" alt="">
+                        <% } else { %>
+                          <span><%= r.author_username.charAt(0).toUpperCase() %></span>
+                        <% } %>
+                      </div>
+                      <div class="comment-body">
+                        <div class="comment-meta">
+                          <a class="comment-author" href="/users/<%= encodeURIComponent(r.author_username) %>"><%= r.author_username %></a>
+                          <span class="comment-time"><%= formatDateTime(r.created_at) %></span>
+                        </div>
+                        <div class="comment-content"><%= r.content %></div>
+                        <div class="comment-actions">
+                          <% if (user && permissions.canDeleteComment(user, r, site)) { %>
+                            <form method="post" action="/comments/<%= r.id %>/delete" style="display:inline" onsubmit="return confirm('Delete this comment?')">
+                              <button type="submit" class="comment-delete-btn">Delete</button>
+                            </form>
+                          <% } %>
+                        </div>
+                      </div>
+                    </li>
+                  <% }); %>
+                </ol>
+              <% } %>
+            </div>
+          </li>
+        <% }); %>
+      </ol>
+    <% } %>
+
+    <!-- Top-level comment form (only for logged-in users for now) -->
+    <% if (user) { %>
+      <form method="post" action="/comments" class="comment-form">
+        <input type="hidden" name="post_slug" value="<%= post.slug %>">
+        <label class="comment-form-label">
+          <span>Add a comment as <strong><%= user.username %></strong></span>
+          <textarea name="content" rows="3" maxlength="4000" placeholder="Share your thoughts…" required></textarea>
+        </label>
+        <button type="submit" class="btn btn-primary">Post comment</button>
+      </form>
+    <% } else { %>
+      <p class="comments-login-cta">
+        <a href="/auth/login?next=<%= encodeURIComponent('/' + post.slug + '#comments') %>" class="btn">Log in to comment</a>
+      </p>
+    <% } %>
+  </section>
+
+  <!-- Reply form toggle -->
+  <script>
+  (function() {
+    document.querySelectorAll('.comment-reply-btn').forEach(function(btn) {
+      btn.addEventListener('click', function() {
+        var id = btn.dataset.replyTo;
+        var form = document.querySelector('[data-reply-form-for="' + id + '"]');
+        if (form) {
+          form.hidden = !form.hidden;
+          if (!form.hidden) form.querySelector('textarea').focus();
+        }
+      });
+    });
+    document.querySelectorAll('.comment-cancel-reply').forEach(function(btn) {
+      btn.addEventListener('click', function() {
+        btn.closest('.comment-reply-form').hidden = true;
+      });
+    });
+  })();
+  </script>
 
   <%# ── Related posts (3-card grid) ─────────────────────────────
@@ -158,15 +200,17 @@
   <% if (relatedPosts && relatedPosts.length > 0) { %>
     <section class="post-related" aria-labelledby="related-heading">
-      <h2 class="post-related-heading" id="related-heading"><%= t('related.title') %></h2>
+      <h2 class="post-related-heading" id="related-heading">Gerelateerde posts</h2>
       <ul class="post-related-grid">
         <% relatedPosts.forEach(function(rp) { %>
           <li class="post-related-card">
-            <a href="<%= rp._urlBase || '' %>/<%= rp.slug %>"<% if (!rp._urlBase) { %>
+            <a href="/<%= rp.slug %>"
                hx-get="/<%= rp.slug %>?partial=1" hx-target="#pcms-main"
-               hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading"<% } %>>
+               hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading">
               <% if (rp.cover_image_url) { %>
-                <span class="post-related-cover<%= rp.nsfw ? ' nsfw-media' : '' %>" aria-hidden="true"><img class="post-related-img" src="<%= rp.cover_image_url %>" alt="" loading="lazy" decoding="async"<% if (rp.cover_video_url) { %> data-ios-mp4="<%= rp.cover_video_url %>"<% } %>><% if (rp.nsfw) { %><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: rp.content_warning }) %></span><% } %></span>
+                <span class="post-related-cover"
+                      style="background-image: url('<%= rp.cover_image_url %>')"
+                      aria-hidden="true"></span>
               <% } else { %>
-                <span class="post-related-cover post-related-cover--empty<%= rp.nsfw ? ' nsfw-media' : '' %>" aria-hidden="true"><% if (rp.nsfw) { %><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: rp.content_warning }) %></span><% } %></span>
+                <span class="post-related-cover post-related-cover--empty" aria-hidden="true"></span>
               <% } %>
               <span class="post-related-meta">
@@ -181,9 +225,80 @@
   <% } %>
 
+  <%# ── Pinned navigation (only when current post is pinned) ───
+      Two card-style links: "Vorige vastgepinde" / "Volgende vastgepinde".
+      Edge-of-stack states show "← BOVENAAN — Nieuwste post" or the equivalent
+      for the bottom. Layout mirrors v9 exactly. %>
+  <% if (post.pinned) { %>
+    <nav class="post-pinned-nav" aria-label="Pinned navigation">
+      <%# LEFT card: post one step UP the pinned stack (towards #1).
+          Uses prevPinnedPost which now means "smaller rank". %>
+      <% if (prevPinnedPost) { %>
+        <a class="post-pinned-card post-pinned-card--prev" href="/<%= prevPinnedPost.slug %>"
+           hx-get="/<%= prevPinnedPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= prevPinnedPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-pinned-arrow" aria-hidden="true">←</span>
+          <span class="post-pinned-body">
+            <span class="post-pinned-label">📌 Vorige vastgepinde</span>
+            <span class="post-pinned-title"><%= prevPinnedPost.title %></span>
+            <span class="post-pinned-cta">Lees deze post →</span>
+          </span>
+        </a>
+      <% } else if (pinnedTopOfStack) { %>
+        <span class="post-pinned-card post-pinned-card--edge">
+          <span class="post-pinned-edge-label">— BOVENAAN —</span>
+          <span class="post-pinned-edge-title">Nieuwste post</span>
+        </span>
+      <% } %>
+
+      <%# RIGHT card: post one step DOWN the pinned stack (away from #1).
+          Uses nextPinnedPost which now means "larger rank". %>
+      <% if (nextPinnedPost) { %>
+        <a class="post-pinned-card post-pinned-card--next" href="/<%= nextPinnedPost.slug %>"
+           hx-get="/<%= nextPinnedPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= nextPinnedPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-pinned-arrow" aria-hidden="true">→</span>
+          <span class="post-pinned-body">
+            <span class="post-pinned-label">📌 Volgende vastgepinde</span>
+            <span class="post-pinned-title"><%= nextPinnedPost.title %></span>
+            <span class="post-pinned-cta">← Lees deze post</span>
+          </span>
+        </a>
+      <% } else if (pinnedBottomOfStack) { %>
+        <span class="post-pinned-card post-pinned-card--edge">
+          <span class="post-pinned-edge-label">— ONDERAAN —</span>
+          <span class="post-pinned-edge-title">Oudste post</span>
+        </span>
+      <% } %>
+    </nav>
+  <% } %>
+
+  <%# ── Chronological prev/next (kept for non-pinned posts) ──────
+      For pinned posts the pinned-nav above replaces this. Non-pinned
+      regular posts still get the simple older/newer pair. %>
+  <% if (!post.pinned && (prevPost || nextPost)) { %>
+    <nav class="post-nav">
+      <% if (prevPost) { %>
+        <a class="post-nav-prev" href="/<%= prevPost.slug %>"
+           hx-get="/<%= prevPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= prevPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-nav-label">← Older</span>
+          <span class="post-nav-title"><%= prevPost.title %></span>
+        </a>
+      <% } else { %><span></span><% } %>
+      <% if (nextPost) { %>
+        <a class="post-nav-next" href="/<%= nextPost.slug %>"
+           hx-get="/<%= nextPost.slug %>?partial=1" hx-target="#pcms-main"
+           hx-push-url="/<%= nextPost.slug %>" hx-indicator="#pcms-loading">
+          <span class="post-nav-label">Newer →</span>
+          <span class="post-nav-title"><%= nextPost.title %></span>
+        </a>
+      <% } %>
+    </nav>
+  <% } %>
 </article>
 <style>
-.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
+.post-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
 .post-cover { margin: 0 0 2rem; border-radius: 8px; overflow: hidden; }
-.post-cover img, .post-cover video { width: 100%; height: auto; display: block; }
+.post-cover img { width: 100%; height: auto; display: block; }
 .post-header { margin-bottom: 2rem; }
 .post-title { font-family: var(--font-display, serif); font-size: 2.5rem; line-height: 1.1; margin: 0 0 1rem; }
@@ -194,25 +309,4 @@
 .post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
 .post-content p { margin: 1.2em 0; }
-/* Poll (a hosted AS2 Question) — display-only; voting happens from the fediverse. */
-.poll { margin: 1.75rem 0; display: flex; flex-direction: column; gap: .5rem; }
-.poll-opt { position: relative; display: flex; align-items: center; gap: .5rem; padding: .55rem .75rem; border: 1px solid var(--line, rgba(128,128,128,.25)); border-radius: 8px; overflow: hidden; font-size: .98rem; }
-.poll-bar { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 18%, transparent); z-index: 0; transition: width .3s ease; }
-.poll-opt.is-lead .poll-bar { background: color-mix(in srgb, var(--accent) 30%, transparent); }
-.poll-name { position: relative; z-index: 1; flex: 1; color: var(--ink); }
-.poll-opt.is-lead .poll-name { font-weight: 600; }
-.poll-pct { position: relative; z-index: 1; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
-.poll-meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .85rem; color: var(--ink-muted); }
-.poll-note { font-size: .8rem; color: var(--ink-muted); margin: .1rem 0 0; }
-.like-btn {
-  display: inline-flex; align-items: center; gap: 0.45rem;
-  padding: 0.4rem 0.85rem; border: 1px solid var(--rule); border-radius: 999px;
-  background: var(--paper-2); color: var(--ink); font: inherit; font-size: 0.95rem;
-  cursor: pointer; text-decoration: none; transition: border-color 120ms, color 120ms, background 120ms;
-}
-.like-btn:hover { border-color: var(--accent); }
-.like-btn .like-heart { font-size: 1.1rem; line-height: 1; }
-.like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
-.like-btn.is-liked .like-heart { color: var(--accent); }
-.like-btn .like-count { font-variant-numeric: tabular-nums; }
 .post-content h2 { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 2rem 0 1rem; }
 .post-content h3 { font-family: var(--font-display, serif); font-size: 1.3rem; margin: 1.5rem 0 0.75rem; }
@@ -226,4 +320,10 @@
 .tag:hover { background: var(--accent); color: white; }
 .post-actions { display: flex; gap: 0.5rem; margin: 2rem 0; padding: 1rem; background: var(--paper-2); border-radius: 6px; flex-wrap: wrap; }
+.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
+.post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
+.post-nav-next { text-align: right; align-items: flex-end; }
+.post-nav-label { font-size: 0.8rem; color: var(--ink-muted); margin-bottom: 0.25rem; }
+.post-nav-title { font-weight: 500; }
+@media (max-width: 600px) { .post-nav { grid-template-columns: 1fr; } }
 
 /* ============================================================
@@ -290,6 +390,4 @@
   gap: 0.5rem;
   align-items: baseline;
-  flex-wrap: wrap;
-  row-gap: 0.1rem;
   font-size: 0.85rem;
   margin-bottom: 0.3rem;
@@ -298,8 +396,4 @@
 .comment-author:hover { color: var(--accent); }
 .comment-time { color: var(--ink-muted, var(--ink-soft)); }
-.comment-meta .fedi-handle { overflow-wrap: anywhere; }
-@media (max-width: 560px) {
-  .comment-meta .comment-time { flex-basis: 100%; font-size: 0.78rem; color: var(--ink-faint, var(--ink-muted)); }
-}
 .comment-content {
   white-space: pre-wrap;
@@ -314,10 +408,5 @@
   gap: 0.5rem;
   margin-top: 0.4rem;
-  flex-wrap: wrap;
-  align-items: center;
-}
-.fedi-owner-react { display: inline; }
-.fedi-owner-reply { display: inline-block; }
-.fedi-owner-reply[open] { flex-basis: 100%; }
+}
 .comment-reply-btn, .comment-delete-btn {
   background: none;
@@ -334,23 +423,4 @@
 .comment-delete-btn:hover { color: #c33; }
 
-/* Owner comment-action icons — same visual language as the News feed, compact (30px). */
-.fedi-cact { width: 30px; height: 30px; flex: 0 0 30px; border-radius: 50%; padding: 0; box-sizing: border-box;
-  display: inline-flex; align-items: center; justify-content: center; list-style: none;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 3%, transparent);
-  color: var(--ink-soft, #888); cursor: pointer; -webkit-tap-highlight-color: transparent;
-  transition: background .12s, border-color .12s, transform .1s; }
-.fedi-cact::-webkit-details-marker { display: none; }
-.fedi-cact svg { width: 15px; height: 15px; display: block; }
-.fedi-cact:active { transform: scale(.9); }
-.fedi-cact-like svg { color: #e8b04b; }
-.fedi-cact-like:hover { background: color-mix(in srgb, #e8b04b 15%, transparent); border-color: color-mix(in srgb, #e8b04b 50%, transparent); }
-.fedi-cact-like.is-on { background: color-mix(in srgb, #e8b04b 18%, transparent); border-color: color-mix(in srgb, #e8b04b 60%, transparent); }
-.fedi-cact-boost svg { color: #2fa85a; }
-.fedi-cact-boost:hover { background: color-mix(in srgb, #2fa85a 15%, transparent); border-color: color-mix(in srgb, #2fa85a 50%, transparent); }
-.fedi-cact-boost.is-on { background: color-mix(in srgb, #2fa85a 18%, transparent); border-color: color-mix(in srgb, #2fa85a 60%, transparent); }
-.fedi-cact-reply svg { color: var(--accent, #06c); }
-.fedi-cact-reply:hover { background: color-mix(in srgb, var(--accent, #888) 14%, transparent); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent); }
-
 .comment-replies {
   margin-top: 1rem;
@@ -380,5 +450,4 @@
   resize: vertical;
   min-height: 70px;
-  max-height: 200px;
   padding: 0.65rem 0.85rem;
   border: 1px solid var(--rule);
@@ -393,13 +462,4 @@
 .comment-form button { align-self: flex-start; }
 .comment-reply-form-actions { display: flex; gap: 0.5rem; }
-/* Show the reply form only AFTER clicking REPLY. The display:flex above would
-   otherwise override the [hidden] attribute → form was always open. */
-.comment-reply-form[hidden] { display: none; }
-.fedi-owner-reply summary { list-style: none; cursor: pointer; }
-.fedi-owner-reply summary::-webkit-details-marker { display: none; }
-.fedi-owner-reply[open] summary { margin-bottom: .45rem; }
-/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
-   of REPLY → pixel-perfect alignment + equal gap. */
-.comment-actions form { display: contents; }
 
 .comments-login-cta { margin-top: 1.5rem; text-align: center; }
@@ -447,10 +507,7 @@
 .post-related-cover {
   display: block;
-  position: relative;
-  overflow: hidden;
   aspect-ratio: 16 / 10;
   background: var(--paper-2) center/cover no-repeat;
 }
-.post-related-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
 .post-related-cover--empty {
   background-image: linear-gradient(135deg,
Index: src/views/pages/prutter-conversation.ejs
===================================================================
--- src/views/pages/prutter-conversation.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/prutter-conversation.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,165 @@
+<div class="container prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
+  <header class="prutter-conv-header">
+    <p class="prutter-back"><a href="<%= siteUrlBase %>/prutter">&larr; Inbox</a></p>
+    <div class="prutter-conv-headline">
+      <div class="prutter-conv-avatar prutter-conv-avatar--lg">
+        <% if (other && other.avatar_url) { %>
+          <img src="<%= other.avatar_url %>" alt="">
+        <% } else { %>
+          <span><%= (other && other.username || '?').charAt(0).toUpperCase() %></span>
+        <% } %>
+      </div>
+      <div>
+        <h1><%= other ? other.username : 'Unknown' %></h1>
+        <% if (other) { %>
+          <p class="prutter-conv-sub"><a href="/users/<%= encodeURIComponent(other.username) %>">View profile</a></p>
+        <% } %>
+      </div>
+    </div>
+  </header>
+
+  <ol class="prutter-thread" id="prutter-thread">
+    <% messages.forEach(function(m) { %>
+      <li class="prutter-msg <%= m.author_id === user.id ? 'prutter-msg--mine' : 'prutter-msg--theirs' %>" data-msg-id="<%= m.id %>">
+        <div class="prutter-msg-bubble"><%= m.content %></div>
+        <div class="prutter-msg-time" title="<%= m.created_at %>"><%= formatDateTime(m.created_at) %></div>
+      </li>
+    <% }); %>
+  </ol>
+
+  <form class="prutter-composer"
+        method="post"
+        action="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
+        hx-post="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
+        hx-target="#prutter-thread"
+        hx-swap="beforeend"
+        hx-on::after-request="this.querySelector('textarea').value=''; window.__prutterScroll && window.__prutterScroll();">
+    <textarea name="content" rows="2" maxlength="2000" placeholder="Type a message…" required></textarea>
+    <button type="submit" class="btn btn-primary">Send</button>
+  </form>
+</div>
+
+<script>
+(function() {
+  // Auto-scroll to bottom on load and after appends.
+  var thread = document.getElementById('prutter-thread');
+  function scrollToBottom() { thread.scrollTop = thread.scrollHeight; }
+  scrollToBottom();
+  window.__prutterScroll = scrollToBottom;
+
+  // Live updates via WebSocket.
+  // Browser auto-resolves protocol/host; same origin as the page.
+  try {
+    var page = document.querySelector('.prutter-conv-page');
+    var convId = page.dataset.conversationId;
+    var me = page.dataset.me;
+    var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    var ws = new WebSocket(proto + '//' + location.host + '/ws/prutter');
+
+    ws.addEventListener('message', function(ev) {
+      try {
+        var data = JSON.parse(ev.data);
+        if (data.type !== 'new_message' || data.conversationId !== convId) return;
+        var m = data.message;
+        // Don't double-render our own messages (already added via HTMX response)
+        if (m.author_id === me) return;
+
+        var li = document.createElement('li');
+        li.className = 'prutter-msg prutter-msg--theirs';
+        li.dataset.msgId = m.id;
+        var bubble = document.createElement('div');
+        bubble.className = 'prutter-msg-bubble';
+        bubble.textContent = m.content;  // text content = automatic escaping
+        var time = document.createElement('div');
+        time.className = 'prutter-msg-time';
+        time.textContent = new Date(m.created_at).toLocaleString();
+        li.appendChild(bubble);
+        li.appendChild(time);
+        thread.appendChild(li);
+        scrollToBottom();
+      } catch (e) { /* ignore malformed */ }
+    });
+  } catch (e) {
+    console.warn('Prutter WS unavailable:', e);
+  }
+})();
+</script>
+
+<style>
+.prutter-conv-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1rem; }
+.prutter-back { margin: 0; }
+.prutter-back a { color: var(--accent); text-decoration: none; }
+
+.prutter-conv-headline {
+  display: flex; align-items: center; gap: 0.85rem;
+  padding-bottom: 1rem; border-bottom: 1px solid var(--rule);
+}
+.prutter-conv-headline h1 {
+  font-family: var(--font-display, serif);
+  font-size: 1.5rem; margin: 0;
+}
+.prutter-conv-sub { margin: 0; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
+.prutter-conv-sub a { color: var(--accent); }
+
+.prutter-conv-avatar--lg { width: 56px; height: 56px; }
+.prutter-conv-avatar--lg span { font-size: 1.2rem; }
+
+.prutter-thread {
+  list-style: none; padding: 0; margin: 0;
+  display: flex; flex-direction: column; gap: 0.5rem;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  padding: 1rem;
+  height: 60vh;
+  overflow-y: auto;
+}
+
+.prutter-msg { display: flex; flex-direction: column; max-width: 80%; }
+.prutter-msg-bubble {
+  padding: 0.55rem 0.85rem;
+  border-radius: 14px;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  line-height: 1.4;
+  font-size: 0.95rem;
+}
+.prutter-msg-time {
+  font-size: 0.7rem;
+  color: var(--ink-muted, var(--ink-soft));
+  margin-top: 0.15rem;
+}
+
+.prutter-msg--theirs { align-self: flex-start; }
+.prutter-msg--theirs .prutter-msg-bubble {
+  background: var(--paper);
+  border: 1px solid var(--rule);
+  border-bottom-left-radius: 4px;
+}
+.prutter-msg--theirs .prutter-msg-time { padding-left: 0.5rem; }
+
+.prutter-msg--mine { align-self: flex-end; align-items: flex-end; }
+.prutter-msg--mine .prutter-msg-bubble {
+  background: var(--accent);
+  color: white;
+  border-bottom-right-radius: 4px;
+}
+.prutter-msg--mine .prutter-msg-time { padding-right: 0.5rem; }
+
+.prutter-composer {
+  display: flex; gap: 0.5rem; align-items: flex-end;
+  padding-bottom: env(safe-area-inset-bottom, 0);
+}
+.prutter-composer textarea {
+  flex: 1;
+  padding: 0.6rem 0.85rem;
+  border: 1px solid var(--rule);
+  border-radius: 8px;
+  background: var(--paper);
+  color: var(--ink);
+  font-family: inherit;
+  font-size: 0.95rem;
+  resize: vertical;
+  min-height: 44px;
+}
+</style>
Index: src/views/pages/prutter-inbox.ejs
===================================================================
--- src/views/pages/prutter-inbox.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/views/pages/prutter-inbox.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,108 @@
+<div class="container prutter-inbox-page">
+  <header class="prutter-header">
+    <h1>Prutter</h1>
+    <p class="prutter-tagline">Your direct messages on this site.</p>
+  </header>
+
+  <% if (!conversations.length) { %>
+    <p class="prutter-empty">
+      No conversations yet. Open someone's profile and click <em>Send DM</em> to start one.
+    </p>
+  <% } else { %>
+    <ol class="prutter-conv-list">
+      <% conversations.forEach(function(c) { %>
+        <li class="prutter-conv-row">
+          <a class="prutter-conv-link" href="<%= siteUrlBase %>/prutter/<%= c.id %>">
+            <div class="prutter-conv-avatar">
+              <% if (c.other_avatar) { %>
+                <img src="<%= c.other_avatar %>" alt="">
+              <% } else { %>
+                <span><%= (c.other_username || '?').charAt(0).toUpperCase() %></span>
+              <% } %>
+            </div>
+            <div class="prutter-conv-body">
+              <div class="prutter-conv-meta">
+                <strong class="prutter-conv-name"><%= c.other_username %></strong>
+                <% if (c.last_message_at) { %>
+                  <span class="prutter-conv-time"><%= formatDateTime(c.last_message_at) %></span>
+                <% } %>
+              </div>
+              <% if (c.last_message_preview) { %>
+                <div class="prutter-conv-preview"><%= c.last_message_preview %></div>
+              <% } else { %>
+                <div class="prutter-conv-preview muted">Empty conversation</div>
+              <% } %>
+            </div>
+            <% if (c.unread_count > 0) { %>
+              <span class="prutter-unread-badge"><%= c.unread_count %></span>
+            <% } %>
+          </a>
+        </li>
+      <% }); %>
+    </ol>
+  <% } %>
+</div>
+
+<style>
+.prutter-inbox-page { max-width: 720px; margin: 3rem auto; padding: 0 1rem; }
+.prutter-header h1 {
+  font-family: var(--font-display, serif);
+  font-size: 2rem; margin: 0 0 0.25rem;
+}
+.prutter-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1.5rem; }
+.prutter-empty {
+  text-align: center; padding: 2rem; color: var(--ink-muted, var(--ink-soft));
+  background: var(--paper-2); border: 1px dashed var(--rule); border-radius: 8px;
+}
+
+.prutter-conv-list { list-style: none; padding: 0; margin: 0; }
+.prutter-conv-row { border-bottom: 1px solid var(--rule); }
+.prutter-conv-row:last-child { border-bottom: 0; }
+.prutter-conv-link {
+  display: flex; gap: 0.85rem; align-items: center;
+  padding: 0.85rem 0;
+  text-decoration: none; color: var(--ink);
+  transition: background 100ms;
+}
+.prutter-conv-link:hover { background: var(--paper-2); }
+
+.prutter-conv-avatar {
+  flex-shrink: 0;
+  width: 44px; height: 44px;
+  border-radius: 50%;
+  overflow: hidden;
+  background: var(--paper-2);
+  border: 1px solid var(--rule);
+  display: inline-flex; align-items: center; justify-content: center;
+}
+.prutter-conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.prutter-conv-avatar span {
+  font-family: var(--font-display, serif);
+  font-weight: 700; color: var(--accent);
+}
+
+.prutter-conv-body { flex: 1; min-width: 0; }
+.prutter-conv-meta {
+  display: flex; justify-content: space-between; gap: 0.5rem;
+  margin-bottom: 0.15rem;
+}
+.prutter-conv-name { font-weight: 600; }
+.prutter-conv-time {
+  color: var(--ink-muted, var(--ink-soft));
+  font-size: 0.8rem; flex-shrink: 0;
+}
+.prutter-conv-preview {
+  color: var(--ink-soft);
+  font-size: 0.9rem;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.prutter-conv-preview.muted { font-style: italic; color: var(--ink-muted, var(--ink-soft)); }
+
+.prutter-unread-badge {
+  background: var(--accent); color: white;
+  border-radius: 10px;
+  padding: 0.1rem 0.5rem;
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+</style>
Index: src/views/pages/search.ejs
===================================================================
--- src/views/pages/search.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/search.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,84 +1,41 @@
-<% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
-<% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %>
-<% const _events = (typeof events !== 'undefined' && events) ? events : []; %>
-<% const _pages = (typeof pages !== 'undefined' && pages) ? pages : []; %>
 <div class="container search-page">
-  <h1><%= t('nav.search') %></h1>
+  <h1>Search</h1>
 
-  <form method="get" action="<%= _base %>/search" class="search-page-form">
+  <form method="get" action="/search" class="search-page-form">
     <input
       type="search"
       name="q"
       value="<%= query %>"
-      placeholder="<%= t('search.placeholder') %>"
+      placeholder="Search posts…"
       autocomplete="off"
       autofocus
-      aria-label="<%= t('nav.search') %>">
-    <button type="submit" class="btn btn-primary"><%= t('search.button') %></button>
+      aria-label="Search query">
+    <button type="submit" class="btn btn-primary">Search</button>
   </form>
 
   <% if (queryError) { %>
-    <p class="search-error"><%= t('search.error') %></p>
+    <p class="search-error">Couldn't run that query. Try a simpler term.</p>
   <% } %>
 
   <% if (query && !queryError) { %>
     <p class="search-meta">
-      <%= t(total === 1 ? 'search.results_one' : 'search.results_other', { n: total, q: query }) %>
+      <%= total %> result<%= total === 1 ? '' : 's' %> for &ldquo;<%= query %>&rdquo;
     </p>
   <% } %>
 
-  <% if (_tracks.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_tracks') %></h2>
-    <% var _inPost = t('search.in_post'); /* t() vóór de loop ophalen: function(t) overschaduwt 'm */ %>
-    <div class="search-tracks">
-      <% _tracks.forEach(function(t) {
-           const tj = JSON.stringify({ id: t.id, url: t.url, title: t.title, artist: t.artist || '', cover: t.cover || '' })
-             .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;'); %>
-        <div class="post-audio-track search-track" id="track-<%= t.id %>"
-             data-pcms-track-id="<%= t.id %>" data-pcms-track-url="<%= t.url %>"
-             data-pcms-track='<%- tj %>'>
-          <button type="button" class="pat-play" aria-label="Speel <%= t.title %>">
-            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg>
-          </button>
-          <% if (t.cover) { %>
-            <span class="search-track-cover" style="background-image:url('<%= t.cover %>')" aria-hidden="true"></span>
-          <% } %>
-          <div class="pat-info">
-            <div class="pat-title"><%= t.title %></div>
-            <% if (t.artist || t.album) { %>
-              <div class="pat-artist">
-                <%= t.artist %><% if (t.artist && t.album) { %> &middot; <% } %><%= t.album %>
-              </div>
-            <% } %>
-          </div>
-          <% if (t.postUrl) { %>
-            <a class="search-track-link"
-               href="<%= t.postUrl %>"
-               hx-get="<%= t.postUrl %>?partial=1"
-               hx-target="#pcms-main"
-               hx-swap="innerHTML"
-               hx-push-url="<%= t.postUrl %>"
-               aria-label="<%= _inPost %>"><%= _inPost %></a>
-          <% } %>
-        </div>
-      <% }); %>
-    </div>
-  <% } %>
-
   <% if (results && results.length) { %>
-    <% if (_tracks.length) { %><h2 class="search-section-title"><%= t('search.section_posts') %></h2><% } %>
     <ol class="search-results">
       <% results.forEach(function(r) { %>
         <li class="search-result">
-          <h3 class="search-result-title">
-            <a href="<%= _base %>/<%= r.slug %>"
-               hx-get="<%= _base %>/<%= r.slug %>?partial=1"
+          <h2 class="search-result-title">
+            <a href="/<%= r.slug %>"
+               hx-get="/<%= r.slug %>?partial=1"
                hx-target="#pcms-main"
                hx-swap="innerHTML"
-               hx-push-url="<%= _base %>/<%= r.slug %>"
+               hx-push-url="/<%= r.slug %>"
                hx-indicator="#pcms-loading">
-              <%= r.title || '(zonder titel)' %>
+              <%= r.title || '(untitled)' %>
             </a>
-          </h3>
+          </h2>
           <p class="search-result-meta">
             <%= r.author_username %>
@@ -89,29 +46,6 @@
       <% }); %>
     </ol>
-  <% } %>
-
-  <% if (_events.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_events') %></h2>
-    <ol class="search-results">
-      <% _events.forEach(function(ev) { %>
-        <li class="search-result">
-          <h3 class="search-result-title"><a href="<%= ev.url %>"><%= ev.where || '—' %></a></h3>
-          <p class="search-result-meta"><%= [ev.date, ev.time].filter(Boolean).join(' ') %></p>
-        </li>
-      <% }); %>
-    </ol>
-  <% } %>
-
-  <% if (_pages.length) { %>
-    <h2 class="search-section-title"><%= t('search.section_pages') %></h2>
-    <ul class="search-pages">
-      <% _pages.forEach(function(pg) { %>
-        <li><a class="search-page-link" href="<%= pg.url %>"><%= pg.label %> <span aria-hidden="true">→</span></a></li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% if (query && !queryError && !results.length && !_tracks.length && !_events.length && !_pages.length) { %>
-    <p class="search-empty"><%= t('search.empty') %></p>
+  <% } else if (query && !queryError) { %>
+    <p class="search-empty">No posts found.</p>
   <% } %>
 </div>
@@ -145,12 +79,4 @@
   margin: 0 0 1rem;
 }
-.search-section-title {
-  font-family: var(--font-display, serif);
-  font-size: 1.1rem;
-  margin: 1.75rem 0 0.75rem;
-  color: var(--ink-soft);
-  text-transform: uppercase;
-  letter-spacing: 0.05em;
-}
 .search-error {
   background: rgba(200, 60, 60, 0.15);
@@ -166,37 +92,9 @@
 }
 
-/* Nummer-resultaten */
-.search-tracks { display: flex; flex-direction: column; gap: 0.25rem; }
-.search-track.post-audio-track {
-  display: flex;
-  align-items: center;
-  gap: 0.75rem;
-  padding: 0.5rem 0.6rem;
-  border-radius: 8px;
-}
-.search-track .search-track-cover {
-  width: 38px; height: 38px;
-  flex: 0 0 38px;
-  border-radius: 5px;
-  background-size: cover;
-  background-position: center;
-  background-color: var(--paper-2);
-}
-.search-track .pat-info { flex: 1; min-width: 0; }
-.search-track .pat-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.search-track .pat-artist { font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.search-track-link {
-  flex: 0 0 auto;
-  font-size: 0.82rem;
-  color: var(--accent);
-  text-decoration: none;
-  white-space: nowrap;
-}
-.search-track-link:hover { text-decoration: underline; }
-
 .search-results {
   list-style: none;
   margin: 0;
   padding: 0;
+  counter-reset: search-result;
 }
 .search-result {
@@ -234,10 +132,3 @@
   border-radius: 3px;
 }
-.search-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
-.search-page-link {
-  display: inline-flex; align-items: center; gap: 0.3rem;
-  padding: 0.45rem 0.8rem; border: 1px solid var(--rule); border-radius: 999px;
-  color: var(--ink); text-decoration: none; font-size: 0.9rem; background: var(--paper-2);
-}
-.search-page-link:hover { border-color: var(--accent); color: var(--accent); }
 </style>
Index: src/views/pages/shows.ejs
===================================================================
--- src/views/pages/shows.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,67 +1,0 @@
-<%
-  var ss = (typeof shows !== 'undefined') ? shows : [];
-  var st = (typeof notifyState !== 'undefined') ? notifyState : null;
-%>
-<section class="sh">
-  <h1 class="sh-h1"><%= t('agenda.title') %></h1>
-
-  <% if (st === 'done') { %><div class="sh-msg"><%= t('agenda.msg_done') %></div>
-  <% } else if (st === 'check') { %><div class="sh-msg"><%= t('agenda.msg_check') %></div>
-  <% } else if (st === 'error') { %><div class="sh-msg sh-bad"><%= (typeof notifyMsg!=='undefined')?notifyMsg:'Er ging iets mis.' %></div><% } %>
-
-  <% if (!ss.length) { %>
-    <p class="sh-empty"><%= t('agenda.empty') %></p>
-  <% } else { %>
-    <ul class="sh-list">
-      <% ss.forEach(function(s){ %>
-        <li class="sh-item">
-          <span class="sh-date">
-            <span class="sh-day"><%= (s.date||'').slice(8) %></span>
-            <span class="sh-mon"><%= (s.date||'').slice(5,7) %>/<%= (s.date||'').slice(0,4) %></span>
-          </span>
-          <span class="sh-meta">
-            <span class="sh-city"><%= s.city %><% if (s.country) { %>, <%= s.country %><% } %></span>
-            <% if (s.venue) { %><span class="sh-venue"><%= s.venue %><% if (s.time) { %> · <%= s.time %><% } %></span><% } else if (s.time) { %><span class="sh-venue"><%= s.time %></span><% } %>
-            <% if (s.notes) { %><span class="sh-notes"><%= s.notes %></span><% } %>
-          </span>
-          <% if (s.ticket_url) { %><a class="sh-tix" href="<%= s.ticket_url %>" target="_blank" rel="noopener"><%= t('agenda.tickets') %></a><% } %>
-        </li>
-      <% }); %>
-    </ul>
-  <% } %>
-
-  <% if (!(typeof footerNewsletter !== 'undefined' && footerNewsletter)) { %>
-  <div class="sh-notify">
-    <h2 class="sh-h2"><%= t('agenda.notify_h') %></h2>
-    <p class="sh-sub"><%= t('agenda.notify_sub') %></p>
-    <form method="POST" action="<%= siteUrlBase %>/shows/notify" class="sh-form">
-      <input type="email" name="email" required placeholder="<%= t('common.email_placeholder') %>" autocomplete="email">
-      <button type="submit" class="sh-btn"><%= t('agenda.notify_btn') %></button>
-    </form>
-  </div>
-  <% } %>
-</section>
-
-<style>
-  .sh { max-width: 640px; margin: 0 auto; padding: 32px 18px 64px; }
-  .sh-h1 { font-size: clamp(26px,5vw,38px); margin: 0 0 18px; }
-  .sh-msg { padding: 11px 14px; border-radius: 10px; background: rgba(60,160,90,.15); margin-bottom: 16px; }
-  .sh-bad { background: rgba(200,60,60,.15); }
-  .sh-list { list-style: none; margin: 0 0 36px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
-  .sh-item { display: flex; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(128,128,128,.2); border-radius: 12px; }
-  .sh-date { flex: 0 0 auto; text-align: center; min-width: 54px; }
-  .sh-day { display: block; font-size: 22px; font-weight: 700; line-height: 1; }
-  .sh-mon { font-size: 11px; opacity: .6; }
-  .sh-meta { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; }
-  .sh-city { font-weight: 600; }
-  .sh-venue { font-size: 13px; opacity: .7; }
-  .sh-notes { font-size: 12.5px; opacity: .6; margin-top: 2px; }
-  .sh-tix { flex: 0 0 auto; padding: 8px 14px; border-radius: 999px; background: var(--accent,#6b8f71); color: #fff; text-decoration: none; font-weight: 600; font-size: 13px; }
-  .sh-empty { opacity: .7; margin-bottom: 36px; }
-  .sh-notify { border-top: 1px solid rgba(128,128,128,.2); padding-top: 24px; }
-  .sh-h2 { margin: 0 0 6px; font-size: 18px; }
-  .sh-sub { opacity: .8; margin: 0 0 14px; }
-  .sh-form { display: flex; gap: 10px; flex-wrap: wrap; }
-  .sh-form input { flex: 1 1 200px; padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(128,128,128,.4); background: transparent; color: inherit; font-size: 15px; }
-  .sh-btn { padding: 12px 18px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; cursor: pointer; }
-</style>
Index: src/views/pages/tag.ejs
===================================================================
--- src/views/pages/tag.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/tag.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,13 +1,13 @@
 <div class="container tag-page">
   <header class="tag-page-header">
-    <p class="tag-eyebrow"><%= t('ptag.eyebrow') %></p>
+    <p class="tag-eyebrow">Tag</p>
     <h1>#<%= tag %></h1>
     <p class="tag-meta">
-      <%= posts.length === 1 ? t('ptag.count_one', { n: posts.length }) : t('ptag.count_many', { n: posts.length }) %>
+      <%= posts.length %> post<%= posts.length === 1 ? '' : 's' %>
     </p>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="tag-empty"><%= t('ptag.empty') %></p>
+    <p class="tag-empty">No posts with this tag yet.</p>
   <% } else { %>
     <ol class="tag-results">
@@ -26,5 +26,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('ptag.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h2>
Index: src/views/pages/type.ejs
===================================================================
--- src/views/pages/type.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/type.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,11 +1,11 @@
 <div class="container type-page">
   <header class="type-page-header">
-    <p class="type-eyebrow"><%= t('ptype.eyebrow') %></p>
+    <p class="type-eyebrow">Type</p>
     <h1><%= type.charAt(0).toUpperCase() + type.slice(1) %></h1>
-    <p class="type-meta"><%= posts.length === 1 ? t('ptype.count_one', { n: posts.length }) : t('ptype.count_many', { n: posts.length }) %></p>
+    <p class="type-meta"><%= posts.length %> post<%= posts.length === 1 ? '' : 's' %></p>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="type-empty"><%= t('ptype.empty') %></p>
+    <p class="type-empty">No posts of this type yet.</p>
   <% } else { %>
     <ol class="type-results">
@@ -24,5 +24,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('ptype.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h2>
Index: src/views/pages/user.ejs
===================================================================
--- src/views/pages/user.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/user.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,5 +3,5 @@
     <div class="user-avatar-large">
       <% if (author.avatar_url) { %>
-        <img src="<%= avatar(author.avatar_url, 320) %>" alt="">
+        <img src="<%= author.avatar_url %>" alt="">
       <% } else { %>
         <span><%= author.username.charAt(0).toUpperCase() %></span>
@@ -19,19 +19,24 @@
       <% } %>
       <p class="user-stats">
-        <%= posts.length %> <%= posts.length === 1 ? t('pusr.post_one') : t('pusr.post_many') %> <%= t('pusr.on_this_site') %>
+        <%= posts.length %> post<%= posts.length === 1 ? '' : 's' %> on this site
         <% if (totalPosts > posts.length) { %>
-          &middot; <%= t('pusr.total', { n: totalPosts }) %>
+          &middot; <%= totalPosts %> total
         <% } %>
         <% if (author.created_at) { %>
-          &middot; <%= t('pusr.joined', { date: formatDate(author.created_at) }) %>
+          &middot; joined <%= formatDate(author.created_at) %>
         <% } %>
       </p>
+      <% if (user && user.id !== author.id && site && site.enable_prutter) { %>
+        <p class="user-actions">
+          <a href="<%= siteUrlBase %>/prutter/new?to=<%= encodeURIComponent(author.username) %>" class="btn btn-primary">💬 Send DM</a>
+        </p>
+      <% } %>
     </div>
   </header>
 
   <% if (!posts.length) { %>
-    <p class="user-empty"><%= t('pusr.empty') %></p>
+    <p class="user-empty">No posts on this site yet.</p>
   <% } else { %>
-    <h2 class="user-posts-heading"><%= t('pusr.posts_heading') %></h2>
+    <h2 class="user-posts-heading">Posts</h2>
     <ol class="user-posts">
       <% posts.forEach(function(p) { %>
@@ -49,5 +54,5 @@
                  hx-get="/<%= p.slug %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
                  hx-push-url="/<%= p.slug %>" hx-indicator="#pcms-loading">
-                <%= p.title || t('pusr.untitled') %>
+                <%= p.title || '(untitled)' %>
               </a>
             </h3>
Index: src/views/pages/viewer-blocked.ejs
===================================================================
--- src/views/pages/viewer-blocked.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-<div class="container vb-page">
-  <div class="vb-card">
-    <div class="vb-ico" aria-hidden="true">👁️</div>
-    <h1 class="vb-title"><%= t('vblk.title') %></h1>
-    <p class="vb-text">
-      <%= t('vblk.text_before') %>
-      <strong><%= t('vblk.text_strong') %></strong> <%= t('vblk.text_after') %>
-    </p>
-    <div class="vb-actions">
-      <button type="button" class="vb-btn vb-btn-primary" data-back>← <%= t('vblk.back') %></button>
-      <a class="vb-btn" href="/"><%= t('vblk.to_home') %></a>
-    </div>
-  </div>
-</div>
-
-<style>
-.vb-page { max-width: 540px; margin: 3rem auto 4rem; padding: 0 1rem; }
-.vb-card {
-  text-align: center;
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 16px;
-  padding: 2.5rem 1.75rem;
-}
-.vb-ico {
-  width: 64px; height: 64px; margin: 0 auto 1rem;
-  display: flex; align-items: center; justify-content: center;
-  font-size: 1.8rem; border-radius: 50%;
-  background: color-mix(in srgb, var(--accent) 14%, var(--paper-2));
-}
-.vb-title { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 0 0 0.5rem; }
-.vb-text { color: var(--ink-muted, var(--ink-soft)); font-size: 0.95rem; line-height: 1.55; margin: 0 auto 1.5rem; max-width: 42ch; }
-.vb-actions { display: flex; gap: 0.6rem; justify-content: center; flex-wrap: wrap; }
-.vb-btn {
-  display: inline-flex; align-items: center; justify-content: center;
-  padding: 0.6rem 1.1rem; min-height: 42px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper-2); color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif; font-size: 0.9rem; font-weight: 600;
-  text-decoration: none; cursor: pointer; transition: border-color 120ms, opacity 120ms;
-}
-.vb-btn:hover { border-color: var(--accent); }
-.vb-btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
-.vb-btn-primary:hover { opacity: 0.92; }
-</style>
Index: src/views/pages/welcome.ejs
===================================================================
--- src/views/pages/welcome.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/pages/welcome.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,14 +1,14 @@
 <div class="container welcome-page">
-  <h1><%= t('welcome.title') %></h1>
-  <p class="welcome-tagline"><%= t('welcome.tagline') %></p>
-
+  <h1>Welcome to PrutCMS</h1>
+  <p class="welcome-tagline">A self-hosted publishing platform.</p>
+  
   <% if (!user) { %>
     <div class="welcome-actions">
-      <a href="/auth/register" class="btn btn-primary"><%= t('auth.create_admin') %></a>
-      <a href="/auth/login" class="btn"><%= t('welcome.have_account') %></a>
+      <a href="/auth/register" class="btn btn-primary">Create your account</a>
+      <a href="/auth/login" class="btn">Already have one? Login</a>
     </div>
-    <p class="welcome-note"><%= t('welcome.note') %></p>
+    <p class="welcome-note">The first user becomes <strong>god</strong> and gets a personal site auto-created.</p>
   <% } else { %>
-    <p><%= t('welcome.nosite', { user: user.username }) %></p>
+    <p>Hi <%= user.username %>! No site is configured yet.</p>
   <% } %>
 </div>
Index: src/views/partials/admin-back.ejs
===================================================================
--- src/views/partials/admin-back.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,11 +1,0 @@
-<%# De terugknop naar /admin.
-
-    Een partial voor een regel van niets, om dezelfde reden als de tabbalk:
-    hij stond op vijf pagina's los, drie met class="ax-btn" en twee met de
-    globale class="btn". Dat scheelt zichtbaar -- .btn is 0.55rem/1rem met
-    radius 5 en geen minimumhoogte, .ax-btn is 0.6rem/1.1rem met radius 8 en
-    min-height 44px. Robin zag het meteen (16-8).
-
-    Het wordt .ax-btn: deze pagina's zijn ax-pagina's, en 44px is de maat waar
-    een vinger op past. %>
-<p><a href="/admin" class="ax-btn">&larr; <%= t('nav.admin') %></a></p>
Index: src/views/partials/admin-styles.ejs
===================================================================
--- src/views/partials/admin-styles.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,227 +1,0 @@
-<%# De ax-*-primitieven voor de beheerpagina's, op EEN plek.
-
-    WAAROM DIT BESTAAT. Deze regels stonden vijf keer los in vijf pagina's, en
-    admin-playlists schreef er zelf boven waarom: "Define them here too so this
-    page is self-contained -- both pages get refreshed independently." Dat is
-    precies wat er daarna gebeurde. Gemeten op 16-8: .ax-header, .ax-header h1,
-    .ax-back en .ax-back:hover stonden in vijf bestanden byte voor byte gelijk,
-    .ax-flash-ok/-err in vier -- terwijl .ax-page in de ene 880px was en in
-    account 720px, en admin-media en admin-videos helemaal buiten het systeem
-    vielen met alles inline.
-
-    Gelijktrekken zonder dit is eenmalig: de volgende die een knop aanpast doet
-    dat in een van de acht kopieen en dan lopen ze weer uiteen. De vorm moet de
-    fout onmogelijk maken, niet alleen onwaarschijnlijk -- zelfde afweging als
-    bij de luisteraarstabel.
-
-    HOE JE HEM GEBRUIKT: neem hem met een include bovenaan de pagina op, en zet
-    daarna alleen nog in een eigen style-blok wat ECHT bij die ene pagina hoort.
-    Staat een regel in twee pagina's, dan hoort hij hier.
-
-    Een <style>-partial en geen los .css-bestand omdat shell.ejs geen
-    pageCss-haak heeft (wel pageJs) en een globale link deze regels op elke
-    pagina van de site zou laden. %>
-<style>
-/* ─── Paginaromp ───────────────────────────────────────────────── */
-.ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; }
-.ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }
-.ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }
-.ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }
-.ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }
-.ax-back {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper); color: var(--ink); text-decoration: none;
-  font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;
-}
-.ax-back:hover { border-color: var(--accent); }
-
-.ax-actions-bar { display: flex; margin-bottom: 1rem; }
-
-/* ─── Tabbalk ──────────────────────────────────────────────────── */
-/* Stond volledig inline in partials/media-tabs.ejs -- een gedeeld element dat
-   zijn eigen opmaak meedroeg, en daardoor niet mee kon met de rest. */
-.ax-tabs {
-  display: flex; gap: 0.4rem; flex-wrap: wrap;
-  margin: 0 0 1.25rem;
-  border-bottom: 1px solid var(--rule);
-}
-.ax-tab {
-  display: inline-flex; align-items: center;
-  padding: 0.55rem 0.9rem;
-  border: 1px solid var(--rule); border-bottom: none;
-  border-radius: 8px 8px 0 0;
-  background: var(--paper-2); color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif;
-  font-size: 0.9rem; text-decoration: none;
-  opacity: 0.8; transition: opacity 120ms, border-color 120ms;
-}
-.ax-tab:hover { opacity: 1; border-color: var(--accent); }
-.ax-tab[aria-current="page"] {
-  opacity: 1; font-weight: 600;
-  background: var(--paper);
-  border-bottom: 2px solid var(--accent);
-}
-
-/* ─── Meldingen ────────────────────────────────────────────────── */
-.ax-flash { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
-.ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
-.ax-flash-err { background: rgba(200,60,60,.15); color: #c33;    border: 1px solid rgba(200,60,60,.3); }
-
-/* ─── Kaart ────────────────────────────────────────────────────── */
-.ax-card {
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 12px;
-  padding: 1.25rem;
-  margin-bottom: 1rem;
-}
-
-/* ─── Knoppen ──────────────────────────────────────────────────── */
-.ax-btn {
-  display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
-  padding: 0.6rem 1.1rem;
-  border: 1px solid var(--rule); background: var(--paper-2);
-  color: var(--ink);
-  font-family: var(--font-ui, system-ui), sans-serif;
-  font-size: 0.95rem; font-weight: 500;
-  text-decoration: none; border-radius: 8px; cursor: pointer;
-  min-height: 44px;
-  transition: background 120ms, border-color 120ms;
-  -webkit-tap-highlight-color: transparent;
-}
-.ax-btn:hover { border-color: var(--accent); }
-.ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
-.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
-/* admin-audio heeft paper-2, admin-playlists transparent. Hier audio's waarde:
-   .ax-btn is al paper-2, dus dit is daar een no-op en verandert niets aan wat
-   er nu staat. Wie de twee ooit echt wil laten verschillen, kies dan bewust. */
-.ax-btn-secondary { background: var(--paper-2); }
-.ax-btn-danger { border-color: #b4452e; color: #b4452e; background: transparent; }
-.ax-btn-danger:hover { background: rgba(180,69,46,.1); border-color: #b4452e; }
-.ax-btn-sm { min-height: 0; padding: 0.3rem 0.6rem; font-size: 0.78rem; }
-
-.ax-icon-btn {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  border: 1px solid var(--rule); border-radius: 8px;
-  background: var(--paper-2); color: var(--ink);
-  cursor: pointer; font-size: 1rem;
-  transition: background 120ms, border-color 120ms, color 120ms;
-}
-.ax-icon-btn:hover { border-color: var(--accent); }
-.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
-
-/* ─── Lege toestand ────────────────────────────────────────────── */
-.ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }
-.ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }
-.ax-empty p { margin: 0; }
-.ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }
-
-/* ─── Lijstrij (audio, playlists, luisteraars) ─────────────────── */
-.ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
-.ax-track {
-  display: grid;
-  grid-template-columns: 64px 1fr auto;
-  gap: 0.85rem; align-items: center;
-  padding: 0.85rem;
-  background: var(--paper);
-  border: 1px solid var(--rule);
-  border-radius: 12px;
-  transition: border-color 120ms;
-}
-.ax-track:hover { border-color: var(--accent); }
-/* object-fit staat OOK op het element zelf, niet alleen op een img erbinnen:
-   admin-audio zet .ax-track-cover rechtstreeks op een <img>, admin-listeners
-   op een omhulsel met een img erin. Op een niet-vervangen element doet
-   object-fit niets, dus het kost de tweede vorm niets. */
-.ax-track-cover {
-  width: 64px; height: 64px; border-radius: 8px;
-  background: var(--paper-2);
-  display: flex; align-items: center; justify-content: center;
-  font-family: var(--font-display, serif);
-  font-size: 1.5rem; color: var(--accent);
-  flex-shrink: 0;
-  overflow: hidden;
-  object-fit: cover;
-}
-.ax-track-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }
-.ax-track-title {
-  font-family: var(--font-display, serif);
-  font-weight: 600; font-size: 1.05rem; color: var(--ink);
-  display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
-}
-.ax-pill {
-  font-family: var(--font-ui);
-  font-size: 0.7rem; font-weight: 500;
-  color: var(--ink-soft);
-  background: var(--paper-2);
-  padding: 0.15em 0.55em; border-radius: 999px;
-  letter-spacing: 0;
-}
-.ax-track-sub {
-  font-size: 0.85rem;
-  color: var(--ink-muted, var(--ink-soft));
-  display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
-}
-.ax-track-sep { opacity: 0.5; }
-.ax-embed {
-  display: inline-block;
-  background: var(--paper-2);
-  padding: 0.15rem 0.5rem;
-  border-radius: 4px;
-  font-family: var(--font-mono, ui-monospace, monospace);
-  font-size: 0.75rem;
-  color: var(--ink-soft);
-  cursor: pointer; user-select: all;
-  margin-top: 0.15rem;
-  word-break: break-all;
-  align-self: flex-start;
-  transition: background 120ms;
-}
-.ax-embed:hover { background: var(--rule); }
-.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
-.ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
-
-/* ─── Tegelraster (afbeeldingen, video's) ──────────────────────── */
-/* Dezelfde randen, hoeken en achtergrond als .ax-track, alleen in een raster
-   in plaats van een rij -- zodat een plaatjespagina en een lijstpagina van
-   hetzelfde ontwerp zijn en niet van twee. */
-.ax-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(var(--ax-tile, 150px), 1fr)); }
-.ax-tile {
-  border: 1px solid var(--rule); border-radius: 12px;
-  overflow: hidden; background: var(--paper);
-  transition: border-color 120ms;
-}
-.ax-tile:hover { border-color: var(--accent); }
-.ax-tile-media { position: relative; background: var(--paper-2); }
-.ax-tile-media img, .ax-tile-media video { width: 100%; height: 100%; display: block; }
-.ax-tile-body { padding: 0.55rem 0.65rem; font-size: 0.8rem; }
-.ax-tile-meta { color: var(--ink-muted, var(--ink-soft)); }
-.ax-tile-actions { display: flex; gap: 0.4rem; margin-top: 0.45rem; }
-.ax-tile-badge {
-  position: absolute; top: 6px; left: 6px; z-index: 1;
-  background: #b4452e; color: #fff;
-  border-radius: 6px; padding: 1px 6px; font-size: 0.68rem;
-}
-.ax-tile-badge-r { left: auto; right: 6px; background: rgba(0,0,0,.6); }
-
-@media (max-width: 480px) {
-  .ax-track {
-    grid-template-columns: 56px 1fr;
-    grid-template-areas: "cover meta" "actions actions";
-    gap: 0.6rem;
-  }
-  .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }
-  .ax-track-meta  { grid-area: meta; }
-  .ax-track-actions {
-    grid-area: actions;
-    justify-content: flex-end;
-    border-top: 1px solid var(--rule);
-    padding-top: 0.5rem;
-    margin-top: 0.25rem;
-  }
-}
-</style>
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/bottom-tab.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -4,14 +4,14 @@
 // Tabs:
 //   logged-out:  Home · Zoek · Inloggen        (3 tabs)
-//   logged-in:   Home · Zoek · [Plus] · Profiel
+//   logged-in:   Home · Zoek · [Plus] · [Prutter] · Profiel
 //   The Plus tab is a FAB-style center action (accent bg, slightly raised).
 //   It only appears for users with create-post permission.
+//   Prutter only appears when the site has DMs enabled.
 
 const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 const _path        = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
-const _canPost     = !!(user && (typeof canMutate === 'undefined' || canMutate)
-                        && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
+const _canPost     = !!(user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
+const _hasPrutter  = !!(user && site && site.enable_prutter);
 const _ownProfile  = user ? ('/users/' + user.username) : null;
-const _homeHref    = _siteUrlBase + '/';
 
 // Strip site prefix for cleaner matching
@@ -26,4 +26,5 @@
 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
 else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create';
+else if (_p.indexOf('/prutter') === 0) _active = 'prutter';
 else if (_p.indexOf('/account') === 0 || (_ownProfile && _p.indexOf(_ownProfile) === 0)) _active = 'profile';
 else if (_p.indexOf('/auth/login') === 0) _active = 'login';
@@ -33,11 +34,8 @@
 
   <!-- Home -->
-  <%# htmx navigation (no full reload): the persistent audio player in document.body
-      survives, so music does not skip when navigating. On mobile the top bar is hidden
-      anyway, so a context reset via full-load is not needed here. %>
   <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
-     href="<%= _homeHref %>"
-     hx-get="<%= _homeHref %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-     hx-push-url="<%= _homeHref %>" hx-indicator="#pcms-loading"
+     href="<%= _siteUrlBase %>/"
+     hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+     hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"
      aria-label="Home" <%= _active === 'home' ? 'aria-current="page"' : '' %>>
     <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -45,15 +43,15 @@
       <polyline points="9 22 9 12 15 12 15 22"/>
     </svg>
-    <span class="bottom-tab-label"><%= t('tab.home') %></span>
+    <span class="bottom-tab-label">Home</span>
   </a>
 
   <!-- Zoek -->
   <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>"
-          id="bottom-tab-search-toggle" aria-label="<%= t('nav.search') %>">
+          id="bottom-tab-search-toggle" aria-label="Zoeken">
     <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
       <circle cx="11" cy="11" r="7"/>
       <line x1="21" y1="21" x2="16.65" y2="16.65"/>
     </svg>
-    <span class="bottom-tab-label"><%= t('tab.search') %></span>
+    <span class="bottom-tab-label">Zoek</span>
   </button>
 
@@ -61,7 +59,7 @@
   <% if (_canPost) { %>
     <a class="bottom-tab-item bottom-tab-fab<%= _active === 'create' ? ' is-active' : '' %>"
-       href="<%= _siteUrlBase %>/posts/new"
-       hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-       hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading"
+       href="/posts/new"
+       hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="/posts/new" hx-indicator="#pcms-loading"
        aria-label="Nieuwe post">
       <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -69,8 +67,22 @@
         <line x1="5" y1="12" x2="19" y2="12"/>
       </svg>
-      <span class="bottom-tab-label"><%= t('tab.write') %></span>
+      <span class="bottom-tab-label">Schrijven</span>
     </a>
   <% } %>
 
+  <!-- Prutter (DMs) -->
+  <% if (_hasPrutter) { %>
+    <a class="bottom-tab-item<%= _active === 'prutter' ? ' is-active' : '' %>"
+       href="<%= _siteUrlBase %>/prutter"
+       aria-label="Prutter berichten" <%= _active === 'prutter' ? 'aria-current="page"' : '' %>>
+      <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+        <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
+      </svg>
+      <span class="bottom-tab-label">Prutter</span>
+      <% if (typeof unreadDmCount !== 'undefined' && unreadDmCount > 0) { %>
+        <span class="bottom-tab-badge" aria-label="<%= unreadDmCount %> ongelezen"><%= unreadDmCount > 99 ? '99+' : unreadDmCount %></span>
+      <% } %>
+    </a>
+  <% } %>
 
   <!-- Profiel / Inloggen -->
@@ -81,18 +93,13 @@
             aria-label="Profiel menu" aria-haspopup="dialog">
       <% if (user.avatar_url) { %>
-        <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+        <img class="bottom-tab-avatar bottom-tab-avatar-img" src="<%= user.avatar_url %>" alt="">
       <% } else { %>
         <span class="bottom-tab-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
       <% } %>
-      <span class="bottom-tab-label"><%= t('tab.profile') %></span>
-      <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %>
-        <span class="bottom-tab-badge" aria-label="<%= notifUnread %> ongelezen meldingen"><%= notifUnread > 99 ? '99+' : notifUnread %></span>
-      <% } %>
+      <span class="bottom-tab-label">Profiel</span>
     </button>
   <% } else { %>
     <a class="bottom-tab-item<%= _active === 'login' ? ' is-active' : '' %>"
        href="/auth/login"
-       hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-       hx-push-url="/auth/login" hx-indicator="#pcms-loading"
        aria-label="Inloggen">
       <svg class="bottom-tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -101,5 +108,5 @@
         <line x1="15" y1="12" x2="3" y2="12"/>
       </svg>
-      <span class="bottom-tab-label"><%= t('nav.login') %></span>
+      <span class="bottom-tab-label">Inloggen</span>
     </a>
   <% } %>
@@ -302,5 +309,16 @@
 </style>
 
-<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
-    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
-    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
+<script>
+// Wire the bottom-tab search button into the existing #search-overlay
+(function() {
+  const btn = document.getElementById('bottom-tab-search-toggle');
+  const overlay = document.getElementById('search-overlay');
+  if (btn && overlay) {
+    btn.addEventListener('click', function() {
+      overlay.hidden = false;
+      const inp = overlay.querySelector('input');
+      if (inp) inp.focus();
+    });
+  }
+})();
+</script>
Index: src/views/partials/chrome.ejs
===================================================================
--- src/views/partials/chrome.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,39 +1,0 @@
-<%
-// Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
-//
-// Waarom een slot: bij htmx-navigatie verversen we deze hele chrome out-of-band
-// (oob=true), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort — terwijl de
-// audioplayer (los in document.body, buiten dit slot) blijft leven → geen
-// verspringen. Op de hub-landing (bareChrome) is het slot leeg: hero = de header.
-//
-// Wordt zowel door shell.ejs (oob niet gezet) als door render.js bij partials
-// (oob=true) gerenderd. De logica staat hier zodat beide identiek zijn.
-//
-//   _isAuth     → focus-scherm (in/uitloggen): helemaal geen chrome.
-//   _headerless → hub-homepagina én full-height views: WÉL de mini topnav-balk,
-//                 GEEN profielkop (hub: hero = header).
-//   anders      → topnav + profielkop (+ view-switcher buiten admin).
-var _isAdmin   = typeof bodyClass === 'string' && bodyClass.indexOf('on-admin') >= 0;
-var _isAuth    = typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0;
-var _isChat    = typeof bodyClass === 'string' && bodyClass.indexOf('on-chat') >= 0;
-var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0);
-// Zelfstandige premium-feature-pagina's hebben hun eigen kop → geen profielkop/
-// switcher, wél de topnav (Robin 2026-06-18: "bovenste weg, behoud de nav").
-// NB: on-shows (agenda) én on-downloads (de downloads-LIJST) horen hier NIET bij —
-// die willen de volledige chrome (profielkop + pill) behouden. De downloads-lijst is
-// vaak gepind en prominent, dus hij mag niet kaal zijn. De losse e-mail-capture
-// (/download/:id → on-download) blijft wél een focus-pagina zonder kop.
-var _featurePage = typeof bodyClass === 'string' &&
-  bodyClass.indexOf('on-downloads') < 0 &&
-  ['on-epk', 'on-download', 'on-linkbio', 'on-newsletter', 'on-special'].some(function (c) { return bodyClass.indexOf(c) >= 0; });
-var _headerless = hubLanding || _isChat || _featurePage;
-%>
-<div id="pcms-chrome"<% if (typeof oob !== 'undefined' && oob) { %> hx-swap-oob="true"<% } %>>
-<% if (!_isAuth) { %>
-  <%- include('topnav') %>
-  <% if (!_headerless) { %>
-    <%- include('profile-header') %>
-    <% if (!_isAdmin && (typeof site !== 'undefined' && site)) { %><%- include('view-switcher') %><% } %>
-  <% } %>
-<% } %>
-</div>
Index: src/views/partials/fedi-node.ejs
===================================================================
--- src/views/partials/fedi-node.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,86 +1,0 @@
-<%# Renders one fediverse thread node (n). Expects: n, t, canManageSite, _base, siteAvatar, formatDateTime, postSlug %>
-<div class="comment-avatar">
-  <% if (n.actor_icon) { %><img src="<%= avatar(n.actor_icon, 96) %>" alt="" loading="lazy">
-  <% } else { %><span><%= (n.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
-</div>
-<div class="comment-body">
-  <div class="comment-meta">
-    <% if (n.actor_url) { %><a class="comment-author" href="<%= n.actor_url %>" rel="nofollow noopener" target="_blank"><%- emojiName(n.actor_name, n.actor_emoji_json) %></a>
-    <% } else { %><span class="comment-author"><%- emojiName(n.actor_name, n.actor_emoji_json) %></span><% } %>
-    <% if (n.actor_handle) { %><span class="fedi-handle"><%= n.actor_handle %></span><% } %>
-    <% if (n.created_at) { %><span class="comment-time"><%= formatDateTime(n.created_at) %></span><% } %>
-  </div>
-  <div class="comment-content"><%- emojiHtml(n.content, n.emoji_json) %></div>
-  <%# Rich replies: media on our own sent replies (attachments on the Note).
-      Visitors see these too, and the reply editor (owner-only) may not render,
-      so make sure the stylesheet is on the page either way. %>
-  <% if (n.media && n.media.length) { %>
-    <% if (!locals.__reAssets) { locals.__reAssets = 1; %><link rel="stylesheet" href="/assets/css/reply-editor.css"><% } %>
-    <div class="re-reply-media">
-      <% n.media.forEach(function (m) { %>
-        <% if ((m.mediaType || '').indexOf('image/') === 0) { %>
-          <a href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= m.url %>" alt="<%= m.name || '' %>" loading="lazy"></a>
-        <% } else if ((m.mediaType || '').indexOf('audio/') === 0) { %>
-          <audio controls preload="none" src="<%= m.url %>"></audio>
-        <% } else { %>
-          <video controls preload="metadata" src="<%= m.url %>"></video>
-        <% } %>
-      <% }); %>
-    </div>
-  <% } %>
-  <div class="comment-actions">
-    <% if (n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.outboxId) { %>
-      <form method="post" action="<%= _base %>/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
-        <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
-      </form>
-    <% } else if (!n.mine && typeof canManageSite !== 'undefined' && canManageSite && n.id && typeof postSlug !== 'undefined' && postSlug) { %>
-      <%# Owner: like / boost / reply directly AS the site — no "your server" detour (it's your own site). %>
-      <form method="post" action="<%= _base %>/posts/<%= postSlug %>/fedi-react" class="fedi-owner-react">
-        <input type="hidden" name="interaction_id" value="<%= n.id %>">
-        <input type="hidden" name="kind" value="like">
-        <button type="submit" class="fedi-cact fedi-cact-like<%= n.acted_like ? ' is-on' : '' %>" title="<%= n.acted_like ? t('fedi.unlike_short') : t('fedi.like_short') %>" aria-label="<%= n.acted_like ? t('fedi.unlike_short') : t('fedi.like_short') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button>
-      </form>
-      <form method="post" action="<%= _base %>/posts/<%= postSlug %>/fedi-react" class="fedi-owner-react">
-        <input type="hidden" name="interaction_id" value="<%= n.id %>">
-        <input type="hidden" name="kind" value="boost">
-        <button type="submit" class="fedi-cact fedi-cact-boost<%= n.acted_boost ? ' is-on' : '' %>" title="<%= n.acted_boost ? t('tl.unboost') : t('fedi.boost_short') %>" aria-label="<%= n.acted_boost ? t('tl.unboost') : t('fedi.boost_short') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button>
-      </form>
-      <% if (locals.mayReply !== false) { %>
-      <details class="fedi-owner-reply">
-        <summary class="fedi-cact fedi-cact-reply" title="<%= t('fedi.remote_reply_short') %>" aria-label="<%= t('fedi.remote_reply_short') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></summary>
-        <%- include('reply-editor', {
-          action: _base + '/posts/' + postSlug + '/fedi-reply',
-          hiddenFields: [{ name: 'interaction_id', value: n.id }],
-          placeholder: t('fedi.reply_ph'),
-          submitLabel: t('fedi.send'),
-          rows: 2,
-          participants: n.participants || [],
-        }) %>
-      </details>
-      <% } %>
-      <%# Owner moderation: report to their instance + remove (tombstoned: never comes back via re-delivery or thread-crawl). %>
-      <form method="post" action="<%= _base %>/interactions/<%= n.id %>/report" class="fedi-owner-react" data-confirm="<%= t('fedi.mod_report_confirm') %>">
-        <button type="submit" class="fedi-cact fedi-cact-report" title="<%= t('fedi.report_send') %>" aria-label="<%= t('fedi.report_send') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg></button>
-      </form>
-      <form method="post" action="<%= _base %>/interactions/<%= n.id %>/remove" class="fedi-owner-react" data-confirm="<%= t('fedi.mod_remove_confirm') %>">
-        <button type="submit" class="fedi-cact fedi-cact-remove" title="<%= t('comments.delete') %>" aria-label="<%= t('comments.delete') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg></button>
-      </form>
-    <% } else if (n.noteId) { %>
-      <%# Visitor: reply via YOUR OWN instance. Also on the site's own (mine) comments —
-          those are dereferenceable Notes (/ap/notes/<id>) too; without this a visitor
-          could not reply to the owner's comments at all (only the owner branches above
-          catch mine-nodes for managers, so this else-if never renders for the owner). %>
-      <button type="button" class="comment-reply-btn fedi-remote-reply-btn" data-fedi-uri="<%= n.noteId %>" data-fedi-ph="<%= t('fedi.remote_ph') %>">
-        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
-        <%= t('fedi.remote_reply_short') %>
-      </button>
-    <% } %>
-  </div>
-  <% if (n.children && n.children.length) { %>
-    <ol class="comment-replies">
-      <% n.children.forEach(function(c){ %>
-        <li class="comment comment-reply"><%- include('../partials/fedi-node', { n: c, t: t, canManageSite: (typeof canManageSite !== 'undefined' ? canManageSite : false), _base: _base, siteAvatar: (typeof siteAvatar !== 'undefined' ? siteAvatar : null), formatDateTime: formatDateTime, postSlug: (typeof postSlug !== 'undefined' ? postSlug : null) }) %></li>
-      <% }); %>
-    </ol>
-  <% } %>
-</div>
Index: src/views/partials/fedi-tabs.ejs
===================================================================
--- src/views/partials/fedi-tabs.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,33 +1,0 @@
-<%# Shared tab bar for the unified Fediverse section. Owner/admin-only.
-    Param: active = 'feed' | 'connect' | 'berichten' | 'blokkeren'
-    (legacy values 'following'/'followers' → Connect, 'reacties'/'meldingen' → Berichten).
-    All tabs share the content width equally (flex:1) and always fit (ellipsis).
-    "Back to site" lives in the top nav, not here. %>
-<% var _a = (typeof active !== 'undefined') ? active : ''; %>
-<% var _nu = (typeof notifUnread !== 'undefined') ? notifUnread : 0; %>
-<nav class="fedi-tabs" aria-label="Fediverse">
-  <a href="/news" class="fedi-tab<%= _a === 'feed' ? ' is-active' : '' %>"
-     hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/news" hx-indicator="#pcms-loading"><%= t('tl.tab_feed') %></a>
-  <a href="/connect" class="fedi-tab<%= (_a === 'connect' || _a === 'following' || _a === 'followers') ? ' is-active' : '' %>"
-     hx-get="/connect?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/connect" hx-indicator="#pcms-loading"><%= t('tl.tab_connect') %></a>
-  <a href="/messages" class="fedi-tab<%= (_a === 'berichten' || _a === 'reacties' || _a === 'meldingen') ? ' is-active' : '' %>"
-     hx-get="/messages?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/messages" hx-indicator="#pcms-loading"><span class="fedi-tab-label"><%= t('msg.tab') %></span><% if (_nu > 0) { %><span class="fedi-tab-badge"><%= _nu > 9 ? '9+' : _nu %></span><% } %></a>
-  <a href="/blocking" class="fedi-tab<%= _a === 'blokkeren' ? ' is-active' : '' %>"
-     hx-get="/blocking?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/blocking" hx-indicator="#pcms-loading"><%= t('blk.title') %></a>
-</nav>
-<style>
-/* All 5 tabs share the width equally and always fit (truncate with ellipsis if needed). */
-.fedi-tabs { display: flex; margin: 0 0 1.25rem;
-  border-bottom: 1px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
-.fedi-tab { flex: 1 1 0; min-width: 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-  padding: .6rem .4rem; text-decoration: none; font-weight: 600; font-size: .95rem;
-  color: var(--ink-soft, #888); border-bottom: 2px solid transparent; margin-bottom: -1px;
-  display: inline-flex; align-items: center; justify-content: center; gap: .3rem; }
-.fedi-tab:hover { color: var(--ink, #000); }
-.fedi-tab.is-active { color: var(--accent, #06c); border-bottom-color: var(--accent, #06c); }
-.fedi-tab-label { overflow: hidden; text-overflow: ellipsis; }
-.fedi-tab-badge { flex: 0 0 auto; min-width: 1.15em; padding: 0 .3em; border-radius: 999px;
-  background: var(--accent, #e8b04b); color: #fff; font-size: .66rem; line-height: 1.5; text-align: center; }
-@media (max-width: 520px) { .fedi-tab { padding: .55rem .25rem; font-size: .82rem; } }
-@media (max-width: 380px) { .fedi-tab { font-size: .74rem; } }
-</style>
Index: src/views/partials/footer.ejs
===================================================================
--- src/views/partials/footer.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/footer.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,30 +1,9 @@
 <footer class="site-footer">
-  <% if (typeof footerNewsletter !== 'undefined' && footerNewsletter && (typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof site !== 'undefined' && site) { %>
-    <div class="container footer-nl">
-      <span class="footer-nl-label">📬 <%= t('footer.subscribe_cta') %></span>
-      <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/nieuwsbrief" class="footer-nl-form">
-        <input type="email" name="email" required placeholder="<%= t('common.email_placeholder') %>" autocomplete="email" aria-label="E-mailadres">
-        <button type="submit"><%= t('footer.subscribe') %></button>
-      </form>
-    </div>
-  <% } %>
   <div class="container site-footer-inner">
     <div>
-      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span>
+      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'PrutFolio' %></span>
     </div>
-    <%# Install-app button — placed in the footer row (centre), saves a whole extra row.
-        JS detects the platform & opens a modal with PWA install steps. %>
-    <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app>
-      <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
-      <span><%= t('footer.install') %></span>
-    </button>
     <div class="footer-meta">
-      <% if (typeof langs !== 'undefined' && langs && langs.length > 1) { %>
-        <select class="footer-lang" aria-label="<%= t('nav.language') %>"
-                data-lang-switch>
-          <% langs.forEach(function(l){ %><option value="<%= l.code %>"<%= l.active ? ' selected' : '' %>><%= l.name %></option><% }); %>
-        </select>
-      <% } %>
-      <a class="footer-version" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/changelog" title="Bekijk wat er per release veranderd is">Klonkt<%= (typeof appVersion !== 'undefined' && appVersion) ? ' v' + appVersion : '' %></a>
+      <span>PrutFolio v1</span>
       <%# Mobile-only theme toggle. On desktop the topnav has its own toggle
           so we hide this one to avoid two visible toggles at once. The same
@@ -41,4 +20,13 @@
   </div>
 
+  <%# P64 — Install-app button. JS detects platform & opens modal with steps.
+      data-apk-url is the direct download for Android users who want the
+      native APK route instead of the PWA. %>
+  <div class="site-footer-install container">
+    <button type="button" class="install-app-btn" data-pcms-install-app data-apk-url="/assets/apk/soundfabrics.apk">
+      <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
+      <span>Installeer app</span>
+    </button>
+  </div>
 </footer>
 
@@ -77,20 +65,4 @@
   }
 }
-.footer-nl {
-  max-width: 800px; margin: 0 auto 1.5rem; padding: 0 1rem;
-  display: flex; align-items: center; justify-content: center; gap: .8rem; flex-wrap: wrap;
-}
-.footer-nl-label { font-weight: 600; color: var(--ink); }
-.footer-nl-form { display: flex; gap: .5rem; flex-wrap: wrap; }
-.footer-nl-form input {
-  padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--rule);
-  background: var(--paper); color: var(--ink); font-size: .9rem; min-width: 200px;
-}
-.footer-nl-form button {
-  padding: .5rem 1rem; border-radius: 8px; border: none; cursor: pointer;
-  background: var(--accent); color: #fff; font-weight: 600; font-size: .9rem;
-}
-@media (max-width: 600px) { .footer-nl { flex-direction: column; } .footer-nl-form input { min-width: 0; flex: 1; } }
-
 .site-footer-inner {
   max-width: 800px;
@@ -107,17 +79,4 @@
   display: inline-flex; align-items: center; gap: 0.75rem;
 }
-.footer-lang {
-  background: transparent; color: var(--ink); opacity: 1;
-  border: 1px solid var(--rule); border-radius: 999px;
-  padding: 0.3rem 0.5rem; font-size: 0.78rem; cursor: pointer;
-  font-family: inherit;
-}
-.footer-lang:hover { border-color: var(--accent); }
-/* Make open dropdown options readable (otherwise white-on-white in dark mode). */
-.footer-lang option { background: var(--paper); color: var(--ink); }
-/* On desktop the language picker lives in the header nav → hide the footer version. */
-@media (min-width: 768px) { .footer-lang { display: none; } }
-.footer-version { color: inherit; text-decoration: none; }
-.footer-version:hover { color: var(--accent); text-decoration: underline; }
 .pcms-main { min-height: 50vh; }
 
@@ -144,17 +103,9 @@
 }
 
-/* Compact install button inside the footer row (centre) instead of a large standalone
-   CTA on its own line — saves vertical space. */
-.install-app-btn--footer {
-  flex: 0 0 auto;
-  width: auto !important;          /* override the mobile .install-app-btn width:100% */
-  padding: 0.42rem 0.95rem;
-  font-size: 0.82rem;
-  gap: 0.4rem;
-}
-.install-app-btn--footer svg { width: 15px; height: 15px; }
-/* On narrow screens: let the three items centre neatly when they wrap. */
-@media (max-width: 600px) {
-  .site-footer-inner { justify-content: center; text-align: center; }
+/* Install-app button container — sits centered under the footer-inner row */
+.site-footer-install {
+  display: flex;
+  justify-content: center;
+  margin-top: 1.25rem;
 }
 </style>
Index: src/views/partials/home-append.ejs
===================================================================
--- src/views/partials/home-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-<% posts.forEach(function(post){ %><%- include('post-card', { post: post }) %><% }); %>
-<%# Grid view: OOB-append the same posts as tiles. htmx inserts the wrapper node
-    itself (beforeend), so display:contents lets the tiles lay out as direct grid
-    items instead of nesting under an extra box. %>
-<div hx-swap-oob="beforeend:#grid-tiles" style="display:contents"><% posts.forEach(function(post){ %><%- include('post-tile', { post: post }) %><% }); %></div>
-<%# Lezen: dezelfde berichten OOB achteraan de leesstroom, net als de tegels
-    hierboven. Zonder dit zou "meer laden" in de leesweergave niets doen. %>
-<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %><div hx-swap-oob="beforeend:#read-stream" style="display:contents"><% readerItems.forEach(function(it){ %><%- include('read-article', { post: it.post, entry: it.entry }) %><% }); %></div><% } %>
-<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: (typeof morePath !== 'undefined' ? morePath : '/'), oob: true }) %>
Index: src/views/partials/load-more.ejs
===================================================================
--- src/views/partials/load-more.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-<%
-  // Reusable "Load more" control. On the full page it sits after the feed; the
-  // append response re-emits it with oob=true so htmx swaps the fresh button
-  // (next offset) in place of the old one, and drops it on the last page.
-  var _oob = (typeof oob !== 'undefined' && oob);
-  var _target = (typeof moreTarget !== 'undefined' && moreTarget) ? moreTarget : '#feed';
-  var _path = (typeof morePath !== 'undefined' && morePath) ? morePath : '';
-  var _base = (typeof moreBase !== 'undefined' && moreBase) ? moreBase : '';
-%>
-<div id="load-more" class="load-more-wrap"<% if (_oob) { %> hx-swap-oob="true"<% } %>>
-  <% if (hasMore) { %>
-    <button type="button" class="btn load-more-btn"
-      hx-get="<%= _base %><%= _path %>?append=1&offset=<%= nextOffset %>"
-      hx-target="<%= _target %>" hx-swap="beforeend"><%= t('feed.load_more') %></button>
-  <% } %>
-</div>
Index: src/views/partials/media-tabs.ejs
===================================================================
--- src/views/partials/media-tabs.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,24 +1,0 @@
-<%# Gedeelde tabbalk voor Media (Afbeeldingen / Video's / Audio / Playlists /
-    Luisteraars).
-
-    Params: active = 'images'|'videos'|'audio'|'playlists'|'listeners';
-            audioOn (bool, standaard true).
-
-    De opmaak stond hier volledig inline en zit nu in partials/admin-styles.ejs
-    bij de rest van de ax-*-primitieven. Een gedeeld element dat zijn eigen
-    opmaak meedroeg kon niet meebewegen met de pagina's eromheen -- en dat was
-    zichtbaar: de balk zag er hetzelfde uit op vijf pagina's die verder van
-    twee verschillende ontwerpen waren.
-
-    De actieve tab is `aria-current="page"` en niet een klasse: dat is wat een
-    schermlezer nodig heeft, en de opmaak kan er net zo goed op haken. %>
-<% var _a = (typeof audioOn === 'undefined') ? true : audioOn; %>
-<nav class="ax-tabs" aria-label="Media">
-  <a href="/admin/media" class="ax-tab"<%- active === 'images' ? ' aria-current="page"' : '' %>><%= t('admin.media_images') %></a>
-  <a href="/admin/media/videos" class="ax-tab"<%- active === 'videos' ? ' aria-current="page"' : '' %>><%= t('admin.media_videos') %></a>
-  <% if (_a) { %>
-  <a href="/admin/audio" class="ax-tab"<%- active === 'audio' ? ' aria-current="page"' : '' %>><%= t('admin.b_audio') %></a>
-  <a href="/admin/playlists" class="ax-tab"<%- active === 'playlists' ? ' aria-current="page"' : '' %>><%= t('admin.b_playlists') %></a>
-  <a href="/admin/listeners" class="ax-tab"<%- active === 'listeners' ? ' aria-current="page"' : '' %>><%= t('admin.b_listeners') %></a>
-  <% } %>
-</nav>
Index: src/views/partials/messages-append.ejs
===================================================================
--- src/views/partials/messages-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-<% items.forEach(function(n){ %><%- include('msg-item', { n: n, seen: seen }) %><% }); %>
-<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#msg-list', morePath: '/messages', oob: true }) %>
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,251 +1,0 @@
-<% if (n.type === 'thread') { %>
-<%
-           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
-           // Een draad is nieuw zodra EEN bericht erin nieuw is.
-           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
-             return m.created_at && Date.parse(m.created_at) > _tseen;
-           });
-           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
-           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
-           // je schreef, niet een lege naam.
-           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
-           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
-           // Zwaaide er iemand in dit gesprek? Dan hoort dat in de KOP
-           // (Robins aanwijzing, 25-8). Ingeklapt zijn de bubbels weg, en juist
-           // een zwaai is het bericht dat om een antwoord vraagt zonder iets te
-           // zeggen -- die mag niet achter een dichtgeklapt gesprek verdwijnen.
-           // Alleen een ONTVANGEN zwaai: je eigen zwaai staat er als 'sent' en
-           // is geen uitnodiging aan jezelf.
-           var _twave = (n.messages || []).some(function (m) { return m.type === 'mention' && m.wave; });
-      %>
-        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
-          <div class="msg-thread-head">
-            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
-                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
-
-                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
-                (Barts aanwijzing): tikken op de tegenpartij is waar je van
-                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
-                geen span met een klikhandler, want dan werkt hij ook met het
-                toetsenbord en leest een schermlezer de stand voor.
-
-                Zonder JavaScript doet hij niets, en dan staat het gesprek
-                gewoon open -- dat is ook de stand waar we mee beginnen. %>
-            <button type="button" class="msg-thread-who" aria-expanded="true">
-              <span class="msg-thread-name"><%= _tlabel %></span>
-              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
-              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-            </button>
-            <%# Blijft staan als het gesprek dicht is, net als het aantal in de
-                uitsparing hierboven -- daar is hij juist het meest waard. %>
-            <% if (_twave) { %><span class="msg-thread-wave" role="img" aria-label="<%= t('msg.waved_at_you') %>" title="<%= t('msg.waved_at_you') %>">&#128075;</span><% } %>
-            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
-            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
-                link is een antwoord in een lijst niet te plaatsen. %>
-            <% if (n.post) { %>
-              <a class="msg-thread-post" href="/<%= n.post.slug %>">
-                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
-                <%= n.post.title || n.post.slug %>
-              </a>
-            <% } %>
-          </div>
-          <%# Antwoorden vanuit de draad zelf, BOVEN de berichten (Robins
-              besluit, 25-8). Sinds het nieuwste bovenaan staat, is de bovenkant
-              waar het gesprek nu is: antwoorden hoort bij het laatste bericht,
-              en dat staat er direct onder. Onderaan zou de knop bij het OUDSTE
-              bericht komen te staan, en dat is precies waar je niet op
-              antwoordt.
-
-              Nog steeds ingeklapt, want een open editor onder elk gesprek maakt
-              de lijst onleesbaar -- juist het probleem dat deze weergave moest
-              oplossen. De route kiest het pad: een draad aan een post krijgt
-              een reply, een draad aan een persoon een direct bericht. %>
-          <%# shaer-r4c: precies de tak die de server ook kiest. /messages/reply
-              gaat naar deliverReply als er een interaction+post is (dat is een
-              ANTWOORD en valt onder de poort), en anders naar deliverDirectNote
-              (dat is een bericht, andere poort). Alleen de eerste verbergen. %>
-          <% var _rt = n.replyTo || {}; var _thread = _rt.interactionId && _rt.postSlug; %>
-          <% var _kan = canMutate && ((_thread && locals.mayReply !== false) || (!_thread && _rt.actorUri)); %>
-          <% if (_kan) { %>
-            <%# Heette msg-thread-foot toen hij onderaan stond. Een naam die
-                zegt waar iets staat is een leugen zodra het verhuist, dus nu
-                zegt hij wat het IS. %>
-            <div class="msg-thread-actions">
-              <details class="msg-reply">
-                <summary><%= t('fedi.reply') %></summary>
-                <div class="msg-reply-form">
-                  <%- include('../partials/reply-editor', {
-                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
-                    hiddenFields: [
-                      { name: 'interaction_id', value: _rt.interactionId || '' },
-                      { name: 'post_slug', value: _rt.postSlug || '' },
-                      { name: 'to', value: _rt.actorUri || '' },
-                    ],
-                    placeholder: t('fedi.reply_ph'),
-                    submitLabel: t('fedi.reply'),
-                    rows: 2,
-                  }) %>
-                </div>
-              </details>
-              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
-                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
-              <% if (_rt.actorUri) { %>
-                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave" data-sent="<%= t('msg.wave_sent') %>">
-                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
-                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
-                </form>
-              <% } %>
-            </div>
-          <% } %>
-          <%# NIEUWSTE BOVENAAN binnen een gesprek (Robins besluit, 25-8). De
-              LIJST van gesprekken en de meldingen blijven staan zoals ze
-              stonden; alleen binnen een draad leest het van nieuw naar oud, dus
-              wat er net bij kwam staat er meteen en niet onderaan een lange
-              draad.
-
-              Op een KOPIE, want groupConversations sorteert `messages`
-              oplopend en de rest van deze pagina rekent daarop -- `_tnew` en
-              `_tto` hierboven lezen dezelfde array. Een reverse() zonder
-              slice() draait het origineel om en dat werkt door naar alles wat
-              er daarna nog naar kijkt.
-
-              In de MARKUP en niet met column-reverse: die draait alleen het
-              beeld om, terwijl een schermlezer en het toetsenbord de oude
-              volgorde houden. %>
-          <ul class="msg-thread-msgs">
-            <% (n.messages || []).slice().reverse().forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
-          </ul>
-        </li>
-<% } else { %>
-<%
-           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
-           var _t = n.type === 'announce' ? 'boost' : n.type; // reply|mention|report|like|boost|follow|sent
-           var _new = _seen && n.created_at ? (Date.parse(n.created_at) > _seen) : false;
-           var _who = n.name || n.handle || '';
-           var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
-           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
-           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
-           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
-           // samenvielen -- en dat is geen cosmetische hernoeming: een
-           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
-           // post, geen herkenbare tegenpartij) hield anders een data-kind
-           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
-           // behalve Alles onzichtbaar.
-           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
-             : _t === 'report' ? 'mod'
-             : 'conv';
-           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
-           // de draad, niet op de losse zinnen erin.
-           var _sub = (typeof inThread !== 'undefined' && inThread);
-           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
-           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
-      %>
-        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
-          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
-            <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
-            <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
-            <% } else if (n.icon) { %><img src="<%= avatar(n.icon, 96) %>" alt="" loading="lazy">
-            <% } else { %><%= _init %><% } %>
-            <span class="msg-dot msg-dot-<%= _t %>">
-              <% if (_t === 'like') { %><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg>
-              <% } else if (_t === 'boost') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
-              <% } else if (_t === 'follow') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
-              <% } else if (_t === 'report') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
-              <% } else if (_t === 'mention') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>
-              <% } else if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
-              <% } else if (_t === 'poll_done') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
-              <% } else { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><% } %>
-            </span>
-          </span>
-
-          <div class="msg-body">
-            <div class="msg-line">
-              <% if (_t === 'sent') { %>
-                <span class="msg-who"><%= t('msg.you') %></span>
-                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
-              <% } else { %>
-                <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
-                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
-                      De handle erachter niet -- die staat in de kop. */ %>
-                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
-              <% } %>
-              <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
-              <% /* When the post was written, like de Krant and de Guardian PWA
-                    show it. created_at (when it reached us) stays the sort key
-                    and drives the "new since your last visit" dot above. */ %>
-              <% if (n.published || n.created_at) { %><span class="msg-time"><%= formatDateTime(n.published || n.created_at) %></span><% } %>
-            </div>
-
-            <% var _hasWhat = !_sub || _priv
-                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
-                 || (_t === 'sent' && n.in_reply_to); %>
-            <% if (_hasWhat) { %>
-            <div class="msg-what">
-              <% if (n.count && n.count > 1) { %>
-                <strong><%= t('msg.and_more', { n: n.count - 1 }) %></strong>
-                <%= _t === 'like' ? t('msg.liked_many') : t('msg.boosted_many') %>
-              <% } else if (_t === 'follow') { %><%= t('notif.followed') %>
-              <% } else if (_t === 'like') { %><%= t('notif.liked') %>
-              <% } else if (_t === 'boost') { %><%= t('notif.boosted') %>
-              <% } else if (_t === 'report') { %><%= t('notif.reported') %>
-              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
-              <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
-              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
-              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
-              <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
-              <% } else if (_t === 'poll_done') { %><strong><%= t('msg.poll_done') %></strong>
-              <% } else { %><%= t('notif.replied') %><% } %>
-              <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
-              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
-              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-              <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-            </div>
-            <% } %>
-
-            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
-            <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div>
-              <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %>
-                <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" data-sent="<%= t('msg.wave_sent') %>" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
-                  <input type="hidden" name="to" value="<%= n.actorUri %>">
-                  <button class="btn small" name="text" value="<%= t('msg.wave_r1') %>"><%= t('msg.wave_r1') %></button>
-                  <button class="btn small" name="text" value="<%= t('msg.wave_r2') %>"><%= t('msg.wave_r2') %></button>
-                  <button class="btn small" name="text" value="👋"><%= t('msg.wave_back') %></button>
-                </form>
-              <% } %>
-            <% } else if (_t === 'poll_done' && n.poll) { %>
-              <div class="msg-poll" role="group">
-                <% n.poll.options.forEach(function (o) { %>
-                  <div class="msg-poll-opt">
-                    <div class="msg-poll-top"><span class="msg-poll-name"><%= o.name %></span><span class="msg-poll-pct"><%= o.pct %>%</span></div>
-                    <div class="msg-poll-bar"><span style="width:<%= o.pct %>%"></span></div>
-                  </div>
-                <% }); %>
-                <div class="msg-poll-total"><%= t('msg.poll_total', { n: n.poll.voters }) %></div>
-              </div>
-            <% } %>
-
-            <% if (_t === 'sent' && n.outboxId) { %>
-              <div class="msg-actions">
-                <details class="msg-edit">
-                  <summary><%= t('fedi.edit') %></summary>
-                  <div class="msg-edit-form">
-                    <%- include('../partials/reply-editor', {
-                      action: '/fediverse/' + n.outboxId + '/edit',
-                      placeholder: t('fedi.reply_ph'),
-                      submitLabel: t('fedi.save_edit'),
-                      rows: 2,
-                      initialHtml: n.content,
-                      initialText: n.editable,
-                      defaultLang: n.language,
-                      noAttach: true,
-                    }) %>
-                  </div>
-                </details>
-                <form method="post" action="/fediverse/<%= n.outboxId %>/delete" data-confirm="<%= t('fedi.delete_confirm') %>">
-                  <button type="submit" class="msg-del"><%= t('comments.delete') %></button>
-                </form>
-              </div>
-            <% } %>
-          </div>
-        </li>
-<% } %>
Index: src/views/partials/news-append.ejs
===================================================================
--- src/views/partials/news-append.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-<% timeline.forEach(function(p){ %><%- include('tl-item', { p: p }) %><% }); %>
-<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#tl-feed', morePath: '/news', oob: true }) %>
Index: src/views/partials/note-body.ejs
===================================================================
--- src/views/partials/note-body.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,54 +1,0 @@
-<%
-  /* The body of a post, rendered the same way wherever a post shows up: de
-     Krant, Berichten and the Guardian PWA. Takes `nb` with the column names an
-     ap_timeline row uses (content, emoji_json, media_json, quote_json,
-     embed_json, nsfw, cw); ap_mentions and ap_interactions carry the same names
-     so a mention or a reply can be handed straight to it.
-
-     Krant-only trimmings (the boost byline, polls, the audio player iframe, the
-     action bar) stay in tl-item: they are about the feed, not about the post. */
-  // De catch vangt KAPOTTE json; hij vangt niet geldige json van het verkeerde
-  // TYPE. Drie posts van een remote server droegen media_json = "[]", dus een
-  // string met daarin [], en JSON.parse geeft dan netjes een string terug. Een
-  // string heeft geen .filter, en daarmee lag de hele Krant plat: één vreemde
-  // note nam de pagina mee. Wat er binnenkomt is niet van ons, dus de vorm moet
-  // hier afgedwongen worden en niet aangenomen.
-  var _m = []; try { _m = JSON.parse(nb.media_json || '[]'); } catch (e) { _m = []; }
-  if (!Array.isArray(_m)) _m = [];
-  var _imgs = _m.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); });
-  var _vids = _m.filter(function (m) { return m && m.url && m.type && /^video\//.test(m.type); });
-  var _auds = _m.filter(function (m) { return m && m.url && m.type && /^audio\//.test(m.type); });
-  var _hasVisual = _imgs.length || _vids.length || _auds.length;
-  var _nsfwVisual = !!nb.nsfw && _hasVisual;
-  var _nsfwText = !!nb.nsfw && !_hasVisual;
-  // One card for both: a fediverse quote and an external link preview look
-  // identical; only where they came from differs. An embed carries a title and
-  // a thumbnail, never an iframe.
-  var _quote = noteQuote(nb.quote_json);
-  if (!_quote) {
-    var _emb = noteQuote(nb.embed_json);
-    // The title comes from a third-party oEmbed provider, so it is escaped
-    // here: the quote card renders `content` as HTML (fine for AP content,
-    // which we sanitise on the way in, but not for this).
-    if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
-                         content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
-  }
-  var _noAudio = !!nb.suppressAudio;   // the Klonkt player iframe already covers these tracks
-%>
-<% if (_nsfwText) { %>
-  <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></span><%- emojiHtml(nb.content, nb.emoji_json) %></div>
-<% } else { %>
-  <div class="tl-content"><%- emojiHtml(nb.content, nb.emoji_json) %></div>
-<% } %>
-<% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
-<% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
-<% if (_imgs.length) { %>
-  <div class="tl-media">
-    <% _imgs.forEach(function (m) { %>
-      <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
-    <% }); %>
-  </div>
-<% } %>
-<% _vids.forEach(function (m) { %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
-<% if (!_noAudio) { _auds.forEach(function (m) { %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
-<% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></div></div><% } %>
Index: src/views/partials/nsfw-veil.ejs
===================================================================
--- src/views/partials/nsfw-veil.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,3 +1,0 @@
-<%# NSFW veil contents (label + reveal button). The caller supplies the wrapper
-    element (<span|div> class="nsfw-veil"), which stays as-is so nothing shifts.
-    Param: cw = the post's content_warning (may be empty → generic warning). %><span class="nsfw-veil-label">🔞 <%= cw || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span>
Index: src/views/partials/page-data.ejs
===================================================================
--- src/views/partials/page-data.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,17 +1,0 @@
-<%#
-  Servergegevens naar een module (shaer-bqr).
-
-  Een module is een statisch bestand, dus er kan geen EJS-tag in. Alles wat een
-  script van de server nodig heeft -- vertalingen, ids, instellingen -- komt
-  hierlangs, en de module leest het met pageData() uit mod/lib.js.
-
-  Waarom dit GEEN CSP-probleem is terwijl het een script-element is: een script
-  met een ander type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus
-  script-src heeft er niets over te zeggen. Het is een gegevensblok dat toevallig
-  in een script-tag staat, en het werkt daarmee ook in inhoud die via htmx
-  binnenkomt.
-
-  De kleiner-dan wordt ontsnapt: zonder dat sluit een sluitende script-tag IN de
-  gegevens dit blok voortijdig af, en dan staat de rest als HTML op de pagina.
--%>
-<% if (typeof pageData !== 'undefined' && pageData) { %><script type="application/json" data-page-data><%- JSON.stringify(pageData).replace(/</g, '\\u003c') %></script><% } %>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/playlist-editor.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -99,8 +99,4 @@
 }
 .pl-field { display: flex; flex-direction: column; gap: .25rem; }
-/* `display: flex` wint van het hidden-attribuut, dus zonder deze regel doet
-   [hidden] op een .pl-field NIETS -- het veld blijft gewoon staan. De
-   uitgavevelden schakelen daarop (album versus afspeellijst). */
-.pl-field[hidden] { display: none; }
 .pl-field-full { grid-column: 1 / -1; }
 .pl-field > span {
@@ -284,30 +280,411 @@
 </style>
 
-<%# Het script staat in assets/js/mod/playlist-editor.js.
-
-    EIGEN GEGEVENSBLOK, niet dat van partials/page-data.ejs. pageData() pakt met
-    querySelector EEN blok, en deze modal wordt ingesloten op pagina's die er al
-    zelf een hebben (admin-playlists, post-edit) -- een tweede blok zou daar
-    stilzwijgend genegeerd worden en dan stond de hele modal weer in het
-    Nederlands. Eigen kenmerk, eigen lezer.
-
-    De teksten stonden hier tot 16-8 hard in het script. Dat viel niet op omdat
-    het toevallig de brontaal was: een Engelse of Duitse beheerder kreeg een
-    Nederlandse modal midden in een vertaalde pagina. %>
-<script type="application/json" data-playlist-editor-i18n><%- JSON.stringify({
-  titel: t('ple.titel'), artiest: t('ple.artiest'), jaar: t('ple.jaar'), type: t('ple.type'),
-  k_album: t('ple.k_album'), k_playlist: t('ple.k_playlist'), k_mixtape: t('ple.k_mixtape'),
-  uitgave: t('ple.uitgave'), mb_release: t('ple.mb_release'),
-  cover: t('ple.cover'), cover_kies: t('ple.cover_kies'), cover_url: t('ple.cover_url'),
-  tracks_in: t('ple.tracks_in'), sleep_hint: t('ple.sleep_hint'),
-  beschikbaar: t('ple.beschikbaar'), zoek: t('ple.zoek'), geen_res: t('ple.geen_res'),
-  leeg_sel: t('ple.leeg_sel'),
-  t_edit: t('ple.t_edit'), t_new: t('ple.t_new'), dialoog: t('ple.dialoog'),
-  sluiten: t('ple.sluiten'), annuleren: t('ple.annuleren'),
-  opslaan: t('ple.opslaan'), aanmaken: t('ple.aanmaken'),
-  bezig_opslaan: t('ple.bezig_opslaan'),
-  versleep: t('ple.versleep'), verwijder: t('ple.verwijder'),
-  geen_audio: t('ple.geen_audio'), e_geen_tracks: t('ple.e_geen_tracks'),
-  e_tracks: t('ple.e_tracks'), e_opslaan: t('ple.e_opslaan'), e_mislukt: t('ple.e_mislukt'),
-  e_alleen_afb: t('ple.e_alleen_afb'), bezig: t('ple.bezig'), e_upload: t('ple.e_upload'),
-}).replace(/</g, '\\u003c') %></script>
+<script>
+(function() {
+  // Idempotency: if window.openPlaylistEditor already defined (multiple
+  // partial includes on a single page), skip re-binding.
+  if (typeof window.openPlaylistEditor === 'function') return;
+
+  const CSRF = '<%= _csrf %>';
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+    }[c]));
+  }
+  function fmtDur(sec) {
+    if (!sec) return '';
+    const m = Math.floor(sec / 60), s = sec % 60;
+    return `${m}:${String(s).padStart(2, '0')}`;
+  }
+
+  async function api(method, url, body) {
+    const opts = {
+      method, credentials: 'same-origin',
+      headers: { 'X-CSRF-Token': CSRF },
+    };
+    if (body !== undefined) {
+      opts.headers['Content-Type'] = 'application/json';
+      opts.body = JSON.stringify(body);
+    }
+    const r = await fetch(url, opts);
+    return r.json();
+  }
+
+  /**
+   * Public entry point: open the editor.
+   *  opts: { mode: 'create'|'edit', id?, onSaved? }
+   *  onSaved is called with { id, playlist } after a successful save.
+   */
+  window.openPlaylistEditor = async function(opts) {
+    opts = opts || {};
+    const mode = opts.mode === 'edit' ? 'edit' : 'create';
+    const isEdit = mode === 'edit';
+
+    // Load all audio tracks for the picker
+    let tracks = [];
+    try {
+      const j = await api('GET', '/admin/playlists/api/tracks');
+      if (Array.isArray(j.tracks)) tracks = j.tracks;
+    } catch (e) {
+      alert('Tracks ophalen mislukt');
+      return;
+    }
+    if (!tracks.length) {
+      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
+      return;
+    }
+
+    // Existing playlist data when editing
+    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
+    if (isEdit && opts.id) {
+      try {
+        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
+        if (j.ok) initial = { ...initial, ...j.playlist };
+      } catch (e) {}
+    }
+
+    // ── DOM ────────────────────────────────────────────────────────
+    const backdrop = document.createElement('div');
+    backdrop.className = 'pl-modal-backdrop';
+    backdrop.innerHTML = `
+      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
+        <div class="pl-modal-header">
+          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
+          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
+        </div>
+        <div class="pl-modal-body">
+          <div class="pl-editor-cols">
+            <div class="pl-editor-left">
+              <div class="pl-meta-grid">
+                <label class="pl-field pl-field-full">
+                  <span>Titel *</span>
+                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
+                </label>
+                <label class="pl-field">
+                  <span>Artiest</span>
+                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
+                </label>
+                <label class="pl-field">
+                  <span>Jaar</span>
+                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
+                </label>
+                <label class="pl-field">
+                  <span>Type</span>
+                  <select id="pli-kind">
+                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
+                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
+                  </select>
+                </label>
+                <div class="pl-field pl-field-full">
+                  <span>Cover</span>
+                  <div class="pl-cover-row">
+                    <span class="pl-cover-thumb" id="pli-cover-thumb">
+                      ${initial.cover
+                        ? `<img src="${esc(initial.cover)}" alt="">`
+                        : `<span class="pl-cover-empty">🎨</span>`}
+                    </span>
+                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
+                  </div>
+                  ${isEdit ? `
+                  <div class="pl-cover-upload-row">
+                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
+                    <span class="pl-cover-status" id="pli-cover-status"></span>
+                  </div>` : `
+                  <small class="pl-cover-note">Upload van een afbeelding kan na het aanmaken van de playlist.</small>`}
+                </div>
+              </div>
+              <div class="pl-section-title">
+                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
+                <small>(sleep ⠿ om te ordenen)</small>
+              </div>
+              <div id="pli-selected" class="pl-selected-list"></div>
+            </div>
+            <div class="pl-editor-right">
+              <div class="pl-section-title">Beschikbare tracks</div>
+              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
+              <div id="pli-available" class="pl-available-list"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pl-modal-footer">
+          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
+          <button type="button" class="btn btn-primary" id="pli-save" disabled>
+            ${isEdit ? 'Opslaan' : 'Aanmaken'}
+          </button>
+        </div>
+      </div>
+    `;
+    document.body.appendChild(backdrop);
+
+    const $ = sel => backdrop.querySelector(sel);
+    const close = () => backdrop.remove();
+    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
+    $('.pl-modal-close').addEventListener('click', close);
+    $('#pli-cancel').addEventListener('click', close);
+
+    // Index tracks by id for fast lookup
+    const trackById = new Map(tracks.map(t => [t.id, t]));
+    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
+
+    const titleEl = $('#pli-title');
+    const artistEl = $('#pli-artist');
+    const yearEl = $('#pli-year');
+    const kindEl = $('#pli-kind');
+    const coverEl = $('#pli-cover');
+    const coverThumb = $('#pli-cover-thumb');
+    const saveBtn = $('#pli-save');
+    const selectedEl = $('#pli-selected');
+    const availEl = $('#pli-available');
+    const searchEl = $('#pli-search');
+    const countEl = $('#pli-count');
+
+    coverEl.addEventListener('input', () => {
+      const u = coverEl.value.trim();
+      coverThumb.innerHTML = u
+        ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
+        : `<span class="pl-cover-empty">🎨</span>`;
+    });
+
+    // ── Cover file upload (edit mode only) ────────────────────
+    // The upload widget only exists once a playlist has an id to attach to.
+    // In create mode the user types/pastes a URL, saves the playlist, then
+    // re-opens for upload. The route is the same as for tracks: returns
+    // { ok, url, cover_url }.
+    const coverFileInput = $('#pli-cover-file');
+    const coverPickBtn   = $('#pli-cover-pick');
+    const coverStatus    = $('#pli-cover-status');
+    if (coverFileInput && coverPickBtn) {
+      coverPickBtn.addEventListener('click', () => coverFileInput.click());
+      coverFileInput.addEventListener('change', async (e) => {
+        const file = e.target.files && e.target.files[0];
+        coverFileInput.value = '';
+        if (!file) return;
+        if (!/^image\//.test(file.type)) {
+          coverStatus.textContent = 'Alleen afbeeldingen';
+          coverStatus.className = 'pl-cover-status is-error';
+          return;
+        }
+        coverStatus.textContent = 'Uploaden…';
+        coverStatus.className = 'pl-cover-status';
+        const fd = new FormData();
+        fd.append('cover', file);
+        try {
+          const r = await fetch(
+            `/admin/playlists/api/${encodeURIComponent(opts.id)}/cover`,
+            { method: 'POST', body: fd, credentials: 'same-origin' }
+          );
+          const j = await r.json();
+          if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
+          const url = j.cover_url || j.url || '';
+          coverEl.value = url;
+          coverThumb.innerHTML = url
+            ? `<img src="${esc(url)}" alt="">`
+            : `<span class="pl-cover-empty">🎨</span>`;
+          coverStatus.textContent = '✓ Geüpload';
+          coverStatus.className = 'pl-cover-status is-ok';
+        } catch (err) {
+          coverStatus.textContent = 'Mislukt: ' + err.message;
+          coverStatus.className = 'pl-cover-status is-error';
+        }
+      });
+    }
+
+    function updateSaveBtn() {
+      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
+    }
+
+    function renderSelected() {
+      countEl.textContent = selected.length;
+      if (selected.length === 0) {
+        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
+        updateSaveBtn();
+        return;
+      }
+      selectedEl.innerHTML = selected.map((id, i) => {
+        const t = trackById.get(id);
+        if (!t) return '';
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
+          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
+          <span class="pl-row-num">${i + 1}</span>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
+          </span>
+          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
+        </div>`;
+      }).join('');
+
+      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
+        b.addEventListener('click', () => {
+          selected = selected.filter(x => x !== b.dataset.id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+      bindDrag();
+      updateSaveBtn();
+    }
+
+    function renderAvailable() {
+      const q = searchEl.value.trim().toLowerCase();
+      const matches = tracks.filter(t => {
+        if (!q) return true;
+        return (t.title || '').toLowerCase().includes(q)
+            || (t.artist || '').toLowerCase().includes(q);
+      });
+      if (matches.length === 0) {
+        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
+        return;
+      }
+      availEl.innerHTML = matches.map(t => {
+        const isAdded = selected.includes(t.id);
+        const cls = ['pl-avail-row'];
+        if (isAdded) cls.push('is-added');
+        if (!t.playable) cls.push('is-unplayable');
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
+          </span>
+          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
+        </div>`;
+      }).join('');
+
+      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
+        if (row.classList.contains('is-unplayable')) return;
+        row.addEventListener('click', () => {
+          const id = row.dataset.id;
+          if (selected.includes(id)) selected = selected.filter(x => x !== id);
+          else selected.push(id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+    }
+
+    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
+    function bindDrag() {
+      selectedEl.querySelectorAll('.pl-row').forEach(row => {
+        const handle = row.querySelector('.pl-row-handle');
+        if (!handle) return;
+        let dragging = false, originalIdx = -1;
+
+        handle.addEventListener('pointerdown', e => {
+          e.preventDefault();
+          handle.setPointerCapture(e.pointerId);
+          dragging = true;
+          originalIdx = parseInt(row.dataset.pos, 10);
+          row.classList.add('is-dragging');
+        });
+        handle.addEventListener('pointermove', e => {
+          if (!dragging) return;
+          e.preventDefault();
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            const r = rows[i]; if (r === row) continue;
+            const rect = r.getBoundingClientRect();
+            const mid = rect.top + rect.height / 2;
+            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
+            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
+              targetIdx = i; above = e.clientY < mid; break;
+            }
+          }
+          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
+        });
+        const finish = e => {
+          if (!dragging) return;
+          dragging = false;
+          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
+          row.classList.remove('is-dragging');
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
+            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
+          }
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          if (targetIdx === -1 || targetIdx === originalIdx) return;
+          const moved = selected[originalIdx];
+          selected.splice(originalIdx, 1);
+          let newIdx = targetIdx;
+          if (originalIdx < targetIdx) newIdx--;
+          if (!above) newIdx++;
+          newIdx = Math.max(0, Math.min(selected.length, newIdx));
+          selected.splice(newIdx, 0, moved);
+          renderSelected();
+        };
+        handle.addEventListener('pointerup', finish);
+        handle.addEventListener('pointercancel', () => {
+          dragging = false;
+          row.classList.remove('is-dragging');
+          selectedEl.querySelectorAll('.drop-above, .drop-below')
+            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
+        });
+      });
+    }
+
+    titleEl.addEventListener('input', updateSaveBtn);
+    searchEl.addEventListener('input', renderAvailable);
+    renderSelected();
+    renderAvailable();
+
+    saveBtn.addEventListener('click', async () => {
+      if (saveBtn.disabled) return;
+      const orig = saveBtn.textContent;
+      saveBtn.disabled = true;
+      saveBtn.textContent = 'Opslaan...';
+
+      const payload = {
+        title:  titleEl.value.trim(),
+        artist: artistEl.value.trim(),
+        year:   parseInt(yearEl.value, 10) || 0,
+        cover:  coverEl.value.trim(),
+        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
+        tracks: selected.slice(),
+      };
+
+      try {
+        const url = isEdit
+          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
+          : '/admin/playlists/api';
+        const j = await api('POST', url, payload);
+        if (!j.ok) {
+          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
+          saveBtn.disabled = false;
+          saveBtn.textContent = orig;
+          return;
+        }
+        const savedId = isEdit ? initial.id : j.id;
+        close();
+        if (typeof opts.onSaved === 'function') {
+          opts.onSaved({ id: savedId, playlist: payload });
+        }
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = orig;
+      }
+    });
+
+    // ESC to close
+    document.addEventListener('keydown', function onEsc(e) {
+      if (e.key === 'Escape' && document.body.contains(backdrop)) {
+        close();
+        document.removeEventListener('keydown', onEsc);
+      }
+    });
+  };
+})();
+</script>
Index: src/views/partials/post-body.ejs
===================================================================
--- src/views/partials/post-body.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,62 +1,0 @@
-<%
-// Het lijf van één bericht, IN de stroom.
-//
-// De poorten waren tot nu toe hele pagina's: paid-gate of fan-gate in plaats
-// van het bericht. Dat kan niet meer zodra de tijdlijn hele berichten toont --
-// dan moet een gesloten bericht zijn PLEK houden en ter plekke zeggen wat er
-// aan de hand is. Weglaten zou erger zijn dan tonen: dan zien een supporter en
-// een vreemde een andere tijdlijn, met andere buren, en klopt "vorige/volgende"
-// voor niemand meer.
-//
-// Het BESLUIT staat niet hier maar in PostAccessService, dezelfde die de
-// postroute gebruikt. Dit sjabloon kiest alleen de vorm. Twee plekken die
-// allebei zelf bepalen wie een betaalde tekst mag lezen, is een lek dat op een
-// gelegenheid wacht.
-//
-// Verwacht: post, access ('full'|'paid'|'fan'|'forbidden'), teaser, _base.
-var _acc = (typeof access !== 'undefined' && access) ? access : 'full';
-var _teaser = (typeof teaser !== 'undefined' && teaser) ? teaser : '';
-var _b = (typeof _base !== 'undefined' && _base) ? _base : '';
-var _href = _b + '/' + post.slug;
-// Het lijf komt als eigen waarde binnen en wordt NIET uit `post` gevist. De rij
-// draagt nog de ruwe `content`; door alleen te tonen wat de aanroeper bewust
-// meegeeft, kan een gesloten poort hier niets laten ontsnappen.
-var _html = (typeof content_html !== 'undefined' && content_html) ? content_html : '';
-%>
-
-<% if (_acc === 'full') { %>
-  <div class="post-content"><%- _html %></div>
-
-<% } else if (_acc === 'paid') { %>
-  <%# Dezelfde woorden als de hele pagina (pgate.*), want het is dezelfde
-      boodschap -- alleen op een andere plek. Eigen teksten hier zouden na één
-      wijziging uiteen gaan lopen. %>
-  <div class="post-content post-content--gated pb-paid">
-    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
-    <div class="pb-gate">
-      <h2 class="pg-h2"><%= t('pgate.h') %></h2>
-      <p class="pg-sub">
-        <% if (post.paid_min_cents) { %><%= t('pgate.sub_cents', { eur: (post.paid_min_cents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
-      </p>
-      <%# De ontgrendeling zelf blijft op de eigen pagina: daar staat de
-          passkey-code (pageJs paid-gate) die hier niet geladen is. Eén knop die
-          ernaartoe gaat is eerlijker dan een halve poort in de stroom. %>
-      <p><a class="pg-btn" href="<%= _href %>"><%= t('pgate.unlock') %></a></p>
-    </div>
-  </div>
-
-<% } else if (_acc === 'fan') { %>
-  <div class="post-content post-content--gated pb-fan">
-    <% if (_teaser) { %><div class="pg-teaser"><p><%= _teaser %></p></div><% } %>
-    <div class="pb-gate">
-      <h2 class="pg-h2"><%= t('fgate.title') %></h2>
-      <p class="pg-sub"><%= t('fgate.sub') %></p>
-      <p><a class="fg-btn" href="/auth/login?next=<%= encodeURIComponent(_href) %>"><%= t('fgate.login') %></a></p>
-    </div>
-  </div>
-
-<% } else { %>
-  <%# 'forbidden' hoort de tijdlijn niet eens te bereiken: die haalt alleen
-      gepubliceerde berichten op. Staat er voor het geval een aanroeper ooit
-      iets anders doorgeeft -- dan liever niets dan een concept. %>
-<% } %>
Index: src/views/partials/post-card.ejs
===================================================================
--- src/views/partials/post-card.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/post-card.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -11,39 +11,8 @@
 //   Hover state on cover. Tap-target stays large.
 
-const _base      = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _external  = post.external_url || null;
-const _href      = _external || (_base + '/' + post.slug);
-const _ext       = !!_external;
-const _src       = post.source_name || '';
-const _realCover = !!post.cover_image_url || !!post.cover_video_url;
-// Content-derived fallback (see post-tile.ejs): a C2S post's media lives in
-// its content, and the card fronts it from there.
-let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
-if (!_realCover && post.content) {
-  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
-  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else {
-    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
-    if (im) _cImg = im[1];
-    else {
-      // An audio post (Robins vraag, 30-7): the upload leg drew a waveform
-      // (data-poster on the tag); the card fronts it like a cover.
-      const am = String(post.content).match(/<audio[^>]*>/i);
-      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
-    }
-  }
-}
-const _hasCover  = _realCover || !!_cVideo || !!_cImg || !!_cWave;
-// A C2S post has no title: fall back to the post's own words, then to a
-// friendly type label instead of "(zonder titel)".
-let _title = post.title || '';
-if (!_title && post.content) {
-  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
-  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
-}
-if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)';
+const _href      = '/' + post.slug;
+const _hasCover  = !!post.cover_image_url;
 const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : '';
 const _isPinned  = !!post.pinned;
-const _isBoost   = !!post.isBoost;
 const _isDraft   = post.status && post.status !== 'published';
 
@@ -69,22 +38,12 @@
 }
 %>
-<li class="post-list-item<%= (_hasCover || post.nsfw) ? ' has-cover' : '' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %>">
+<li class="post-list-item<%= _hasCover ? ' has-cover' : '' %><%= _isPinned ? ' is-pinned' : '' %>">
 
   <% if (_hasCover) { %>
-    <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
-       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= _title %>" tabindex="-1">
-      <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>"
-           srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
-           sizes="(min-width: 768px) 120px, 100vw"
-           alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-      <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="post-cover-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
-      <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
-    </a>
-  <% } else if (post.nsfw) { %>
-    <a class="post-list-cover nsfw-media" href="<%= _href %>"
-       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
-       aria-label="<%= _title %>" tabindex="-1">
-      <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span>
+    <a class="post-list-cover" href="<%= _href %>"
+       hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"
+       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
+      <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
     </a>
   <% } %>
@@ -92,7 +51,6 @@
   <div class="post-list-body">
     <div class="post-list-meta mono">
-      <% if (_isBoost) { %><span class="post-list-pin post-list-boost" aria-label="Boost"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span><% } else if (_isPinned) { %><span class="post-list-pin" aria-label="Vastgepind"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg></span><% } %>
+      <% if (_isPinned) { %><span class="post-list-pin" aria-label="Vastgepind">★</span><% } %>
       <time datetime="<%= post.published_at || post.created_at %>"><%= _ddmmyyyy(post.published_at || post.created_at) %></time>
-      <% if (_src) { %><span class="post-list-type">&middot; via <%= _src %></span><% } %>
       <% if (_typeLabel) { %>
         <span class="post-list-type">· <%= _typeLabel.charAt(0).toUpperCase() + _typeLabel.slice(1) %></span>
@@ -105,6 +63,7 @@
     <h3 class="post-list-title">
       <a href="<%= _href %>"
-         <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
-        <%= _title %>
+         hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+         hx-push-url="<%= _href %>" hx-indicator="#pcms-loading">
+        <%= post.title || '(zonder titel)' %>
       </a>
     </h3>
@@ -117,7 +76,7 @@
       <div class="post-list-tags">
         <% _tags.forEach(function(t) { %>
-          <a href="<%= _base %>/tag/<%= encodeURIComponent(t) %>" class="tag-chip"
-             hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1" hx-target="#pcms-main"
-             hx-swap="innerHTML" hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
+          <a href="/tag/<%= encodeURIComponent(t) %>" class="tag-chip"
+             hx-get="/tag/<%= encodeURIComponent(t) %>?partial=1" hx-target="#pcms-main"
+             hx-swap="innerHTML" hx-push-url="/tag/<%= encodeURIComponent(t) %>"
              hx-indicator="#pcms-loading">#<%= t %></a>
         <% }); %>
@@ -168,16 +127,11 @@
     margin-right: calc(-1 * clamp(1.25rem, 4vw, 2rem));
     width: calc(100% + 2 * clamp(1.25rem, 4vw, 2rem)) !important;
-    /* Accent-gradient placeholder shown while the cover loads (the image fades in over it). */
-    background-image: linear-gradient(135deg,
-        color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2)) 0%,
-        color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2)) 100%);
-  }
-  .post-list-cover img, .post-list-cover video {
+    background: var(--paper-2);
+  }
+  .post-list-cover img {
     width: 100%; height: 100%;
     object-fit: cover;
     display: block;
-    transition: opacity .4s ease;
-  }
-  .post-list-cover img.is-loading { opacity: 0; }
+  }
 
   /* Re-apply rounded corners on slightly larger phones */
@@ -206,7 +160,4 @@
   }
 
-  /* Audio cover (Shaer): white-on-transparent waveform on its own accent
-     gradient, contained so the shape reads at 120px and full-bleed alike. */
-  /* (see also .grid-tile-wave in style.css for the grid tiles) */
   /* Pinned indicator */
   .post-list-item.is-pinned .post-list-title a {
@@ -253,18 +204,4 @@
 
 /* ============================================================
-   AUDIO COVER (shared) — Shaer audio post (Robins vraag, 30-7):
-   the waveform PNG is white on transparent; give it its own accent
-   gradient and contain it so the shape reads at 120px and full-bleed.
-   ============================================================ */
-.post-list-cover .post-cover-wave {
-  width: 100%; height: 100%;
-  object-fit: contain !important;
-  padding: 14% 8%;
-  background-image: linear-gradient(135deg,
-      color-mix(in srgb, var(--accent, #888) 26%, var(--paper-2, var(--paper))) 0%,
-      color-mix(in srgb, var(--accent, #888) 8%, var(--paper-2, var(--paper))) 100%);
-}
-
-/* ============================================================
    PIN + DRAFT INDICATORS (shared)
    ============================================================ */
@@ -272,10 +209,6 @@
   color: var(--accent);
   margin-right: .35rem;
-  display: inline-flex;
-  align-items: center;
-  vertical-align: middle;
-}
-.post-list-pin svg { width: 13px; height: 13px; display: block; }
-.post-list-boost { color: #2fa85a; }
+  font-size: .9rem;
+}
 .post-list-draft {
   color: var(--accent);
Index: src/views/partials/post-nav.ejs
===================================================================
--- src/views/partials/post-nav.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,49 +1,0 @@
-<%# Post navigation (Newer link left / Older right) — shared by post.ejs and
-    fan-gate.ejs. Expects newerPost + olderPost in scope. If a neighbour is missing,
-    a greyed-out placeholder keeps every post in the same two-card structure. %>
-<% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %>
-  <nav class="post-nav post-nav--top">
-    <% if (newerPost) { %>
-      <a class="post-nav-prev" href="<%= newerPost._urlBase || '' %>/<%= newerPost.slug %>"<% if (!newerPost._urlBase) { %>
-         hx-get="/<%= newerPost.slug %>?partial=1" hx-target="#pcms-main"
-         hx-push-url="/<%= newerPost.slug %>" hx-indicator="#pcms-loading"<% } %>>
-        <span class="post-nav-label"><% if (newerPost.pinned) { %><span class="post-nav-pin" title="Vastgepind">📌</span> <% } %>← <%= t('postnav.newer') %></span>
-        <span class="post-nav-title"><%= newerPost.title %></span>
-      </a>
-    <% } else { %>
-      <div class="post-nav-prev is-empty" aria-hidden="true">
-        <span class="post-nav-label">← Newer</span>
-        <span class="post-nav-title"><%= t('postnav.newest') %></span>
-      </div>
-    <% } %>
-    <% if (olderPost) { %>
-      <a class="post-nav-next" href="<%= olderPost._urlBase || '' %>/<%= olderPost.slug %>"<% if (!olderPost._urlBase) { %>
-         hx-get="/<%= olderPost.slug %>?partial=1" hx-target="#pcms-main"
-         hx-push-url="/<%= olderPost.slug %>" hx-indicator="#pcms-loading"<% } %>>
-        <span class="post-nav-label"><%= t('postnav.older') %> →<% if (olderPost.pinned) { %> <span class="post-nav-pin" title="Vastgepind">📌</span><% } %></span>
-        <span class="post-nav-title"><%= olderPost.title %></span>
-      </a>
-    <% } else { %>
-      <div class="post-nav-next is-empty" aria-hidden="true">
-        <span class="post-nav-label">Older →</span>
-        <span class="post-nav-title"><%= t('postnav.oldest') %></span>
-      </div>
-    <% } %>
-  </nav>
-<% } %>
-
-<style>
-.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
-/* Above the post: no line/gap above it, but a dividing line BELOW (toward the content). */
-.post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); }
-.post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
-/* Empty slot (no newer/older post): same card, greyed out + not clickable,
-   so every post keeps the same two-card structure. */
-.post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; }
-.post-nav-prev.is-empty .post-nav-title, .post-nav-next.is-empty .post-nav-title { font-weight: 400; font-style: italic; color: var(--ink-muted); }
-.post-nav-next { text-align: right; align-items: flex-end; }
-.post-nav-label { font-size: 0.8rem; color: var(--ink-muted); margin-bottom: 0.25rem; }
-.post-nav-title { font-weight: 500; }
-.post-nav-pin { font-size: 0.85em; }
-@media (max-width: 600px) { .post-nav { grid-template-columns: 1fr; } }
-</style>
Index: src/views/partials/post-tile.ejs
===================================================================
--- src/views/partials/post-tile.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/post-tile.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -5,67 +5,29 @@
 const _tileHue = ((post.id || post.slug || '0').charCodeAt(0) * 7) % 360;
 const _isPinned = !!post.pinned;
-const _isBoost = !!post.isBoost;
-const _hasCover = !!post.cover_image_url || !!post.cover_video_url;
-// A C2S post carries its media IN the content (a cover next to it showed the
-// video twice on the post page). The tile derives its picture from the
-// content instead, like the Cirkel card: first <video> (with its poster) or
-// first <img>.
-let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
-if (!_hasCover && post.content) {
-  const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
-  if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
-  else {
-    const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
-    if (im) _cImg = im[1];
-    else {
-      // An audio post (Robins vraag, 30-7): the waveform the upload leg drew
-      // (data-poster) goes OVER the gradient, so the tile keeps its own hue.
-      const am = String(post.content).match(/<audio[^>]*>/i);
-      if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
-    }
-  }
-}
-const _showCover = _hasCover || !!_cVideo || !!_cImg;   // the wave keeps the gradient
-// A C2S post has no title: fall back to the post's own words, and for a
-// media-only post to a friendly type label instead of "(untitled)". A tile
-// that already shows its picture needs no forced label at all.
-let _title = post.title || '';
-if (!_title && post.content) {
-  const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
-  if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
-}
-if (!_title) _title = _cAudio ? '♪ audio' : (_cVideo || _cImg) ? '' : '(untitled)';
+const _hasCover = !!post.cover_image_url;
 const _typeLabel = (post.type && post.type !== 'post') ? post.type : '';
-const _src = post.source_name || '';   // bron-site (cirkel-feed: van welke site komt deze post)
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _external = post.external_url || null;
-const _href = _external || (_base + '/' + post.slug);
-const _ext = !!_external;
 %>
-<a class="grid-tile<%= _showCover ? '' : ' grid-tile-gradient' %><%= _cWave ? ' grid-tile-audio' : '' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %><%= post.nsfw ? ' nsfw-media' : '' %>"
-   href="<%= _href %>"
-   style="--tile-hue: <%= _tileHue %>;"
-   <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
-
-  <% if (post.cover_image_url) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>><% } else if (post.cover_video_url) { %><video class="grid-tile-img" src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video class="grid-tile-img" src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img class="grid-tile-img" src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="grid-tile-img grid-tile-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
+<a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= _isPinned ? ' is-pinned' : '' %>"
+   href="/<%= post.slug %>"
+   <% if (_hasCover) { %>style="background-image: url('<%= post.cover_image_url %>'); --tile-hue: <%= _tileHue %>;"<% } else { %>style="--tile-hue: <%= _tileHue %>;"<% } %>
+   hx-get="/<%= post.slug %>?partial=1"
+   hx-target="#pcms-main"
+   hx-swap="innerHTML"
+   hx-push-url="/<%= post.slug %>"
+   hx-indicator="#pcms-loading">
 
   <% if (_typeLabel) { %>
     <span class="grid-tile-type"><%= _typeLabel %></span>
   <% } %>
-  <% if (_isBoost) { %>
-    <span class="grid-tile-pin grid-tile-boost"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>Boost</span>
-  <% } else if (_isPinned) { %>
-    <span class="grid-tile-pin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>Pinned</span>
+  <% if (_isPinned) { %>
+    <span class="grid-tile-pin">Pinned</span>
   <% } %>
 
   <% if (_hasCover) { %>
     <div class="grid-tile-overlay">
-      <strong><%= _title %></strong>
-      <% if (_src) { %><span class="grid-tile-overlay-src">van <%= _src %></span><% } %>
+      <strong><%= post.title || '(untitled)' %></strong>
     </div>
   <% } else { %>
-    <span class="grid-tile-title"><%= _title %></span>
-    <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
+    <span class="grid-tile-title"><%= post.title || '(untitled)' %></span>
   <% } %>
-  <% if (post.nsfw) { %><div class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></div><% } %>
 </a>
Index: src/views/partials/profile-header.ejs
===================================================================
--- src/views/partials/profile-header.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/profile-header.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -26,13 +26,4 @@
   try { _profileLinks = JSON.parse(site.profile_links) || []; } catch (e) { _profileLinks = []; }
 }
-
-// Profile-summary (lightbox) helpers — used by the avatar + the summary modal.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const _avatarUrl = (site && site.profile_photo) || ((typeof siteOwnerAvatar !== 'undefined' && siteOwnerAvatar) || '');
-const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {};
-const _linkHref = function (p, u) { u = String(u || '').trim(); if (p === 'email' && u && u.indexOf('mailto:') !== 0) return 'mailto:' + u; if (p === 'phone' && u && !/^tel:/i.test(u)) return 'tel:' + u.replace(/[^\d+]/g, ''); return u; };
-const _linkVal = function (u) { return String(u || '').replace(/^(mailto:|tel:|https?:\/\/)/i, '').replace(/\/$/, ''); };
-const _isPrem = (typeof isPremium !== 'undefined' && isPremium);
-let _sinceStr = ''; try { if (site && site.created_at) _sinceStr = new Date(site.created_at).toLocaleDateString('nl-NL', { day: '2-digit', month: 'short', year: 'numeric' }); } catch (e) {}
 %>
 
@@ -41,9 +32,14 @@
   <div class="container profile-header-inner">
 
-    <%# Clicking the avatar — photo OR placeholder — opens the profile-summary modal. %>
-    <button type="button" class="profile-photo" data-pf-summary aria-label="<%= _displayName || 'Profile' %>">
-      <% if (_avatarUrl) { %>
-        <img src="<%= avatar(_avatarUrl, 256) %>" alt="">
-      <% } else if (site.enable_audio_player && (typeof audioEnabled === 'undefined' || audioEnabled)) { %>
+    <a class="profile-photo" href="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/"
+       hx-get="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/?partial=1"
+       hx-target="#pcms-main" hx-swap="innerHTML"
+       hx-push-url="<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '' %>/"
+       hx-indicator="#pcms-loading" aria-label="Home">
+      <% if (typeof user !== 'undefined' && user && user.avatar_url) { %>
+        <img src="<%= user.avatar_url %>" alt="">
+      <% } else if (site.profile_photo) { %>
+        <img src="<%= site.profile_photo %>" alt="">
+      <% } else if (site.enable_audio_player) { %>
         <span class="profile-photo-fallback profile-photo-fallback--music" aria-hidden="true">
           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -56,5 +52,5 @@
         <span class="profile-photo-fallback" aria-hidden="true"><%= (_displayName || '?').charAt(0).toUpperCase() %></span>
       <% } %>
-    </button>
+    </a>
 
     <div class="profile-info">
@@ -72,8 +68,5 @@
       <% } %>
 
-      <%# Follow-via-fediverse: a compact icon in the links row (keeps the header
-          short), and hidden for the owner — you don't follow yourself. %>
-      <% var _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled); %>
-      <% if (_profileLinks.length || _showFollow) { %>
+      <% if (_profileLinks.length) { %>
         <% const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {}; %>
         <ul class="profile-links" aria-label="External links">
@@ -93,10 +86,4 @@
             </li>
           <% }); %>
-          <% if (_showFollow) { %>
-            <li style="order:-1">
-              <button type="button" class="profile-link profile-fedi-link" data-fedi-actor-slug="<%= site.slug %>"
-                      aria-label="<%= t('fedi.profile_follow') %>" title="<%= t('fedi.profile_follow') %>"><svg viewBox="151 31 530 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g transform="translate(6.6789703,-32.495842)"><path d="M257.1,266.8c-4.9,9.4-12.6,17.1-22.1,22l121.3,121.8l29.2-14.8L257.1,266.8z M417.1,427.4l-29.2,14.8l61.5,61.7c4.9-9.4,12.6-17.1,22.1-22L417.1,427.4z"/><path d="M557.5,315l-68.7,34.8l5.1,32.4l77.7-39.4C564.1,335.2,559.2,325.5,557.5,315z M448.9,370l-162.4,82.3c7.5,7.6,12.4,17.3,14.1,27.8L454,402.4L448.9,370z"/><path d="M396.7,167.3l-78.4,153l23.1,23.2l83-162C414,179.7,404.3,174.7,396.7,167.3z M298,360l-39.7,77.5c10.5,1.8,20.2,6.7,27.7,14.2l35.1-68.5L298,360z"/><path d="M234.3,289.1c-8,4-16.9,5.9-25.8,5.4c-1.7-0.1-3.3-0.3-5-0.5l23.2,148.2c8-4,16.9-5.9,25.8-5.4c1.7,0.1,3.3,0.3,5,0.5L234.3,289.1z"/><path d="M300.8,480.8c0.5,3.4,0.7,6.9,0.5,10.4c-0.4,7.1-2.3,14-5.5,20.4l148.2,23.8c-0.5-3.4-0.7-6.9-0.5-10.4c0.4-7.1,2.3-14,5.5-20.4L300.8,480.8z"/><path d="M572.1,343.3l-68.4,133.6c10.5,1.8,20.2,6.7,27.7,14.2l68.4-133.6C589.4,355.8,579.7,350.8,572.1,343.3z"/><path d="M478.8,154.4c-4.9,9.4-12.6,17.1-22.1,22l105.9,106.4c4.9-9.4,12.6-17.1,22.1-22L478.8,154.4z"/><path d="M382.1,138.9l-133.9,67.9c7.5,7.6,12.4,17.3,14.1,27.8l133.9-67.9C388.7,159.1,383.8,149.4,382.1,138.9z"/><path d="M456.5,176.6c-8.1,4.1-17.1,6.1-26.2,5.6c-1.5-0.1-3-0.3-4.5-0.5l11.9,76l32.4,5.2L456.5,176.6z M444.5,301.8l28.1,179.6c7.9-3.9,16.7-5.7,25.4-5.2c1.8,0.1,3.6,0.3,5.4,0.6L476.8,307L444.5,301.8z"/><path d="M262.4,235.2c0.6,3.5,0.7,7,0.6,10.6c-0.4,7-2.2,13.9-5.4,20.2l76,12.2l14.9-29.2L262.4,235.2z M392.7,256.1l-14.9,29.2l179.6,28.9c-0.5-3.4-0.7-6.9-0.5-10.3c0.4-7.1,2.3-14.1,5.5-20.5L392.7,256.1z"/><circle cx="433" cy="130.6" r="47"/><circle cx="608.4" cy="306.6" r="47"/><circle cx="495.1" cy="527.8" r="47"/><circle cx="249.7" cy="488.4" r="47"/><circle cx="211.3" cy="242.9" r="47"/></g></svg></button>
-            </li>
-          <% } %>
         </ul>
       <% } %>
@@ -105,62 +92,4 @@
   </div>
 </aside>
-
-<%# Profile-summary modal — opened by clicking the avatar. Shows photo + title + subtitle
-    + contact details + start date + premium status. %>
-<div class="pf-summary">
-  <div class="pf-summary-card" role="dialog" aria-label="<%= _displayName %>">
-    <button type="button" class="pf-summary-close" aria-label="Sluiten">&times;</button>
-    <% if (_avatarUrl) { %>
-      <img class="pf-summary-photo" src="<%= avatar(_avatarUrl, 128) %>" alt="">
-    <% } else { %>
-      <div class="pf-summary-photo pf-summary-photo--ph" aria-hidden="true">
-        <% if (site.enable_audio_player && (typeof audioEnabled === 'undefined' || audioEnabled)) { %>
-          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>
-        <% } else { %><span><%= (_displayName || '?').charAt(0).toUpperCase() %></span><% } %>
-      </div>
-    <% } %>
-    <h3 class="pf-summary-name"><%= _displayName %></h3>
-    <span class="pf-summary-prem<%= _isPrem ? ' is-prem' : '' %>"><%= _isPrem ? '★ Premium' : t('profile.free') %></span>
-    <% if (_bioText) { %><p class="pf-summary-bio"><%= _bioText %></p><% } %>
-    <% if (_profileLinks.length) { %>
-      <ul class="pf-summary-links">
-        <% _profileLinks.forEach(function (l) { var meta = _platforms[l.platform]; if (!meta) return; var href = _linkHref(l.platform, l.url); var ext = (l.platform !== 'email' && l.platform !== 'phone'); %>
-          <li>
-            <a href="<%= href %>" <% if (ext) { %>target="_blank" rel="noopener noreferrer"<% } %> style="--brand: <%= meta.brand %>">
-              <span class="pf-summary-ico" aria-hidden="true"><%- meta.svg %></span>
-              <span class="pf-summary-rowtext"><span class="pf-summary-lbl"><%= meta.label %></span><span class="pf-summary-val"><%= _linkVal(l.url) %></span></span>
-            </a>
-          </li>
-        <% }); %>
-      </ul>
-    <% } %>
-    <% if (_showFollow) { %>
-      <button type="button" class="pf-summary-follow profile-fedi-link" data-fedi-actor-slug="<%= site.slug %>" aria-label="<%= t('fedi.profile_follow') %>">
-        <svg class="pf-summary-follow-ico" viewBox="151 31 530 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g transform="translate(6.6789703,-32.495842)"><path d="M257.1,266.8c-4.9,9.4-12.6,17.1-22.1,22l121.3,121.8l29.2-14.8L257.1,266.8z M417.1,427.4l-29.2,14.8l61.5,61.7c4.9-9.4,12.6-17.1,22.1-22L417.1,427.4z"/><path d="M557.5,315l-68.7,34.8l5.1,32.4l77.7-39.4C564.1,335.2,559.2,325.5,557.5,315z M448.9,370l-162.4,82.3c7.5,7.6,12.4,17.3,14.1,27.8L454,402.4L448.9,370z"/><path d="M396.7,167.3l-78.4,153l23.1,23.2l83-162C414,179.7,404.3,174.7,396.7,167.3z M298,360l-39.7,77.5c10.5,1.8,20.2,6.7,27.7,14.2l35.1-68.5L298,360z"/><path d="M234.3,289.1c-8,4-16.9,5.9-25.8,5.4c-1.7-0.1-3.3-0.3-5-0.5l23.2,148.2c8-4,16.9-5.9,25.8-5.4c1.7,0.1,3.3,0.3,5,0.5L234.3,289.1z"/><path d="M300.8,480.8c0.5,3.4,0.7,6.9,0.5,10.4c-0.4,7.1-2.3,14-5.5,20.4l148.2,23.8c-0.5-3.4-0.7-6.9-0.5-10.4c0.4-7.1,2.3-14,5.5-20.4L300.8,480.8z"/><path d="M572.1,343.3l-68.4,133.6c10.5,1.8,20.2,6.7,27.7,14.2l68.4-133.6C589.4,355.8,579.7,350.8,572.1,343.3z"/><path d="M478.8,154.4c-4.9,9.4-12.6,17.1-22.1,22l105.9,106.4c4.9-9.4,12.6-17.1,22.1-22L478.8,154.4z"/><path d="M382.1,138.9l-133.9,67.9c7.5,7.6,12.4,17.3,14.1,27.8l133.9-67.9C388.7,159.1,383.8,149.4,382.1,138.9z"/><path d="M456.5,176.6c-8.1,4.1-17.1,6.1-26.2,5.6c-1.5-0.1-3-0.3-4.5-0.5l11.9,76l32.4,5.2L456.5,176.6z M444.5,301.8l28.1,179.6c7.9-3.9,16.7-5.7,25.4-5.2c1.8,0.1,3.6,0.3,5.4,0.6L476.8,307L444.5,301.8z"/><path d="M262.4,235.2c0.6,3.5,0.7,7,0.6,10.6c-0.4,7-2.2,13.9-5.4,20.2l76,12.2l14.9-29.2L262.4,235.2z M392.7,256.1l-14.9,29.2l179.6,28.9c-0.5-3.4-0.7-6.9-0.5-10.3c0.4-7.1,2.3-14.1,5.5-20.5L392.7,256.1z"/><circle cx="433" cy="130.6" r="47"/><circle cx="608.4" cy="306.6" r="47"/><circle cx="495.1" cy="527.8" r="47"/><circle cx="249.7" cy="488.4" r="47"/><circle cx="211.3" cy="242.9" r="47"/></g></svg>
-        <span><%= t('fedi.profile_follow') %></span>
-      </button>
-    <% } %>
-    <% if (_sinceStr) { %><p class="pf-summary-since"><%= t('profile.since') %> <%= _sinceStr %></p><% } %>
-  </div>
-</div>
-<% if (_showFollow) { %>
-<%# Follow-via-fediverse modal — replaces the native prompt() (which looked ugly). %>
-<div class="pf-follow">
-  <div class="pf-follow-card" role="dialog" aria-label="<%= t('fedi.follow_heading') %>">
-    <button type="button" class="pf-follow-cancel pf-follow-x" aria-label="Sluiten">&times;</button>
-    <h3 class="pf-follow-title"><%= t('fedi.follow_heading') %></h3>
-    <p class="pf-follow-sub"><%= t('fedi.remote_prompt') %></p>
-    <input type="text" class="pf-follow-input" inputmode="url" autocomplete="off" autocapitalize="none" spellcheck="false"
-           placeholder="<%= t('fedi.remote_ph') %>">
-    <div class="pf-follow-actions">
-      <button type="button" class="pf-follow-cancel"><%= t('fedi.cancel') %></button>
-      <button type="button" class="pf-follow-go"><%= t('fedi.follow_btn') %></button>
-    </div>
-  </div>
-</div>
-<% } %>
-<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
-    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
-    (shaer-bqr). %>
 <% } %>
 
@@ -187,5 +116,4 @@
   width: 80px;
   height: 80px;
-  padding: 0;
   border-radius: 50%;
   overflow: hidden;
@@ -194,9 +122,5 @@
   transition: width 250ms, height 250ms;
   cursor: pointer;
-  font: inherit;
-  -webkit-appearance: none;
-  appearance: none;
-}
-.profile-photo[data-lightbox] { cursor: zoom-in; }
+}
 .profile-photo:hover {
   border-color: var(--accent);
@@ -326,65 +250,3 @@
 .on-special .profile-links,
 .on-archive .profile-links { display: none; }
-
-/* Follow-via-fediverse: compact icon button, sits in the links row */
-.profile-fedi-link { border: 0; padding: 0; background: var(--paper-2); cursor: pointer; -webkit-appearance: none; appearance: none; }
-/* The fediverse logo is an open line-network, so it reads smaller than a solid
-   glyph at 18px — render it larger INSIDE the same 36px circle (circle unchanged). */
-.profile-fedi-link svg { width: 22px; height: 22px; }
-
-/* Profile-summary modal (click the avatar) */
-.profile-photo[data-pf-summary] { cursor: zoom-in; }
-.pf-summary { position: fixed; inset: 0; z-index: 2147483600; display: none;
-  align-items: center; justify-content: center; padding: 4vmin;
-  background: rgba(0,0,0,.7); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
-.pf-summary.is-open { display: flex; }
-.pf-summary-card { position: relative; width: 100%; max-width: 380px; max-height: 90vh; overflow-y: auto;
-  background: var(--paper); color: var(--ink); border: 1px solid var(--rule); border-radius: 20px;
-  padding: 1.75rem 1.5rem; box-shadow: 0 20px 60px rgba(0,0,0,.4); text-align: center; }
-.pf-summary-close { position: absolute; top: .6rem; right: .7rem; width: 36px; height: 36px; border: 0;
-  border-radius: 50%; background: var(--paper-2); color: var(--ink-soft); font-size: 1.5rem; line-height: 1;
-  cursor: pointer; display: flex; align-items: center; justify-content: center; }
-.pf-summary-close:hover { color: var(--ink); }
-.pf-summary-photo { width: 110px; height: 110px; border-radius: 50%; object-fit: cover; margin: 0 auto .9rem;
-  display: block; border: 3px solid var(--paper-2); background: #fff; }
-.pf-summary-photo--ph { display: flex; align-items: center; justify-content: center; background: var(--paper-2); }
-.pf-summary-photo--ph svg { width: 46px; height: 46px; color: var(--accent); }
-.pf-summary-photo--ph span { font-family: var(--font-display, serif); font-size: 2.6rem; font-weight: 700; color: var(--ink-soft); }
-.pf-summary-name { font-family: var(--font-display, serif); font-size: 1.4rem; margin: 0 0 .35rem; }
-.pf-summary-prem { display: inline-block; margin: 0 0 .7rem; padding: .15rem .6rem; border-radius: 999px;
-  font-size: .75rem; font-weight: 600; background: var(--paper-2); color: var(--ink-soft); }
-.pf-summary-prem.is-prem { background: color-mix(in srgb, #e8b04b 22%, transparent); color: #b8860b; }
-.pf-summary-bio { color: var(--ink-soft); font-size: .92rem; line-height: 1.5; margin: 0 0 1rem; }
-.pf-summary-links { list-style: none; padding: 0; margin: .25rem 0 1rem; display: flex; flex-direction: column; gap: .4rem; text-align: left; }
-.pf-summary-links a { display: flex; align-items: center; gap: .7rem; padding: .5rem .7rem; border-radius: 12px;
-  text-decoration: none; color: var(--ink); background: var(--paper-2); transition: background .12s; }
-.pf-summary-links a:hover { background: color-mix(in srgb, var(--brand, var(--accent)) 16%, var(--paper-2)); }
-.pf-summary-ico { flex: 0 0 22px; width: 22px; height: 22px; color: var(--brand, var(--ink-soft)); display: inline-flex; }
-.pf-summary-ico svg { width: 22px; height: 22px; }
-.pf-summary-rowtext { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
-.pf-summary-lbl { font-size: .72rem; font-weight: 600; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .03em; }
-.pf-summary-val { font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.pf-summary-since { color: var(--ink-soft); font-size: .8rem; margin: 0; }
-.pf-summary-follow { display: inline-flex; align-items: center; gap: .5rem; margin: .2rem auto 1rem; padding: .5rem 1.15rem;
-  border: 1.5px solid var(--accent); border-radius: 999px; background: transparent; color: var(--accent);
-  font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; transition: background .15s, color .15s; }
-.pf-summary-follow:hover { background: var(--accent); color: var(--paper); }
-.pf-summary-follow-ico { width: 18px; height: 18px; flex: 0 0 auto; }
-.pf-follow { position: fixed; inset: 0; z-index: 2147483601; display: none; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,.55); }
-.pf-follow.is-open { display: flex; }
-.pf-follow-card { position: relative; width: 100%; max-width: 380px; text-align: left;
-  background: var(--paper); color: var(--ink); border: 1px solid var(--rule); border-radius: 16px;
-  padding: 1.5rem 1.4rem 1.3rem; box-shadow: 0 18px 50px rgba(0,0,0,.4); }
-.pf-follow-x { position: absolute; top: .55rem; right: .65rem; width: 2rem; height: 2rem; border: 0; border-radius: 999px;
-  background: transparent; color: var(--ink-soft); font-size: 1.4rem; line-height: 1; cursor: pointer; }
-.pf-follow-title { margin: 0 0 .35rem; font-size: 1.15rem; }
-.pf-follow-sub { margin: 0 0 .8rem; color: var(--ink-soft); font-size: .86rem; line-height: 1.45; }
-.pf-follow-input { width: 100%; box-sizing: border-box; padding: .6rem .8rem; border: 1.5px solid var(--rule);
-  border-radius: 10px; background: var(--paper-2); color: var(--ink); font: inherit; font-size: .95rem; }
-.pf-follow-input:focus { outline: none; border-color: var(--accent); }
-.pf-follow-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
-.pf-follow-actions button { padding: .5rem 1.1rem; border-radius: 999px; font: inherit; font-weight: 600; font-size: .88rem; cursor: pointer; border: 0; }
-.pf-follow-actions .pf-follow-cancel { background: transparent; border: 1.5px solid var(--rule); color: var(--ink); }
-.pf-follow-actions .pf-follow-go { background: var(--accent); color: var(--paper); }
-.pf-follow-actions .pf-follow-go:hover { filter: brightness(1.06); }
 </style>
Index: src/views/partials/profile-sheet.ejs
===================================================================
--- src/views/partials/profile-sheet.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/profile-sheet.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -14,6 +14,5 @@
 
 const _isAdmin = (user.role === 'god' || user.role === 'admin');
-const _canPost = !!((typeof canMutate === 'undefined' || canMutate)
-                    && typeof permissions !== 'undefined'
+const _canPost = !!(typeof permissions !== 'undefined'
                     && permissions
                     && permissions.canCreatePost
@@ -22,6 +21,4 @@
                  : (user.role === 'editor') ? 'redacteur'
                  : '';
-// Site-scoped link (post-aanmaken) heeft in hub de /user/<slug>-prefix nodig.
-const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 
@@ -37,5 +34,5 @@
     <div class="profile-sheet-header">
       <% if (user.avatar_url) { %>
-        <img class="profile-sheet-avatar profile-sheet-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="" aria-hidden="true">
+        <img class="profile-sheet-avatar profile-sheet-avatar-img" src="<%= user.avatar_url %>" alt="" aria-hidden="true">
       <% } else { %>
         <div class="profile-sheet-avatar" aria-hidden="true"><%= user.username.charAt(0).toUpperCase() %></div>
@@ -52,32 +49,13 @@
         <a href="/account" class="profile-sheet-item" role="menuitem" data-close-sheet>
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
-          <span class="psi-label"><%= t('nav.account') %></span>
+          <span class="psi-label">Account</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
       </li>
-      <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-      <li role="none">
-        <a href="/notifications" class="profile-sheet-item" role="menuitem" data-close-sheet>
-          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
-          <span class="psi-label"><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
-          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
-        </a>
-      </li>
-      <% } %>
-      <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-      <li role="none">
-        <a href="/news" class="profile-sheet-item" role="menuitem" data-close-sheet
-           hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/news" hx-indicator="#pcms-loading">
-          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-          <span class="psi-label"><%= t('nav.fediverse') %></span>
-          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
-        </a>
-      </li>
-      <% } %>
-      <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
+      <% if (_isAdmin) { %>
       <li role="none">
         <a href="/admin" class="profile-sheet-item" role="menuitem" data-close-sheet>
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z"/></svg>
-          <span class="psi-label"><%= t('nav.admin') %></span>
+          <span class="psi-label">Beheer</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
@@ -86,9 +64,9 @@
       <% if (_canPost) { %>
       <li role="none">
-        <a href="<%= _base %>/posts/new" class="profile-sheet-item" role="menuitem" data-close-sheet
-           hx-get="<%= _base %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="<%= _base %>/posts/new" hx-indicator="#pcms-loading">
+        <a href="/posts/new" class="profile-sheet-item" role="menuitem" data-close-sheet
+           hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+           hx-push-url="/posts/new" hx-indicator="#pcms-loading">
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
-          <span class="psi-label"><%= t('nav.new_post') %></span>
+          <span class="psi-label">Nieuwe post</span>
           <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
         </a>
@@ -104,6 +82,6 @@
         <button type="button" class="profile-sheet-item" id="profile-sheet-theme" role="menuitemcheckbox">
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
-          <span class="psi-label"><%= t('nav.theme_label') %></span>
-          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
+          <span class="psi-label">Thema</span>
+          <span class="psi-meta" id="profile-sheet-theme-state">Donker</span>
         </button>
       </li>
@@ -117,5 +95,5 @@
         <a href="/auth/logout" class="profile-sheet-item is-destructive" role="menuitem">
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
-          <span class="psi-label"><%= t('nav.logout') %></span>
+          <span class="psi-label">Uitloggen</span>
         </a>
       </li>
@@ -326,3 +304,98 @@
 </style>
 
-<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
+<script>
+(function() {
+  const sheet = document.getElementById('profile-sheet');
+  if (!sheet) return;
+
+  const backdrop  = document.getElementById('profile-sheet-backdrop');
+  const panel     = sheet.querySelector('.profile-sheet-panel');
+  const closeBtn  = document.getElementById('profile-sheet-close');
+  const dragZone  = document.getElementById('profile-sheet-drag-zone');
+  const themeBtn  = document.getElementById('profile-sheet-theme');
+  const themeLbl  = document.getElementById('profile-sheet-theme-state');
+
+  function openSheet() {
+    sheet.classList.add('is-open');
+    sheet.setAttribute('aria-hidden', 'false');
+    document.body.classList.add('profile-sheet-locked');
+    syncTheme();
+  }
+  function closeSheet() {
+    sheet.classList.remove('is-open');
+    sheet.setAttribute('aria-hidden', 'true');
+    document.body.classList.remove('profile-sheet-locked');
+    panel.style.removeProperty('--pcms-drag-y');
+  }
+
+  // Open: any element with [data-profile-sheet-toggle]
+  document.addEventListener('click', function(e) {
+    const trigger = e.target.closest('[data-profile-sheet-toggle]');
+    if (trigger) {
+      e.preventDefault();
+      openSheet();
+    }
+  });
+
+  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
+  if (backdrop) backdrop.addEventListener('click', closeSheet);
+  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
+  });
+
+  // Menu items that navigate: close synchronously before nav fires
+  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
+    el.addEventListener('click', closeSheet);
+  });
+
+  // Drag-down-to-close on touch.
+  // Uses --pcms-drag-y custom property rather than overwriting the panel's
+  // transform string. This composes correctly with any horizontal centering
+  // (currently none here, but the pattern matches audio-sheet for safety).
+  let startY = 0, lastY = 0, dragging = false;
+  function onDown(e) {
+    if (e.pointerType !== 'touch') return;
+    if (panel.scrollTop > 0) return;
+    startY = lastY = e.clientY;
+    dragging = true;
+    panel.classList.add('is-dragging');
+  }
+  function onMove(e) {
+    if (!dragging) return;
+    lastY = e.clientY;
+    const dy = Math.max(0, lastY - startY);
+    panel.style.setProperty('--pcms-drag-y', dy + 'px');
+  }
+  function onUp() {
+    if (!dragging) return;
+    dragging = false;
+    panel.classList.remove('is-dragging');
+    const dy = lastY - startY;
+    if (dy > 80) closeSheet();
+    else panel.style.removeProperty('--pcms-drag-y');
+  }
+  if (window.PointerEvent && dragZone) {
+    dragZone.addEventListener('pointerdown', onDown);
+    document.addEventListener('pointermove', onMove);
+    document.addEventListener('pointerup', onUp);
+    document.addEventListener('pointercancel', onUp);
+  }
+
+  // Theme toggle inside sheet — syncs label
+  function syncTheme() {
+    if (!themeLbl) return;
+    const t = document.documentElement.getAttribute('data-theme') || 'dark';
+    themeLbl.textContent = t === 'dark' ? 'Donker' : 'Licht';
+  }
+  if (themeBtn) {
+    themeBtn.addEventListener('click', function() {
+      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+      const next = cur === 'dark' ? 'light' : 'dark';
+      document.documentElement.setAttribute('data-theme', next);
+      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+      syncTheme();
+    });
+  }
+})();
+</script>
Index: src/views/partials/quote-card.ejs
===================================================================
--- src/views/partials/quote-card.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-<%
-  // FEP-044f embedded quote card: the quoted post itself (author + content +
-  // first image), nested under a note — the web mirror of the Shaer QuoteCard.
-  // `q` is the resolved snapshot from noteQuote(p.quote_json).
-  var _qa = q.author || {};
-  var _qm = Array.isArray(q.media) ? q.media.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); }) : [];
-%>
-<div class="tl-quote">
-  <div class="tl-quote-head">
-    <% if (_qa.icon) { %><img class="tl-quote-avatar" src="<%= avatar(_qa.icon, 96) %>" alt="" loading="lazy"><% } %>
-    <span class="tl-quote-name"><%- emojiName(_qa.name || _qa.handle || '', _qa.emojis) %></span>
-    <% if (_qa.handle) { %><span class="tl-quote-handle"><%= _qa.handle %></span><% } %>
-  </div>
-  <% if (q.content) { %><div class="tl-quote-body"><%- emojiHtml(q.content, q.emojis) %></div><% } %>
-  <% if (_qm.length) { %><a class="tl-quote-media" href="<%= _qm[0].url %>" target="_blank" rel="noopener"><img src="<%= thumb(_qm[0].url, 640) %>" alt="" loading="lazy"></a><% } %>
-</div>
Index: src/views/partials/read-article.ejs
===================================================================
--- src/views/partials/read-article.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,95 +1,0 @@
-<%
-// Eén bericht in de leesstroom.
-//
-// Dit is het stuk dat de client AANVULT: bij de onderrand komt de oudere buur
-// eronder, bij de bovenrand de nieuwere erboven. Geen sprongen, geen
-// scrollpositie die stiekem verzet wordt -- de stroom groeit gewoon in de
-// richting waarin je leest, en de browser snapt naar de grenzen.
-//
-// Elk artikel draagt zijn eigen buren, dus de client hoeft niets te onthouden:
-// wat er nog te halen valt staat op het stuk dat er al is.
-var _b = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-var _newer = (typeof newerPost !== 'undefined' && newerPost) ? newerPost : null;
-var _older = (typeof olderPost !== 'undefined' && olderPost) ? olderPost : null;
-%>
-<article class="read-post"
-         data-slug="<%= post.slug %>"
-         data-newer="<%= _newer ? _newer.slug : '' %>"
-         data-older="<%= _older ? _older.slug : '' %>"
-         data-base="<%= _b %>"
-         data-title="<%= post.title || '' %>"
-         aria-label="<%= post.title || '' %>">
-  <%# Het SNAPPUNT, en met opzet een leeg element van nul hoog in plaats van het
-      artikel zelf. Een snapgebied dat GROTER is dan het scherm mag van de spec
-      overal blijven staan waar het het scherm nog vult -- dus bij een bericht
-      van 1009px op een scherm van 812 accepteert de browser elke positie tussen
-      de bovenkant en "onderkant op de onderrand". Die tweede stand zet de
-      bovenkant van het VOLGENDE bericht op de onderrand van het scherm, en dat
-      is wat Robin zag (20-8). Een anker van nul hoog heeft maar een geldige
-      positie: zijn bovenkant op de bovenrand. %>
-  <span class="read-anker" aria-hidden="true"></span>
-  <%# De titel is een echte link naar het bericht zelf. De leesstroom toont de
-      TEKST, maar reacties, waarderingen en boosts staan op de berichtpagina --
-      en een stroom waar je niet uit kunt naar het gesprek is een doodlopende weg.
-      Een <a> en niet alleen een tik-afhandelaar: dat werkt ook met een
-      toetsenbord, met een schermlezer, en met cmd-klik. %>
-  <%# De cover hoort hier net zo goed als op de berichtpagina: het is het beeld
-      van het stuk, en zonder hem begint elk bericht in de stroom met kale tekst.
-      Boven de titel, in dezelfde volgorde als post.ejs.
-
-      Twee dingen komen hier van post-card en NIET van post.ejs:
-
-      - De SLUIER. Op de berichtpagina hangt de waarschuwing als banner boven de
-        hele pagina, want daar staat een bericht. In een stroom kan dat niet: de
-        buren rollen gewoon voorbij, dus het beeld moet zijn eigen sluier dragen
-        of het is onderweg al te zien. De afhandelaar zit gedelegeerd op
-        document (shell.ejs), dus hij werkt ook op wat de client later aanschuift.
-      - thumb(). De bron kan het volle bestand zijn en hier staan er meerdere
-        onder elkaar in een stroom die dooraanvult.
-
-      cover_image_url en cover_video_url komen mee: beide voedingen van
-      readerItems() halen `SELECT p.*` op (routes/posts.js, de pinned- en de
-      gewone feed-query). Nagekeken, want een versmalde lijst zou hier niets
-      opleveren zonder ergens te klagen. %>
-  <% if (post.cover_image_url || post.cover_video_url) { %>
-    <figure class="read-cover<%= post.nsfw ? ' nsfw-media' : '' %>">
-      <% if (post.cover_image_url) { %>
-        <img src="<%= thumb(post.cover_image_url, 1280) %>"
-             srcset="<%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
-             sizes="(min-width: 768px) 46rem, 100vw"
-             alt="<%= post.cover_alt || '' %>" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
-      <% } else { %>
-        <video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 1280) %>"
-               controls loop muted playsinline preload="metadata"></video>
-      <% } %>
-      <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
-    </figure>
-  <% } %>
-
-  <header class="read-head">
-    <%# Vastgepind hoort zichtbaar te zijn in de titel: in de leesstroom staat een
-        gepind bericht bovenaan zonder dat je ziet WAAROM het daar staat. Zelfde
-        icoon als in de tijdlijn (post-card.ejs), zodat het hetzelfde ding is. %>
-    <h1 class="read-title">
-      <% if (post.pinned > 0) { %><span class="read-pin" title="<%= t('read.pinned') %>" aria-label="<%= t('read.pinned') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg></span><% } %><a href="<%= _b %>/<%= post.slug %>"><%= post.title || '' %></a>
-    </h1>
-    <% if (post.published_at) { %><p class="read-when"><%= formatDateTime(post.published_at) %></p><% } %>
-  </header>
-
-  <%- include('post-body', {
-        post: post, access: entry.access, teaser: entry.teaser,
-        content_html: entry.content_html, _base: _b,
-      }) %>
-
-  <%# Zegt waar je heen gaat, in plaats van te vertrouwen op een tik die niemand
-      ziet. De tik-afhandelaar in mod/read.js is het gemak; dit is de aankondiging.
-
-      #fediverse is de sectie op de berichtpagina met de ⭐/🔁/💬-tellers en de
-      reacties (post.ejs). Alleen DEZE link springt daarheen: de titel en de tik
-      openen het bericht bovenaan, want die zeggen "lees dit", niet "laat het
-      gesprek zien". Staat de sectie er niet -- AP uit, of een concept -- dan
-      negeert de browser de anker en beland je gewoon bovenaan. %>
-  <footer class="read-foot">
-    <a class="read-open" href="<%= _b %>/<%= post.slug %>#fediverse"><%= t('read.open') %> &rarr;</a>
-  </footer>
-</article>
Index: src/views/partials/reply-editor.ejs
===================================================================
--- src/views/partials/reply-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,85 +1,0 @@
-<%#
-  Rich reply editor (shared component) — progressive enhancement.
-
-  Renders a plain <textarea name="text"> form that works without JS; the shared
-  mod/reply-editor.js upgrades it to a rich contenteditable with a small
-  toolbar, a language select and (on ≤700px) a full-screen compose overlay,
-  which is the right pattern on mobile. On submit the JS fills the hidden
-  `content` field with the editor HTML and `text` with a plain-text fallback;
-  the server prefers `content` and sanitizes it (fedi-reply / authorize_interaction).
-
-  Locals:
-    action        POST target (required)
-    hiddenFields  [{ name, value }] extra hidden inputs (optional)
-    placeholder   textarea/editor placeholder (required)
-    submitLabel   submit button label (required)
-    rows          textarea rows for the no-JS fallback (default 3)
-    defaultLang   preselected reply language (default: UI lang)
-    initialHtml   prefill for EDIT mode (our own sanitized HTML) (optional)
-    initialText   plain-text prefill for the no-JS textarea (optional)
-    noAttach      true hides the media button (edits do not touch attachments)
-    participants  [{uri, url, handle}] conversation partners → the mentions bar
-                  (u02): implicit mentions live here as removable chips, not in
-                  the text. Absent = no bar, legacy addressing.
-%>
-<% var _reInitHtml = (typeof initialHtml !== 'undefined' && initialHtml) || ''; %>
-<% var _reInitText = (typeof initialText !== 'undefined' && initialText) || ''; %>
-<% var _reNoAttach = (typeof noAttach !== 'undefined' && noAttach); %>
-<% var _reParts = (typeof participants !== 'undefined' && participants && participants.length) ? participants : null; %>
-<% var _reLang = (typeof defaultLang !== 'undefined' && defaultLang) || (typeof lang !== 'undefined' ? lang : 'en'); %>
-<form method="post" action="<%= action %>" class="re-form" data-re
-      data-upload="/posts/upload-reply-media" data-upload-err="<%= t('re.attach_err') %>">
-  <% (typeof hiddenFields !== 'undefined' ? hiddenFields : []).forEach(function (h) { %>
-    <input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
-  <% }); %>
-  <input type="hidden" name="content" value="">
-  <%# Mentions bar (u02): the conversation partners as removable chips. The
-      hidden field always carries the CURRENT kept list (server-rendered full,
-      so the no-JS path addresses everyone). %>
-  <% if (_reParts) { %>
-    <input type="hidden" name="mentions" value="<%= JSON.stringify(_reParts) %>">
-    <div class="re-mentions">
-      <span class="re-mentions-label"><%= t('re.to') %></span>
-      <% _reParts.forEach(function (p) { %>
-        <span class="re-mention" data-uri="<%= p.uri %>"><%= p.handle && p.handle[0] === '@' ? p.handle : '@' + p.handle %><button type="button" class="re-mention-del" title="<%= t('re.mention_del') %>" aria-label="<%= t('re.mention_del') %>">×</button></span>
-      <% }); %>
-    </div>
-  <% } %>
-  <%# Full-screen top bar (mobile compose). Hidden until JS + fullscreen. %>
-  <div class="re-head" hidden>
-    <button type="button" class="re-cancel" aria-label="<%= t('comments.cancel') %>">×</button>
-    <strong class="re-head-title"><%= t('re.title') %></strong>
-    <button type="submit" class="btn re-send-top"><%= submitLabel %></button>
-  </div>
-  <div class="re-toolbar" role="toolbar" aria-label="<%= t('re.title') %>" hidden>
-    <button type="button" data-cmd="bold" title="<%= t('re.bold') %>" aria-label="<%= t('re.bold') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/></svg></button>
-    <button type="button" data-cmd="italic" title="<%= t('re.italic') %>" aria-label="<%= t('re.italic') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg></button>
-    <button type="button" data-cmd="link" title="<%= t('re.link') %>" aria-label="<%= t('re.link') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
-    <button type="button" data-cmd="list" title="<%= t('re.list') %>" aria-label="<%= t('re.list') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg></button>
-    <button type="button" data-cmd="quote" title="<%= t('re.quote') %>" aria-label="<%= t('re.quote') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5C6 17.5 4 19 3 19v2z"/><path d="M14 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h2.5c-.5 4.5-2.5 6-3.5 6v2z"/></svg></button>
-    <% if (!_reNoAttach) { %>
-      <span class="re-toolbar-sep" aria-hidden="true"></span>
-      <button type="button" data-cmd="attach" title="<%= t('re.attach') %>" aria-label="<%= t('re.attach') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
-      <input type="file" class="re-file" accept="image/*,audio/*,video/*" multiple hidden>
-    <% } %>
-  </div>
-  <% if (!_reNoAttach) { %>
-    <input type="hidden" name="attachments" value="">
-    <div class="re-attachments" hidden></div>
-  <% } %>
-  <textarea name="text" rows="<%= typeof rows !== 'undefined' ? rows : 3 %>" required placeholder="<%= placeholder %>"><%= _reInitText %></textarea>
-  <div class="re-editor" contenteditable="true" role="textbox" aria-multiline="true"
-       data-ph="<%= placeholder %>" aria-label="<%= placeholder %>" hidden><%- _reInitHtml %></div>
-  <div class="re-foot">
-    <select name="language" class="re-lang" aria-label="<%= t('re.lang') %>" hidden>
-      <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
-        <option value="<%= l[0] %>" <%= l[0] === _reLang ? 'selected' : '' %>><%= l[1] %></option>
-      <% }); %>
-    </select>
-    <button type="submit" class="btn re-send"><%= submitLabel %></button>
-  </div>
-</form>
-<%# Assets once per render, even when this partial repeats per comment. %>
-<% if (!locals.__reAssets) { locals.__reAssets = 1; %>
-<link rel="stylesheet" href="/assets/css/reply-editor.css">
-<% } %>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/shared-styles.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -6,15 +6,12 @@
   border-bottom: 1px solid var(--rule);
   background: var(--paper);
-  /* No extra margin-top: this centres the pill between the profile-header
-     rule (above) and the own border (below). Equal space above/below the
-     pill comes from the symmetric container padding below. The margin-bottom
-     (1.5rem in style.css) keeps breathing room toward the feed. */
-  margin-top: 0;
+  /* Breathing room: profile-header has padding-bottom 1.5rem already, so a
+     small additional margin-top gives the switcher its own visual layer. */
+  margin-top: 0.5rem;
 }
 .view-switcher-wrap .container {
   display: flex; align-items: center; justify-content: center; gap: 0.75rem;
-  /* Symmetric padding → equal space above and below the pill (≈8px),
-     centred between the two dividing lines. */
-  padding: 0.5rem 1rem;
+  /* Slimmer bar: was 0.4rem (≈12px total chrome above pill); now 0.25rem. */
+  padding: 0.25rem 1rem;
   max-width: 1200px; margin: 0 auto;
   flex-wrap: wrap;
@@ -36,36 +33,5 @@
 }
 .view-switch-btn:hover { color: var(--ink); }
-/* Active = white (light background, dark text). Via aria-selected (element
-   attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non-
-   feed pages syncAria sets aria-selected=false → both grey. */
-.view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
-/* Lezen in de cirkel: zichtbaar maar dood. Niet weggelaten, want dan springt de
-   pil van breedte tussen Solo en Cirkel, en juist DAT verbergt dat er een
-   tweede weergave bestaat. Grijs + not-allowed zegt "bestaat, kan hier niet". */
-.view-switch-btn[disabled] { opacity: .38; cursor: not-allowed; }
-.view-switch-btn[disabled]:hover { color: var(--ink-muted, var(--ink-soft)); background: transparent; }
-
-/* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
-   disturbed). Same look as the view-switcher pill. Visible in all modes. */
-.agenda-pill { display: inline-flex; align-items: center; }   /* no box — standalone icon+text link */
-.vs-agenda {
-  display: inline-flex; align-items: center; gap: 0.4rem;
-  padding: 0.35rem 0.5rem;
-  color: var(--ink-muted, var(--ink-soft));
-  text-decoration: none;
-  font-size: 0.9rem; font-family: inherit;
-  transition: color 120ms;
-}
-.vs-agenda:hover { color: var(--ink); }
-/* Active (on the agenda page) = bright/white. Element-class (in the OOB-rendered
-   chrome) rather than body-class CSS → stays reliable after htmx navigation. */
-.vs-agenda.is-active { color: var(--ink); font-weight: 600; }
-
-/* Mobile: Agenda on its own row at the top, Solo/Circle + Timeline/Grid together
-   on the row below (together ~330px → fits within a phone, instead of the old
-   unbalanced split Agenda+Solo / Timeline). */
-@media (max-width: 520px) {
-  .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; }
-}
+.view-switch-btn[aria-selected="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.06); }
 
 .grid-cols-picker {
@@ -96,21 +62,4 @@
    visible so the user keeps a consistent return-to-feed control. */
 .on-admin .view-switcher-wrap { display: none; }
-
-/* On a standalone post there is no feed to switch, so no button should look "active"
-   (coloured). The active state lives in style.css on body[data-feed-view]
-   / body[data-grid-cols] (background:var(--ink)); we override that MORE SPECIFICALLY
-   via body.on-post so the buttons become neutral/grey — but remain clickable
-   (the click-handler then navigates to the feed in that view). The
-   body-class switches client-side on HTMX-nav, so this works without a reload too. */
-body.on-post[data-feed-view="reader"] .view-switch-btn[data-view="reader"],
-body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"],
-body.on-post[data-grid-cols="2"] .grid-cols-btn[data-cols="2"],
-body.on-post[data-grid-cols="3"] .grid-cols-btn[data-cols="3"],
-body.on-post[data-grid-cols="4"] .grid-cols-btn[data-cols="4"] {
-  background: transparent;
-  color: var(--ink-muted, var(--ink-soft));
-  border-color: var(--rule);
-}
-
 
 /* Spacing between switcher-bar and the page content below.
@@ -168,7 +117,4 @@
 .auth-link { text-align: center; color: var(--ink-muted); font-size: 0.9rem; margin: 1rem 0 0; }
 .auth-link a { color: var(--accent); }
-.auth-intro { text-align: center; color: var(--ink-muted); font-size: 0.95rem; margin: 0 0 1rem; }
-.auth-divider { display: flex; align-items: center; gap: 0.75rem; margin: 1.25rem 0; color: var(--ink-muted); font-size: 0.85rem; }
-.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: var(--rule); }
 
 /* HTMX loading indicator */
@@ -188,69 +134,3 @@
 .htmx-request .pcms-loading { display: block; }
 @keyframes spin { to { transform: rotate(360deg); } }
-
-/* Load more (Solo, Cirkel, News, Messages) — page size 72, htmx append. */
-.load-more-wrap { display: flex; justify-content: center; margin: 1.5rem 0 .5rem; }
-.load-more-wrap:empty { margin: 0; }
-.load-more-btn { padding: .6rem 1.4rem; border-radius: 999px; cursor: pointer;
-  font: inherit; font-weight: 600; color: var(--ink, inherit);
-  background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
-.load-more-btn:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
-.load-more-btn.htmx-request { opacity: .6; pointer-events: none; }
-
-/* FEP-9098 custom emojis: small inline images (Shaer parity), rendered in note
-   content and display names across the timeline, messages and threads. Never
-   styled as media (no rounding/background), and never blown up to media width. */
-img.emoji { height: 1.3em; width: auto; margin: 0 .05em; vertical-align: -0.2em;
-  display: inline-block; border-radius: 0; background: none; box-shadow: none; }
-.tl-content img.emoji, .tl-quote-body img.emoji, .msg-content img.emoji, .comment-content img.emoji,
-.tl-author img.emoji, .tl-boost-by img.emoji, .tl-quote-name img.emoji, .msg-who img.emoji, .comment-author img.emoji {
-  max-width: none; height: 1.3em; border-radius: 0; background: none; box-shadow: none; vertical-align: -0.2em; }
-
-/* ── The body of a post ────────────────────────────────────────────────────
-   Shared with partials/note-body.ejs, so a post looks the same in de Krant, in
-   Berichten and in the Guardian PWA. These used to live in the Krant's own
-   <style> block, which is why a post outside de Krant came out unstyled.
-   Krant-only behaviour (the clamp + "read more", the poll, the action bar)
-   stays in pages/news.ejs. */
-.tl-content { line-height: 1.55; overflow-wrap: anywhere; }
-.tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
-.tl-content a { color: var(--accent, #06c); }
-.tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
-
-/* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). Also carries
-   an external link preview: same card, different origin. */
-.tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
-.tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
-.tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
-.tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
-.tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-.tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
-  overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
-.tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
-.tl-quote-body a { color: var(--accent, #06c); }
-.tl-quote-media { display: block; margin: .45rem 0 0; }
-.tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
-
-/* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
-.tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
-.tl-media-img { display: block; border-radius: 12px; overflow: hidden;
-  background-image: linear-gradient(135deg,
-    color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
-    color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
-.tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
-.tl-media-img img.is-loading { opacity: 0; }
-/* Reserve placeholder space while the (natural-aspect) feed image loads. */
-.tl-media-img:has(img.is-loading) { min-height: 220px; }
-.tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
-.tl-media-video { max-height: 480px; background: #000; }
-
-/* In Berichten a post sits inside a notification row, so it starts tighter and
-   its media stays modest: the row is a pointer to the post, not the post page. */
-.msg-note .tl-content { line-height: 1.5; }
-.msg-note .tl-quote, .msg-note .tl-media, .msg-note .tl-media-video, .msg-note .tl-media-audio { margin-top: .5rem; }
-.msg-note .tl-media-img:has(img.is-loading) { min-height: 120px; }
-.msg-note .tl-media-video { max-height: 320px; }
 </style>
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,48 +1,0 @@
-<li class="tl-item">
-          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%- emojiName(p.reblog_name, p.reblog_emoji_json) %></strong> <%= t('tl.boosted') %></div><% } %>
-          <div class="tl-head">
-            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 96) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
-            <span class="tl-id">
-              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%- emojiName(p.author_name, p.author_emoji_json) %></a>
-              <span class="tl-handle"><%= p.author_handle %></span>
-            </span>
-            <% if (p.published || p.created_at) { %><time class="tl-time"><%= formatDateTime(p.published || p.created_at) %></time><% } %>
-          </div>
-
-          <% /* Content, quote/preview card and media: shared with Berichten and the
-                Guardian PWA so a post looks the same wherever it turns up.
-                suppressAudio: a Klonkt audio post carries an embed player below
-                that already covers these tracks, so don't ALSO render the raw
-                Audio attachments as bare players. */ %>
-          <%- include('../partials/note-body', { nb: Object.assign({}, p, { suppressAudio: !!(p.embedHtml || p.embedUrl) }) }) %>
-
-          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
-          <% if (p.embedUrl) { %><a class="tl-embed-open" href="<%= p.embedUrl %>" target="_blank" rel="noopener">▶ <%= t('tl.open_player') %></a><% } %>
-
-          <% if (p.poll) { var _pl=p.poll; var _tot=_pl.options.reduce(function(s,o){return s+(o.count||0);},0); var _voteable=!_pl.closed&&!_pl.voted; %>
-          <div class="tl-poll">
-            <% if (_voteable) { %>
-              <form method="post" action="/news/vote" class="tl-poll-form">
-                <input type="hidden" name="note" value="<%= p.id %>">
-                <% _pl.options.forEach(function(o){ %><label class="tl-poll-choice"><input type="<%= _pl.multiple?'checkbox':'radio' %>" name="choice" value="<%= o.name %>"><span><%= o.name %></span></label><% }); %>
-                <button type="submit" class="btn btn-primary tl-poll-btn"><%= t('poll.vote') %></button>
-              </form>
-            <% } else { _pl.options.forEach(function(o){ var _pct=_tot?Math.round((o.count||0)*100/_tot):0; var _mine=_pl.voted&&(Array.isArray(_pl.voted)?_pl.voted.indexOf(o.name)>=0:_pl.voted===o.name); %><div class="tl-poll-res<%= _mine?' is-mine':'' %>"><span class="tl-poll-fill" style="width:<%= _pct %>%"></span><span class="tl-poll-name"><%= _mine?'✓ ':'' %><%= o.name %></span><span class="tl-poll-pct"><%= _pct %>%</span></div><% }); } %>
-            <div class="tl-poll-foot"><%= (_pl.voters!=null?_pl.voters:_tot) %> <%= t('poll.votes') %><% if(_pl.closed){ %> · <%= t('poll.closed') %><% } %></div>
-          </div>
-          <% } %>
-
-          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
-
-          <% var _mv = (typeof movedTo !== 'undefined' && movedTo) ? movedTo : null; %>
-          <%# Verhuisd: liken, boosten en reageren worden door de service toch
-              geweigerd. Grijs ze dan ook, want een knop die niets doet laat je
-              zoeken naar een storing die er niet is. Blokkeren blijft wel werken:
-              dat is een veiligheidsklep, net als rapporteren. %>
-          <div class="tl-actions<%= _mv ? ' is-moved' : '' %>">
-            <form method="post" action="/news/like" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-like<%= p.liked ? ' is-on' : '' %>" title="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>" aria-label="<%= p.liked ? t('fedi.unlike_short') : t('fedi.likes') %>"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.96l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.95z"/></svg></button></form>
-            <form method="post" action="/news/boost" class="tl-act-form tl-react-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-boost<%= p.boosted ? ' is-on' : '' %>" title="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>" aria-label="<%= p.boosted ? t('tl.unboost') : t('fedi.boosts') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></button></form>
-            <button type="button" <%= _mv ? "disabled" : "" %> class="tl-act tl-act-reply fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>" aria-label="<%= t('fedi.remote_reply') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg></button>
-            <form method="post" action="/blocking/add" class="tl-act-form" data-confirm="<%= t('tl.block') %>?"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>" aria-label="<%= t('tl.block') %>"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg></button></form>
-          </div>
-        </li>
Index: src/views/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/topnav.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -9,14 +9,12 @@
 // Desktop (≥768px):
 //   - Full v9 masthead: brand left, action row right
-//     (Archief / Search / Install / Theme / User-menu).
+//     (Archief / Search / Install / Theme / Prutter / User-menu).
 //   - bottom-tab is hidden via CSS, so all nav must live here.
 
 const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 const _isAdmin     = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0;
-// Fediverse pages (on-special) get the same top-nav "back to site" treatment as admin.
-const _isSpecial   = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-special') >= 0;
 const _hasSearch   = !site || site.show_search === undefined || site.show_search;
-const _canPost     = user && (typeof canMutate === 'undefined' || canMutate)
-                     && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
+const _hasPrutter  = user && site && site.enable_prutter;
+const _canPost     = user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
 %>
 
@@ -26,8 +24,12 @@
     <!-- Brand block (always visible) -->
     <div class="masthead-brand">
-      <% if (_isAdmin || _isSpecial) { %>
-        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
+      <% if (_isAdmin) { %>
+        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin">← Terug naar site</a>
+      <% } else if (site) { %>
+        <a class="site-title" href="<%= _siteUrlBase %>/"
+           hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+           hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>
       <% } else { %>
-        <a class="site-title" href="/">Klonkt</a>
+        <a class="site-title" href="/">PrutFolio</a>
       <% } %>
     </div>
@@ -47,5 +49,5 @@
     <nav class="top-nav top-nav-desktop" aria-label="Hoofdnavigatie">
 
-      <% if (!_isAdmin && !_isSpecial) { %>
+      <% if (!_isAdmin) { %>
         <a class="nav-link" href="<%= _siteUrlBase %>/archive"
            hx-get="<%= _siteUrlBase %>/archive?partial=1" hx-target="#pcms-main"
@@ -53,10 +55,10 @@
            hx-indicator="#pcms-loading">
           <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5" rx="1"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
-          <span class="nav-label"><%= t('nav.archive') %></span>
+          <span class="nav-label">Archief</span>
         </a>
       <% } %>
 
       <% if (_hasSearch) { %>
-        <button type="button" class="nav-btn" id="search-toggle" aria-label="<%= t('nav.search') %>" title="<%= t('nav.search') %>">
+        <button type="button" class="nav-btn" id="search-toggle" aria-label="Zoeken" title="Zoeken">
           <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
         </button>
@@ -71,27 +73,7 @@
       </button>
 
-      <% if (typeof langs !== 'undefined' && langs && langs.length > 1) {
-           var _curLang = (langs.find(function(l){ return l.active; }) || { code: 'nl' }).code;
-           var _langR = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
-      %>
-        <details class="lang-menu">
-          <summary class="nav-btn lang-toggle" aria-label="<%= t('nav.language') %>" title="<%= t('nav.language') %>">
-            <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
-            <span class="lang-code"><%= _curLang.toUpperCase() %></span>
-            <svg class="lang-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
-          </summary>
-          <div class="lang-dropdown" role="menu">
-            <% langs.forEach(function(l){ %>
-              <a class="lang-item<%= l.active ? ' is-active' : '' %>" role="menuitem" href="/lang/<%= l.code %>?r=<%= encodeURIComponent(_langR) %>"><span class="lang-item-code"><%= l.code.toUpperCase() %></span><%= l.name %></a>
-            <% }); %>
-          </div>
-        </details>
-      <% } %>
-
-      <% if (user && typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-        <a class="nav-btn nav-notif" href="/notifications" aria-label="<%= t('notif.title') %>" title="<%= t('notif.title') %>"
-           hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/notifications" hx-indicator="#pcms-loading">
-          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
-          <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %><span class="notif-badge"><%= notifUnread > 9 ? '9+' : notifUnread %></span><% } %>
+      <% if (_hasPrutter) { %>
+        <a class="nav-btn" href="<%= _siteUrlBase %>/prutter" aria-label="Prutter (DMs)" title="Prutter">
+          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
         </a>
       <% } %>
@@ -101,5 +83,5 @@
           <summary class="nav-btn user-toggle" aria-label="Gebruikersmenu">
             <% if (user.avatar_url) { %>
-              <img class="user-avatar user-avatar-img" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+              <img class="user-avatar user-avatar-img" src="<%= user.avatar_url %>" alt="">
             <% } else { %>
               <span class="user-avatar"><%= user.username.charAt(0).toUpperCase() %></span>
@@ -109,5 +91,5 @@
             <div class="user-dropdown-header">
               <% if (user.avatar_url) { %>
-                <img class="udh-avatar" src="<%= avatar(user.avatar_url, 128) %>" alt="">
+                <img class="udh-avatar" src="<%= user.avatar_url %>" alt="">
               <% } else { %>
                 <span class="udh-avatar udh-avatar-fallback"><%= user.username.charAt(0).toUpperCase() %></span>
@@ -116,5 +98,5 @@
                 <span class="udh-name"><%= user.username %></span>
                 <% if (user.role === 'god') { %>
-                  <span class="udh-role">God</span>
+                  <span class="udh-role">Admin</span>
                 <% } else if (user.role === 'admin' || user.role === 'moderator') { %>
                   <span class="udh-role"><%= user.role.charAt(0).toUpperCase() + user.role.slice(1) %></span>
@@ -123,32 +105,20 @@
             </div>
             <div class="user-dropdown-list">
-              <a href="/account" role="menuitem" class="udi"
-                 hx-get="/account?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                 hx-push-url="/account" hx-indicator="#pcms-loading">
+              <a href="/account" role="menuitem" class="udi">
                 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
-                <span><%= t('nav.account') %></span>
+                <span>Account</span>
               </a>
-              <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
-                <a href="/admin" role="menuitem" class="udi"
-                   hx-get="/admin?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="/admin" hx-indicator="#pcms-loading">
+              <% if (user.role === 'god' || user.role === 'admin') { %>
+                <a href="/admin" role="menuitem" class="udi">
                   <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3h7v7H3z"/><path d="M14 3h7v7h-7z"/><path d="M14 14h7v7h-7z"/><path d="M3 14h7v7H3z"/></svg>
-                  <span><%= t('nav.admin') %></span>
+                  <span>Beheer</span>
                 </a>
               <% } %>
               <% if (_canPost) { %>
-                <a href="<%= _siteUrlBase %>/posts/new" role="menuitem" class="udi"
-                   hx-get="<%= _siteUrlBase %>/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="<%= _siteUrlBase %>/posts/new" hx-indicator="#pcms-loading">
+                <a href="/posts/new" role="menuitem" class="udi"
+                   hx-get="/posts/new?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
+                   hx-push-url="/posts/new" hx-indicator="#pcms-loading">
                   <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
-                  <span><%= t('nav.new_post') %></span>
-                </a>
-              <% } %>
-              <% if (typeof canManageFedi !== 'undefined' && canManageFedi && apEnabled) { %>
-                <a href="/news" role="menuitem" class="udi"
-                   hx-get="/news?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-                   hx-push-url="/news" hx-indicator="#pcms-loading">
-                  <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
-                  <span><%= t('nav.fediverse') %></span>
+                  <span>Nieuwe post</span>
                 </a>
               <% } %>
@@ -158,5 +128,5 @@
               <a href="/auth/logout" role="menuitem" class="udi udi-danger">
                 <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
-                <span><%= t('nav.logout') %></span>
+                <span>Uitloggen</span>
               </a>
             </div>
@@ -164,8 +134,6 @@
         </details>
       <% } else { %>
-        <a class="nav-link" href="/auth/login"
-           hx-get="/auth/login?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
-           hx-push-url="/auth/login" hx-indicator="#pcms-loading">
-          <span class="nav-label"><%= t('nav.login') %></span>
+        <a class="nav-link" href="/auth/login">
+          <span class="nav-label">Inloggen</span>
         </a>
       <% } %>
@@ -176,23 +144,61 @@
 
 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
-<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
-    module is een statisch bestand en kan geen vertaling interpoleren. %>
-<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
-     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
+<div id="search-overlay" class="search-overlay" hidden>
   <div class="container">
     <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
-      <input type="search" name="q" placeholder="<%= t('search.placeholder') %>" autocomplete="off">
-      <button type="submit" class="nav-btn" aria-label="<%= t('search.button') %>">
+      <input type="search" name="q" placeholder="Zoeken in posts…" autocomplete="off">
+      <button type="submit" class="nav-btn" aria-label="Zoeken">
         <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
       </button>
-      <button type="button" class="nav-btn" id="search-close" aria-label="<%= t('imed.cancel') %>">
+      <button type="button" class="nav-btn" id="search-close" aria-label="Sluiten">
         <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
       </button>
     </form>
-    <div id="search-suggest" class="search-suggest" data-lenis-prevent></div>
   </div>
 </div>
 
-<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
+<script>
+(function() {
+  // Theme toggle (works for both mobile + desktop buttons)
+  function toggleTheme() {
+    const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+    const next = cur === 'dark' ? 'light' : 'dark';
+    document.documentElement.setAttribute('data-theme', next);
+    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+  }
+  // Theme toggle — event delegation so it works for ANY button with the
+  // matching ids, even ones rendered later in the page (footer is below
+  // topnav in source order, so getElementById would have returned null
+  // when this IIFE ran).
+  document.body.addEventListener('click', function(e) {
+    const btn = e.target.closest(
+      '#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer'
+    );
+    if (btn) toggleTheme();
+  });
+
+  // Search overlay
+  const overlay = document.getElementById('search-overlay');
+  document.getElementById('search-toggle')?.addEventListener('click', function() {
+    if (overlay) {
+      overlay.hidden = false;
+      const inp = overlay.querySelector('input');
+      if (inp) inp.focus();
+    }
+  });
+  document.getElementById('search-close')?.addEventListener('click', function() {
+    if (overlay) overlay.hidden = true;
+  });
+  document.addEventListener('keydown', function(e) {
+    if (e.key === 'Escape' && overlay && !overlay.hidden) overlay.hidden = true;
+  });
+
+  // Close user dropdown on outside click
+  document.addEventListener('click', function(e) {
+    const open = document.querySelector('.user-menu[open]');
+    if (open && !open.contains(e.target)) open.removeAttribute('open');
+  });
+})();
+</script>
 
 <style>
@@ -222,15 +228,4 @@
 }
 
-/* Hub-home brand IS visible — the home button on artist/hub pages. */
-.masthead-brand .nav-hub-home {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 40px; height: 40px;
-  color: var(--ink); text-decoration: none;
-  border-radius: 8px;
-  transition: color 120ms, background 120ms;
-}
-.masthead-brand .nav-hub-home:hover { color: var(--accent); background: var(--paper-2); }
-.masthead-brand .nav-hub-home svg { width: 22px; height: 22px; }
-
 /* On mobile, hide the entire masthead except for admin pages. The brand
    appears in the profile-header on content pages, and the bottom-tab
@@ -239,8 +234,4 @@
 @media (max-width: 767px) {
   body:not(.on-admin) .masthead { display: none; }
-  /* The masthead is gone on mobile, so it can't carry the iOS safe-area top inset
-     (env() resolves to 0 in the home-screen PWA; --ios-safe-top is set by JS in shell).
-     Pad the body instead so the profile-header clears the Dynamic Island / notch. */
-  body:not(.on-admin) { padding-top: var(--ios-safe-top, 0px); }
 }
 
@@ -349,8 +340,7 @@
   min-width: 220px;
   padding: .35rem;
-  /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */
-  background: color-mix(in srgb, var(--paper) 60%, transparent);
-  backdrop-filter: blur(34px) saturate(190%);
-  -webkit-backdrop-filter: blur(34px) saturate(190%);
+  background: color-mix(in srgb, var(--paper) 92%, transparent);
+  backdrop-filter: blur(14px) saturate(180%);
+  -webkit-backdrop-filter: blur(14px) saturate(180%);
   border: 1px solid var(--rule);
   border-radius: 12px;
@@ -466,24 +456,3 @@
   display: inline-flex; align-items: center; justify-content: center;
 }
-/* Live-resultaten dropdown */
-.search-suggest { margin-top: .6rem; max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; gap: .25rem; }
-.search-suggest:empty { display: none; }
-.ss-group { padding: .25rem 0; }
-.ss-title { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-soft, var(--ink-muted)); padding: .35rem .25rem .2rem; }
-.ss-item { display: block; padding: .55rem .6rem; border-radius: 8px; color: var(--ink); text-decoration: none; font-size: .95rem; }
-.ss-item:hover { background: var(--paper-2); color: var(--accent); }
-.ss-item.ss-noclick { opacity: .7; cursor: default; }
-.ss-sub { color: var(--ink-soft, var(--ink-muted)); font-size: .85em; }
-.ss-empty { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }
-.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
-.ss-all:hover { text-decoration: underline; }
-/* Notification bell + unread badge */
-.nav-notif { position: relative; }
-.notif-badge {
-  position: absolute; top: 1px; right: 1px;
-  min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
-  display: inline-flex; align-items: center; justify-content: center;
-  background: #e0245e; color: #fff; font-size: 10px; font-weight: 700;
-  border-radius: 999px; line-height: 1; pointer-events: none;
-}
 </style>
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/track-editor.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -195,17 +195,4 @@
 }
 
-/* ── Credit field with © insert button ──────────────────────────── */
-.te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; }
-.te-credit-row input { flex: 1; min-width: 0; }
-.te-sym-btn {
-  flex: 0 0 auto;
-  width: 44px; min-height: 44px;
-  border: 1px solid var(--rule); border-radius: 6px;
-  background: var(--paper-2); color: var(--ink);
-  font-size: 1.1rem; cursor: pointer;
-  transition: border-color 120ms, background 120ms;
-}
-.te-sym-btn:hover { border-color: var(--accent); }
-
 /* ── Cover field with thumb + button + URL ─────────────────── */
 .te-cover-row {
@@ -284,3 +271,352 @@
 </style>
 
-<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
+<script>
+(function() {
+
+  function esc(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  }
+
+  async function api(method, url, body) {
+    const opts = { method, credentials: 'same-origin', headers: {} };
+    if (body !== undefined) {
+      if (body instanceof FormData) {
+        opts.body = body;
+      } else {
+        opts.headers['Content-Type'] = 'application/json';
+        opts.body = JSON.stringify(body);
+      }
+    }
+    const res = await fetch(url, opts);
+    let data;
+    try { data = await res.json(); }
+    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
+    if (!res.ok) data.ok = false;
+    return data;
+  }
+
+  /**
+   * Open the track editor.
+   * @param {object} opts
+   * @param {string} opts.id        — track id to edit
+   * @param {function?} opts.onSaved — called with updated track on success
+   */
+  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
+    if (!id) return;
+    let track, albumSuggestions = [];
+    try {
+      const [trackJson, listJson] = await Promise.all([
+        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
+        api('GET', '/admin/audio/api/albums'),
+      ]);
+      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
+      track = trackJson.track;
+      if (listJson.ok && Array.isArray(listJson.albums)) {
+        albumSuggestions = listJson.albums.filter(Boolean);
+      }
+    } catch (err) {
+      alert('Track ophalen mislukt: ' + err.message);
+      return;
+    }
+
+    const backdrop = document.createElement('div');
+    backdrop.className = 'te-backdrop';
+    backdrop.innerHTML = `
+      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
+        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
+
+        <div class="te-header">
+          <h3>✎ Track bewerken</h3>
+          <button type="button" class="te-close" aria-label="Sluiten">×</button>
+        </div>
+
+        <div class="te-body">
+
+          ${track.stream_url ? `
+          <div class="te-preview">
+            <div class="te-preview-cover">
+              ${track.cover_url
+                ? `<img src="${esc(track.cover_url)}" alt="">`
+                : `🎵`}
+            </div>
+            <div class="te-preview-meta">
+              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
+              <div class="te-preview-sub">
+                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
+              </div>
+            </div>
+            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
+          </div>
+          ` : ''}
+
+          <div class="te-form">
+
+            <label class="te-field">
+              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
+              <input type="text" id="te-title" maxlength="200" required
+                     autocomplete="off" autocapitalize="words" spellcheck="false"
+                     value="${esc(track.title || '')}">
+            </label>
+
+            <div class="te-row te-row-2">
+              <label class="te-field">
+                <span>Artiest</span>
+                <input type="text" id="te-artist" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       value="${esc(track.artist || '')}">
+              </label>
+              <label class="te-field">
+                <span>Album</span>
+                <input type="text" id="te-album" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       list="te-album-list" value="${esc(track.album || '')}">
+                <datalist id="te-album-list">
+                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
+                </datalist>
+              </label>
+            </div>
+
+            <label class="te-field">
+              <span>Duur <small>(seconden — bv. 142 voor 2:22)</small></span>
+              <input type="number" id="te-duration" min="0" step="1"
+                     inputmode="numeric" pattern="[0-9]*"
+                     value="${track.duration || ''}" placeholder="0">
+            </label>
+
+            <div class="te-field">
+              <span>Cover</span>
+              <div class="te-cover-row">
+                <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
+                     aria-label="Klik om cover te kiezen">
+                  ${track.cover_url
+                    ? `<img src="${esc(track.cover_url)}" alt="">`
+                    : `<span class="te-cover-empty">🎨</span>`}
+                </div>
+                <input type="file" id="te-cover-file"
+                       accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                <div class="te-cover-actions">
+                  <div class="te-cover-btn-row">
+                    <button type="button" class="te-cover-btn" id="te-cover-pick">
+                      📷 Foto kiezen
+                    </button>
+                    ${track.cover_url ? `
+                    <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
+                      × Verwijder
+                    </button>` : ''}
+                  </div>
+                  <input type="url" id="te-cover-url"
+                         placeholder="of plak URL: https://…"
+                         autocomplete="off" autocapitalize="none" spellcheck="false"
+                         value="${esc(track.cover_url || '')}">
+                  <div class="te-cover-status" id="te-cover-status"></div>
+                </div>
+              </div>
+            </div>
+
+          </div>
+        </div>
+
+        <div class="te-footer">
+          <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
+          <div class="te-footer-spacer"></div>
+          <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
+        </div>
+      </div>
+    `;
+
+    document.body.appendChild(backdrop);
+    document.body.classList.add('te-modal-open');
+
+    const $ = sel => backdrop.querySelector(sel);
+
+    // ── Inline preview player (routed through global mini-player) ──
+    // We don't build our own <audio>; instead we tell the global
+    // window.pcmsAudioPlayer to load this single track. Visual state
+    // syncs against the global audio element so toggle works correctly
+    // even if the user pauses from the mini-bar.
+    const previewBtn = $('#te-preview-play');
+    if (previewBtn) {
+      const setPreviewState = (playing) => {
+        previewBtn.textContent = playing ? '⏸' : '▶';
+        previewBtn.classList.toggle('is-playing', playing);
+        previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
+      };
+      const isOurTrack = () => {
+        const audio = document.getElementById('audio-element');
+        return audio && track.stream_url && audio.src.endsWith(track.stream_url);
+      };
+      const resync = () => {
+        const audio = document.getElementById('audio-element');
+        const playing = audio && !audio.paused && !audio.ended && isOurTrack();
+        setPreviewState(!!playing);
+      };
+
+      previewBtn.addEventListener('click', () => {
+        const player = window.pcmsAudioPlayer;
+        if (!player || !track.stream_url) {
+          console.warn('preview: miniplayer or url missing');
+          return;
+        }
+        const audio = document.getElementById('audio-element');
+        if (audio && isOurTrack()) {
+          // Same track loaded — toggle
+          if (audio.paused) player.play(); else player.pause();
+        } else {
+          player.setQueue([{
+            url:    track.stream_url,
+            title:  track.title  || '(zonder titel)',
+            artist: track.artist || '',
+            album:  track.album  || '',
+            cover:  track.cover_url || '',
+          }], 0);
+        }
+      });
+
+      const audio = document.getElementById('audio-element');
+      if (audio) {
+        const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
+        evs.forEach(ev => audio.addEventListener(ev, resync));
+        // Detach listeners on close so we don't leak them
+        backdrop._previewCleanup = () => {
+          evs.forEach(ev => audio.removeEventListener(ev, resync));
+        };
+        resync();   // initial state
+      }
+    }
+
+    function close() {
+      // Detach our resync listeners (mini-player stays running)
+      if (backdrop._previewCleanup) backdrop._previewCleanup();
+      document.body.classList.remove('te-modal-open');
+      document.removeEventListener('keydown', onEsc);
+      backdrop.remove();
+    }
+    function onEsc(e) { if (e.key === 'Escape') close(); }
+    document.addEventListener('keydown', onEsc);
+
+    backdrop.addEventListener('click', e => {
+      // Click on the backdrop itself (not modal content) closes
+      if (e.target === backdrop) close();
+    });
+    $('.te-close').addEventListener('click', close);
+    $('#te-cancel').addEventListener('click', close);
+
+    // ── Cover picking + URL paste + drag-drop ────────────────
+    const thumb     = $('#te-cover-thumb');
+    const fileInput = $('#te-cover-file');
+    const urlInput  = $('#te-cover-url');
+    const status    = $('#te-cover-status');
+    const pickBtn   = $('#te-cover-pick');
+    const removeBtn = $('#te-cover-remove');
+
+    function setStatus(text, kind) {
+      status.textContent = text || '';
+      status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
+    }
+    function setThumb(url) {
+      if (url) {
+        thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
+      } else {
+        thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
+      }
+    }
+
+    pickBtn.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('keydown', e => {
+      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
+    });
+
+    async function uploadCoverFile(file) {
+      if (!file) return;
+      if (!/^image\//.test(file.type)) {
+        setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
+      }
+      setStatus('Uploaden…', null);
+      const fd = new FormData();
+      fd.append('cover', file);
+      try {
+        const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
+          method: 'POST', body: fd, credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
+        urlInput.value = j.cover_url || '';
+        setThumb(j.cover_url);
+        setStatus('✓ Geüpload', 'ok');
+      } catch (err) {
+        setStatus('Mislukt: ' + err.message, 'error');
+      }
+    }
+
+    fileInput.addEventListener('change', e => {
+      const f = e.target.files && e.target.files[0];
+      if (f) uploadCoverFile(f);
+      fileInput.value = '';
+    });
+
+    // Drag-drop on thumb (desktop nicety)
+    ['dragenter', 'dragover'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
+    ['dragleave', 'drop'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
+    thumb.addEventListener('drop', e => {
+      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+      if (f) uploadCoverFile(f);
+    });
+
+    // URL paste auto-preview
+    urlInput.addEventListener('input', () => {
+      const v = urlInput.value.trim();
+      setThumb(v);
+    });
+
+    if (removeBtn) {
+      removeBtn.addEventListener('click', () => {
+        urlInput.value = '';
+        setThumb('');
+        removeBtn.remove();
+      });
+    }
+
+    // ── Save ────────────────────────────────────────────────
+    $('#te-save').addEventListener('click', async () => {
+      const titleEl = $('#te-title');
+      const title = titleEl.value.trim();
+      if (!title) {
+        titleEl.focus();
+        alert('Titel is verplicht');
+        return;
+      }
+      const saveBtn = $('#te-save');
+      saveBtn.disabled = true;
+      saveBtn.textContent = '⏳ Opslaan…';
+
+      try {
+        const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
+          title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
+          cover_url: urlInput.value.trim() || null,
+        });
+        if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
+        if (typeof onSaved === 'function') onSaved(j.track || { id, title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          cover_url: urlInput.value.trim() || null });
+        close();
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = '💾 Opslaan';
+      }
+    });
+
+    // Focus title for fast typing
+    setTimeout(() => $('#te-title').focus(), 60);
+  };
+})();
+</script>
Index: src/views/partials/view-switcher.ejs
===================================================================
--- src/views/partials/view-switcher.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/partials/view-switcher.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -3,87 +3,18 @@
     handlers are wired in shell.ejs via event-delegation on document so
     HTMX-swapped content keeps working without rebinding. %>
-<%
-  var _bc = (typeof bodyClass === 'string') ? bodyClass : '';
-  var _onShows = _bc.indexOf('on-shows') >= 0;
-  // Pagina's zonder actieve feed-weergave → niets in de pill is 'actief' (Solo/Cirkel
-  // + Tijdlijn/Grid grijs). Alleen Agenda krijgt z'n eigen is-active op de agenda.
-  var _neutral = ['on-shows','on-downloads','on-post','on-epk','on-linkbio','on-newsletter','on-archive']
-                   .some(function(c){ return _bc.indexOf(c) >= 0; });
-  // Lezen bestaat alleen waar een leessectie is, en dat is de eigen feed. De
-  // cirkel toont berichten van ANDEREN, opgehaald van hun servers -- daar is
-  // "het hele stuk" niet iets wat wij in handen hebben.
-  //
-  // De knop verdween daar eerst helemaal, met het argument dat een knop die
-  // niets doet erger is dan geen knop. In de praktijk pakte dat andersom uit:
-  // de pil werd smaller op de cirkel en breder op solo, en die sprong leest als
-  // "er is hier iets weg" -- terwijl juist NIET duidelijk werd dat Lezen
-  // bestaat en hier alleen niet kan. Hij staat er nu dood bij: zichtbaar, grijs
-  // en niet aan te klikken. Dan is er maar een mogelijkheid, Grid, en je ziet
-  // meteen dat dat geen toeval is maar de enige.
-  var _leesUit = _bc.indexOf('on-cirkel') >= 0;
-  var _gridStandaard = (typeof site !== 'undefined' && site && site.feed_view_default === 'grid');
-  // Welke tweede weergave biedt deze site aan? Bij 'auto' tekenen we ZE ALLEBEI
-  // en laat de CSS er per viewport een zien -- Lezen op mobiel, Tijdlijn op
-  // desktop. Dat scheelt een resize-afhandeling en een knop die van naam
-  // verspringt terwijl je kijkt.
-  var _alt = (typeof site !== 'undefined' && site && site.feed_alt_view) || 'reader';
-  if (['reader', 'timeline', 'auto'].indexOf(_alt) < 0) _alt = 'reader';
-  var _tweede = _alt === 'auto' ? ['reader', 'timeline'] : [_alt];
-  // De tijdlijn WERKT wel in de cirkel -- dat is er zelfs de natuurlijke vorm.
-  // Alleen Lezen kan daar niet, want dat zijn berichten van anderen.
-  var _dood = function (v) { return v === 'reader' && _leesUit; };
-  // Staat er GEEN levende tweede knop, dan is Grid het enige dat overblijft en
-  // hoort die aan te staan. Bij een tijdlijn-site is dat in de cirkel niet zo:
-  // daar werkt de tijdlijn gewoon.
-  var _alleenGrid = _tweede.every(_dood);
-  var _icoon = {
-    reader: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
-    timeline: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
-  };
-%>
 <div class="view-switcher-wrap">
   <div class="container">
-    <% if (typeof premiumUnlocked !== 'undefined' && premiumUnlocked && typeof agendaEnabled !== 'undefined' && agendaEnabled) {
-         var _sbA = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-    %>
-    <div class="agenda-pill">
-      <a class="vs-agenda<%= _onShows ? ' is-active' : '' %>" href="<%= _sbA %>/shows" aria-label="Agenda — evenementen">
-        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
-        <span><%= t('switch.agenda') %></span>
-      </a>
-    </div>
-    <% } %>
-    <% if (typeof hasCirkel !== 'undefined' && hasCirkel) {
-         var _onCirkel = (typeof bodyClass === 'string' && bodyClass.indexOf('on-cirkel') >= 0);
-         var _sb = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-    %>
-    <div class="feed-scope" role="tablist" aria-label="Solo of cirkel">
-      <a class="feed-scope-btn<%= (!_neutral && !_onCirkel) ? ' is-active' : '' %>" href="<%= _sb %>/"
-         role="tab" aria-selected="<%= (!_neutral && !_onCirkel) ? 'true' : 'false' %>"><%= t('switch.solo') %></a>
-      <a class="feed-scope-btn<%= (!_neutral && _onCirkel) ? ' is-active' : '' %>" href="<%= _sb %>/cirkel"
-         role="tab" aria-selected="<%= (!_neutral && _onCirkel) ? 'true' : 'false' %>"><%= t('switch.circle') %></a>
-    </div>
-    <% } %>
     <div class="view-switcher" role="tablist" aria-label="Weergave">
-
-      <%# De tweede weergave naast Grid. Welke dat is, staat in de instellingen
-          van de site (feed_alt_view). Bij 'auto' staan ze er allebei en kiest
-          een mediaquery; vandaar de klasse vs-alleen-*. %>
-      <% _tweede.forEach(function (v) { %>
-      <button type="button" class="view-switch-btn<%= _alt === 'auto' ? ' vs-alleen-' + (v === 'reader' ? 'mobiel' : 'desktop') : '' %>"
-              data-view="<%= v %>" role="tab"
-              <%- _dood(v) ? 'disabled aria-disabled="true" tabindex="-1" title="' + t('switch.reader_solo_only') + '"' : '' %>
-              aria-selected="<%= (!_dood(v) && !_neutral && !_gridStandaard) ? 'true' : 'false' %>">
-        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><%- _icoon[v] %></svg>
-        <span><%= t('switch.' + v) %></span>
+      <button type="button" class="view-switch-btn" data-view="timeline" role="tab"
+              aria-selected="<%= (typeof site !== 'undefined' && site && site.feed_view_default === 'grid') ? 'false' : 'true' %>">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
+        <span>Tijdlijn</span>
       </button>
-      <% }); %>
       <button type="button" class="view-switch-btn" data-view="grid" role="tab"
-              aria-selected="<%= (!_neutral && (_alleenGrid || _gridStandaard)) ? 'true' : 'false' %>">
+              aria-selected="<%= (typeof site !== 'undefined' && site && site.feed_view_default === 'grid') ? 'true' : 'false' %>">
         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
-        <span><%= t('switch.grid') %></span>
+        <span>Grid</span>
       </button>
     </div>
-    <% if (!_neutral) { %>
     <div class="grid-cols-picker" aria-label="Aantal kolommen">
       <button type="button" class="grid-cols-btn" data-cols="2" title="2 kolommen">2</button>
@@ -91,5 +22,4 @@
       <button type="button" class="grid-cols-btn" data-cols="4" title="4 kolommen">4</button>
     </div>
-    <% } %>
   </div>
 </div>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/views/shell.ejs	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,18 +1,13 @@
 <%
 // ── Helpers used inside this template ─────────────────────────────
-// Escape for double-quoted HTML attributes. IMPORTANT: emit this with the RAW EJS output
-// tag, never the escaping one — escaping it a second time turned og:title "Jason's" into the
-// double-escaped "Jason&amp;#39;s", and naive OG scrapers (Signal/WhatsApp) show that literally.
-// We deliberately do NOT escape the apostrophe: it is safe inside a double-quoted attribute and
-// a literal apostrophe is what link-preview scrapers expect.
 function _e(s) {
   return String(s == null ? '' : s)
     .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;');
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
 }
 
 const safeSite     = site || {};
 const safeUrlBase  = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
-const safeAccent   = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#e8b04b';
+const safeAccent   = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#c2410c';
 const lang         = safeSite.language || 'nl';
 const ogLocale     = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US'));
@@ -26,5 +21,5 @@
 // Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we
 // just use the site title alone, otherwise apply the template.
-const _siteTitle  = safeSite.title || 'Klonkt';
+const _siteTitle  = safeSite.title || 'PrutCMS';
 const _rawTitle   = pageTitle || _siteTitle;
 const _tpl        = safeSite.title_template || '{title} — {site}';
@@ -38,5 +33,5 @@
 // Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content)
 if (currentPath) {
-  if (/^\/(?:search|tag|type|archive|users|account|admin)(?:$|\/)/.test(currentPath)) {
+  if (/^\/(?:search|tag|type|archive|users|prutter|account|admin)(?:$|\/)/.test(currentPath)) {
     _shouldIndex = false;
   }
@@ -47,10 +42,8 @@
 }
 
-// ── Canonical URL: per-site override (admin SEO), else the .env base
-//    (PUBLIC_BASE_URL, via ogOrigin → falls back to the request host) ──
+// ── Canonical URL (only when we have one configured) ─────────────
 let _canonical = null;
-const _canonBase = safeSite.canonical || (typeof ogOrigin !== 'undefined' ? ogOrigin : '');
-if (_canonBase) {
-  const _base = _canonBase.replace(/\/+$/, '');
+if (safeSite.canonical) {
+  const _base = safeSite.canonical.replace(/\/+$/, '');
   let _path = '/';
   if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug;
@@ -64,20 +57,7 @@
   ? socialDescr
   : (safeSite.default_description || safeSite.description || '');
-// og:image — custom (post/site) first; otherwise the auto-generated themed card
-// (/og/<slug>.png), so every site has a branded social preview by default.
-let _socialImage = '', _ogGenerated = false;
-if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage;
-else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default;
-else if (safeSite.default_cover) _socialImage = safeSite.default_cover;
-else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) {
-  _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png';
-  _ogGenerated = true;
-}
-// og:image / twitter:image / JSON-LD image MUST be absolute (OGP spec). A post cover arrives as
-// a relative /media/... path; strict scrapers (WhatsApp/Signal/some fediverse clients) won't
-// resolve it against the page URL → no preview image. Absolutize against the canonical origin.
-if (_socialImage && _socialImage.charAt(0) === '/' && _socialImage.charAt(1) !== '/' && typeof ogOrigin !== 'undefined' && ogOrigin) {
-  _socialImage = ogOrigin + _socialImage;
-}
+const _socialImage = (typeof socialImage !== 'undefined' && socialImage)
+  ? socialImage
+  : (safeSite.og_image_default || safeSite.default_cover || '');
 const _ogType = isPostPage ? 'article' : 'website';
 
@@ -90,10 +70,4 @@
 if (safeSite.publisher_logo) {
   _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo };
-}
-// Dezelfde koppeling die de fediverse-actor draagt, hier in de JSON-LD
-// (shaer-mbz). sameAs is schema.org-eigen, dus dit is geen extra vocabulaire --
-// het is hetzelfde feit, verteld aan de andere lezer.
-if (safeSite.mb_artist_id) {
-  _publisher.sameAs = 'https://musicbrainz.org/artist/' + safeSite.mb_artist_id;
 }
 let _jsonLd = null;
@@ -120,17 +94,6 @@
   };
 }
-%>
-<%
-// De cache-buster voor alles onder assets/js/mod (en de gedeelde bestanden die
-// die modules importeren). Stond alleen in de bootstrap onderaan; hij staat nu
-// hier omdat de importmap in de <head> hem OOK nodig heeft, en twee losse
-// nummers lopen gegarandeerd een keer uit elkaar.
-//
-// Ophogen bij ELKE wijziging in assets/js/mod -- /assets wordt buiten
-// ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie
-// iedereen behalve wie de fout al heeft.
-const MOD_V = 64;
 %><!DOCTYPE html>
-<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
+<html lang="<%= _e(lang) %>" data-palette="<%= _e(safeSite.palette || 'sage') %>">
 <head>
 <meta charset="utf-8">
@@ -139,91 +102,58 @@
 
 <title><%= _finalTitle %></title>
-<meta name="description" content="<%- _e(_socialDescr) %>">
-<meta name="theme-color" content="<%- _e(safeAccent) %>">
+<meta name="description" content="<%= _e(_socialDescr) %>">
+<meta name="theme-color" content="<%= _e(safeAccent) %>">
 <meta name="robots" content="<%= _shouldIndex ? 'index,follow' : 'noindex,nofollow' %>">
-<% if (safeSite.author) { %><meta name="author" content="<%- _e(safeSite.author) %>"><% } %>
-<% if (_canonical) { %><link rel="canonical" href="<%- _e(_canonical) %>"><% } %>
+<% if (safeSite.author) { %><meta name="author" content="<%= _e(safeSite.author) %>"><% } %>
+<% if (_canonical) { %><link rel="canonical" href="<%= _e(_canonical) %>"><% } %>
 
 <!-- Search-engine verification -->
-<% if (safeSite.google_verification)    { %><meta name="google-site-verification" content="<%- _e(safeSite.google_verification) %>"><% } %>
-<% if (safeSite.bing_verification)      { %><meta name="msvalidate.01" content="<%- _e(safeSite.bing_verification) %>"><% } %>
-<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%- _e(safeSite.pinterest_verification) %>"><% } %>
-<% if (safeSite.yandex_verification)    { %><meta name="yandex-verification" content="<%- _e(safeSite.yandex_verification) %>"><% } %>
+<% if (safeSite.google_verification)    { %><meta name="google-site-verification" content="<%= _e(safeSite.google_verification) %>"><% } %>
+<% if (safeSite.bing_verification)      { %><meta name="msvalidate.01" content="<%= _e(safeSite.bing_verification) %>"><% } %>
+<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%= _e(safeSite.pinterest_verification) %>"><% } %>
+<% if (safeSite.yandex_verification)    { %><meta name="yandex-verification" content="<%= _e(safeSite.yandex_verification) %>"><% } %>
 
 <!-- Feed autodiscovery -->
 <% if (site) { %>
-<link rel="alternate" type="application/rss+xml"  title="<%- _e(_siteTitle) %> — RSS"  href="<%- _e(safeUrlBase + '/feed.xml') %>">
-<link rel="alternate" type="application/atom+xml" title="<%- _e(_siteTitle) %> — Atom" href="<%- _e(safeUrlBase + '/atom.xml') %>">
+<link rel="alternate" type="application/rss+xml"  title="<%= _e(_siteTitle) %> — RSS"  href="<%= _e(safeUrlBase + '/feed.xml') %>">
+<link rel="alternate" type="application/atom+xml" title="<%= _e(_siteTitle) %> — Atom" href="<%= _e(safeUrlBase + '/atom.xml') %>">
 <% } %>
 
 <!-- PWA -->
-<link rel="manifest" href="<%- _e(safeUrlBase + '/manifest.webmanifest') %>">
+<link rel="manifest" href="<%= _e(safeUrlBase + '/manifest.webmanifest') %>">
 <meta name="mobile-web-app-capable" content="yes">
 <meta name="apple-mobile-web-app-capable" content="yes">
 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
-<meta name="apple-mobile-web-app-title" content="<%- _e(_siteTitle.slice(0, 16)) %>">
-<link rel="apple-touch-icon" href="<%- _e(safeSite.profile_photo || '/favicon.ico') %>">
-<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
-<link rel="alternate icon" href="/favicon.ico?v=sf">
+<meta name="apple-mobile-web-app-title" content="<%= _e(_siteTitle.slice(0, 16)) %>">
+<link rel="apple-touch-icon" href="<%= _e(safeSite.profile_photo || '/favicon.ico') %>">
+<link rel="icon" type="image/svg+xml" href="/favicon.svg">
+<link rel="alternate icon" href="/favicon.ico">
 
 <!-- OpenGraph -->
 <meta property="og:type"        content="<%= _ogType %>">
-<meta property="og:title"       content="<%- _e(_socialTitle) %>">
-<meta property="og:description" content="<%- _e(_socialDescr) %>">
-<meta property="og:site_name"   content="<%- _e(_siteTitle) %>">
-<meta property="og:locale"      content="<%- _e(ogLocale) %>">
+<meta property="og:title"       content="<%= _e(_socialTitle) %>">
+<meta property="og:description" content="<%= _e(_socialDescr) %>">
+<meta property="og:site_name"   content="<%= _e(_siteTitle) %>">
+<meta property="og:locale"      content="<%= _e(ogLocale) %>">
 <% if (_socialImage) { %>
-<meta property="og:image"     content="<%- _e(_socialImage) %>">
-<meta property="og:image:alt" content="<%- _e(_socialTitle) %>">
-<% if (_ogGenerated) { %>
-<meta property="og:image:width"  content="1200">
-<meta property="og:image:height" content="630">
-<meta property="og:image:type"   content="image/png">
-<% } %>
-<% } %>
-<% if (_canonical) { %><meta property="og:url" content="<%- _e(_canonical) %>"><% } %>
-<%
-// Fediverse/social PLAYER card for posts with audio: instead of shipping the raw
-// mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an
-// inline player that streams via the gated /audio/stream (no downloadable file).
-const _postAudio = !!(typeof post !== 'undefined' && post
-  && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio
-  && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
-const _embedUrl = _postAudio
-  ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug))
-  : '';
-%>
-<% if (_postAudio) { %>
-<meta property="og:video"            content="<%- _e(_embedUrl) %>">
-<meta property="og:video:secure_url" content="<%- _e(_embedUrl) %>">
-<meta property="og:video:type"       content="text/html">
-<meta property="og:video:width"      content="480">
-<meta property="og:video:height"     content="480">
-<% } %>
+<meta property="og:image"     content="<%= _e(_socialImage) %>">
+<meta property="og:image:alt" content="<%= _e(_socialTitle) %>">
+<% } %>
+<% if (_canonical) { %><meta property="og:url" content="<%= _e(_canonical) %>"><% } %>
 <% if (typeof post !== 'undefined' && post && post.published_at) { %>
-<meta property="article:published_time" content="<%- _e(post.published_at) %>">
-<% if (post.author_username) { %><meta property="article:author" content="<%- _e(post.author_username) %>"><% } %>
-<% } %>
-<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%- _e(safeSite.facebook_app_id) %>"><% } %>
+<meta property="article:published_time" content="<%= _e(post.published_at) %>">
+<% if (post.author_username) { %><meta property="article:author" content="<%= _e(post.author_username) %>"><% } %>
+<% } %>
+<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%= _e(safeSite.facebook_app_id) %>"><% } %>
 
 <!-- Twitter Cards -->
-<meta name="twitter:card"        content="<%= _postAudio ? 'player' : (_socialImage ? 'summary_large_image' : 'summary') %>">
-<% if (_postAudio) { %>
-<meta name="twitter:player"        content="<%- _e(_embedUrl) %>">
-<meta name="twitter:player:width"  content="480">
-<meta name="twitter:player:height" content="480">
-<% } %>
-<meta name="twitter:title"       content="<%- _e(_socialTitle) %>">
-<meta name="twitter:description" content="<%- _e(_socialDescr) %>">
-<% if (_socialImage)         { %><meta name="twitter:image"   content="<%- _e(_socialImage) %>"><% } %>
-<% if (safeSite.twitter)     { %><meta name="twitter:creator" content="<%- _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%- _e(safeSite.twitter) %>"><% } %>
+<meta name="twitter:card"        content="<%= _socialImage ? 'summary_large_image' : 'summary' %>">
+<meta name="twitter:title"       content="<%= _e(_socialTitle) %>">
+<meta name="twitter:description" content="<%= _e(_socialDescr) %>">
+<% if (_socialImage)         { %><meta name="twitter:image"   content="<%= _e(_socialImage) %>"><% } %>
+<% if (safeSite.twitter)     { %><meta name="twitter:creator" content="<%= _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%= _e(safeSite.twitter) %>"><% } %>
 
 <% if (_jsonLd) { %>
 <script type="application/ld+json"><%- JSON.stringify(_jsonLd) %></script>
-<% } %>
-<% if (typeof musicLd !== 'undefined' && musicLd) { %>
-<%# Music posts also carry standard schema.org MusicRecording/MusicAlbum data (Phase 1 of
-    music federation): real web standard, read by search engines + generic consumers. %>
-<script type="application/ld+json"><%- JSON.stringify(musicLd) %></script>
 <% } %>
 
@@ -233,59 +163,10 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=110">
-<script>
-/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
-   BOTH Safari and the installed PWA (standalone), even with viewport-fit=cover, so the
-   masthead can't clear the camera. We measure env directly and, when it comes back empty
-   on a notched iPhone, fall back to a fixed inset (a bit larger in standalone for the
-   Dynamic Island). We set --ios-safe-top AND force the masthead padding inline — the
-   latter survives a stale cached stylesheet that lacks the var. Re-applied after htmx
-   chrome swaps (the top-nav is out-of-band swapped on navigation). */
-(function(){
-  var ua = navigator.userAgent || '';
-  var isIOS = /iP(hone|od|ad)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
-  if (!isIOS) return;
-  var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;
-  function apply(){
-    if (!document.body) return;
-    var p = document.createElement('div');
-    p.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;padding-top:env(safe-area-inset-top,0px);visibility:hidden;pointer-events:none';
-    document.body.appendChild(p);
-    var rawEnv = parseFloat(getComputedStyle(p).paddingTop) || 0;
-    p.remove();
-    var i = rawEnv;
-    if (i < 20 && Math.max(screen.width, screen.height) >= 812) {
-      // Portrait: the island sits at the TOP → full inset (UNCHANGED: standalone?59:47).
-      // Landscape: the island moves to the SIDE, so the top inset is ~0 → no top padding
-      // (only the masthead's own base padding remains). env() can't tell us (returns 0),
-      // so we key off orientation directly.
-      i = (window.innerWidth > window.innerHeight) ? 0 : (standalone ? 59 : 47);
-    }
-    document.documentElement.style.setProperty('--ios-safe-top', i + 'px');
-    var mh = document.querySelector('.masthead');
-    var mhHidden = !mh || getComputedStyle(mh).display === 'none' || mh.offsetHeight === 0;
-    if (mhHidden) {
-      /* Mobile: the masthead is hidden (bottom-tab replaces it ≤767px) so it can't carry
-         the inset. Pad the body instead → the profile-header clears the island/notch. */
-      document.body.style.paddingTop = i + 'px';
-      if (mh) mh.style.paddingTop = '';
-    } else {
-      /* Desktop/landscape: the sticky masthead carries the inset in its own padding. */
-      document.body.style.paddingTop = '';
-      mh.style.paddingTop = 'calc(.55rem + ' + i + 'px)';
-    }
-  }
-  if (document.body) apply(); else document.addEventListener('DOMContentLoaded', apply);
-  document.addEventListener('htmx:afterSettle', apply);
-  window.addEventListener('orientationchange', function(){ setTimeout(apply, 250); });
-})();
-</script>
+<link rel="stylesheet" href="/assets/css/style.css">
 
 <!-- Audio player styles: loaded on every page so the mini-player works
      anywhere (admin previews, post embeds, etc). The player itself is
      a singleton — see the script tag near </body>. -->
-<link rel="stylesheet" href="/assets/css/audio.css?v=11">
-<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
-<link rel="stylesheet" href="/assets/css/embed.css?v=9">
+<link rel="stylesheet" href="/assets/css/audio.css">
 
 <%- include('partials/shared-styles') %>
@@ -299,7 +180,7 @@
   :root,
   [data-palette] {
-    --accent: <%- _e(safeAccent) %>;
-    --accent-soft: color-mix(in srgb, <%- _e(safeAccent) %> 80%, white);
-    --accent-tint: color-mix(in srgb, <%- _e(safeAccent) %> 12%, transparent);
+    --accent: <%= _e(safeAccent) %>;
+    --accent-soft: color-mix(in srgb, <%= _e(safeAccent) %> 80%, white);
+    --accent-tint: color-mix(in srgb, <%= _e(safeAccent) %> 12%, transparent);
   }
 </style>
@@ -323,5 +204,5 @@
     try {
       var siteDefault   = '<%= safeSite.theme_override || "" %>';
-      var sitePalette   = '<%= (typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt') %>';
+      var sitePalette   = '<%= safeSite.palette || "sage" %>';
 
       // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
@@ -350,23 +231,4 @@
 <script src="/assets/js/htmx.min.js"></script>
 
-<%# De cache-buster reikt ook tot wat de modules ONDERLING importeren (shaer-724).
-    De bootstrap hangt ?v= aan elke module die hij zelf laadt, maar een import
-    BINNEN een module is relatief, en een relatieve specifier erft de query
-    niet: `./lib.js` naast `post.js?v=63` wordt gewoon `/assets/js/mod/lib.js`.
-    Dat bestand wordt met max-age van een jaar geserveerd, dus het bleef bij
-    terugkerende bezoekers staan terwijl de modules eromheen ververst werden --
-    en juist lib.js is gedeeld, dus een fout daarin overleeft elke MOD_V-bump.
-
-    Een importmap lost dat op zonder die elf imports aan te raken: hij vertaalt
-    de OPGELOSTE URL naar zijn geversioneerde vorm. Hij moet in de head staan,
-    voor de eerste module-import. De toets bij dit bestand bewaakt dat elke
-    relatieve import onder assets/js hier een ingang heeft. %>
-<script type="importmap">
-{"imports":{
-  "/assets/js/mod/lib.js": "/assets/js/mod/lib.js?v=<%= MOD_V %>",
-  "/assets/js/shared/post-music-type.js": "/assets/js/shared/post-music-type.js?v=<%= MOD_V %>"
-}}
-</script>
-
 <!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
 <% if (safeSite.custom_head_html) { %>
@@ -375,40 +237,23 @@
 </head>
 
-<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default === 'grid' ? 'grid' : (["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader")) %>" data-feed-alt="<%- _e(["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader") %>" data-reader-pages="<%- safeSite.reader_full_page ? 1 : 0 %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
-
-<% if (typeof isViewer !== 'undefined' && isViewer) { %>
-  <div class="viewer-banner" role="status">
-    <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
-    <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
-  </div>
-  <style>
-    .viewer-banner {
-      position: sticky; top: 0; z-index: 60;
-      display: flex; align-items: center; justify-content: center; gap: 0.5rem;
-      padding: 0.5rem 1rem;
-      background: linear-gradient(90deg,
-        color-mix(in srgb, var(--accent) 88%, #000) 0%,
-        var(--accent) 100%);
-      color: #fff;
-      font-size: 0.85rem; line-height: 1.3;
-      box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
-    }
-    .viewer-banner-ico { font-size: 1rem; }
-    .viewer-banner-text strong { font-weight: 700; }
-  </style>
-<% } %>
-
-<%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
-    Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs +
-    render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de
-    audioplayer (los in document.body) blijft leven → geen verspringen. Op de
-    hub-landing is het slot leeg: de hero is daar de header. %>
-<%- include('partials/chrome') %>
-
-<%# Geen hx-history-elt: htmx' eigen history staat uit (zie de link-boost).
-    Back/forward wordt door onze popstate-listener gedaan, die de partial
-    her-fetcht (incl. correcte OOB-chrome). Met hx-history-elt + htmx-history
-    aan dumpte htmx de partial ongefilterd hier → dubbele kop. %>
-<main id="pcms-main" class="pcms-main">
+<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%= _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3">
+
+<%- include('partials/topnav') %>
+
+<%- include('partials/profile-header') %>
+
+<%# View switcher — visible on every non-admin page. On feed pages
+    (home, archive, tag, type, user) it toggles the visual layout;
+    on non-feed pages (post, account, search, auth) it navigates to /
+    in the chosen view. Click logic is in shell.ejs at the bottom. %>
+<% if (!isAdminPage) { %>
+  <%- include('partials/view-switcher') %>
+<% } %>
+
+<%# hx-history-elt scopes HTMX history snapshots to #pcms-main only, so
+    when the user navigates back the switcher (which lives in the shell
+    OUTSIDE this element) keeps its place rather than being replaced
+    along with the rest of the body. %>
+<main id="pcms-main" class="pcms-main" hx-history-elt>
   <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
   <%- pageContent %>
@@ -417,10 +262,6 @@
 <%- include('partials/footer') %>
 
-<!-- Mobile bottom-tab navigation (auto-hidden ≥768px). Toont overal — óók op de
-     hub-landing (zodat mobiel altijd Home/Zoek/Inloggen heeft) — behalve op de
-     auth-focusschermen. -->
-<% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %>
+<!-- Mobile bottom-tab navigation (auto-hidden ≥768px) -->
 <%- include('partials/bottom-tab') %>
-<% } %>
 
 <!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
@@ -432,12 +273,6 @@
      is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
      enable_audio_player since it's a public-page concept (auto-discovered
-     tracks from rendered post embeds).
-
-     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
-     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
-<script src="/assets/js/audio-player.js?v=39"></script>
-<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
-     player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
-<script src="/assets/js/embed-player.js?v=17" defer></script>
+     tracks from rendered post embeds). -->
+<script src="/assets/js/audio-player.js"></script>
 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
   <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
@@ -445,5 +280,5 @@
 
 <!-- Install-app button: detects platform + shows install instructions modal -->
-<script src="/assets/js/install-app.js?v=2" defer></script>
+<script src="/assets/js/install-app.js" defer></script>
 
 <!-- Service Worker registration -->
@@ -461,112 +296,20 @@
 <script>
 (function() {
-  // De page-context body-classes. Bij pcmsNav strippen we ze allemaal en zetten
-  // we opnieuw wat de server stuurde. 'on-auth' staat erbij zodat het login-
-  // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
-  // ALLES wat de server als page-class kan sturen moet hier staan: wat er niet in
-  // staat wordt bij een htmx-navigatie stilletjes weggefilterd.
-  var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
-    'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
-    'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
+  // The set of mutually-exclusive page-context body classes. When pcmsNav
+  // fires we strip ALL of these and apply just the one the server told us.
+  var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin'];
 
   document.body.addEventListener('pcmsNav', function(ev) {
-    var d = ev.detail || {};
-    // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
-    // dus zonder dit erft een artiest de kleuren van de vorige pagina).
-    if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
-      var sa = document.getElementById('pcms-site-accent');
-      if (sa) {
-        sa.textContent =
-          ':root,[data-palette]{--accent:' + d.accent +
-          ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
-          ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
-      }
-    }
-    if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
-      document.documentElement.setAttribute('data-palette', d.palette);
-    }
-
-    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
-    // dit zodra deze handler klaar is.
-    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
-      document.body.setAttribute('data-js', d.js);
-    }
-
-    var next = d.bodyClass;
+    var next = ev.detail && ev.detail.bodyClass;
     if (!next) return;
-    // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
-    // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
-    // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
-    // de chrome/tab van de vorige pagina houden.
-    var matched = String(next).split(/\s+/).filter(function(c) {
+    // Server may send a multi-class string ("on-post extra"). Only the first
+    // page-class is what we care about — strip and replace.
+    var firstClass = String(next).split(/\s+/).find(function(c) {
       return PAGE_CLASSES.indexOf(c) >= 0;
     });
-    if (!matched.length) return;
+    if (!firstClass) return;
     PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
-    matched.forEach(function(c) { document.body.classList.add(c); });
+    document.body.classList.add(firstClass);
   });
-
-  // (Back/forward wordt afgehandeld door de popstate-listener in de link-boost
-  //  hieronder — die her-fetcht de partial via htmx.ajax, wat de OOB-chrome +
-  //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
-  //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
-})();
-</script>
-
-<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
-     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
-     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
-     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
-     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
-     wél de goede nonce heeft.
-
-     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
-     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
-<script>
-(function () {
-  if (window.__modBoot) return;
-  window.__modBoot = true;
-
-  // ?v=N — cache-buster voor ALLE modules in assets/js/mod, net als bij
-  // audio-player.js hierboven. BUMP BIJ ELKE WIJZIGING IN DIE MAP. Zonder
-  // query staat /assets op max-age=1y, en dan blijft een browser die de
-  // pagina eerder bezocht een jaar lang de oude module draaien: een
-  // reparatie bereikt precies de bezoekers die hem al hebben.
-  // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
-  // bumpen kost een bugfix die nooit aankomt.
-  var MOD_V = <%= MOD_V %>;   // uit de EJS-variabele bovenaan; zie de importmap in de head
-
-  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
-  // die `init` exporteert draait die bij ELKE paginawissel waarop hij actief
-  // is -- dat is het gedrag van de oude inline scripts, die per render
-  // draaiden. Een module zonder init draait alleen zijn top-level, een keer.
-  var loaded = {};
-  function start(name, m) {
-    if (!m || typeof m.init !== 'function') return;
-    try { m.init(); } catch (e) { console.warn('[mod] ' + name + ' init:', e && e.message); }
-  }
-  function load() {
-    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
-    names.forEach(function (name) {
-      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
-      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
-      // streep.
-      if (!name || !/^[a-z0-9-]+$/.test(name)) return;
-      if (loaded[name]) { start(name, loaded[name]); return; }
-      loaded[name] = 1;
-      import('/assets/js/mod/' + name + '.js?v=' + MOD_V).then(function (m) {
-        loaded[name] = m;
-        start(name, m);
-      }).catch(function (e) {
-        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
-      });
-    });
-  }
-  load();
-  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
-  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
-  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
-  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
-  document.body.addEventListener('pcmsNav', load);
 })();
 </script>
@@ -577,61 +320,8 @@
 (function() {
   var body = document.body;
-
-  // DE CIRKEL HEEFT MAAR EEN WEERGAVE: Grid.
-  //
-  // Lezen is daar geen optie -- dat zijn berichten van ANDEREN, van hun servers,
-  // en "het hele stuk" hebben wij daar niet in handen. De knop staat er wel,
-  // maar dood (zie view-switcher.ejs). Er valt dus niets te kiezen en dus ook
-  // niets te onthouden: de eigen cirkel-voorkeur die hier stond
-  // ('pcms-cirkel-view') is vervallen, want hij kon nog maar een waarde hebben.
-  //
-  // Wat WEL blijft: de cirkel raakt je solo-keuze niet aan. Die staat in
-  // 'pcms-feed-view' en wordt hier nergens geschreven, zodat je thuiskomt in de
-  // weergave die je achterliet.
-  var CIRKEL_SLEUTEL = 'pcms-cirkel-view';
-  function opCirkel() { return body.classList.contains('on-cirkel'); }
-
-  /** Zet de weergave die bij DEZE pagina hoort. Draait bij init en bij elke nav. */
-  function pasWeergaveToe() {
-    try {
-      if (opCirkel()) {
-        // Lezen kan hier niet -- dat zijn berichten van ANDEREN, van hun servers,
-        // en "het hele stuk" hebben wij niet in handen. De knop staat er wel maar
-        // dood. Een TIJDLIJN kan hier juist bij uitstek: dat is de natuurlijke
-        // vorm van een cirkel.
-        //
-        // Beschikbaar, niet verplicht. Dit dwong eerst 'timeline' af zodra de
-        // site geen Lezen-site was, en dan kon je in de cirkel geen Grid meer
-        // kiezen. Nu is Grid de landing en is Tijdlijn een keuze ernaast.
-        var altC = body.dataset.feedAlt || 'reader';
-        var tijdlijnHier = altC === 'timeline'
-          || (altC === 'auto' && window.matchMedia('(min-width: 768px)').matches);
-        var magC = tijdlijnHier ? ['grid', 'timeline'] : ['grid'];
-        // Een EIGEN geheugen, en niet dat van solo. Twee redenen: hier kun je
-        // alleen uit Grid en Tijdlijn kiezen, dus een solo-keuze 'reader' zegt
-        // hier niets; en andersom mag een tuimeling hier je thuisweergave niet
-        // veranderen -- dan kom je thuis in iets anders dan je achterliet.
-        // (Dit geheugen stond er eerder al, verdween toen de cirkel nog maar een
-        // mogelijkheid had, en is nu weer nodig omdat Tijdlijn een echte keuze is.)
-        var vC = null;
-        try { vC = localStorage.getItem(CIRKEL_SLEUTEL); } catch (e) { /* geen opslag */ }
-        body.dataset.feedView = magC.indexOf(vC) >= 0 ? vC : 'grid';
-        return;
-      }
-      var v = localStorage.getItem('pcms-feed-view');
-      // 'timeline' is sinds 20-8 weer een geldige keuze, maar alleen op een site
-      // die hem AANBIEDT. Wie de tijdlijn koos op de ene site en daarna een
-      // Lezen-site bezoekt, moet daar niet op een weergave landen die er geen
-      // knop heeft: dan staat de pil grijs en is de feed niet wat hij koos.
-      // Vandaar de toets tegen wat deze site biedt, in plaats van een vaste
-      // vertaling naar 'reader'.
-      var alt = body.dataset.feedAlt || 'reader';
-      var mag = alt === 'auto' ? ['grid', 'reader', 'timeline'] : ['grid', alt];
-      if (mag.indexOf(v) >= 0) body.dataset.feedView = v;
-    } catch (e) { /* geen opslag: de server-standaard blijft staan */ }
-  }
-  pasWeergaveToe();
-
+  // Restore feed view + grid cols from localStorage (overrides server default)
   try {
+    var v = localStorage.getItem('pcms-feed-view');
+    if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
     var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
     if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
@@ -639,20 +329,6 @@
 
   function syncAria() {
-    // Alleen op een feed-pagina hoort Tijdlijn/Grid 'actief' (wit) te zijn; op
-    // agenda/downloads/post/etc. beide grijs. Inline feed-check (FEED_PAGE_CLASSES
-    // staat verderop, maar deze functie draait al bij init).
-    var _feedC = ['on-home','on-tag','on-type','on-user','on-cirkel'];
-    var _onFeed = _feedC.some(function(c){ return body.classList.contains(c); });
-    // Een weergave die op DEZE pagina niet KAN, kan ook niet actief zijn. In de
-    // cirkel staat Lezen er wel maar uitgeschakeld; pasWeergaveToe() heeft
-    // feedView daar al op 'grid' gezet, dus de terugval hieronder vuurt er niet.
-    // Hij blijft staan voor het geval een pagina een knop mist: zonder terugval
-    // staat de hele pil grijs terwijl er wel degelijk iets te zien is, en dan
-    // ziet de lezer nergens meer waar hij is. De solo-VOORKEUR blijft intact.
-    var _view = String(body.dataset.feedView || 'reader');
-    if (!/^[a-z]+$/.test(_view)) _view = 'reader';
-    if (!document.querySelector('.view-switch-btn[data-view="' + _view + '"]')) _view = 'reader';
     document.querySelectorAll('.view-switch-btn').forEach(function(b) {
-      b.setAttribute('aria-selected', (_onFeed && !b.disabled && b.dataset.view === _view) ? 'true' : 'false');
+      b.setAttribute('aria-selected', b.dataset.view === body.dataset.feedView ? 'true' : 'false');
     });
     document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
@@ -661,13 +337,6 @@
   }
   syncAria();
-  // Re-sync after HTMX brings in new content (e.g. navigating back to home).
-  // pcmsNav vuurt ná de body-class-update (zie de pcmsNav-listener hierboven), dus
-  // dáár weet syncAria de juiste pagina-class — los van de afterSettle-timing.
-  // Een vertraagde herhaling wint eventuele resterende races (OOB-chrome-swap).
-  // Eerst de weergave die bij de NIEUWE pagina hoort, dan pas de pil bijwerken:
-  // van de cirkel naar huis en terug wisselt niet alleen de knop maar ook welke
-  // onthouden keuze telt.
-  document.body.addEventListener('htmx:afterSettle', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
-  document.body.addEventListener('pcmsNav', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
+  // Re-sync after HTMX brings in new content (e.g. navigating back to home)
+  document.body.addEventListener('htmx:afterSettle', syncAria);
 
   // Pages where the body actually has a feed to toggle. On these the
@@ -676,8 +345,5 @@
   // navigate to home in the chosen view, so the switcher is never
   // a dead control.
-  // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
-  // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
-  // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
-  var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
+  var FEED_PAGE_CLASSES = ['on-home', 'on-archive', 'on-tag', 'on-type', 'on-user'];
   function isFeedPage() {
     for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
@@ -691,19 +357,7 @@
     var sw = e.target.closest('.view-switch-btn');
     if (sw) {
-      // Een dode knop doet niets. Een <button disabled> vuurt in de meeste
-      // browsers geen click, maar deze listener hangt op document en vangt ook
-      // wat er langs de randen komt -- dus expliciet, niet op goed vertrouwen.
-      if (sw.disabled) return;
       var view = sw.dataset.view;
       body.dataset.feedView = view;
-      // Op de cirkel schrijven we NIETS. Daar is Grid de enige mogelijkheid, dus
-      // er valt niets te onthouden -- en je solo-keuze mag er zeker niet door
-      // veranderen: die moet je terugvinden zoals je hem achterliet.
-      try {
-        // De cirkel heeft zijn eigen geheugen, zodat een keuze daar je
-        // thuisweergave niet aanraakt en andersom.
-        if (opCirkel()) localStorage.setItem(CIRKEL_SLEUTEL, view);
-        else localStorage.setItem('pcms-feed-view', view);
-      } catch(_) {}
+      try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
       syncAria();
       // On non-feed pages the switcher acts as a navigation: take the
@@ -711,12 +365,9 @@
       // the page transition matches the rest of the site's nav.
       if (!isFeedPage()) {
-        // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
-        // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
-        var base = body.dataset.siteBase || '';
         if (window.htmx) {
-          window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
-          history.pushState({}, '', base + '/');
+          window.htmx.ajax('GET', '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
+          history.pushState({}, '', '/');
         } else {
-          location.href = base + '/';
+          location.href = '/';
         }
       }
@@ -730,175 +381,4 @@
     }
   });
-})();
-</script>
-
-<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
-     zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
-     bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
-     elke link los htmx te maken. Links die écht een volledige load nodig hebben
-     (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
-     net als links die al hun eigen hx-* hebben. -->
-<script>
-(function () {
-  if (!window.htmx) return;
-  // htmx' EIGEN history-afhandeling volledig uitzetten. We doen back/forward zelf
-  // via de popstate-listener hieronder (htmx.ajax → verwerkt de OOB-chrome netjes).
-  // Lieten we htmx z'n gang gaan, dan herstelde 'ie #pcms-main door de partial
-  // (inclusief de <div id=pcms-chrome hx-swap-oob>) ONGEFILTERD in #pcms-main te
-  // dumpen → een tweede, geneste kop = de pagina dubbel. Eén mechanisme nu.
-  try { window.htmx.config.historyEnabled = false; } catch (_) {}
-
-  function fullLoad(a, url) {
-    if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
-    if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
-    if (a.target && a.target !== '_self') return true;
-    if (a.getAttribute('rel') === 'external') return true;
-    var p = url.pathname;
-    // Sessie/redirect-acties → volledige navigatie (cookies, Google-redirect).
-    // Maar de auth-FORMULIERpagina's (/auth/admin, /auth/login, /auth/register,
-    // /auth/reset…) mogen wél via htmx, zodat de audiospeler blijft doorspelen
-    // i.p.v. te herstarten/verspringen bij een volledige page-load.
-    if (/^\/(logout|oauth)(?:\/|$)/.test(p)) return true;
-    if (/^\/auth\/(logout|google)(?:\/|$)/.test(p)) return true;
-    // Feeds, PWA, service-worker, statics, media-streams, downloads.
-    if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
-    if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
-    return false;
-  }
-
-  var lastPath = location.pathname + location.search;
-  var navTimer = null;
-
-  // Eén plek voor alle programmatische navigatie-swaps. Annuleert eerst een nog
-  // lopende request op #pcms-main (anti-race: bij snel klikken/terug-gaan kan een
-  // trage oude response anders een nieuwe pagina overschrijven → "kale content").
-  function doNav(dest) {
-    try { window.htmx.trigger('#pcms-main', 'htmx:abort'); } catch (_) {}
-    window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
-  }
-
-  document.addEventListener('click', function (e) {
-    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-    var a = e.target.closest('a[href]');
-    if (!a) return;
-    var href = a.getAttribute('href');
-    if (!href || href.charAt(0) === '#') return;
-    var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
-    if (url.origin !== location.origin) return;
-    if (fullLoad(a, url)) return;
-    e.preventDefault();
-    var dest = url.pathname + url.search;
-    if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
-    lastPath = dest;
-    if (navTimer) { clearTimeout(navTimer); navTimer = null; }
-    doNav(dest);
-    try { window.scrollTo(0, 0); } catch (_) {}
-  });
-
-  window.addEventListener('popstate', function () {
-    var here = location.pathname + location.search;
-    if (here === lastPath) return;
-    lastPath = here;
-    // Debounce: bij heel snel/herhaald terug-vooruit niet elke tussenpagina ophalen,
-    // alleen de LAATSTE bestemming. Voorkomt overlappende swaps ("kale content").
-    if (navTimer) clearTimeout(navTimer);
-    navTimer = setTimeout(function () {
-      navTimer = null;
-      doNav(location.pathname + location.search);
-    }, 90);
-  });
-
-  // Links MÉT eigen hx-get + hx-push-url (post-card/post-tile/topnav/…) lopen NIET
-  // via de boost hierboven, en hx-push-url is een no-op nu htmx-history uitstaat.
-  // Doe daarom de adresbalk-update hier zelf zodra htmx swapt. Programmatische
-  // htmx.ajax-calls (boost/popstate) hebben geen elt met hx-push-url → geen dubbel.
-  document.body.addEventListener('htmx:beforeRequest', function (evt) {
-    try {
-      var elt = evt.detail && evt.detail.elt;       // het TRIGGERENDE element (de link)
-      if (!elt || !elt.closest) return;
-      var node = elt.closest('[hx-push-url]');
-      if (!node) return;
-      var u = node.getAttribute('hx-push-url');
-      if (!u || u === 'false') return;
-      if (u !== (location.pathname + location.search)) history.pushState({ b: 1 }, '', u);
-      lastPath = u;
-    } catch (_) {}
-  });
-
-  // Spring naar boven na ELKE navigatie-swap van #pcms-main. De boost hierboven
-  // scrollt al, maar links met hun eigen hx-get (post-nav Newer/Older, post-kaarten)
-  // lopen NIET via de boost → zonder dit blijf je op de oude scrollpositie hangen
-  // bij het openen van een gerelateerde/volgende post. Alleen #pcms-main, zodat
-  // in-page swaps (comments e.d.) en de OOB-chrome-swap niet meescrollen.
-  document.body.addEventListener('htmx:afterSwap', function (evt) {
-    var t = evt.detail && evt.detail.target;
-    if (t && t.id === 'pcms-main') { try { window.scrollTo(0, 0); } catch (_) {} }
-  });
-})();
-</script>
-
-<!-- Mobiel toetsenbord vs. site-layout: zet body.kb-open zodra het toetsenbord
-     open is (visual viewport fors korter dan het venster) ÉN er een invoerveld
-     focus heeft. CSS verbergt dan de vaste onderbalken (bottom-tab + mini-speler)
-     zodat ze niet over het invoerveld zweven. -->
-<script>
-(function () {
-  var vv = window.visualViewport;
-  if (!vv) return;
-  function isField(el) {
-    if (!el) return false;
-    var t = el.tagName;
-    return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
-  }
-  function update() {
-    var open = (window.innerHeight - vv.height) > 150 && isField(document.activeElement);
-    document.body.classList.toggle('kb-open', open);
-  }
-  vv.addEventListener('resize', update);
-  vv.addEventListener('scroll', update);
-  document.addEventListener('focusin', function () { setTimeout(update, 60); });
-  document.addEventListener('focusout', function () { setTimeout(update, 60); });
-})();
-</script>
-
-<!-- Afbeeldingen lastiger op te slaan: rechtsklik-menu + slepen blokkeren op <img>.
-     Frictie, geen echte beveiliging (DevTools/screenshot blijven kunnen). -->
-<script>
-(function () {
-  document.addEventListener('contextmenu', function (e) {
-    if (e.target && e.target.tagName === 'IMG') e.preventDefault();
-  });
-  document.addEventListener('dragstart', function (e) {
-    if (e.target && e.target.tagName === 'IMG') e.preventDefault();
-  });
-})();
-</script>
-
-<!-- Auto-resize: elk <textarea> groeit mee met de inhoud i.p.v. intern te scrollen
-     (scroll-binnen-scroll is verwarrend). Site-breed; ook na htmx-swaps. -->
-<script>
-(function () {
-  function autoSize(ta) {
-    if (!ta || ta.tagName !== 'TEXTAREA') return;
-    // Skip hidden textareas (e.g. inside a closed <details> or an unopened reply
-    // box): measuring scrollHeight there yields a bad height that sticks as inline
-    // style and makes the field open huge. They get sized on focus once visible.
-    if (ta.offsetParent === null && ta.offsetHeight === 0) return;
-    ta.style.height = 'auto';
-    var maxH = parseFloat(getComputedStyle(ta).maxHeight);
-    var sh = ta.scrollHeight;
-    var h = (maxH && !isNaN(maxH)) ? Math.min(sh, maxH) : sh;   // respect a CSS max-height
-    ta.style.height = h + 'px';
-    ta.style.overflowY = sh > h ? 'auto' : 'hidden';
-  }
-  function sizeAll(root) {
-    (root || document).querySelectorAll('textarea').forEach(autoSize);
-  }
-  document.addEventListener('input', function (e) { autoSize(e.target); });
-  document.addEventListener('focusin', function (e) { autoSize(e.target); });
-  // Init + opnieuw na htmx-navigatie/partials.
-  sizeAll();
-  document.body.addEventListener('htmx:afterSettle', function () { sizeAll(); });
-  window.addEventListener('load', function () { sizeAll(); });
 })();
 </script>
@@ -929,128 +409,4 @@
 </script>
 
-<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
-     click reveals instead of following the card link or firing htmx navigation. -->
-<script>
-// Cover fade-in: a cover image that's still loading is hidden so the accent-gradient
-// placeholder behind it shows; it fades in once loaded. Cached/complete images stay
-// visible (no flash). Runs on load + htmx swaps.
-(function () {
-  if (window.__coverFadeWired) return; window.__coverFadeWired = true;
-  function scan(root) {
-    (root || document).querySelectorAll('img.grid-tile-img, .post-list-cover img, .tl-media-img img').forEach(function (img) {
-      if (img.dataset.fade) return; img.dataset.fade = '1';
-      if (img.complete && img.naturalWidth > 0) return;   // already loaded → leave visible
-      img.classList.add('is-loading');
-      var done = function () { img.classList.remove('is-loading'); };
-      img.addEventListener('load', done, { once: true });
-      img.addEventListener('error', done, { once: true });
-    });
-  }
-  scan(document);
-  document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
-})();
-
-// Light anti-grab friction: suppress the right-click menu on visual media (covers, images,
-// videos) so the art isn't one right-click away from "Save as". Friction, NOT protection —
-// the files are public and reachable via devtools/network. Middle/Ctrl-click (open in new
-// tab) still works; only the context menu is blocked. Delegated → covers htmx-swapped content.
-(function () {
-  if (window.__noMediaCtxWired) return; window.__noMediaCtxWired = true;
-  document.addEventListener('contextmenu', function (e) {
-    if (e.target.closest('img, video, .grid-tile, .post-list-cover, .post-cover, .tl-media-img')) {
-      e.preventDefault();
-    }
-  });
-})();
-
-// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
-// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
-// other browser keeps the crisp WebP. Runs on load + htmx swaps.
-(function () {
-  if (window.__iosVideoWired) return; window.__iosVideoWired = true;
-  var ua = navigator.userAgent || '';
-  var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
-               (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
-  if (!IS_IOS) return;
-  function swap(root) {
-    (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
-      var mp4 = img.getAttribute('data-ios-mp4');
-      if (!mp4 || img.dataset.iosSwapped) return;
-      img.dataset.iosSwapped = '1';
-      var v = document.createElement('video');
-      v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
-      v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
-      v.poster = img.getAttribute('src') || '';
-      v.className = img.className;
-      if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
-      if (img.parentNode) img.parentNode.replaceChild(v, img);
-      var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
-    });
-  }
-  swap(document);
-  document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
-})();
-</script>
-
-<script>
-(function () {
-  if (window.__nsfwWired) return; window.__nsfwWired = true;
-  document.addEventListener('click', function (e) {
-    var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
-    if (!hit) return;
-    e.preventDefault(); e.stopPropagation();
-    var box = hit.closest('.nsfw-media, .nsfw-gate');
-    if (box) box.classList.add('is-shown');
-  }, true);
-})();
-</script>
-
-<script>
-// Delegated replacements for inline on* handlers, so the CSP needs no
-// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
-(function () {
-  if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
-  // Confirm before submitting a form that carries data-confirm.
-  document.addEventListener('submit', function (e) {
-    var f = e.target;
-    if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
-  });
-  // Auto-submit a form / switch language when a <select> changes.
-  document.addEventListener('change', function (e) {
-    var el = e.target;
-    if (!el || !el.dataset) return;
-    if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
-    else if (el.dataset.langSwitch !== undefined) {
-      location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
-    }
-  });
-  // Misc click helpers (select-all in a field, history-back button).
-  document.addEventListener('click', function (e) {
-    var el = e.target.closest && e.target.closest('[data-selectall],[data-back],[data-share]');
-    if (!el) return;
-    if (el.dataset.selectall !== undefined && el.select) el.select();
-    if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
-    if (el.dataset.share !== undefined) {
-      e.preventDefault();
-      var url = location.href, title = el.dataset.shareTitle || document.title;
-      if (navigator.share) { navigator.share({ title: title, url: url }).catch(function () {}); }
-      else if (navigator.clipboard && navigator.clipboard.writeText) {
-        navigator.clipboard.writeText(url).then(function () {
-          var fb = document.getElementById('post-share-feedback');
-          if (fb) { fb.hidden = false; setTimeout(function () { fb.hidden = true; }, 2000); }
-        }).catch(function () { window.prompt('Copy link:', url); });
-      } else { window.prompt('Copy link:', url); }
-    }
-  });
-  // Image fallback (the error event doesn't bubble → capture phase).
-  document.addEventListener('error', function (e) {
-    var el = e.target;
-    if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
-      el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
-    }
-  }, true);
-})();
-</script>
-
 <!-- Per-site custom footer HTML -->
 <% if (safeSite.custom_foot_html) { %>
