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()))];
-}
